@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.
- package/CHANGELOG.md +67 -0
- package/README.md +13 -3
- package/bin/gsd-t-depgraph-validate.cjs +140 -0
- package/bin/gsd-t-economics.cjs +287 -0
- package/bin/gsd-t-file-disjointness.cjs +227 -0
- package/bin/gsd-t-in-session-usage.cjs +213 -0
- package/bin/gsd-t-orchestrator-config.cjs +100 -3
- package/bin/gsd-t-orchestrator.js +2 -1
- package/bin/gsd-t-parallel.cjs +382 -0
- package/bin/gsd-t-report-tokens.cjs +549 -0
- package/bin/gsd-t-task-graph.cjs +366 -0
- package/bin/gsd-t-token-capture.cjs +29 -14
- package/bin/gsd-t-token-dashboard.cjs +35 -0
- package/bin/gsd-t-tool-attribution.cjs +377 -0
- package/bin/gsd-t-tool-cost.cjs +195 -0
- package/bin/gsd-t-unattended-platform.cjs +7 -1
- package/bin/gsd-t-unattended.cjs +2 -0
- package/bin/gsd-t.js +155 -5
- package/bin/headless-auto-spawn.cjs +69 -49
- package/bin/headless-auto-spawn.js +18 -24
- package/bin/runway-estimator.cjs +212 -0
- package/bin/spawn-plan-derive.cjs +163 -0
- package/bin/spawn-plan-status-updater.cjs +292 -0
- package/bin/spawn-plan-writer.cjs +204 -0
- package/commands/gsd-t-debug.md +26 -7
- package/commands/gsd-t-execute.md +36 -28
- package/commands/gsd-t-help.md +11 -0
- package/commands/gsd-t-integrate.md +27 -7
- package/commands/gsd-t-quick.md +30 -13
- package/commands/gsd-t-scan.md +5 -5
- package/commands/gsd-t-unattended-watch.md +4 -3
- package/commands/gsd-t-unattended.md +9 -3
- package/commands/gsd-t-verify.md +5 -5
- package/commands/gsd-t-wave.md +21 -8
- package/commands/gsd.md +45 -3
- package/docs/GSD-T-README.md +43 -5
- package/docs/architecture.md +423 -3
- package/docs/requirements.md +203 -0
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +256 -0
- package/scripts/gsd-t-compact-detector.js +223 -0
- package/scripts/gsd-t-compaction-scanner.js +305 -0
- package/scripts/gsd-t-dashboard-autostart.cjs +172 -0
- package/scripts/gsd-t-dashboard-server.js +179 -0
- package/scripts/gsd-t-dashboard.html +3 -3
- package/scripts/gsd-t-heartbeat.js +50 -2
- package/scripts/gsd-t-post-commit-spawn-plan.sh +86 -0
- package/scripts/gsd-t-transcript.html +546 -43
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +84 -0
- package/scripts/spawn-plan-fmt-tokens.cjs +80 -0
- package/templates/CLAUDE-global.md +8 -3
- package/templates/CLAUDE-project.md +17 -14
- 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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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 === '
|
|
270
|
-
if (type === '
|
|
271
|
-
if (type === '
|
|
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
|
-
|
|
297
|
-
|
|
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) =>
|
|
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
|
-
|
|
404
|
-
|
|
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>
|