@tekyzinc/gsd-t 3.16.12 → 3.18.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +13 -3
  3. package/bin/gsd-t-depgraph-validate.cjs +140 -0
  4. package/bin/gsd-t-economics.cjs +287 -0
  5. package/bin/gsd-t-file-disjointness.cjs +227 -0
  6. package/bin/gsd-t-in-session-usage.cjs +213 -0
  7. package/bin/gsd-t-orchestrator-config.cjs +100 -3
  8. package/bin/gsd-t-orchestrator.js +2 -1
  9. package/bin/gsd-t-parallel.cjs +382 -0
  10. package/bin/gsd-t-report-tokens.cjs +549 -0
  11. package/bin/gsd-t-task-graph.cjs +366 -0
  12. package/bin/gsd-t-token-capture.cjs +29 -14
  13. package/bin/gsd-t-token-dashboard.cjs +35 -0
  14. package/bin/gsd-t-tool-attribution.cjs +377 -0
  15. package/bin/gsd-t-tool-cost.cjs +195 -0
  16. package/bin/gsd-t-unattended-platform.cjs +7 -1
  17. package/bin/gsd-t-unattended.cjs +2 -0
  18. package/bin/gsd-t.js +155 -5
  19. package/bin/headless-auto-spawn.cjs +69 -49
  20. package/bin/headless-auto-spawn.js +18 -24
  21. package/bin/runway-estimator.cjs +212 -0
  22. package/bin/spawn-plan-derive.cjs +163 -0
  23. package/bin/spawn-plan-status-updater.cjs +292 -0
  24. package/bin/spawn-plan-writer.cjs +204 -0
  25. package/commands/gsd-t-debug.md +26 -7
  26. package/commands/gsd-t-execute.md +36 -28
  27. package/commands/gsd-t-help.md +11 -0
  28. package/commands/gsd-t-integrate.md +27 -7
  29. package/commands/gsd-t-quick.md +30 -13
  30. package/commands/gsd-t-scan.md +5 -5
  31. package/commands/gsd-t-unattended-watch.md +4 -3
  32. package/commands/gsd-t-unattended.md +9 -3
  33. package/commands/gsd-t-verify.md +5 -5
  34. package/commands/gsd-t-wave.md +21 -8
  35. package/commands/gsd.md +45 -3
  36. package/docs/GSD-T-README.md +43 -5
  37. package/docs/architecture.md +423 -3
  38. package/docs/requirements.md +203 -0
  39. package/package.json +1 -1
  40. package/scripts/gsd-t-calibration-hook.js +256 -0
  41. package/scripts/gsd-t-compact-detector.js +223 -0
  42. package/scripts/gsd-t-compaction-scanner.js +305 -0
  43. package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
  44. package/scripts/gsd-t-dashboard-server.js +179 -0
  45. package/scripts/gsd-t-dashboard.html +3 -3
  46. package/scripts/gsd-t-heartbeat.js +50 -2
  47. package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
  48. package/scripts/gsd-t-transcript.html +546 -43
  49. package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
  50. package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
  51. package/templates/CLAUDE-global.md +8 -3
  52. package/templates/CLAUDE-project.md +17 -14
  53. package/templates/hooks/post-commit-spawn-plan.sh +85 -0
@@ -22,10 +22,30 @@
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; grid-template-rows: auto 1fr; min-height: 100vh; }
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; }
26
26
  body > header { grid-column: 1 / -1; }
27
- body > aside { 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; }
27
+ 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
28
  body > main { grid-column: 2; grid-row: 2; }
29
+ /* M44 D8 — right-side spawn-plan panel (additive; does not alter main stream) */
30
+ 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
+ .spawn-panel h2 { margin: 0 0 6px 0; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
32
+ .spawn-panel section { border: 1px solid var(--border); border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; background: var(--bg); }
33
+ .spawn-panel section.dimmed { opacity: 0.45; }
34
+ .spawn-panel section .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
35
+ .spawn-panel section .sec-title { font-weight: 600; color: var(--fg); font-size: 12px; }
36
+ .spawn-panel section .sec-sub { color: var(--fg-xdim); font-family: var(--mono); font-size: 10px; }
37
+ .spawn-panel section .sec-totals { font-family: var(--mono); font-size: 10px; color: var(--fg-dim); margin-top: 2px; text-align: right; }
38
+ .spawn-panel .task-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
39
+ .spawn-panel .task-row .icon { flex: 0 0 14px; font-family: var(--mono); color: var(--fg-xdim); }
40
+ .spawn-panel .task-row.status-done .icon { color: var(--green); }
41
+ .spawn-panel .task-row.status-in-progress .icon { color: var(--accent); }
42
+ .spawn-panel .task-row .title { flex: 1; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
43
+ .spawn-panel .task-row.status-done .title { color: var(--fg-dim); }
44
+ .spawn-panel .task-row .tokens { flex: 0 0 auto; font-family: var(--mono); font-size: 10px; color: var(--fg-xdim); text-align: right; }
45
+ .spawn-panel .empty { color: var(--fg-xdim); font-style: italic; padding: 4px 0; font-size: 11px; }
46
+ .spawn-panel .wave-group { border-top: 1px dashed var(--border); padding-top: 4px; margin-top: 4px; }
47
+ .spawn-panel .wave-group:first-child { border-top: none; padding-top: 0; margin-top: 0; }
48
+ .spawn-panel .wave-label { font-size: 10px; color: var(--fg-xdim); font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.05em; margin: 4px 0 2px 0; }
29
49
  aside h3 { margin: 0 12px 8px 12px; font-size: 11px; text-transform: uppercase; color: var(--fg-xdim); letter-spacing: 0.08em; }
30
50
  aside .tree { font-size: 13px; }
31
51
  aside .node { display: flex; align-items: center; padding: 5px 12px; cursor: pointer; gap: 6px; }
@@ -42,7 +62,31 @@
42
62
  aside .node .kill:hover:not([disabled]) { background: var(--red); color: #fff; }
43
63
  aside .node .kill[disabled] { opacity: 0.3; cursor: not-allowed; }
44
64
  aside .empty { padding: 12px; color: var(--fg-xdim); font-size: 12px; font-style: italic; }
65
+ /* M43 D6 — tool-cost panel */
66
+ aside .panel { border-top: 1px solid var(--border); margin-top: 10px; padding-top: 10px; }
67
+ aside .panel > summary { list-style: none; cursor: pointer; padding: 4px 12px; display: flex; align-items: center; gap: 6px; color: var(--fg-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
68
+ aside .panel > summary::-webkit-details-marker { display: none; }
69
+ aside .panel > summary::before { content: "▶"; font-size: 8px; color: var(--fg-xdim); transition: transform 0.15s ease; }
70
+ aside .panel[open] > summary::before { transform: rotate(90deg); }
71
+ aside .panel .panel-body { padding: 6px 12px 10px 12px; }
72
+ aside .panel .live-badge { margin-left: auto; font-size: 9px; font-family: var(--mono); background: var(--bg-soft); color: var(--fg-xdim); border: 1px solid var(--border); border-radius: 8px; padding: 1px 6px; }
73
+ aside .panel .live-badge.on { background: rgba(63, 185, 80, 0.15); color: var(--green); border-color: var(--green); }
74
+ aside .tool-row { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-family: var(--mono); font-size: 11px; }
75
+ aside .tool-row .tool-name { color: var(--fg); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
76
+ aside .tool-row .tool-tokens { color: var(--fg-dim); flex: 0 0 auto; }
77
+ aside .tool-empty { color: var(--fg-xdim); font-size: 11px; font-style: italic; }
78
+ aside .tool-error { color: var(--yellow); font-size: 11px; font-style: italic; }
45
79
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
80
+ /* 10s attention-pulse for a freshly-arrived spawn — fires once when
81
+ auto-follow snaps focus to a new session, then self-removes. */
82
+ aside .node.pulsing { animation: attn 1.25s ease-in-out 8; box-shadow: inset 0 0 0 0 var(--accent); }
83
+ @keyframes attn {
84
+ 0% { background: var(--bg-soft); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 0 rgba(88,166,255,0.6); }
85
+ 50% { background: rgba(88,166,255,0.18); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 6px rgba(88,166,255,0); }
86
+ 100% { background: var(--bg-soft); box-shadow: inset 3px 0 0 0 var(--accent), 0 0 0 0 rgba(88,166,255,0); }
87
+ }
88
+ header .auto-follow { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--fg-dim); cursor: pointer; user-select: none; margin-left: 12px; }
89
+ header .auto-follow input { margin: 0; cursor: pointer; }
46
90
  header { position: sticky; top: 0; z-index: 10; background: var(--bg-raised); border-bottom: 1px solid var(--border); padding: 10px 16px; display: flex; align-items: center; gap: 12px; }
47
91
  header .title { font-weight: 600; }
48
92
  header .spawn-id { font-family: var(--mono); color: var(--fg-dim); font-size: 12px; }
@@ -81,19 +125,52 @@
81
125
  .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; }
82
126
  .jump-to-live.visible { display: block; }
83
127
  .jump-to-live:hover { background: #79c0ff; }
128
+ /* Q2b — compact_marker inline timeline badge */
129
+ .frame.compact-marker { display: flex; align-items: center; gap: 8px; margin: 14px 0; padding: 0; }
130
+ .frame.compact-marker .cm-line { flex: 1; height: 1px; background: #d29922; opacity: 0.4; }
131
+ .frame.compact-marker .cm-badge { flex: 0 0 auto; background: rgba(210,153,34,0.15); border: 1px solid #d29922; border-radius: 12px; padding: 3px 10px; font-family: var(--mono); font-size: 11px; color: #d29922; font-weight: 600; cursor: default; white-space: nowrap; }
132
+ .frame.compact-marker .cm-badge:hover { background: rgba(210,153,34,0.28); }
133
+ /* Per-frame arrival timestamp pill — renders to the LEFT of every frame
134
+ so a stuck stream is visible at a glance (large gap between consecutive ts). */
135
+ .frame > .ts { display: inline-block; flex: 0 0 auto; font-family: var(--mono); font-size: 10px; color: var(--fg-xdim); margin-right: 8px; vertical-align: top; min-width: 64px; user-select: none; }
136
+ .frame.tool-use > .ts, .frame.thinking > .ts { padding: 8px 0 0 12px; }
137
+ .frame.tool-result > .ts { padding-top: 8px; }
138
+ .frame.compact-marker > .ts { padding: 0; min-width: 64px; }
84
139
  </style>
85
140
  </head>
86
141
  <body data-spawn-id="__SPAWN_ID__">
87
142
  <header>
88
143
  <div class="title">GSD-T Transcript</div>
89
144
  <div class="spawn-id" id="hdr-spawn-id"></div>
145
+ <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>
90
146
  <div class="status" id="hdr-status"><span class="dot"></span><span class="label">connecting…</span></div>
91
147
  </header>
92
- <aside>
148
+ <aside class="left-rail">
93
149
  <h3>Spawns</h3>
94
150
  <div class="tree" id="tree"></div>
151
+ <details class="panel" id="tool-cost-panel" open>
152
+ <summary>Tool Cost <span class="live-badge" id="tool-cost-live">off</span></summary>
153
+ <div class="panel-body" id="tool-cost-body">
154
+ <div class="tool-empty">Loading…</div>
155
+ </div>
156
+ </details>
95
157
  </aside>
96
158
  <main id="stream"></main>
159
+ <!-- M44 D8 — Spawn Plan panel: Layer 1 (project) + Layer 2 (active spawn).
160
+ Additive; does not change the transcript stream rendering. -->
161
+ <aside class="spawn-panel" id="spawn-plan-panel">
162
+ <h2>Spawn Plan</h2>
163
+ <section id="spawn-layer-project">
164
+ <div class="sec-head"><span class="sec-title" id="project-title">Project</span><span class="sec-sub" id="project-sub">loading…</span></div>
165
+ <div class="sec-totals" id="project-totals">—</div>
166
+ <div id="project-tasks"><div class="empty">loading…</div></div>
167
+ </section>
168
+ <section id="spawn-layer-active">
169
+ <div class="sec-head"><span class="sec-title" id="active-title">Active Spawn</span><span class="sec-sub" id="active-sub">—</span></div>
170
+ <div class="sec-totals" id="active-totals">—</div>
171
+ <div id="active-tasks"><div class="empty">no active spawn plan</div></div>
172
+ </section>
173
+ </aside>
97
174
  <button class="jump-to-live" id="jump-btn">↓ Jump to live</button>
98
175
 
99
176
  <script>
@@ -134,24 +211,47 @@
134
211
  jumpBtn.classList.remove('visible');
135
212
  });
136
213
 
137
- function appendFrame(el) {
214
+ // Two-digit zero-pad for timestamp formatting.
215
+ function pad2(n) { return n < 10 ? '0' + n : '' + n; }
216
+ // Format a Date as HH:MM:SS — local time, monospace-friendly width.
217
+ function fmtTs(d) {
218
+ if (!(d instanceof Date) || isNaN(d.getTime())) d = new Date();
219
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
220
+ }
221
+ window.__gsdtFmtTs = fmtTs;
222
+
223
+ // Per-frame arrival timestamp. The renderer captures Date.now() once
224
+ // per SSE message and threads it through; everything else falls back
225
+ // to "now" so manually-rendered frames stay visible too. Spotting a
226
+ // stuck stream is then trivial — adjacent frames will show identical
227
+ // or far-apart timestamps in the left-margin pill.
228
+ function appendFrame(el, arrivedAt) {
229
+ const ts = document.createElement('span');
230
+ ts.className = 'ts';
231
+ ts.textContent = fmtTs(arrivedAt instanceof Date ? arrivedAt : new Date());
232
+ ts.title = (arrivedAt instanceof Date ? arrivedAt : new Date()).toISOString();
233
+ // Insert as the first child so flex/inline layouts keep working.
234
+ if (el.firstChild) el.insertBefore(ts, el.firstChild);
235
+ else el.appendChild(ts);
138
236
  stream.appendChild(el);
139
237
  if (autoScroll) {
140
238
  requestAnimationFrame(() => window.scrollTo(0, document.body.scrollHeight));
141
239
  }
142
240
  }
143
241
 
144
- function renderSystem(frame) {
242
+ function renderSystem(frame, arrivedAt) {
145
243
  const div = document.createElement('div');
146
244
  div.className = 'frame system';
147
245
  const sid = frame.session_id ? ' session=' + frame.session_id.slice(0, 8) : '';
148
246
  const model = frame.model ? ' model=' + frame.model : '';
149
247
  const subtype = frame.subtype ? ' ' + frame.subtype : '';
150
- div.textContent = 'system' + subtype + sid + model;
151
- appendFrame(div);
248
+ const txt = document.createElement('span');
249
+ txt.textContent = 'system' + subtype + sid + model;
250
+ div.appendChild(txt);
251
+ appendFrame(div, arrivedAt);
152
252
  }
153
253
 
154
- function renderUserMessage(content) {
254
+ function renderUserMessage(content, arrivedAt) {
155
255
  const div = document.createElement('div');
156
256
  div.className = 'frame user';
157
257
  const p = document.createElement('span');
@@ -161,17 +261,19 @@
161
261
  const span = document.createElement('span');
162
262
  span.textContent = typeof content === 'string' ? content : JSON.stringify(content);
163
263
  div.appendChild(span);
164
- appendFrame(div);
264
+ appendFrame(div, arrivedAt);
165
265
  }
166
266
 
167
- function renderAssistantText(text) {
267
+ function renderAssistantText(text, arrivedAt) {
168
268
  const div = document.createElement('div');
169
269
  div.className = 'frame assistant-text';
170
- div.textContent = text;
171
- appendFrame(div);
270
+ const span = document.createElement('span');
271
+ span.textContent = text;
272
+ div.appendChild(span);
273
+ appendFrame(div, arrivedAt);
172
274
  }
173
275
 
174
- function renderThinking(text) {
276
+ function renderThinking(text, arrivedAt) {
175
277
  const d = document.createElement('details');
176
278
  d.className = 'frame thinking';
177
279
  const s = document.createElement('summary');
@@ -180,7 +282,7 @@
180
282
  const pre = document.createElement('div');
181
283
  pre.textContent = text;
182
284
  d.appendChild(pre);
183
- appendFrame(d);
285
+ appendFrame(d, arrivedAt);
184
286
  }
185
287
 
186
288
  function toolUsePreview(input) {
@@ -198,7 +300,7 @@
198
300
  return '';
199
301
  }
200
302
 
201
- function renderToolUse(block) {
303
+ function renderToolUse(block, arrivedAt) {
202
304
  const d = document.createElement('details');
203
305
  d.className = 'frame tool-use';
204
306
  const s = document.createElement('summary');
@@ -209,10 +311,10 @@
209
311
  catch { pre.textContent = String(block.input); }
210
312
  d.appendChild(pre);
211
313
  toolUseById.set(block.id, { name: block.name, el: d });
212
- appendFrame(d);
314
+ appendFrame(d, arrivedAt);
213
315
  }
214
316
 
215
- function renderToolResult(block) {
317
+ function renderToolResult(block, arrivedAt) {
216
318
  const div = document.createElement('div');
217
319
  div.className = 'frame tool-result';
218
320
  const pre = document.createElement('pre');
@@ -233,10 +335,10 @@
233
335
  });
234
336
  div.appendChild(more);
235
337
  }
236
- appendFrame(div);
338
+ appendFrame(div, arrivedAt);
237
339
  }
238
340
 
239
- function renderBoundary(frame) {
341
+ function renderBoundary(frame, arrivedAt) {
240
342
  const div = document.createElement('div');
241
343
  div.className = 'frame boundary ' + (frame.state || '');
242
344
  const label = document.createElement('span');
@@ -253,51 +355,88 @@
253
355
  if (frame.durationMs != null) bits.push('duration=' + Math.round(frame.durationMs/1000) + 's');
254
356
  meta.textContent = bits.join(' ');
255
357
  div.appendChild(meta);
256
- appendFrame(div);
358
+ appendFrame(div, arrivedAt);
257
359
  }
258
360
 
259
- function renderRaw(line) {
361
+ function renderRaw(line, arrivedAt) {
260
362
  const div = document.createElement('div');
261
363
  div.className = 'frame raw';
262
- div.textContent = line;
263
- appendFrame(div);
364
+ const span = document.createElement('span');
365
+ span.textContent = line;
366
+ div.appendChild(span);
367
+ appendFrame(div, arrivedAt);
368
+ }
369
+
370
+ function renderCompactMarker(frame, arrivedAt) {
371
+ const div = document.createElement('div');
372
+ div.className = 'frame compact-marker';
373
+
374
+ const lineL = document.createElement('span');
375
+ lineL.className = 'cm-line';
376
+
377
+ const badge = document.createElement('span');
378
+ badge.className = 'cm-badge';
379
+ badge.textContent = '⚡ CW boundary';
380
+
381
+ // Build tooltip text from available fields.
382
+ const tipParts = [];
383
+ if (frame.ts) tipParts.push('ts: ' + frame.ts);
384
+ if (frame.trigger) tipParts.push('trigger: ' + frame.trigger);
385
+ if (frame.prior_session_id) tipParts.push('prior: ' + frame.prior_session_id.slice(0, 12));
386
+ if (frame.session_id) tipParts.push('new: ' + frame.session_id.slice(0, 12));
387
+ if (frame.preTokens != null) tipParts.push('pre: ' + frame.preTokens.toLocaleString());
388
+ if (frame.postTokens != null) tipParts.push('post: ' + frame.postTokens.toLocaleString());
389
+ badge.title = tipParts.join('\n') || 'Context Window boundary';
390
+
391
+ const lineR = document.createElement('span');
392
+ lineR.className = 'cm-line';
393
+
394
+ div.appendChild(lineL);
395
+ div.appendChild(badge);
396
+ div.appendChild(lineR);
397
+ appendFrame(div, arrivedAt);
264
398
  }
399
+ window.__gsdtRenderCompactMarker = renderCompactMarker;
265
400
 
266
- function renderFrame(frame) {
401
+ function renderFrame(frame, arrivedAt) {
267
402
  if (!frame || typeof frame !== 'object') return;
403
+ const ts = arrivedAt instanceof Date ? arrivedAt : new Date();
268
404
  const type = frame.type;
269
- if (type === 'system') { renderSystem(frame); return; }
270
- if (type === 'task-boundary') { renderBoundary(frame); return; }
271
- if (type === 'raw') { renderRaw(frame.line || ''); return; }
405
+ if (type === 'compact_marker') { renderCompactMarker(frame, ts); return; }
406
+ if (type === 'system') { renderSystem(frame, ts); return; }
407
+ if (type === 'task-boundary') { renderBoundary(frame, ts); return; }
408
+ if (type === 'raw') { renderRaw(frame.line || '', ts); return; }
272
409
  if (type === 'assistant' && frame.message && Array.isArray(frame.message.content)) {
273
410
  for (const b of frame.message.content) {
274
- if (b.type === 'text') renderAssistantText(b.text || '');
275
- else if (b.type === 'thinking') renderThinking(b.thinking || b.text || '');
276
- else if (b.type === 'tool_use') renderToolUse(b);
277
- else renderRaw(JSON.stringify(b));
411
+ if (b.type === 'text') renderAssistantText(b.text || '', ts);
412
+ else if (b.type === 'thinking') renderThinking(b.thinking || b.text || '', ts);
413
+ else if (b.type === 'tool_use') renderToolUse(b, ts);
414
+ else renderRaw(JSON.stringify(b), ts);
278
415
  }
279
416
  return;
280
417
  }
281
418
  if (type === 'user' && frame.message && Array.isArray(frame.message.content)) {
282
419
  for (const b of frame.message.content) {
283
- if (b.type === 'tool_result') renderToolResult(b);
284
- else if (b.type === 'text') renderUserMessage(b.text || '');
285
- else renderRaw(JSON.stringify(b));
420
+ if (b.type === 'tool_result') renderToolResult(b, ts);
421
+ else if (b.type === 'text') renderUserMessage(b.text || '', ts);
422
+ else renderRaw(JSON.stringify(b), ts);
286
423
  }
287
424
  return;
288
425
  }
289
426
  if (type === 'user' && typeof frame.message === 'string') {
290
- renderUserMessage(frame.message);
427
+ renderUserMessage(frame.message, ts);
291
428
  return;
292
429
  }
293
430
  if (type === 'result') {
294
431
  const d = document.createElement('div');
295
432
  d.className = 'frame system';
296
- d.textContent = 'result subtype=' + (frame.subtype || '?') + ' duration=' + (frame.duration_ms || 0) + 'ms';
297
- appendFrame(d);
433
+ const span = document.createElement('span');
434
+ span.textContent = 'result subtype=' + (frame.subtype || '?') + ' duration=' + (frame.duration_ms || 0) + 'ms';
435
+ d.appendChild(span);
436
+ appendFrame(d, ts);
298
437
  return;
299
438
  }
300
- renderRaw(JSON.stringify(frame));
439
+ renderRaw(JSON.stringify(frame), ts);
301
440
  }
302
441
 
303
442
  // ── Sidebar ─────────────────────────────────────────────────────────
@@ -376,14 +515,172 @@
376
515
  for (const r of roots) render(r, 0);
377
516
  }
378
517
 
518
+ // ── Auto-follow latest spawn ────────────────────────────────────────
519
+ //
520
+ // Default ON. Persisted in localStorage under 'gsdt.autoFollow' so a
521
+ // user's per-tab decision survives reloads. When ON, pollSpawns()
522
+ // checks for a newer running spawn than the currently-focused one and
523
+ // immediately snaps focus to it via location.hash. The new spawn's
524
+ // sidebar node gets a 10s 'pulsing' class so the eye lands on it even
525
+ // after the SSE reconnect repaints the main pane.
526
+
527
+ const AUTOFOLLOW_KEY = 'gsdt.autoFollow';
528
+ const autoFollowEl = document.getElementById('auto-follow');
529
+ const stored = localStorage.getItem(AUTOFOLLOW_KEY);
530
+ autoFollowEl.checked = stored === null ? true : stored === '1';
531
+ autoFollowEl.addEventListener('change', () => {
532
+ localStorage.setItem(AUTOFOLLOW_KEY, autoFollowEl.checked ? '1' : '0');
533
+ });
534
+
535
+ // Track which spawnIds we've already seen-live so we only treat the
536
+ // FIRST appearance of a newly-live spawn as a follow event. Without
537
+ // this guard the tree poll would re-trigger follow every 3s while the
538
+ // spawn stayed running (no-op since the id would equal currentId, but
539
+ // also no-op when the user has manually navigated away).
540
+ const seenLive = new Set();
541
+
542
+ function pulseNode(spawnId) {
543
+ // Defer one frame so the tree DOM exists.
544
+ requestAnimationFrame(() => {
545
+ const nodes = document.querySelectorAll('aside .node');
546
+ for (const n of nodes) {
547
+ const nm = n.querySelector('.name');
548
+ if (nm && nm.textContent.endsWith(spawnId.slice(-8))) {
549
+ n.classList.add('pulsing');
550
+ setTimeout(() => n.classList.remove('pulsing'), 10000);
551
+ break;
552
+ }
553
+ }
554
+ });
555
+ }
556
+
557
+ function maybeAutoFollow(spawns) {
558
+ if (!autoFollowEl.checked) return;
559
+ const currentId = (location.hash || '').slice(1) || spawnId;
560
+ // Most recent running spawn by startedAt (descending).
561
+ const running = spawns
562
+ .filter((s) => s.status === 'running')
563
+ .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
564
+ if (!running.length) return;
565
+ const latest = running[0];
566
+ if (latest.spawnId === currentId) {
567
+ seenLive.add(latest.spawnId);
568
+ return;
569
+ }
570
+ // Only follow if this is a NEWLY-live spawn — i.e., we haven't seen
571
+ // it as live in a previous poll. This stops us from yanking focus
572
+ // back every 3s when the user has manually navigated to an older
573
+ // spawn whose sibling is still running.
574
+ if (seenLive.has(latest.spawnId)) return;
575
+ seenLive.add(latest.spawnId);
576
+ location.hash = latest.spawnId;
577
+ pulseNode(latest.spawnId);
578
+ }
579
+
379
580
  let pollTimer = null;
380
581
  function pollSpawns() {
381
582
  fetch('/transcripts')
382
583
  .then((r) => r.json())
383
- .then((j) => renderTree(buildTree(j.spawns || [])))
584
+ .then((j) => {
585
+ const spawns = j.spawns || [];
586
+ renderTree(buildTree(spawns));
587
+ maybeAutoFollow(spawns);
588
+ })
384
589
  .catch(() => { /* keep last render */ });
385
590
  }
386
591
 
592
+ // ── M43 D6 — Tool Cost panel ────────────────────────────────────────
593
+ //
594
+ // Fetches /transcript/:id/tool-cost on render + on turn_complete SSE
595
+ // events (debounced 2s). Shows top-N tools by attributed tokens, or
596
+ // a friendly "not yet available" message when D2 library is 503.
597
+
598
+ const TOOL_COST_TOP_N = 8;
599
+ let toolCostTimer = null;
600
+
601
+ function formatTokens(n) {
602
+ if (n == null || isNaN(n)) return '—';
603
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
604
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
605
+ return String(n);
606
+ }
607
+
608
+ function renderToolCostPanel(tools) {
609
+ const body = document.getElementById('tool-cost-body');
610
+ if (!body) return;
611
+ body.innerHTML = '';
612
+ if (!Array.isArray(tools) || tools.length === 0) {
613
+ const e = document.createElement('div');
614
+ e.className = 'tool-empty';
615
+ e.textContent = 'No tool activity yet.';
616
+ body.appendChild(e);
617
+ return;
618
+ }
619
+ // Sort by attributed tokens (input + output) desc, then slice top-N.
620
+ const ranked = tools
621
+ .map((t) => {
622
+ const tokens = (t.inputTokens || 0) + (t.outputTokens || 0)
623
+ || t.tokens || t.attributedTokens || 0;
624
+ return { name: t.tool || t.name || 'unknown', tokens };
625
+ })
626
+ .sort((a, b) => b.tokens - a.tokens)
627
+ .slice(0, TOOL_COST_TOP_N);
628
+ for (const r of ranked) {
629
+ const row = document.createElement('div');
630
+ row.className = 'tool-row';
631
+ const n = document.createElement('span'); n.className = 'tool-name'; n.textContent = r.name;
632
+ const v = document.createElement('span'); v.className = 'tool-tokens'; v.textContent = formatTokens(r.tokens);
633
+ row.appendChild(n); row.appendChild(v);
634
+ body.appendChild(row);
635
+ }
636
+ }
637
+
638
+ function renderToolCostError(message) {
639
+ const body = document.getElementById('tool-cost-body');
640
+ if (!body) return;
641
+ body.innerHTML = '';
642
+ const e = document.createElement('div');
643
+ e.className = 'tool-error';
644
+ e.textContent = message || 'Tool attribution not yet available';
645
+ body.appendChild(e);
646
+ }
647
+ window.__gsdtRenderToolCostPanel = renderToolCostPanel;
648
+ window.__gsdtRenderToolCostError = renderToolCostError;
649
+
650
+ function fetchToolCost(id) {
651
+ if (!id) return;
652
+ fetch('/transcript/' + encodeURIComponent(id) + '/tool-cost')
653
+ .then((r) => {
654
+ if (r.status === 503) {
655
+ renderToolCostError('Tool attribution not yet available');
656
+ return null;
657
+ }
658
+ if (!r.ok) {
659
+ renderToolCostError('Tool cost unavailable (HTTP ' + r.status + ')');
660
+ return null;
661
+ }
662
+ return r.json();
663
+ })
664
+ .then((j) => {
665
+ if (!j) return;
666
+ renderToolCostPanel(j.tools || []);
667
+ })
668
+ .catch(() => { renderToolCostError('Tool cost fetch failed'); });
669
+ }
670
+
671
+ function setToolCostLive(on) {
672
+ const badge = document.getElementById('tool-cost-live');
673
+ if (!badge) return;
674
+ badge.textContent = on ? 'live' : 'off';
675
+ badge.classList.toggle('on', !!on);
676
+ }
677
+
678
+ function scheduleToolCostRefresh(id) {
679
+ if (toolCostTimer) clearTimeout(toolCostTimer);
680
+ toolCostTimer = setTimeout(() => { fetchToolCost(id); }, 2000);
681
+ }
682
+ window.__gsdtFetchToolCost = fetchToolCost;
683
+
387
684
  // ── SSE connection (reconnectable on hash change) ───────────────────
388
685
 
389
686
  let src = null;
@@ -395,13 +692,33 @@
395
692
  jumpBtn.classList.remove('visible');
396
693
  document.getElementById('hdr-spawn-id').textContent = id;
397
694
  setStatus('', 'connecting…');
695
+ // M43 D6 — kick off an initial tool-cost fetch for this spawn.
696
+ fetchToolCost(id);
697
+ setToolCostLive(false);
398
698
  src = new EventSource('/transcript/' + encodeURIComponent(id) + '/stream');
399
- src.onopen = () => setStatus('connected', 'live');
400
- src.onerror = () => setStatus('error', 'disconnected');
699
+ src.onopen = () => { setStatus('connected', 'live'); setToolCostLive(true); };
700
+ src.onerror = () => { setStatus('error', 'disconnected'); setToolCostLive(false); };
401
701
  src.onmessage = (ev) => {
402
702
  if (!ev.data) return;
403
- try { renderFrame(JSON.parse(ev.data)); }
404
- catch { renderRaw(ev.data); }
703
+ // Capture arrival time once per SSE message — every render call for
704
+ // this frame stamps the same wall-clock value, so the user can spot
705
+ // a stuck stream by adjacent identical/far-apart timestamps.
706
+ const arrivedAt = new Date();
707
+ try {
708
+ const frame = JSON.parse(ev.data);
709
+ renderFrame(frame, arrivedAt);
710
+ // M43 D6 — refresh tool-cost on turn-complete frames (debounced).
711
+ // Various producers emit different turn-complete markers; accept
712
+ // any of them.
713
+ if (frame && (
714
+ frame.type === 'turn_complete' ||
715
+ frame.subtype === 'turn_complete' ||
716
+ frame.event_type === 'turn_complete' ||
717
+ frame.type === 'result' // `result` frames mark end-of-turn in stream-json
718
+ )) {
719
+ scheduleToolCostRefresh(id);
720
+ }
721
+ } catch { renderRaw(ev.data, arrivedAt); }
405
722
  };
406
723
  }
407
724
 
@@ -416,6 +733,192 @@
416
733
 
417
734
  pollSpawns();
418
735
  pollTimer = setInterval(pollSpawns, 3000);
736
+
737
+ // ── M44 D8 — Spawn Plan panel ───────────────────────────────────────
738
+ //
739
+ // Two-layer right-side panel. Layer 1 shows project-wide task status
740
+ // (derived from partition + tasks.md); Layer 2 shows the currently-
741
+ // active spawn's task slice. Both flip live via the SSE channel
742
+ // `/api/spawn-plans/stream` and the initial GET `/api/spawn-plans`.
743
+ //
744
+ // Token cells render as `in=12.5k out=1.7k $0.42` (k-suffix above
745
+ // 1000, 2-decimal USD). `—` when null, per the "zero is a
746
+ // measurement, dash is acknowledged gap" rule.
747
+
748
+ const STATUS_ICON = { pending: '☐', in_progress: '◐', done: '✓' };
749
+
750
+ function fmtK(n) {
751
+ if (n == null || isNaN(n)) return '0';
752
+ const num = Number(n);
753
+ if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1) + 'k';
754
+ return String(num);
755
+ }
756
+ window.__gsdtFmtK = fmtK;
757
+
758
+ function fmtTokens(tokens) {
759
+ if (!tokens || typeof tokens !== 'object') return '—';
760
+ const ins = fmtK(tokens.in);
761
+ const out = fmtK(tokens.out);
762
+ const cost = (typeof tokens.cost_usd === 'number') ? '$' + tokens.cost_usd.toFixed(2) : '';
763
+ return ['in=' + ins, 'out=' + out, cost].filter(Boolean).join(' ');
764
+ }
765
+ window.__gsdtFmtTokens = fmtTokens;
766
+
767
+ function sumTokens(tasks) {
768
+ const acc = { in: 0, out: 0, cr: 0, cc: 0, cost_usd: 0 };
769
+ let hit = false;
770
+ for (const t of (tasks || [])) {
771
+ if (!t || t.status !== 'done' || !t.tokens) continue;
772
+ acc.in += Number(t.tokens.in || 0);
773
+ acc.out += Number(t.tokens.out || 0);
774
+ acc.cr += Number(t.tokens.cr || 0);
775
+ acc.cc += Number(t.tokens.cc || 0);
776
+ acc.cost_usd += Number(t.tokens.cost_usd || 0);
777
+ hit = true;
778
+ }
779
+ if (!hit) return null;
780
+ acc.cost_usd = Math.round(acc.cost_usd * 100) / 100;
781
+ return acc;
782
+ }
783
+ window.__gsdtSumTokens = sumTokens;
784
+
785
+ function renderTaskList(container, tasks) {
786
+ container.innerHTML = '';
787
+ if (!Array.isArray(tasks) || tasks.length === 0) {
788
+ const e = document.createElement('div');
789
+ e.className = 'empty';
790
+ e.textContent = 'no tasks';
791
+ container.appendChild(e);
792
+ return;
793
+ }
794
+ for (const t of tasks) {
795
+ if (!t || !t.id) continue;
796
+ const row = document.createElement('div');
797
+ row.className = 'task-row status-' + (t.status || 'pending').replace('_', '-');
798
+ const icon = document.createElement('span');
799
+ icon.className = 'icon';
800
+ icon.textContent = STATUS_ICON[t.status] || '☐';
801
+ const title = document.createElement('span');
802
+ title.className = 'title';
803
+ title.textContent = t.id + ' — ' + (t.title || '');
804
+ title.title = (t.commit ? 'commit ' + t.commit + ' · ' : '') + (t.title || '');
805
+ const tok = document.createElement('span');
806
+ tok.className = 'tokens';
807
+ tok.textContent = (t.status === 'done') ? fmtTokens(t.tokens) : '';
808
+ row.appendChild(icon);
809
+ row.appendChild(title);
810
+ row.appendChild(tok);
811
+ container.appendChild(row);
812
+ }
813
+ }
814
+ window.__gsdtRenderTaskList = renderTaskList;
815
+
816
+ // Group tasks by wave prefix embedded in the plan (optional); fall
817
+ // back to a single flat list when no wave is set.
818
+ function renderProjectLayer(plan) {
819
+ const titleEl = document.getElementById('project-title');
820
+ const subEl = document.getElementById('project-sub');
821
+ const totalsEl = document.getElementById('project-totals');
822
+ const tasksEl = document.getElementById('project-tasks');
823
+ if (!plan || !plan.tasks) {
824
+ titleEl.textContent = 'Project';
825
+ subEl.textContent = '—';
826
+ totalsEl.textContent = '—';
827
+ tasksEl.innerHTML = '<div class="empty">no plan yet</div>';
828
+ return;
829
+ }
830
+ const done = plan.tasks.filter((t) => t && t.status === 'done').length;
831
+ const total = plan.tasks.length;
832
+ titleEl.textContent = (plan.milestone || 'Project');
833
+ subEl.textContent = (plan.wave ? plan.wave + ' · ' : '') + done + '/' + total;
834
+ const totals = sumTokens(plan.tasks);
835
+ totalsEl.textContent = totals ? fmtTokens(totals) : '—';
836
+ renderTaskList(tasksEl, plan.tasks);
837
+ }
838
+ window.__gsdtRenderProjectLayer = renderProjectLayer;
839
+
840
+ function renderActiveLayer(plan) {
841
+ const section = document.getElementById('spawn-layer-active');
842
+ const titleEl = document.getElementById('active-title');
843
+ const subEl = document.getElementById('active-sub');
844
+ const totalsEl = document.getElementById('active-totals');
845
+ const tasksEl = document.getElementById('active-tasks');
846
+ if (!plan) {
847
+ section.classList.add('dimmed');
848
+ titleEl.textContent = 'Active Spawn';
849
+ subEl.textContent = '—';
850
+ totalsEl.textContent = '—';
851
+ tasksEl.innerHTML = '<div class="empty">no active spawn plan</div>';
852
+ return;
853
+ }
854
+ const isActive = plan.endedAt == null;
855
+ section.classList.toggle('dimmed', !isActive);
856
+ const done = (plan.tasks || []).filter((t) => t && t.status === 'done').length;
857
+ const total = (plan.tasks || []).length;
858
+ titleEl.textContent = (plan.kind || 'spawn');
859
+ subEl.textContent = (plan.spawnId || '').slice(-10) + ' · ' + done + '/' + total;
860
+ const totals = sumTokens(plan.tasks || []);
861
+ totalsEl.textContent = totals ? fmtTokens(totals) : '—';
862
+ renderTaskList(tasksEl, plan.tasks || []);
863
+ }
864
+ window.__gsdtRenderActiveLayer = renderActiveLayer;
865
+
866
+ // Project layer is derived from the most-recent active plan that has
867
+ // any tasks (preferring unattended-worker kind when multiple exist).
868
+ // Active layer is the same plan — both layers share data for now;
869
+ // future versions may split project layer off a dedicated endpoint.
870
+ let spawnPlanState = { plans: [] };
871
+ function updateSpawnPlanPanel() {
872
+ const plans = spawnPlanState.plans || [];
873
+ const byRecency = plans.slice().sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
874
+ const active = byRecency.find((p) => p && p.endedAt == null) || null;
875
+ // Layer 1: use the active plan if available; else the most recent plan.
876
+ renderProjectLayer(active || byRecency[0] || null);
877
+ // Layer 2: only the active one.
878
+ renderActiveLayer(active);
879
+ }
880
+ window.__gsdtUpdateSpawnPlanPanel = updateSpawnPlanPanel;
881
+
882
+ function applyPlanUpdate(plan) {
883
+ if (!plan || !plan.spawnId) return;
884
+ const plans = spawnPlanState.plans || [];
885
+ const idx = plans.findIndex((p) => p && p.spawnId === plan.spawnId);
886
+ if (idx >= 0) plans[idx] = plan;
887
+ else plans.push(plan);
888
+ spawnPlanState.plans = plans;
889
+ updateSpawnPlanPanel();
890
+ }
891
+ window.__gsdtApplyPlanUpdate = applyPlanUpdate;
892
+
893
+ function fetchInitialPlans() {
894
+ fetch('/api/spawn-plans')
895
+ .then((r) => r.ok ? r.json() : null)
896
+ .then((j) => {
897
+ if (!j) return;
898
+ spawnPlanState.plans = Array.isArray(j.plans) ? j.plans : [];
899
+ updateSpawnPlanPanel();
900
+ })
901
+ .catch(() => { /* leave empty state */ });
902
+ }
903
+
904
+ function subscribePlanUpdates() {
905
+ try {
906
+ const es = new EventSource('/api/spawn-plans/stream');
907
+ es.onmessage = (ev) => {
908
+ if (!ev.data) return;
909
+ try {
910
+ const msg = JSON.parse(ev.data);
911
+ if (msg && msg.plan) applyPlanUpdate(msg.plan);
912
+ } catch { /* ignore */ }
913
+ };
914
+ es.onerror = () => { /* auto-reconnect by browser */ };
915
+ } catch { /* EventSource unsupported; fall back to polling below */ }
916
+ }
917
+
918
+ fetchInitialPlans();
919
+ subscribePlanUpdates();
920
+ // Light poll every 10s as a safety net if SSE is disconnected.
921
+ setInterval(fetchInitialPlans, 10000);
419
922
  })();
420
923
  </script>
421
924
  </body>