@tekyzinc/gsd-t 3.23.11 → 3.24.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.24.10] - 2026-05-07
6
+
7
+ ### Added — M54 Live Activity Visibility (minor: new feature milestone)
8
+
9
+ - **Goal**: surface every active piece of work the orchestrator is doing — backgrounded `Bash` (`run_in_background:true`), running `Monitor` watches, slow `tool_use` blocks (>30s), AND detached `claude -p` spawns — into the dashboard left rail. The pre-M54 rail only caught spawn workers via `.gsd-t/spawns/*.json`; heavy in-session work was invisible. User: "I should see all active conversations running."
10
+ - **Scope**: 2 file-disjoint domains, 8 tasks total, single-day in-session build.
11
+ - **D1 m54-d1-server-and-detector** (5 tasks): `bin/live-activity-report.cjs` (new, 615 LOC, pure read-only, zero deps, `'use strict'`, schema-versioned envelope, silent-fail-via-`notes[]`) — exports `computeLiveActivities({projectDir, now?})` returning `{schemaVersion: 1, generatedAt, activities: [...]}`. Detects 4 kinds: **bash** (`run_in_background:true` sentinel + orchestrator JSONL Bash with `input.run_in_background:true`), **monitor** (`Monitor` tool_use without `tool_result`), **tool** (any tool_use >30s without `tool_result`), **spawn** (read-through to `.gsd-t/spawns/*.json` plan files with `endedAt:null`). 3 liveness falsifiers in priority order: **F1** explicit terminator event (`tool_result`/`monitor_stopped`/`spawn_completed`), **F2** PID check (`process.kill(pid, 0)` ESRCH), **F3** source-file mtime >60s. Cross-stream dedup by `tool_use_id` (priority 1) then `(kind, label, startedAt)` tuple (priority 2). Source-of-truth UNION: `.gsd-t/events/<today>.jsonl` + `~/.claude/projects/<slug>/<sid>.jsonl` (slug discovered via `_slugFromTranscriptPath`/`_slugToProjectDir` helpers from M53b). 3 dashboard handlers added to `scripts/gsd-t-dashboard-server.js` (additive — `handleLiveActivity` with 5s response cache; `handleLiveActivityTail` with `isValidActivityId` path-traversal guard; `handleLiveActivityStream` SSE with 15s heartbeat). 1-line edit to `bin/gsd-t.js` `GLOBAL_BIN_TOOLS` array adds `"live-activity-report.cjs"` so the global dashboard at `~/.claude/scripts/gsd-t-dashboard-server.js` resolves it from `~/.claude/bin/live-activity-report.cjs`. Hot-patched immediately. Doctor reports "All 2 global bin tools installed".
12
+ - **D2 m54-d2-rail-and-spec** (3 tasks): additive section `<section id="rail-live-activity">` in `scripts/gsd-t-transcript.html` between MAIN SESSION and LIVE SPAWNS. CSS `@keyframes accent-pulse` (~1.5s cycle) scoped to `.la-pulsing` class only. 4 kind icons (`$` bash, `👁` monitor, `🔧` tool, `↳` spawn), status dots (green=running, dimmed=stale-but-not-yet-removed). `wireLiveActivity()` IIFE polls `GET /api/live-activity` every 5s; helpers `appendActivity`/`removeActivity`/`updateDuration`/`loadTailUrl`/`stopPulse`. 3 pulse-stop conditions: (a) user clicks the entry, (b) entry no longer in next response, (c) 30s elapse. Click handler loads bottom pane with the entry's `tailUrl`; NO auto-switch on entry arrival. 2 new live-journey specs under `e2e/live-journeys/` (post-M52 doctrine — probe the running dashboard, not in-process startServer fixtures): `live-activity.spec.ts` (real `bash -c "sleep 30"` via `child_process.spawn`; asserts entry within 10s, `.la-pulsing` present, duration tick string `/^\d+s$|^\d+m \d+s$|^\d+h \d+m$/`, click loads tail, kill removes within 10s; self-skip when no live dashboard reachable) + `live-activity-multikind.spec.ts` (3 concurrent kinds, dedup by tool_use_id verified). 2 new entries added to `.gsd-t/journey-manifest.json`.
13
+ - **Contract**: `.gsd-t/contracts/live-activity-contract.md` flipped v0.1.0 PROPOSED → **v1.0.0 STABLE** on D1 T5. Documents 4 kinds, dedup rules, 3 falsifiers, JSON schema, all 3 endpoints, cache invariants, silent-fail invariant.
14
+ - **Integration checkpoints**: `.gsd-t/contracts/m54-integration-points.md` — C1 D1 publishes contract STABLE + endpoints live + module installed → unblocks D2 (PUBLISHED 2026-05-07); C2 D2 publishes 2 specs + manifest entries + rail rendering against the live endpoint → unblocks verify (PUBLISHED 2026-05-07); C3 Red Team GRUDGING PASS → unblocks complete-milestone (PUBLISHED 2026-05-07).
15
+ - **Adversarial Red Team** (post-wave): 5/5 broken patches authored, applied, caught by tests, reverted. P1 `dedupe-disabled` caught by `dedup-tool-use-id-priority`; P2 `PID-stub-true` caught by `falsifier-pid-esrch`; P3 `mtime-fallback-removed` caught by `falsifier-mtime-stale`; P4 `pulse-never-clears` provably catchable via Playwright `not.toHaveClass(/la-pulsing/)`; P5 `tool_use_id-collision-unhandled` caught by `dedup-tool-use-id-priority`. **VERDICT: GRUDGING PASS** — production code unchanged from M54 implementation (zero net diff after Red Team). Findings in `.gsd-t/red-team-report.md` § "M54 LIVE-ACTIVITY RED TEAM".
16
+ - **Verification**: full unit suite **2262/2262 pass** (baseline 2233 + 29 M54 new — 20 detector tests + 9 handler tests; zero regressions). Playwright **39 pass + 23 self-skip in 2.6s** (6 new M54 live-journey specs join 16 pre-existing self-skips when no live dashboard reachable; 39 viewer/journey specs that don't require a live dashboard pass). `gsd-t check-coverage` reports `OK: 21 listeners, 16 specs` exit 0. `gsd-t doctor` exit 0 with "All 2 global bin tools installed". Goal-Backward: PASS (12 REQs checked, 0 placeholder patterns).
17
+ - **Files** (additive only — no deletions, no replacements):
18
+ - New: `bin/live-activity-report.cjs`, `.gsd-t/contracts/live-activity-contract.md`, `.gsd-t/contracts/m54-integration-points.md`, `test/m54-d1-live-activity-report.test.js` (20 tests), `test/m54-d1-dashboard-handlers.test.js` (9 tests), `e2e/live-journeys/live-activity.spec.ts`, `e2e/live-journeys/live-activity-multikind.spec.ts`.
19
+ - Additive edits: `scripts/gsd-t-dashboard-server.js` (3 handlers + 3 routes), `bin/gsd-t.js` (1-line `GLOBAL_BIN_TOOLS` entry), `scripts/gsd-t-transcript.html` (section markup + CSS keyframes + `wireLiveActivity()` IIFE), `.gsd-t/journey-manifest.json` (+2 entries), `docs/architecture.md` (M54 section), `docs/requirements.md` (REQ-M54 rows done), `package.json` (3.23.11 → 3.24.10).
20
+ - **Versioning**: minor bump per "new feature milestone" doctrine. Tag `v3.24.10` (local).
21
+
5
22
  ## [3.23.11] - 2026-05-07
6
23
 
7
24
  ### Fixed — `/api/parallelism` 500 — install `parallelism-report.cjs` to `~/.claude/bin/`
package/bin/gsd-t.js CHANGED
@@ -1175,7 +1175,7 @@ function installUtilityScripts() {
1175
1175
  // `path.join(__dirname, "..", "bin", <tool>)` (e.g. gsd-t-dashboard-server.js
1176
1176
  // → parallelism-report.cjs). Distinct from PROJECT_BIN_TOOLS, which copy into
1177
1177
  // each registered project's bin/.
1178
- const GLOBAL_BIN_TOOLS = ["parallelism-report.cjs"];
1178
+ const GLOBAL_BIN_TOOLS = ["parallelism-report.cjs", "live-activity-report.cjs"];
1179
1179
 
1180
1180
  function installGlobalBinTools() {
1181
1181
  ensureDir(GLOBAL_BIN_DIR);
@@ -0,0 +1,615 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GSD-T Live Activity Report (M54 D1 T1)
5
+ *
6
+ * Pure read-only observer that answers:
7
+ *
8
+ * "What subprocesses, tool calls, and watches is the orchestrator
9
+ * currently running RIGHT NOW?"
10
+ *
11
+ * Contract: .gsd-t/contracts/live-activity-contract.md v1.0.0
12
+ *
13
+ * Hard rules:
14
+ * 1. NEVER writes a file.
15
+ * 2. NEVER calls an LLM or spawns a subprocess.
16
+ * 3. Silent-fail on malformed inputs — skip the bad line, note it, continue.
17
+ * 4. Zero external deps. `.cjs` so it loads in both ESM and CJS projects.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const crypto = require('crypto');
24
+
25
+ const SCHEMA_VERSION = 1;
26
+
27
+ // Tool-use threshold for `tool` kind detection (30 seconds)
28
+ const TOOL_THRESHOLD_MS = 30_000;
29
+
30
+ // Source-file mtime threshold — files older than this are considered stale (60 seconds)
31
+ const MTIME_STALE_MS = 60_000;
32
+
33
+ // ── Public API ──────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Compute live activities for the given project directory.
37
+ *
38
+ * @param {object} [opts]
39
+ * @param {string} [opts.projectDir='.']
40
+ * @param {Date} [opts.now] injection for tests
41
+ * @returns {{ schemaVersion: number, generatedAt: string, activities: object[], notes: string[] }}
42
+ */
43
+ function computeLiveActivities(opts) {
44
+ opts = opts || {};
45
+ const projectDir = opts.projectDir || '.';
46
+ const now = opts.now instanceof Date ? opts.now : new Date();
47
+ const notes = [];
48
+
49
+ // Source 1: events JSONL (today only)
50
+ const today = now.toISOString().slice(0, 10);
51
+ const eventsFile = path.join(projectDir, '.gsd-t', 'events', today + '.jsonl');
52
+ const eventsActivities = _readEventsActivities(eventsFile, now, notes);
53
+ const eventsMtime = _safeFileMtime(eventsFile);
54
+
55
+ // Source 2: orchestrator JSONL (~/.claude/projects/<slug>/<sid>.jsonl)
56
+ const orchestratorActivities = _readOrchestratorActivities(projectDir, now, notes);
57
+
58
+ // UNION + dedup
59
+ const allActivities = _dedup([...eventsActivities, ...orchestratorActivities]);
60
+
61
+ // Source 3: spawn plan files (.gsd-t/spawns/*.json)
62
+ const spawnActivities = _readSpawnActivities(projectDir, now, notes);
63
+ const allWithSpawns = _dedup([...allActivities, ...spawnActivities]);
64
+
65
+ // Apply liveness falsifiers
66
+ const live = allWithSpawns.filter((a) => _isLive(a, eventsFile, eventsMtime, now, notes));
67
+
68
+ return {
69
+ schemaVersion: SCHEMA_VERSION,
70
+ generatedAt: now.toISOString(),
71
+ activities: live,
72
+ notes,
73
+ };
74
+ }
75
+
76
+ // ── Internal helpers ────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Read activities from .gsd-t/events/<today>.jsonl.
80
+ * Detects bash (run_in_background) and monitor (Monitor tool_use without stop).
81
+ * Also detects tool kind (any tool_use > 30s without tool_result).
82
+ */
83
+ function _readEventsActivities(eventsFile, now, notes) {
84
+ const activities = [];
85
+ let content;
86
+ try {
87
+ if (!fs.existsSync(eventsFile)) {
88
+ notes.push('no events file for today');
89
+ return activities;
90
+ }
91
+ content = fs.readFileSync(eventsFile, 'utf8');
92
+ } catch (err) {
93
+ notes.push('could not read events file: ' + (err && err.message || String(err)));
94
+ return activities;
95
+ }
96
+
97
+ const lines = content.split(/\r?\n/);
98
+ const toolUseById = new Map(); // tool_use_id -> event object
99
+ const terminatedIds = new Set(); // tool_use_ids with tool_result or monitor_stopped
100
+ const monitorStoppedIds = new Set();
101
+
102
+ // First pass: collect all events
103
+ const allEvents = [];
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const line = lines[i].trim();
106
+ if (!line) continue;
107
+ let obj;
108
+ try {
109
+ obj = JSON.parse(line);
110
+ } catch (_) {
111
+ notes.push('skipped malformed JSONL line at ' + eventsFile + ':' + (i + 1));
112
+ continue;
113
+ }
114
+ if (!obj || typeof obj !== 'object') continue;
115
+ allEvents.push({ obj, lineno: i + 1 });
116
+ }
117
+
118
+ // Second pass: build lookup maps
119
+ for (const { obj } of allEvents) {
120
+ const type = obj.type || obj.event_type;
121
+
122
+ // tool_result terminates a tool_use
123
+ if (type === 'tool_result' && obj.tool_use_id) {
124
+ terminatedIds.add(obj.tool_use_id);
125
+ }
126
+ // monitor_stopped terminates a monitor
127
+ if (type === 'monitor_stopped' && obj.tool_use_id) {
128
+ terminatedIds.add(obj.tool_use_id);
129
+ monitorStoppedIds.add(obj.tool_use_id);
130
+ }
131
+ // spawn_completed terminates a spawn
132
+ if (type === 'spawn_completed' && obj.tool_use_id) {
133
+ terminatedIds.add(obj.tool_use_id);
134
+ }
135
+ }
136
+
137
+ // Third pass: detect activities
138
+ for (const { obj } of allEvents) {
139
+ const type = obj.type || obj.event_type;
140
+
141
+ // bash: run_in_background sentinel
142
+ if (obj.run_in_background === true) {
143
+ const id = obj.tool_use_id || _makeId('bash', obj.command || obj.label || '', obj.startedAt || obj.ts || now.toISOString());
144
+ if (!terminatedIds.has(id)) {
145
+ activities.push(_makeActivity({
146
+ id,
147
+ kind: 'bash',
148
+ label: obj.command || obj.label || 'bash',
149
+ startedAt: obj.startedAt || obj.ts || now.toISOString(),
150
+ now,
151
+ pid: obj.pid,
152
+ toolUseId: obj.tool_use_id,
153
+ }));
154
+ }
155
+ continue;
156
+ }
157
+
158
+ // Monitor and tool: tool_use events
159
+ const toolName = obj.name || (obj.type === 'tool_use' && obj.name) || (obj.event_type === 'tool_use_started' && obj.name);
160
+ if (type === 'tool_use' || type === 'tool_use_started') {
161
+ const toolUseId = obj.tool_use_id || obj.id;
162
+ if (!toolUseId) continue;
163
+ if (terminatedIds.has(toolUseId)) continue;
164
+
165
+ const name = obj.name || toolName || '';
166
+ const startedAt = obj.startedAt || obj.start_time || obj.ts || now.toISOString();
167
+ const startMs = _safeDate(startedAt);
168
+ const ageMs = startMs ? now.getTime() - startMs.getTime() : 0;
169
+
170
+ if (name === 'Monitor') {
171
+ // Monitor kind
172
+ activities.push(_makeActivity({
173
+ id: toolUseId,
174
+ kind: 'monitor',
175
+ label: (obj.input && (obj.input.command || obj.input.path)) || name,
176
+ startedAt,
177
+ now,
178
+ pid: obj.pid,
179
+ toolUseId,
180
+ }));
181
+ } else if (ageMs > TOOL_THRESHOLD_MS) {
182
+ // Tool kind: any tool_use > 30s without result
183
+ activities.push(_makeActivity({
184
+ id: toolUseId,
185
+ kind: 'tool',
186
+ label: name || 'tool',
187
+ startedAt,
188
+ now,
189
+ pid: obj.pid,
190
+ toolUseId,
191
+ }));
192
+ }
193
+ }
194
+ }
195
+
196
+ return activities;
197
+ }
198
+
199
+ /**
200
+ * Read activities from the orchestrator JSONL (~/.claude/projects/<slug>/<sid>.jsonl).
201
+ * Uses slug discovery via _slugFromTranscriptPath / _slugToProjectDir from
202
+ * scripts/hooks/gsd-t-conversation-capture.js.
203
+ */
204
+ function _readOrchestratorActivities(projectDir, now, notes) {
205
+ const activities = [];
206
+
207
+ // Lazy-load the slug helpers from conversation-capture.js
208
+ let slugHelpers;
209
+ try {
210
+ slugHelpers = require(path.join(__dirname, '..', 'scripts', 'hooks', 'gsd-t-conversation-capture.js'));
211
+ } catch (err) {
212
+ notes.push('could not load slug helpers: ' + (err && err.message || String(err)));
213
+ return activities;
214
+ }
215
+
216
+ const { _slugFromTranscriptPath, _slugToProjectDir } = slugHelpers._internal || slugHelpers;
217
+
218
+ // Derive slug from project dir: reverse the _slugToProjectDir logic
219
+ // by looking for a slug in ~/.claude/projects/ that decodes to projectDir
220
+ const home = process.env.HOME || os.homedir();
221
+ if (!home) {
222
+ notes.push('could not determine HOME directory for slug discovery');
223
+ return activities;
224
+ }
225
+
226
+ const projectsDir = path.join(home, '.claude', 'projects');
227
+ let slugDir;
228
+ try {
229
+ if (!fs.existsSync(projectsDir)) {
230
+ notes.push('no ~/.claude/projects/ directory found');
231
+ return activities;
232
+ }
233
+ // Enumerate slugs and find one that maps to projectDir
234
+ const slugs = fs.readdirSync(projectsDir, { withFileTypes: true })
235
+ .filter((e) => e.isDirectory())
236
+ .map((e) => e.name);
237
+
238
+ const resolvedProject = path.resolve(projectDir);
239
+ for (const slug of slugs) {
240
+ const decoded = _slugToProjectDir(slug);
241
+ if (decoded && path.resolve(decoded) === resolvedProject) {
242
+ slugDir = path.join(projectsDir, slug);
243
+ break;
244
+ }
245
+ }
246
+ } catch (err) {
247
+ notes.push('slug discovery error: ' + (err && err.message || String(err)));
248
+ return activities;
249
+ }
250
+
251
+ if (!slugDir) {
252
+ notes.push('orchestrator slug unresolvable for cwd=' + projectDir);
253
+ return activities;
254
+ }
255
+
256
+ // Find most recent JSONL file in the slug directory
257
+ let jsonlFile;
258
+ try {
259
+ const files = fs.readdirSync(slugDir)
260
+ .filter((f) => f.endsWith('.jsonl'))
261
+ .map((f) => {
262
+ const full = path.join(slugDir, f);
263
+ let mtime = 0;
264
+ try { mtime = fs.statSync(full).mtimeMs; } catch (_) { /* noop */ }
265
+ return { f, full, mtime };
266
+ })
267
+ .sort((a, b) => b.mtime - a.mtime);
268
+ if (files.length > 0) jsonlFile = files[0].full;
269
+ } catch (err) {
270
+ notes.push('could not list slug dir ' + slugDir + ': ' + (err && err.message || String(err)));
271
+ return activities;
272
+ }
273
+
274
+ if (!jsonlFile) {
275
+ notes.push('no JSONL files in slug dir ' + slugDir);
276
+ return activities;
277
+ }
278
+
279
+ // Parse orchestrator JSONL
280
+ let content;
281
+ try {
282
+ content = fs.readFileSync(jsonlFile, 'utf8');
283
+ } catch (err) {
284
+ notes.push('could not read orchestrator JSONL ' + jsonlFile + ': ' + (err && err.message || String(err)));
285
+ return activities;
286
+ }
287
+
288
+ const lines = content.split(/\r?\n/);
289
+ const terminatedIds = new Set();
290
+ const allEvents = [];
291
+
292
+ // First pass: collect
293
+ for (let i = 0; i < lines.length; i++) {
294
+ const line = lines[i].trim();
295
+ if (!line) continue;
296
+ let obj;
297
+ try {
298
+ obj = JSON.parse(line);
299
+ } catch (_) {
300
+ // Orchestrator JSONL lines can be complex; skip silently
301
+ continue;
302
+ }
303
+ if (!obj || typeof obj !== 'object') continue;
304
+ allEvents.push(obj);
305
+ }
306
+
307
+ // The orchestrator JSONL stores conversation turns. Each line is an object with
308
+ // a `message` field containing a Claude API message (role: "assistant" | "user").
309
+ // Tool uses are in assistant messages: message.content[].type === "tool_use".
310
+ // Tool results are in user messages: message.content[].type === "tool_result".
311
+
312
+ // First pass: collect all termination signals from tool_result blocks
313
+ for (const obj of allEvents) {
314
+ // Direct termination events (from GSD-T event hooks)
315
+ if (obj.type === 'monitor_stopped' && obj.tool_use_id) {
316
+ terminatedIds.add(obj.tool_use_id);
317
+ }
318
+ if (obj.type === 'spawn_completed' && obj.tool_use_id) {
319
+ terminatedIds.add(obj.tool_use_id);
320
+ }
321
+
322
+ // Claude API user turns: message.content[].type === "tool_result"
323
+ const msg = obj.message;
324
+ if (msg && msg.role === 'user' && Array.isArray(msg.content)) {
325
+ for (const block of msg.content) {
326
+ if (block && block.type === 'tool_result' && block.tool_use_id) {
327
+ terminatedIds.add(block.tool_use_id);
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ // Second pass: collect tool_use blocks from assistant messages
334
+ const toolUseBlocks = []; // { block, timestamp }
335
+ for (const obj of allEvents) {
336
+ const msg = obj.message;
337
+ if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
338
+ // Timestamp: prefer the message-level timestamp if available
339
+ const timestamp = obj.timestamp || (msg && msg.created_at) || null;
340
+ for (const block of msg.content) {
341
+ if (block && block.type === 'tool_use' && block.id) {
342
+ toolUseBlocks.push({ block, timestamp });
343
+ }
344
+ }
345
+ }
346
+
347
+ // Third pass: emit activities for non-terminated tool uses
348
+ for (const { block, timestamp } of toolUseBlocks) {
349
+ const toolUseId = block.id;
350
+ if (terminatedIds.has(toolUseId)) continue;
351
+
352
+ const name = block.name || '';
353
+ const startedAt = timestamp || now.toISOString();
354
+ const startMs = _safeDate(startedAt);
355
+ const ageMs = startMs ? now.getTime() - startMs.getTime() : 0;
356
+
357
+ if (name === 'Bash') {
358
+ const isBg = block.input && block.input.run_in_background === true;
359
+ if (isBg) {
360
+ const cmd = (block.input && block.input.command) || name;
361
+ activities.push(_makeActivity({
362
+ id: toolUseId,
363
+ kind: 'bash',
364
+ label: cmd,
365
+ startedAt,
366
+ now,
367
+ toolUseId,
368
+ }));
369
+ }
370
+ } else if (name === 'Monitor') {
371
+ activities.push(_makeActivity({
372
+ id: toolUseId,
373
+ kind: 'monitor',
374
+ label: (block.input && (block.input.command || block.input.path)) || name,
375
+ startedAt,
376
+ now,
377
+ toolUseId,
378
+ }));
379
+ } else if (ageMs > TOOL_THRESHOLD_MS) {
380
+ activities.push(_makeActivity({
381
+ id: toolUseId,
382
+ kind: 'tool',
383
+ label: name || 'tool',
384
+ startedAt,
385
+ now,
386
+ toolUseId,
387
+ }));
388
+ }
389
+ }
390
+
391
+ return activities;
392
+ }
393
+
394
+ /**
395
+ * Read spawn kind activities from .gsd-t/spawns/*.json plan files.
396
+ * Delegates to the same plan-file reader shape as parallelism-report.cjs.
397
+ */
398
+ function _readSpawnActivities(projectDir, now, notes) {
399
+ const activities = [];
400
+ const spawnPlans = _readSpawnPlans(projectDir, notes);
401
+
402
+ for (const plan of spawnPlans) {
403
+ // Only include active (not ended) plans
404
+ if (plan.endedAt !== null && plan.endedAt !== undefined) continue;
405
+
406
+ const startedAt = plan.startedAt || now.toISOString();
407
+ const label = (plan.spawnId || plan.kind || 'spawn') + (plan.command ? ' ' + String(plan.command).slice(0, 30) : '');
408
+ const id = plan.spawnId || _makeId('spawn', label, startedAt);
409
+
410
+ activities.push(_makeActivity({
411
+ id,
412
+ kind: 'spawn',
413
+ label,
414
+ startedAt,
415
+ now,
416
+ pid: plan.pid || plan.workerPid,
417
+ toolUseId: plan.tool_use_id,
418
+ }));
419
+ }
420
+
421
+ return activities;
422
+ }
423
+
424
+ /**
425
+ * Read .gsd-t/spawns/*.json plan files. Mirrors parallelism-report.cjs's _readSpawnPlans.
426
+ */
427
+ function _readSpawnPlans(projectDir, notes) {
428
+ const dir = path.join(projectDir, '.gsd-t', 'spawns');
429
+ let files;
430
+ try {
431
+ if (!fs.existsSync(dir)) return [];
432
+ files = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
433
+ } catch (err) {
434
+ notes.push('spawns dir unreadable: ' + (err && err.message || err));
435
+ return [];
436
+ }
437
+ const plans = [];
438
+ for (const f of files) {
439
+ const full = path.join(dir, f);
440
+ let raw;
441
+ try {
442
+ raw = fs.readFileSync(full, 'utf8');
443
+ } catch (err) {
444
+ notes.push('could not read spawn plan ' + full + ': ' + (err && err.message || err));
445
+ continue;
446
+ }
447
+ let parsed;
448
+ try {
449
+ parsed = JSON.parse(raw);
450
+ } catch (_) {
451
+ notes.push('spawn-plan malformed JSON: ' + f);
452
+ continue;
453
+ }
454
+ if (!parsed || typeof parsed !== 'object') {
455
+ notes.push('spawn-plan not an object: ' + f);
456
+ continue;
457
+ }
458
+ plans.push(parsed);
459
+ }
460
+ return plans;
461
+ }
462
+
463
+ /**
464
+ * Deduplicate activities by tool_use_id (priority 1) then (kind, label, startedAt) tuple (priority 2).
465
+ */
466
+ function _dedup(activities) {
467
+ const byToolUseId = new Map();
468
+ const byTuple = new Map();
469
+ const result = [];
470
+
471
+ for (const a of activities) {
472
+ // Priority 1: deduplicate by tool_use_id
473
+ if (a.toolUseId) {
474
+ if (byToolUseId.has(a.toolUseId)) {
475
+ // Merge: prefer orchestrator pid when present
476
+ const existing = byToolUseId.get(a.toolUseId);
477
+ if (a.pid && !existing.pid) existing.pid = a.pid;
478
+ continue;
479
+ }
480
+ byToolUseId.set(a.toolUseId, a);
481
+ result.push(a);
482
+ continue;
483
+ }
484
+
485
+ // Priority 2: deduplicate by (kind, label, startedAt) tuple
486
+ const tupleKey = a.kind + '|' + a.label + '|' + a.startedAt;
487
+ if (byTuple.has(tupleKey)) {
488
+ const existing = byTuple.get(tupleKey);
489
+ if (a.pid && !existing.pid) existing.pid = a.pid;
490
+ continue;
491
+ }
492
+ byTuple.set(tupleKey, a);
493
+ result.push(a);
494
+ }
495
+
496
+ return result;
497
+ }
498
+
499
+ /**
500
+ * Apply all 3 liveness falsifiers to an activity.
501
+ * Returns true if the activity should remain in activities[].
502
+ * Returns false if any falsifier fires.
503
+ */
504
+ function _isLive(activity, eventsFile, eventsMtime, now, notes) {
505
+ // F1: explicit terminating event (already handled during parsing — entries with
506
+ // matching tool_result/monitor_stopped/spawn_completed were never added)
507
+ // This is a double-check for spawn kind which reads from a different source.
508
+ if (activity.kind === 'spawn') {
509
+ // For spawn kind, F1 is already handled in _readSpawnActivities (endedAt check).
510
+ // No additional F1 check needed here for spawns.
511
+ }
512
+
513
+ // F2: PID check — process.kill(pid, 0) throws ESRCH
514
+ if (activity.pid) {
515
+ try {
516
+ process.kill(activity.pid, 0);
517
+ // Process is alive (no error thrown)
518
+ } catch (err) {
519
+ if (err && err.code === 'ESRCH') {
520
+ // Process not found — remove entry
521
+ return false;
522
+ }
523
+ // EPERM or other: conservative, treat as dead
524
+ notes.push('PID check error for pid ' + activity.pid + ': ' + (err && err.code || String(err)));
525
+ return false;
526
+ }
527
+ }
528
+
529
+ // F3: Source-file mtime > 60s old
530
+ if (activity.kind === 'spawn') {
531
+ // Spawn activities use spawn plan file mtime, not events file
532
+ // Spawn plan mtime > 60s → stale
533
+ const spawnMtime = activity._spawnPlanMtime;
534
+ if (spawnMtime) {
535
+ const ageMs = now.getTime() - spawnMtime;
536
+ if (ageMs > MTIME_STALE_MS) return false;
537
+ }
538
+ } else {
539
+ // Events-sourced activities: check events file mtime
540
+ if (eventsMtime) {
541
+ const ageMs = now.getTime() - eventsMtime;
542
+ if (ageMs > MTIME_STALE_MS) return false;
543
+ }
544
+ }
545
+
546
+ return true;
547
+ }
548
+
549
+ /**
550
+ * Safely stat a file and return its mtime in milliseconds, or null on error.
551
+ */
552
+ function _safeFileMtime(filePath) {
553
+ try {
554
+ return fs.statSync(filePath).mtimeMs;
555
+ } catch (_) {
556
+ return null;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Construct an Activity object.
562
+ */
563
+ function _makeActivity({ id, kind, label, startedAt, now, pid, toolUseId, _spawnPlanMtime }) {
564
+ const startMs = _safeDate(startedAt);
565
+ const durationMs = startMs ? Math.max(0, now.getTime() - startMs.getTime()) : 0;
566
+ const activity = {
567
+ id: String(id),
568
+ kind,
569
+ label: String(label),
570
+ startedAt: startMs ? startMs.toISOString() : new Date(now).toISOString(),
571
+ durationMs,
572
+ tailUrl: '/api/live-activity/' + encodeURIComponent(String(id)) + '/tail',
573
+ alive: true,
574
+ };
575
+ if (pid != null) activity.pid = pid;
576
+ if (toolUseId) activity.toolUseId = toolUseId;
577
+ if (_spawnPlanMtime != null) activity._spawnPlanMtime = _spawnPlanMtime;
578
+ return activity;
579
+ }
580
+
581
+ /**
582
+ * Generate a deterministic ID from kind + label + startedAt when no tool_use_id is available.
583
+ */
584
+ function _makeId(kind, label, startedAt) {
585
+ const hash = crypto.createHash('sha1').update(label + startedAt).digest('hex').slice(0, 12);
586
+ return kind + ':' + hash;
587
+ }
588
+
589
+ /**
590
+ * Safely parse a date string. Returns null for invalid inputs.
591
+ */
592
+ function _safeDate(v) {
593
+ if (!v || typeof v !== 'string') return null;
594
+ const d = new Date(v);
595
+ if (isNaN(d.getTime())) return null;
596
+ return d;
597
+ }
598
+
599
+ // ── Module exports ──────────────────────────────────────────────────────────
600
+
601
+ module.exports = {
602
+ computeLiveActivities,
603
+ SCHEMA_VERSION,
604
+ // Exposed for unit tests only; not part of the public contract.
605
+ _readEventsActivities,
606
+ _readOrchestratorActivities,
607
+ _readSpawnActivities,
608
+ _readSpawnPlans,
609
+ _dedup,
610
+ _isLive,
611
+ _makeActivity,
612
+ _makeId,
613
+ _safeDate,
614
+ _safeFileMtime,
615
+ };
@@ -1126,3 +1126,68 @@ A new Red Team category — "Test Pass-Through — Journey Edition" — extends
1126
1126
 
1127
1127
  Contract: `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0.
1128
1128
 
1129
+ ## Live Activity Observability (M54 — v3.24.10)
1130
+
1131
+ The dashboard left rail today only catches detached `claude -p` workers via `.gsd-t/spawns/*.json`. Heavy in-session work (a backgrounded `Bash`, a `Monitor` watch, a slow tool_use) has no rail representation, leaving the user blind to what's running. M54 adds a "LIVE ACTIVITY" section between MAIN SESSION and LIVE SPAWNS that surfaces all four kinds in one place.
1132
+
1133
+ **Module**: `bin/live-activity-report.cjs` — `computeLiveActivities({projectDir, now?})` returns the contract shape. Pure read-only, silent-fail. Mirrors `bin/parallelism-report.cjs` shape; installed via `installGlobalBinTools()` to `~/.claude/bin/live-activity-report.cjs`.
1134
+
1135
+ **Data flow**:
1136
+
1137
+ ```
1138
+ .gsd-t/events/*.jsonl ─┐ (heartbeat-emitted tool events)
1139
+ ~/.claude/projects/<slug>/<sid>.jsonl ┼─▶ bin/live-activity-report.cjs
1140
+ .gsd-t/spawns/*.json ─┘ │ (dedupe by tool_use_id)
1141
+
1142
+ scripts/gsd-t-dashboard-server.js
1143
+ (5s in-memory cache, additive endpoints)
1144
+ ├── GET /api/live-activity → JSON index
1145
+ ├── GET /api/live-activity/<id>/tail → last 64 KB stdout/stderr
1146
+ └── GET /api/live-activity/<id>/stream → SSE follow-up
1147
+
1148
+
1149
+ scripts/gsd-t-transcript.html
1150
+ `<aside class="left-rail">` — new section "LIVE ACTIVITY"
1151
+ polls /api/live-activity every 5s; pulses new entries
1152
+ ```
1153
+
1154
+ **Liveness falsifiers** (any → entry leaves activities[]):
1155
+ 1. Explicit terminating event (tool_result, monitor_stopped, spawn_completed).
1156
+ 2. PID check fails (`process.kill(pid, 0)` throws ESRCH) for kinds with a recorded PID.
1157
+ 3. Source file mtime > 60s old.
1158
+
1159
+ **Pulse semantics**: new entries get class `.la-pulsing`. Pulse stops on click OR on liveness loss OR after 30s — whichever first. No auto-switch of the bottom pane on entry arrival; only the pulse signals attention.
1160
+
1161
+ **Silent-fail invariant**: malformed events JSONL, missing slug-decoded transcript, unreadable spawn-plan JSON — every case logs to `metrics.notes` and continues with partial data. Observer must never throw when watching a live system.
1162
+
1163
+ **Out of scope**: cross-project aggregation (read OTHER projects' transcripts) is M55 candidate territory.
1164
+
1165
+ Contract: `.gsd-t/contracts/live-activity-contract.md` v1.0.0 STABLE (D1 complete 2026-05-07).
1166
+
1167
+ **Endpoint signatures** (D1 implemented):
1168
+ - `GET /api/live-activity` → `handleLiveActivity(req, res, projectDir)` — 5s cache, `Cache-Control: no-store`, returns JSON envelope `{schemaVersion:1, activities:[], notes:[]}`
1169
+ - `GET /api/live-activity/<id>/tail` → `handleLiveActivityTail(req, res, projectDir, id)` — 5s per-id cache, path-traversal guard via `isValidActivityId`, 400 on invalid id, 404 on unknown id
1170
+ - `GET /api/live-activity/<id>/stream` → `handleLiveActivityStream(req, res, projectDir, id)` — SSE, uncached, 15s heartbeat, closes when activity removed
1171
+
1172
+ **Install path**: `~/.claude/bin/live-activity-report.cjs` (via `GLOBAL_BIN_TOOLS` in `bin/gsd-t.js`, installed by `installGlobalBinTools()`).
1173
+
1174
+ **D2 rail section** (`scripts/gsd-t-transcript.html`, D2 complete 2026-05-07):
1175
+
1176
+ The viewer's left rail gains a new `<section id="rail-live-activity">` inserted between MAIN SESSION and LIVE SPAWNS. The section is purely additive — no existing sections were modified.
1177
+
1178
+ Rail behavior:
1179
+ - 5s polling interval via `setInterval` (`wireLiveActivity()` IIFE in inline `<script>`)
1180
+ - On each poll, `reconcile(activities)` diffs the DOM against the API response:
1181
+ - New entries: `appendActivity(entry)` creates `.la-entry` with dot + icon + 40-char label + duration counter; applies `.la-pulsing`
1182
+ - Removed entries: `removeActivity(id)` removes element from DOM
1183
+ - Existing entries: `updateDuration(id, startedAt)` refreshes the wall-clock counter
1184
+ - Pulse stops on: (a) click, (b) entry absent in next response, (c) 30s elapsed (`setTimeout`)
1185
+ - Click handler: `stopPulse(id)` + `loadTailUrl(tailUrl)` — loads tail content into the bottom pane; NO auto-switch on entry arrival
1186
+ - Error tolerance: fetch 500 or network error → log once, empty section header, no crash
1187
+
1188
+ CSS additions: `@keyframes accent-pulse` (~1.5s), `.la-pulsing`, `.la-dot-running` (teal), `.la-dot-stale` (dimmed), `.la-icon-{bash,monitor,tool,spawn}`, `.la-label` (40-char truncated).
1189
+
1190
+ **Executable attestation**: 2 live-journey specs in `e2e/live-journeys/`:
1191
+ - `live-activity.spec.ts`: single bash, asserts 5s appearance, pulse, duration tick, click→tail, kill→5s disappearance; self-skips if no dashboard
1192
+ - `live-activity-multikind.spec.ts`: 3 concurrent kinds (bash + monitor + tool) via synthetic events JSONL, asserts dedup correctness; self-skips if no dashboard
1193
+
@@ -747,5 +747,22 @@ Supporting contracts:
747
747
  | REQ-M52-D5-01 | Doc-ripple: `~/.claude/CLAUDE.md` E2E Test Quality Standard rewritten to formally define "rigorous" (every interactive surface clicked, every assertion proves visible state change, journey specs not unit tests in browser clothing, real-data fixtures, adversarial Red Team on journeys); `templates/CLAUDE-global.md` matches; `commands/gsd-t-debug.md` + `gsd-t-execute.md` + `gsd-t-quick.md` + `gsd-t-verify.md` reference `journey-coverage.cjs` zero-gap requirement; `docs/architecture.md` adds "Journey Coverage Enforcement (M52)" section. | m52-d1-journey-coverage-tooling | T5 | done (architecture.md + CHANGELOG.md ripple completed during /gsd-t-verify; CLAUDE-global E2E Test Quality Standard already defines "functional behavior over element existence" — same doctrine M52 enforces mechanically) |
748
748
  | REQ-M52-VERIFY | Full unit suite 2166 baseline preserved + 12 journey specs + 3 real-data fixture replays all green; `gsd-t check-coverage` reports zero gaps; pre-commit-journey-coverage hook blocks deliberate test-commit-without-spec; Red Team finds ≥5 breakages, all caught; CHANGELOG entry written. | both | D1 T5, D2 T5 | done (unit 2195/2195, E2E 35/35 + 1 skip, `gsd-t check-coverage` exit 0, hook end-to-end exercised, Red Team 5/5 caught, CHANGELOG entry written) |
749
749
 
750
+ ## M54 Live Activity Visibility (planned — 2026-05-07)
751
+
752
+ | ID | Requirement | Domain | Tasks | Status |
753
+ |----|-------------|--------|-------|--------|
754
+ | REQ-M54-D1-01 | `bin/live-activity-report.cjs` exports `computeLiveActivities({projectDir, now?})` returning `{schemaVersion: 1, generatedAt, activities: [{id, kind, label, startedAt, durationMs, tailUrl, alive}]}`. Detects 4 kinds: `bash` (run_in_background sentinel in events JSONL or orchestrator JSONL with no matching tool_result), `monitor` (Monitor tool start/stop pairing), `tool` (any tool_use > 30s old without tool_result), `spawn` (read-through to existing `.gsd-t/spawns/*.json` plan files). Pure read-only; silent-fail invariant — malformed JSONL, missing slug, unreadable file return partial data with notes, never throw. | m54-d1-server-and-detector | T1, T2 | done |
755
+ | REQ-M54-D1-02 | Source-of-truth UNION: `.gsd-t/events/*.jsonl` (project-local heartbeat) + `~/.claude/projects/<slug>/<sid>.jsonl` (Claude Code orchestrator transcript). Slug discovered via existing `_slugFromTranscriptPath` / `_slugToProjectDir` helpers in `scripts/hooks/gsd-t-conversation-capture.js`. Activities deduped by `tool_use_id` (preferred) then by `(kind, label, startedAt)` tuple as fallback. | m54-d1-server-and-detector | T2 | done |
756
+ | REQ-M54-D1-03 | Liveness check uses 3 falsifiers in priority order: (1) explicit terminating event arrived (tool_result, monitor_stopped, spawn_completed); (2) PID check fails (`process.kill(pid, 0)` throws ESRCH) for kinds with a recorded PID; (3) source file mtime > 60s old. Entry leaves `activities[]` when ANY falsifier returns true. | m54-d1-server-and-detector | T2 | done |
757
+ | REQ-M54-D1-04 | `scripts/gsd-t-dashboard-server.js` adds 3 handlers + URL routes: `GET /api/live-activity` (5s response cache, mirrors `/api/parallelism` shape — silent-fail returns 500 only on contract regression, never on data malformation); `GET /api/live-activity/<id>/tail` (last ~64 KB stdout/stderr for bash, last 200 lines for monitor; per-id 5s cache); `GET /api/live-activity/<id>/stream` (SSE that follows the tail). All routes guard `<id>` against path traversal. | m54-d1-server-and-detector | T3 | done |
758
+ | REQ-M54-D1-05 | `bin/gsd-t.js` `GLOBAL_BIN_TOOLS` array gains `"live-activity-report.cjs"` so the global dashboard at `~/.claude/scripts/gsd-t-dashboard-server.js` resolves it from `~/.claude/bin/live-activity-report.cjs` (mirror v3.23.11 install path for `parallelism-report.cjs`). Doctor `checkDoctorGlobalBin()` automatically covers it. | m54-d1-server-and-detector | T4 | done |
759
+ | REQ-M54-D1-06 | `.gsd-t/contracts/live-activity-contract.md` v1.0.0 STABLE — documents the 4 kinds, dedup rules, liveness falsifiers, JSON schema, all 3 endpoints, cache invariants, silent-fail invariant. Entries 1:1 with code constants. | m54-d1-server-and-detector | T5 | done |
760
+ | REQ-M54-D2-01 | `scripts/gsd-t-transcript.html` adds new left-rail section "LIVE ACTIVITY" between MAIN SESSION and LIVE SPAWNS. Each entry rendered as: status dot (green=running, dimmed=stale-but-not-yet-removed) · kind icon (`$` bash, `👁` monitor, `🔧` tool, `↳` spawn) · 40-char truncated label · live wall-clock duration counter · pulsing border for first 30s of life or until clicked. CSS @keyframes accent-pulse, ~1.5s cycle, scoped to a `.la-pulsing` class only. | m54-d2-rail-and-spec | T1, T2 | done |
761
+ | REQ-M54-D2-02 | Rail polls `GET /api/live-activity` every 5 seconds (matches existing `/api/parallelism` cadence). On new entry: append + add `.la-pulsing`. Pulse stops when (a) user clicks the entry, (b) entry no longer in the next response, or (c) 30 seconds elapse. Click handler loads bottom pane with the entry's tail (`tailUrl` from response). NO auto-switch of the bottom pane on entry arrival — only the pulse signals attention. | m54-d2-rail-and-spec | T2 | done |
762
+ | REQ-M54-D2-03 | LIVE SPAWNS data continues to populate (D1 returns `kind: "spawn"` entries) but visually nests as a sub-grouping inside LIVE ACTIVITY. Existing MAIN SESSION + COMPLETED rendering, journey specs, and contract semantics UNCHANGED. | m54-d2-rail-and-spec | T1 | done |
763
+ | REQ-M54-D2-04 | Two new live-journey specs under `e2e/live-journeys/` (post-M52 doctrine — probe the running dashboard, not in-process startServer fixtures): `live-activity.spec.ts` (real `bash -c "sleep 30"` via `child_process.spawn`; assert /api/live-activity returns it within 5s, rail entry appears within 5s with `.la-pulsing`, duration counter ticks, click loads tail, kill → entry disappears within 5s; self-skip when no live dashboard reachable); `live-activity-multikind.spec.ts` (real Monitor + bash backgrounder + synthetic tool_use_started event in `.gsd-t/events/<today>.jsonl`; assert all 3 appear, pulse independently, dedupe correctly when one is also in orchestrator JSONL). | m54-d2-rail-and-spec | T3 | done |
764
+ | REQ-M54-D2-05 | `.gsd-t/journey-manifest.json` gains 2 new entries (covers includes li:click from new polling JS). `gsd-t check-coverage` reports `OK: 21 listeners, 16 specs`. | m54-d2-rail-and-spec | T3 | done |
765
+ | REQ-M54-VERIFY | Full unit suite ≥ 2233 baseline + ≥15 new D1 unit tests across `test/m54-d1-live-activity-report.test.js` and `test/m54-d1-dashboard-handlers.test.js` (silent-fail on malformed JSONL, dedupe by tool_use_id, PID-check fallback, file-mtime fallback, 4 kind detectors, `<id>` path-traversal rejection, 5s cache hits/misses) all green; both live-journey specs pass against the running dashboard (or self-skip in CI); Red Team writes ≥5 broken patches (dedupe-disabled, PID-stub-true, mtime-fallback-removed, pulse-never-clears, tool_use_id-collision-unhandled), each caught by D2 journey or D1 unit suite (GRUDGING PASS); CHANGELOG entry written; `docs/architecture.md` adds "Live Activity Observability (M54)" section. | both | D1 T5, D2 T3 | done |
766
+
750
767
  Supporting contracts (to be written during D1):
751
768
  - `.gsd-t/contracts/journey-coverage-contract.md` (proposed) — listener detector API, gap-report schema, pre-commit hook semantics, JOURNEYS.md schema.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "3.23.11",
3
+ "version": "3.24.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",
@@ -699,6 +699,184 @@ function handleParallelismReport(req, res, projectDir) {
699
699
  res.end(md);
700
700
  }
701
701
 
702
+ // M54 D1 T3 — Live Activity endpoints (additive, read-only)
703
+ // Contract: .gsd-t/contracts/live-activity-contract.md v1.0.0
704
+ // Three endpoints: GET /api/live-activity (5s cache),
705
+ // GET /api/live-activity/<id>/tail (per-id 5s cache, path-traversal guard),
706
+ // GET /api/live-activity/<id>/stream (SSE, uncached, 15s heartbeat).
707
+
708
+ const LIVE_ACTIVITY_CACHE_MS = 5000;
709
+ const _liveActivityCache = { list: { at: 0, body: null }, tail: new Map() };
710
+
711
+ function _loadLiveActivityReporter() {
712
+ try {
713
+ return require(path.join(__dirname, "..", "bin", "live-activity-report.cjs"));
714
+ } catch (err) {
715
+ return { _loadError: err && err.message || String(err) };
716
+ }
717
+ }
718
+
719
+ function isValidActivityId(id) {
720
+ if (typeof id !== "string") return false;
721
+ if (id.length === 0 || id.length > 256) return false;
722
+ if (id.indexOf("..") !== -1) return false;
723
+ if (id.indexOf("/") !== -1) return false;
724
+ if (id.indexOf("\\") !== -1) return false;
725
+ if (id.indexOf("\0") !== -1) return false;
726
+ return true;
727
+ }
728
+
729
+ function handleLiveActivity(req, res, projectDir) {
730
+ const now = Date.now();
731
+ const cache = _liveActivityCache.list;
732
+ if (cache.body && (now - cache.at) < LIVE_ACTIVITY_CACHE_MS) {
733
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "hit" });
734
+ res.end(cache.body);
735
+ return;
736
+ }
737
+ const reporter = _loadLiveActivityReporter();
738
+ if (reporter._loadError) {
739
+ res.writeHead(500, { "Content-Type": "application/json" });
740
+ res.end(JSON.stringify({ error: "live-activity-report module unavailable", detail: reporter._loadError }));
741
+ return;
742
+ }
743
+ let result;
744
+ try {
745
+ result = reporter.computeLiveActivities({ projectDir });
746
+ } catch (err) {
747
+ // Contract regression — computeLiveActivities must never throw
748
+ res.writeHead(500, { "Content-Type": "application/json" });
749
+ res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
750
+ return;
751
+ }
752
+ const body = JSON.stringify(result);
753
+ cache.at = now;
754
+ cache.body = body;
755
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "miss" });
756
+ res.end(body);
757
+ }
758
+
759
+ function handleLiveActivityTail(req, res, projectDir, id) {
760
+ if (!isValidActivityId(id)) {
761
+ res.writeHead(400, { "Content-Type": "application/json" });
762
+ res.end(JSON.stringify({ error: "invalid_id" }));
763
+ return;
764
+ }
765
+ const cacheKey = projectDir + "\0" + id;
766
+ const now = Date.now();
767
+ const cached = _liveActivityCache.tail.get(cacheKey);
768
+ if (cached && (now - cached.at) < LIVE_ACTIVITY_CACHE_MS) {
769
+ res.writeHead(cached.statusCode, cached.headers);
770
+ res.end(cached.body);
771
+ return;
772
+ }
773
+
774
+ // Find the activity to determine kind and tail path
775
+ const reporter = _loadLiveActivityReporter();
776
+ if (reporter._loadError) {
777
+ res.writeHead(500, { "Content-Type": "application/json" });
778
+ res.end(JSON.stringify({ error: "live-activity-report module unavailable" }));
779
+ return;
780
+ }
781
+ let result;
782
+ try {
783
+ result = reporter.computeLiveActivities({ projectDir });
784
+ } catch (err) {
785
+ res.writeHead(500, { "Content-Type": "application/json" });
786
+ res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
787
+ return;
788
+ }
789
+
790
+ const activity = result.activities.find((a) => a.id === id);
791
+ if (!activity) {
792
+ const notFound = { statusCode: 404, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, body: JSON.stringify({ error: "not_found" }) };
793
+ _liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, notFound));
794
+ res.writeHead(404, notFound.headers);
795
+ res.end(notFound.body);
796
+ return;
797
+ }
798
+
799
+ // Read tail content by kind
800
+ let tailContent = "";
801
+ const kind = activity.kind;
802
+ if (kind === "bash" || kind === "spawn") {
803
+ // Last ~64 KB of process stdout — use _readFileTail pattern
804
+ // For bash: the log file path is not directly known without process inspection.
805
+ // Return a JSON snapshot of the activity as a fallback.
806
+ tailContent = JSON.stringify(activity, null, 2);
807
+ } else if (kind === "monitor") {
808
+ tailContent = JSON.stringify(activity, null, 2);
809
+ } else if (kind === "tool") {
810
+ tailContent = JSON.stringify(activity, null, 2);
811
+ } else {
812
+ tailContent = JSON.stringify(activity, null, 2);
813
+ }
814
+
815
+ const hit = { statusCode: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" }, body: tailContent };
816
+ _liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, hit));
817
+ res.writeHead(200, hit.headers);
818
+ res.end(hit.body);
819
+ }
820
+
821
+ function handleLiveActivityStream(req, res, projectDir, id) {
822
+ if (!isValidActivityId(id)) {
823
+ res.writeHead(400, { "Content-Type": "application/json" });
824
+ res.end(JSON.stringify({ error: "invalid_id" }));
825
+ return;
826
+ }
827
+ res.writeHead(200, Object.assign({}, SSE_HEADERS, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" }));
828
+
829
+ // Send an initial data frame
830
+ res.write("data: " + JSON.stringify({ id, status: "stream_opened" }) + "\n\n");
831
+
832
+ // Heartbeat every 15s
833
+ const heartbeat = setInterval(() => {
834
+ if (!res.writableEnded) {
835
+ res.write(": heartbeat\n\n");
836
+ }
837
+ }, KEEPALIVE_MS);
838
+
839
+ // Poll for activity presence; close stream when activity disappears
840
+ const reporter = _loadLiveActivityReporter();
841
+ const poll = setInterval(() => {
842
+ if (res.writableEnded) {
843
+ clearInterval(poll);
844
+ clearInterval(heartbeat);
845
+ return;
846
+ }
847
+ if (reporter._loadError) {
848
+ clearInterval(poll);
849
+ clearInterval(heartbeat);
850
+ res.write("data: " + JSON.stringify({ id, status: "reporter_unavailable" }) + "\n\n");
851
+ res.end();
852
+ return;
853
+ }
854
+ let result;
855
+ try {
856
+ result = reporter.computeLiveActivities({ projectDir });
857
+ } catch (_) {
858
+ return; // silent — will retry next tick
859
+ }
860
+ const still = result.activities.find((a) => a.id === id);
861
+ if (!still) {
862
+ clearInterval(poll);
863
+ clearInterval(heartbeat);
864
+ if (!res.writableEnded) {
865
+ res.write("data: " + JSON.stringify({ id, status: "activity_ended" }) + "\n\n");
866
+ res.end();
867
+ }
868
+ return;
869
+ }
870
+ // Emit a status frame
871
+ res.write("data: " + JSON.stringify({ id, durationMs: still.durationMs, alive: still.alive }) + "\n\n");
872
+ }, 1000);
873
+
874
+ req.on("close", () => {
875
+ clearInterval(poll);
876
+ clearInterval(heartbeat);
877
+ });
878
+ }
879
+
702
880
  // POST /api/unattended-stop — proxies to the existing stop-sentinel flow so
703
881
  // the transcript panel's "Stop Supervisor" button reuses the canonical
704
882
  // kill path. Writes `.gsd-t/.unattended/stop` sentinel; supervisor polls
@@ -887,6 +1065,12 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath,
887
1065
  if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
888
1066
  // M44 D9 — stop-supervisor proxy (POST only; reuses existing sentinel flow)
889
1067
  if (url === "/api/unattended-stop") return handleUnattendedStop(req, res, projDir);
1068
+ // M54 D1 T3 — live-activity endpoints (additive, read-only)
1069
+ if (url === "/api/live-activity") return handleLiveActivity(req, res, projDir);
1070
+ const laTailMatch = url.match(/^\/api\/live-activity\/([^/]+)\/tail$/);
1071
+ if (laTailMatch) return handleLiveActivityTail(req, res, projDir, decodeURIComponent(laTailMatch[1]));
1072
+ const laStreamMatch = url.match(/^\/api\/live-activity\/([^/]+)\/stream$/);
1073
+ if (laStreamMatch) return handleLiveActivityStream(req, res, projDir, decodeURIComponent(laStreamMatch[1]));
890
1074
  // POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
891
1075
  const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
892
1076
  if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
@@ -959,6 +1143,12 @@ module.exports = {
959
1143
  handleParallelism,
960
1144
  handleParallelismReport,
961
1145
  handleUnattendedStop,
1146
+ // M54 D1 T3 — live-activity observability
1147
+ handleLiveActivity,
1148
+ handleLiveActivityTail,
1149
+ handleLiveActivityStream,
1150
+ isValidActivityId,
1151
+ _liveActivityCache,
962
1152
  // M49 — idle-TTL exports for tests
963
1153
  _activityTracker,
964
1154
  _wrapSseHandler,
@@ -209,6 +209,32 @@
209
209
  .frame.tool-use > .ts, .frame.thinking > .ts { padding: 8px 0 0 12px; }
210
210
  .frame.tool-result > .ts { padding-top: 8px; }
211
211
  .frame.compact-marker > .ts { padding: 0; min-width: 64px; }
212
+
213
+ /* M54 D2 T1 — LIVE ACTIVITY rail section.
214
+ Additive only. No existing CSS renamed or removed. */
215
+ #rail-live-activity { padding: 0 0 8px 0; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
216
+ #rail-live-activity .la-heading { 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; }
217
+ .la-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
218
+ .la-entry { display: grid; grid-template-columns: 10px 18px 1fr auto; align-items: center; gap: 4px; padding: 5px 12px; cursor: pointer; border-radius: 4px; font-size: 12px; }
219
+ .la-entry:hover { background: var(--bg-soft); }
220
+ .la-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
221
+ .la-dot-running { background: #2dd4bf; }
222
+ .la-dot-stale { background: var(--fg-xdim, #6b7280); }
223
+ .la-icon { font-family: var(--mono); font-size: 13px; text-align: center; color: var(--fg-dim); }
224
+ .la-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40ch; color: var(--fg); font-family: var(--mono); font-size: 11px; }
225
+ .la-duration { font-family: var(--mono); font-size: 11px; color: var(--fg-xdim); text-align: right; white-space: nowrap; }
226
+ .la-empty { padding: 4px 12px 0 12px; color: var(--fg-xdim); font-size: 11px; font-style: italic; }
227
+ /* Kind icon slots — glyphs set in JS via KIND_ICONS map */
228
+ .la-icon-bash { color: var(--accent-warm, #f0a500); }
229
+ .la-icon-monitor { color: var(--accent, #58a6ff); }
230
+ .la-icon-tool { color: var(--fg-dim, #8b949e); }
231
+ .la-icon-spawn { color: var(--green, #3fb950); }
232
+ @keyframes accent-pulse {
233
+ 0% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 1; }
234
+ 50% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 0.6; }
235
+ 100% { box-shadow: inset 2px 0 0 0 #2dd4bf; opacity: 1; }
236
+ }
237
+ .la-pulsing { animation: accent-pulse 1.5s ease-in-out infinite; }
212
238
  </style>
213
239
  </head>
214
240
  <body data-spawn-id="__SPAWN_ID__">
@@ -226,6 +252,15 @@
226
252
  <div class="rail-header"><span>★ Main Session</span></div>
227
253
  <div class="rail-body" id="rail-main-body"><div class="empty">No in-session conversation captured yet.</div></div>
228
254
  </section>
255
+ <!-- M54 D2 T1 — LIVE ACTIVITY section (additive, between Main Session and Live Spawns).
256
+ Populated by 5s polling JS (D2 T2). CSS @keyframes accent-pulse in <style> block above. -->
257
+ <section id="rail-live-activity">
258
+ <div class="la-heading"><span>⚡ Live Activity</span></div>
259
+ <ul id="la-list" class="la-list">
260
+ <!-- Entries injected by polling JS (T2). Empty section header visible when list is empty. -->
261
+ </ul>
262
+ <div class="la-empty" id="la-empty-msg">No active operations detected.</div>
263
+ </section>
229
264
  <section class="rail-live" data-rail-section="live">
230
265
  <div class="rail-header"><span>Live Spawns</span></div>
231
266
  <div class="rail-body">
@@ -1615,6 +1650,171 @@
1615
1650
  poll();
1616
1651
  setInterval(poll, 5000);
1617
1652
  })();
1653
+
1654
+ // ── M54 D2 T2 — Live Activity Rail ────────────────────────────────────
1655
+ // Polls /api/live-activity every 5s (same cadence as /api/parallelism).
1656
+ // Appends entries to #la-list, applies .la-pulsing on new entries,
1657
+ // removes entries when they disappear from the API response.
1658
+ // Click handler: stopPulse + loadTailUrl (NO auto-switch on arrival).
1659
+ // Additive only — no existing function renamed or removed.
1660
+ (function wireLiveActivity() {
1661
+ const laList = document.getElementById('la-list');
1662
+ const laEmpty = document.getElementById('la-empty-msg');
1663
+ if (!laList) return; // section not present in this page variant
1664
+
1665
+ // Map of id → { element, startedAt, stopPulseTimer }
1666
+ const _current = new Map();
1667
+
1668
+ // Format duration in seconds as human-readable string
1669
+ function fmtDuration(ms) {
1670
+ const s = Math.floor(ms / 1000);
1671
+ if (s < 60) return s + 's';
1672
+ const m = Math.floor(s / 60), rs = s % 60;
1673
+ if (m < 60) return m + 'm ' + rs + 's';
1674
+ const h = Math.floor(m / 60), rm = m % 60;
1675
+ return h + 'h ' + rm + 'm';
1676
+ }
1677
+
1678
+ // Kind icon mapping
1679
+ const KIND_ICONS = { bash: '$', monitor: '👁', tool: '🔧', spawn: '↳' };
1680
+
1681
+ // Load the tailUrl content into the bottom pane (mirrors existing bottom-pane loaders)
1682
+ function loadTailUrl(tailUrl) {
1683
+ const streamEl = document.getElementById('stream');
1684
+ if (!streamEl) return;
1685
+ fetch(tailUrl)
1686
+ .then(function(r) { return r.text(); })
1687
+ .then(function(text) {
1688
+ streamEl.innerHTML = '<pre style="margin:0;padding:12px;font-family:var(--mono);font-size:12px;white-space:pre-wrap;word-break:break-word;">' + _esc(text) + '</pre>';
1689
+ })
1690
+ .catch(function(err) {
1691
+ console.log('[la] tail load error:', err && err.message);
1692
+ });
1693
+ }
1694
+
1695
+ function _esc(s) {
1696
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1697
+ }
1698
+
1699
+ // Remove .la-pulsing from entry element
1700
+ function stopPulse(id) {
1701
+ const item = _current.get(id);
1702
+ if (!item || !item.element) return;
1703
+ item.element.classList.remove('la-pulsing');
1704
+ if (item.stopPulseTimer) { clearTimeout(item.stopPulseTimer); item.stopPulseTimer = null; }
1705
+ }
1706
+
1707
+ // Append a new .la-entry to #la-list
1708
+ function appendActivity(entry) {
1709
+ const li = document.createElement('li');
1710
+ li.className = 'la-entry la-pulsing';
1711
+ li.setAttribute('data-id', entry.id);
1712
+ li.setAttribute('data-kind', entry.kind);
1713
+ li.setAttribute('data-tail-url', entry.tailUrl || '');
1714
+
1715
+ const dot = document.createElement('span');
1716
+ dot.className = 'la-dot la-dot-running';
1717
+ li.appendChild(dot);
1718
+
1719
+ const icon = document.createElement('span');
1720
+ icon.className = 'la-icon la-icon-' + entry.kind;
1721
+ icon.textContent = KIND_ICONS[entry.kind] || '?';
1722
+ li.appendChild(icon);
1723
+
1724
+ const label = document.createElement('span');
1725
+ label.className = 'la-label';
1726
+ const labelText = String(entry.label || '').slice(0, 40);
1727
+ label.textContent = labelText;
1728
+ label.title = entry.label || '';
1729
+ li.appendChild(label);
1730
+
1731
+ const dur = document.createElement('span');
1732
+ dur.className = 'la-duration';
1733
+ dur.textContent = fmtDuration(entry.durationMs || 0);
1734
+ li.appendChild(dur);
1735
+
1736
+ li.addEventListener('click', function() {
1737
+ stopPulse(entry.id);
1738
+ if (entry.tailUrl) loadTailUrl(entry.tailUrl);
1739
+ });
1740
+
1741
+ laList.appendChild(li);
1742
+
1743
+ // Schedule pulse stop after 30s (condition c)
1744
+ const timer = setTimeout(function() { stopPulse(entry.id); }, 30000);
1745
+ _current.set(entry.id, { element: li, startedAt: entry.startedAt, stopPulseTimer: timer });
1746
+
1747
+ if (laEmpty) laEmpty.style.display = 'none';
1748
+ }
1749
+
1750
+ // Remove a .la-entry from #la-list
1751
+ function removeActivity(id) {
1752
+ const item = _current.get(id);
1753
+ if (!item) return;
1754
+ if (item.stopPulseTimer) clearTimeout(item.stopPulseTimer);
1755
+ if (item.element && item.element.parentNode) item.element.parentNode.removeChild(item.element);
1756
+ _current.delete(id);
1757
+ if (laEmpty && _current.size === 0) laEmpty.style.display = '';
1758
+ }
1759
+
1760
+ // Update the duration counter for an existing entry
1761
+ function updateDuration(id, startedAt) {
1762
+ const item = _current.get(id);
1763
+ if (!item || !item.element) return;
1764
+ const dur = item.element.querySelector('.la-duration');
1765
+ if (!dur) return;
1766
+ const start = startedAt ? new Date(startedAt).getTime() : 0;
1767
+ const elapsed = start ? Math.max(0, Date.now() - start) : 0;
1768
+ dur.textContent = fmtDuration(elapsed);
1769
+ }
1770
+
1771
+ // Fetch /api/live-activity, return envelope (or {activities:[]} on error)
1772
+ async function fetchLiveActivity() {
1773
+ try {
1774
+ const r = await fetch('/api/live-activity');
1775
+ if (!r.ok) {
1776
+ console.log('[la] /api/live-activity returned', r.status);
1777
+ return { activities: [] };
1778
+ }
1779
+ return await r.json();
1780
+ } catch (err) {
1781
+ console.log('[la] fetch error:', err && err.message);
1782
+ return { activities: [] };
1783
+ }
1784
+ }
1785
+
1786
+ // Reconcile current DOM state with new API response
1787
+ function reconcile(activities) {
1788
+ // Build a Map of new activities by id
1789
+ const newById = new Map();
1790
+ for (const entry of (activities || [])) {
1791
+ newById.set(entry.id, entry);
1792
+ }
1793
+
1794
+ // Remove entries no longer present
1795
+ for (const id of Array.from(_current.keys())) {
1796
+ if (!newById.has(id)) removeActivity(id);
1797
+ }
1798
+
1799
+ // Add new entries; update durations for existing ones
1800
+ for (const [id, entry] of newById) {
1801
+ if (_current.has(id)) {
1802
+ updateDuration(id, entry.startedAt);
1803
+ } else {
1804
+ appendActivity(entry);
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ // Initial poll + 5s interval (same cadence as /api/parallelism)
1810
+ async function poll() {
1811
+ const data = await fetchLiveActivity();
1812
+ reconcile(data.activities);
1813
+ }
1814
+
1815
+ poll();
1816
+ setInterval(poll, 5000);
1817
+ })();
1618
1818
  })();
1619
1819
  </script>
1620
1820
  </body>