reflectt-node 0.1.11 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reflectt-node",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Coordinate your AI agent team. Shared tasks, memory, reflections, and presence. Self-host for free.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -2219,6 +2219,70 @@ async function batchApproveHighConfidence() {
2219
2219
  } catch (e) { console.error('Batch approve failed:', e); }
2220
2220
  }
2221
2221
 
2222
+ // ---- Agent Action Approval Queue (review_requested events from agent runs) ----
2223
+ let agentApprovalData = null;
2224
+
2225
+ async function loadAgentApprovals() {
2226
+ try {
2227
+ const res = await fetch(BASE + '/approval-queue?category=review');
2228
+ agentApprovalData = await res.json();
2229
+ renderAgentApprovals();
2230
+ } catch (e) {
2231
+ const body = document.getElementById('agent-approval-body');
2232
+ if (body) body.innerHTML = '<div class="empty">Failed to load agent approvals</div>';
2233
+ }
2234
+ }
2235
+
2236
+ function renderAgentApprovals() {
2237
+ const body = document.getElementById('agent-approval-body');
2238
+ const count = document.getElementById('agent-approval-count');
2239
+ if (!body) return;
2240
+
2241
+ const items = (agentApprovalData && agentApprovalData.items) ? agentApprovalData.items : [];
2242
+ if (count) count.textContent = items.length > 0 ? items.length + ' pending' : '';
2243
+
2244
+ if (items.length === 0) {
2245
+ body.innerHTML = '<div class="empty" style="text-align:center;padding:20px;color:var(--text-dim)">✓ No pending agent approvals.</div>';
2246
+ return;
2247
+ }
2248
+
2249
+ let html = '';
2250
+ items.forEach(function(item) {
2251
+ const urgencyColor = item.urgency === 'critical' ? '#ef4444' : item.urgency === 'high' ? '#f59e0b' : 'var(--text-dim)';
2252
+ const title = (item.title || item.event && item.event.payload && item.event.payload.action_required || 'Agent action pending').substring(0, 80);
2253
+ const desc = item.description || (item.event && item.event.payload && item.event.payload.description) || '';
2254
+ const agentId = item.agentId || '?';
2255
+ const runId = item.runId || '';
2256
+ html += '<div class="approval-card" style="border-left:3px solid ' + urgencyColor + '">';
2257
+ html += '<div class="approval-header">';
2258
+ html += '<span style="color:' + urgencyColor + '">⚡</span> ';
2259
+ html += '<span class="approval-title">' + esc(title) + '</span>';
2260
+ html += '<span class="assignee-tag" style="margin-left:8px">@' + esc(agentId) + '</span>';
2261
+ if (item.urgency) html += '<span style="font-size:10px;color:' + urgencyColor + ';margin-left:6px">' + esc(item.urgency) + '</span>';
2262
+ html += '</div>';
2263
+ if (desc) html += '<div class="approval-meta" style="font-size:12px;margin-top:4px">' + esc(desc.substring(0, 120)) + '</div>';
2264
+ if (runId) html += '<div class="approval-meta" style="font-size:10px;color:var(--text-dim)">Run: ' + esc(runId) + '</div>';
2265
+ html += '<div class="approval-actions">';
2266
+ html += '<button class="btn-reject" onclick="decideAgentApproval(\'' + esc(item.id) + '\',\'reject\')">✗ Reject</button>';
2267
+ html += '<button class="btn-approve" onclick="decideAgentApproval(\'' + esc(item.id) + '\',\'approve\')">✓ Approve</button>';
2268
+ html += '</div>';
2269
+ html += '</div>';
2270
+ });
2271
+
2272
+ body.innerHTML = html;
2273
+ }
2274
+
2275
+ async function decideAgentApproval(eventId, decision) {
2276
+ try {
2277
+ await fetch(BASE + '/approval-queue/' + encodeURIComponent(eventId) + '/decide', {
2278
+ method: 'POST',
2279
+ headers: { 'Content-Type': 'application/json' },
2280
+ body: JSON.stringify({ decision: decision, actor: 'dashboard' })
2281
+ });
2282
+ await loadAgentApprovals();
2283
+ } catch (e) { console.error('Agent approval decision failed:', e); }
2284
+ }
2285
+
2222
2286
  // ---- Routing Policy Editor ----
2223
2287
  function toggleRoutingPolicy() {
2224
2288
  routingPolicyVisible = !routingPolicyVisible;
@@ -2394,7 +2458,7 @@ async function refresh() {
2394
2458
  if (refreshCount === 1 || forceFull) await refreshAgentRegistry();
2395
2459
  await loadTasks(forceFull);
2396
2460
  renderReviewQueue();
2397
- await Promise.all([loadPresence(), loadChat(forceFull), loadActivity(forceFull), loadResearch(), loadSharedArtifacts(), loadHealth(), loadReleaseStatus(forceFull), loadBuildInfo(), loadRuntimeTruthCard(), loadApprovalQueue(), loadFeedback(), loadPauseStatus(), loadIntensityControl(), loadPolls()]);
2461
+ await Promise.all([loadPresence(), loadChat(forceFull), loadActivity(forceFull), loadResearch(), loadSharedArtifacts(), loadHealth(), loadReleaseStatus(forceFull), loadBuildInfo(), loadRuntimeTruthCard(), loadApprovalQueue(), loadAgentApprovals(), loadFeedback(), loadPauseStatus(), loadIntensityControl(), loadPolls()]);
2398
2462
  await renderPromotionSSOT();
2399
2463
  }
2400
2464
 
package/public/docs.md CHANGED
@@ -878,6 +878,10 @@ Set via `reflectionNudge` in policy config:
878
878
  | GET | `/usage/caps` | List active spend caps with current utilization status. |
879
879
  | POST | `/usage/caps` | Create spend cap. Body: `{ scope: "global"\|"agent"\|"team", scope_id?, period: "daily"\|"weekly"\|"monthly", limit_usd, action: "warn"\|"throttle"\|"block" }`. |
880
880
  | DELETE | `/usage/caps/:id` | Delete a spend cap. |
881
+ | GET | `/agents/:agentId/spend` | Get current spend totals for a specific agent. Returns `{ agentId, totalCost, inputTokens, outputTokens, periodStart }`. |
882
+ | POST | `/agents/:agentId/enforce-cost` | Trigger cost enforcement check for agent. Evaluates active caps and applies configured action (warn/throttle/block). Body: `{}`. |
883
+ | POST | `/usage/record` | Record a single usage event. Body: `{ agentId, inputTokens, outputTokens, cost?, model?, taskId? }`. |
884
+ | POST | `/usage/purge` | Purge old usage records. Body: `{ maxAgeDays? }` (default 90). |
881
885
  | GET | `/usage/routing-suggestions` | Smart routing savings suggestions (which low-stakes categories could use cheaper models). Query: `since`. |
882
886
  | GET | `/costs` | Cost dashboard — aggregated spend for COO/PM monitoring. Query: `days` (1–90, default 7). Returns: `daily_by_model` (spend per model per day), `daily_totals` (per-day rolled-up for threshold alerting), `avg_cost_by_lane` (avg cost per closed task by `qa_bundle.lane`, 30-day floor), `avg_cost_by_agent` (avg cost per closed task per agent + `top_model`, 30-day floor), `top_tasks_by_cost` (top 20 most expensive tasks in window), `summary` (total tokens + cost), `lane_agent_window_days` (actual window used for lane/agent averages). |
883
887
 
@@ -0,0 +1,473 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Presence Loop — Live Demo</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0a0f;
10
+ --surface: #13131a;
11
+ --border: #1e1e2e;
12
+ --text: #e2e2f0;
13
+ --text-dim: #6b6b8a;
14
+ --accent: #7c3aed;
15
+ --accent-glow: rgba(124,58,237,0.15);
16
+ --green: #10b981;
17
+ --amber: #f59e0b;
18
+ --red: #ef4444;
19
+ --blue: #3b82f6;
20
+ }
21
+ * { box-sizing: border-box; margin: 0; padding: 0; }
22
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding: 40px 20px; }
23
+
24
+ /* ── Ambient state ── */
25
+ #ambient {
26
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
27
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
28
+ transition: opacity 0.8s ease;
29
+ }
30
+ .pulse-ring {
31
+ width: 80px; height: 80px; border-radius: 50%;
32
+ border: 2px solid var(--accent);
33
+ animation: pulse 3s ease-in-out infinite;
34
+ display: flex; align-items: center; justify-content: center;
35
+ }
36
+ .pulse-ring::after {
37
+ content: ''; display: block;
38
+ width: 20px; height: 20px; border-radius: 50%;
39
+ background: var(--accent); opacity: 0.6;
40
+ animation: pulse-inner 3s ease-in-out infinite;
41
+ }
42
+ @keyframes pulse {
43
+ 0%,100% { transform: scale(1); opacity: 0.6; }
44
+ 50% { transform: scale(1.1); opacity: 1; }
45
+ }
46
+ @keyframes pulse-inner {
47
+ 0%,100% { transform: scale(1); }
48
+ 50% { transform: scale(1.3); }
49
+ }
50
+ .ambient-label { margin-top: 20px; font-size: 13px; color: var(--text-dim); letter-spacing: 1px; text-transform: uppercase; }
51
+ .ambient-agent { margin-top: 8px; font-size: 11px; color: var(--text-dim); opacity: 0.6; }
52
+
53
+ /* ── Session surface ── */
54
+ #session {
55
+ width: 100%; max-width: 680px;
56
+ opacity: 0; transform: translateY(20px);
57
+ transition: opacity 0.5s ease, transform 0.5s ease;
58
+ pointer-events: none;
59
+ }
60
+ #session.visible { opacity: 1; transform: translateY(0); pointer-events: all; }
61
+
62
+ .session-header {
63
+ display: flex; align-items: center; gap: 12px;
64
+ padding: 16px 20px;
65
+ background: var(--surface);
66
+ border: 1px solid var(--border);
67
+ border-radius: 12px 12px 0 0;
68
+ }
69
+ .agent-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: blink 2s ease-in-out infinite; }
70
+ @keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
71
+ .session-title { font-weight: 600; font-size: 15px; flex: 1; }
72
+ .session-state { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
73
+
74
+ .run-stream {
75
+ background: var(--surface);
76
+ border: 1px solid var(--border);
77
+ border-top: none;
78
+ padding: 16px 20px;
79
+ min-height: 120px;
80
+ max-height: 240px;
81
+ overflow-y: auto;
82
+ font-size: 13px;
83
+ line-height: 1.7;
84
+ }
85
+ .run-line { padding: 2px 0; color: var(--text-dim); }
86
+ .run-line.active { color: var(--text); }
87
+ .run-line .ts { color: var(--accent); opacity: 0.5; margin-right: 8px; font-size: 11px; }
88
+
89
+ /* ── Approval surface ── */
90
+ #approval-surface {
91
+ background: var(--surface);
92
+ border: 1px solid var(--amber);
93
+ border-top: none;
94
+ padding: 20px;
95
+ transition: all 0.4s ease;
96
+ }
97
+ #approval-surface.hidden { display: none; }
98
+ .approval-header-row { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
99
+ .approval-icon { font-size: 24px; }
100
+ .approval-title { font-size: 15px; font-weight: 600; }
101
+ .approval-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
102
+ .urgency-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; background: var(--amber); color: #000; margin-left: 8px; }
103
+ .approval-actions { display: flex; gap: 12px; margin-top: 16px; }
104
+ .btn-approve {
105
+ flex: 1; padding: 12px; border-radius: 8px;
106
+ background: var(--green); color: #000; border: none;
107
+ font-weight: 700; font-size: 14px; cursor: pointer;
108
+ transition: opacity 0.2s;
109
+ }
110
+ .btn-approve:hover { opacity: 0.85; }
111
+ .btn-reject {
112
+ padding: 12px 20px; border-radius: 8px;
113
+ background: none; color: var(--red); border: 1px solid var(--red);
114
+ font-weight: 600; font-size: 14px; cursor: pointer;
115
+ transition: opacity 0.2s;
116
+ }
117
+ .btn-reject:hover { opacity: 0.7; }
118
+ .btn-approve:disabled, .btn-reject:disabled { opacity: 0.4; cursor: not-allowed; }
119
+
120
+ /* ── Result ── */
121
+ #result-surface {
122
+ background: var(--surface);
123
+ border: 1px solid var(--green);
124
+ border-top: none;
125
+ padding: 20px;
126
+ display: none;
127
+ }
128
+ #result-surface.visible { display: block; }
129
+ .result-header { color: var(--green); font-weight: 600; margin-bottom: 8px; }
130
+ .result-body { font-size: 13px; color: var(--text-dim); }
131
+
132
+ /* ── Collapse / footer ── */
133
+ .session-footer {
134
+ background: var(--surface);
135
+ border: 1px solid var(--border);
136
+ border-top: none;
137
+ border-radius: 0 0 12px 12px;
138
+ padding: 10px 20px;
139
+ display: flex; align-items: center; justify-content: space-between;
140
+ font-size: 12px; color: var(--text-dim);
141
+ }
142
+ .btn-collapse {
143
+ background: none; border: 1px solid var(--border); color: var(--text-dim);
144
+ padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
145
+ }
146
+ .btn-collapse:hover { border-color: var(--accent); color: var(--text); }
147
+
148
+ /* ── Loop controls ── */
149
+ #controls {
150
+ margin-top: 40px; width: 100%; max-width: 680px;
151
+ background: var(--surface); border: 1px solid var(--border);
152
+ border-radius: 12px; padding: 20px;
153
+ }
154
+ .controls-title { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
155
+ .btn-start {
156
+ width: 100%; padding: 14px; border-radius: 8px;
157
+ background: var(--accent); color: #fff; border: none;
158
+ font-weight: 700; font-size: 15px; cursor: pointer;
159
+ transition: opacity 0.2s;
160
+ }
161
+ .btn-start:hover { opacity: 0.85; }
162
+ .btn-start:disabled { opacity: 0.4; cursor: not-allowed; }
163
+ .status-line { margin-top: 12px; font-size: 12px; color: var(--text-dim); text-align: center; min-height: 20px; }
164
+
165
+ /* ── Step indicators ── */
166
+ .steps { display: flex; gap: 8px; margin-top: 16px; }
167
+ .step {
168
+ flex: 1; padding: 8px 4px; border-radius: 6px;
169
+ border: 1px solid var(--border); text-align: center;
170
+ font-size: 10px; color: var(--text-dim); transition: all 0.3s;
171
+ }
172
+ .step.active { border-color: var(--accent); background: var(--accent-glow); color: var(--text); }
173
+ .step.done { border-color: var(--green); color: var(--green); }
174
+
175
+ #log { margin-top: 8px; font-size: 11px; color: var(--text-dim); max-height: 60px; overflow-y: auto; }
176
+ </style>
177
+ </head>
178
+ <body>
179
+
180
+ <!-- Ambient state -->
181
+ <div id="ambient">
182
+ <div class="pulse-ring"></div>
183
+ <div class="ambient-label">Ambient</div>
184
+ <div class="ambient-agent">kai · ready</div>
185
+ </div>
186
+
187
+ <!-- Session surface (hidden until run starts) -->
188
+ <div id="session">
189
+ <div class="session-header">
190
+ <div class="agent-dot" id="agent-dot"></div>
191
+ <div class="session-title" id="session-title">Agent run in progress</div>
192
+ <div class="session-state" id="session-state">working</div>
193
+ </div>
194
+ <div class="run-stream" id="run-stream">
195
+ <div class="run-line" style="color:var(--text-dim)">Waiting for run...</div>
196
+ </div>
197
+ <div id="approval-surface" class="hidden">
198
+ <div class="approval-header-row">
199
+ <div class="approval-icon">⚡</div>
200
+ <div>
201
+ <div class="approval-title" id="approval-title">Action requires approval <span class="urgency-badge" id="urgency-badge">critical</span></div>
202
+ <div class="approval-desc" id="approval-desc">Agent is requesting your decision to continue.</div>
203
+ </div>
204
+ </div>
205
+ <div class="approval-actions">
206
+ <button class="btn-reject" id="btn-reject" onclick="decide('reject')">✗ Reject</button>
207
+ <button class="btn-approve" id="btn-approve" onclick="decide('approve')">✓ Approve</button>
208
+ </div>
209
+ </div>
210
+ <div id="result-surface">
211
+ <div class="result-header">✓ Approved — result</div>
212
+ <div class="result-body" id="result-body">Run completed successfully.</div>
213
+ </div>
214
+ <div class="session-footer">
215
+ <span id="run-id-label" style="font-family:monospace"></span>
216
+ <button class="btn-collapse" onclick="collapse()">Collapse to ambient</button>
217
+ </div>
218
+ </div>
219
+
220
+ <!-- Controls -->
221
+ <div id="controls">
222
+ <div class="controls-title">Presence Loop — Live Demo</div>
223
+ <button class="btn-start" id="btn-start" onclick="startLoop()">▶ Start Loop</button>
224
+ <div class="steps">
225
+ <div class="step" id="step-0">Ambient</div>
226
+ <div class="step" id="step-1">Run</div>
227
+ <div class="step" id="step-2">Approval</div>
228
+ <div class="step" id="step-3">Result</div>
229
+ <div class="step" id="step-4">Collapse</div>
230
+ </div>
231
+ <div class="status-line" id="status-line">Click Start to run the loop end-to-end</div>
232
+ <div id="log"></div>
233
+ </div>
234
+
235
+ <script>
236
+ const BASE = window.location.origin;
237
+ let currentRunId = null;
238
+ let currentApprovalId = null;
239
+ let sseSource = null;
240
+
241
+ function log(msg) {
242
+ const el = document.getElementById('log');
243
+ el.innerHTML = msg;
244
+ }
245
+
246
+ function setStatus(msg) {
247
+ document.getElementById('status-line').textContent = msg;
248
+ }
249
+
250
+ function setStep(n) {
251
+ for (let i = 0; i <= 4; i++) {
252
+ const el = document.getElementById('step-' + i);
253
+ el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
254
+ }
255
+ }
256
+
257
+ function addStreamLine(text, active) {
258
+ const stream = document.getElementById('run-stream');
259
+ const line = document.createElement('div');
260
+ line.className = 'run-line' + (active ? ' active' : '');
261
+ const ts = new Date().toLocaleTimeString('en-US', {hour12:false, hour:'2-digit', minute:'2-digit', second:'2-digit'});
262
+ line.innerHTML = '<span class="ts">' + ts + '</span>' + text;
263
+ if (stream.firstChild && stream.firstChild.textContent.includes('Waiting')) {
264
+ stream.innerHTML = '';
265
+ }
266
+ stream.appendChild(line);
267
+ stream.scrollTop = stream.scrollHeight;
268
+ }
269
+
270
+ async function startLoop() {
271
+ document.getElementById('btn-start').disabled = true;
272
+ document.getElementById('ambient').style.opacity = '0';
273
+ document.getElementById('ambient').style.pointerEvents = 'none';
274
+
275
+ setStep(0);
276
+ setStatus('Starting run...');
277
+
278
+ // Step 1: Create run
279
+ try {
280
+ const res = await fetch(BASE + '/agents/kai/runs', {
281
+ method: 'POST',
282
+ headers: {'Content-Type':'application/json'},
283
+ body: JSON.stringify({objective: 'Presence loop demo: ambient → run → approve → result → collapse'})
284
+ });
285
+ const run = await res.json();
286
+ currentRunId = run.id;
287
+ if (!currentRunId) throw new Error('No run ID returned: ' + JSON.stringify(run));
288
+ } catch(e) {
289
+ setStatus('Failed to create run: ' + e.message);
290
+ document.getElementById('btn-start').disabled = false;
291
+ document.getElementById('ambient').style.opacity = '1';
292
+ document.getElementById('ambient').style.pointerEvents = 'all';
293
+ return;
294
+ }
295
+
296
+ document.getElementById('run-id-label').textContent = currentRunId;
297
+ document.getElementById('session').classList.add('visible');
298
+ document.getElementById('session-title').textContent = 'Presence loop demo';
299
+ document.getElementById('session-state').textContent = 'working';
300
+ setStep(1);
301
+ setStatus('Run started — streaming events...');
302
+ addStreamLine('Run created: ' + currentRunId, true);
303
+
304
+ // Subscribe to SSE stream
305
+ if (sseSource) { sseSource.close(); }
306
+ sseSource = new EventSource(BASE + '/agents/kai/stream');
307
+ sseSource.onmessage = function(e) {
308
+ try {
309
+ const evt = JSON.parse(e.data);
310
+ if (evt.eventType === 'review_requested' && evt.runId === currentRunId) {
311
+ showApproval(evt);
312
+ } else if ((evt.eventType === 'review_approved' || evt.eventType === 'review_rejected') && evt.runId === currentRunId) {
313
+ handleDecisionEvent(evt);
314
+ }
315
+ } catch(_) {}
316
+ };
317
+
318
+ // Step 2: Post a step event, then request approval
319
+ await delay(600);
320
+ addStreamLine('Analyzing task requirements...', true);
321
+ await delay(700);
322
+ addStreamLine('Preparing deployment payload...', true);
323
+ await delay(500);
324
+
325
+ // Post review_requested
326
+ try {
327
+ const res = await fetch(BASE + '/agents/kai/events', {
328
+ method: 'POST',
329
+ headers: {'Content-Type':'application/json'},
330
+ body: JSON.stringify({
331
+ eventType: 'review_requested',
332
+ runId: currentRunId,
333
+ payload: {
334
+ title: 'Deploy presence loop update to production',
335
+ action_required: 'Approve deployment to proceed',
336
+ urgency: 'high',
337
+ owner: 'kai',
338
+ description: 'Ready to deploy. This will update the live node. Approve to continue, reject to cancel.',
339
+ rationale: {
340
+ choice: 'Requesting human gate before production deploy',
341
+ considered: ['auto-deploy', 'defer'],
342
+ constraint: 'Production changes require explicit approval'
343
+ }
344
+ }
345
+ })
346
+ });
347
+ const evt = await res.json();
348
+ currentApprovalId = evt.id;
349
+ addStreamLine('⚡ Approval requested — waiting for human decision', true);
350
+ setStep(2);
351
+ setStatus('Waiting for your approval...');
352
+ showApproval({
353
+ id: currentApprovalId,
354
+ payload: {
355
+ title: 'Deploy presence loop update to production',
356
+ description: 'Ready to deploy. This will update the live node. Approve to continue, reject to cancel.',
357
+ urgency: 'high'
358
+ }
359
+ });
360
+ } catch(e) {
361
+ addStreamLine('Failed to post approval request: ' + e.message, false);
362
+ setStatus('Error: ' + e.message);
363
+ }
364
+ }
365
+
366
+ function showApproval(evt) {
367
+ const payload = evt.payload || {};
368
+ document.getElementById('approval-title').innerHTML =
369
+ (payload.title || 'Action requires approval') +
370
+ ' <span class="urgency-badge">' + (payload.urgency || 'high') + '</span>';
371
+ document.getElementById('approval-desc').textContent =
372
+ payload.description || payload.action_required || 'Agent is requesting your decision.';
373
+ document.getElementById('approval-surface').classList.remove('hidden');
374
+ document.getElementById('session-state').textContent = 'waiting for approval';
375
+ if (!currentApprovalId && evt.id) currentApprovalId = evt.id;
376
+ }
377
+
378
+ async function decide(decision) {
379
+ if (!currentApprovalId) { setStatus('No approval event ID — run the loop first'); return; }
380
+ document.getElementById('btn-approve').disabled = true;
381
+ document.getElementById('btn-reject').disabled = true;
382
+ setStatus('Submitting decision...');
383
+
384
+ try {
385
+ const res = await fetch(BASE + '/approval-queue/' + encodeURIComponent(currentApprovalId) + '/decide', {
386
+ method: 'POST',
387
+ headers: {'Content-Type':'application/json'},
388
+ body: JSON.stringify({ decision: decision, actor: 'human' })
389
+ });
390
+ const data = await res.json();
391
+ if (!res.ok) throw new Error(data.error || 'Decision failed');
392
+
393
+ document.getElementById('approval-surface').classList.add('hidden');
394
+
395
+ if (decision === 'approve') {
396
+ addStreamLine('✓ Approved by human — continuing...', true);
397
+ await delay(400);
398
+ addStreamLine('Deploying update...', true);
399
+ await delay(600);
400
+ addStreamLine('Deploy complete. Node healthy.', true);
401
+ showResult('Deployment approved and complete. Node confirmed healthy at /health.');
402
+ setStep(3);
403
+ setStatus('Approved — result visible');
404
+ } else {
405
+ addStreamLine('✗ Rejected — run cancelled', false);
406
+ showResult('Run rejected by human. No changes deployed.');
407
+ document.getElementById('result-surface').style.borderColor = 'var(--red)';
408
+ document.querySelector('#result-surface .result-header').style.color = 'var(--red)';
409
+ document.querySelector('#result-surface .result-header').textContent = '✗ Rejected';
410
+ setStep(3);
411
+ setStatus('Rejected — run cancelled');
412
+ }
413
+ } catch(e) {
414
+ setStatus('Decision error: ' + e.message);
415
+ document.getElementById('btn-approve').disabled = false;
416
+ document.getElementById('btn-reject').disabled = false;
417
+ }
418
+ }
419
+
420
+ function handleDecisionEvent(evt) {
421
+ // SSE confirmation — already handled by direct button flow
422
+ }
423
+
424
+ function showResult(text) {
425
+ document.getElementById('result-body').textContent = text;
426
+ document.getElementById('result-surface').classList.add('visible');
427
+ document.getElementById('session-state').textContent = 'complete';
428
+ const dot = document.getElementById('agent-dot');
429
+ dot.style.background = 'var(--green)';
430
+ dot.style.animation = 'none';
431
+ }
432
+
433
+ function collapse() {
434
+ setStep(4);
435
+ setStatus('Collapsing to ambient...');
436
+ const session = document.getElementById('session');
437
+ session.style.opacity = '0';
438
+ session.style.transform = 'translateY(20px)';
439
+
440
+ setTimeout(function() {
441
+ session.classList.remove('visible');
442
+ session.style.opacity = '';
443
+ session.style.transform = '';
444
+
445
+ // Reset for next run
446
+ document.getElementById('run-stream').innerHTML = '<div class="run-line" style="color:var(--text-dim)">Waiting for run...</div>';
447
+ document.getElementById('approval-surface').classList.add('hidden');
448
+ document.getElementById('result-surface').classList.remove('visible');
449
+ document.getElementById('result-surface').style.borderColor = '';
450
+ document.querySelector('#result-surface .result-header').style.color = '';
451
+ document.querySelector('#result-surface .result-header').textContent = '✓ Approved — result';
452
+ document.getElementById('btn-approve').disabled = false;
453
+ document.getElementById('btn-reject').disabled = false;
454
+ document.getElementById('session-state').textContent = 'working';
455
+ const dot = document.getElementById('agent-dot');
456
+ dot.style.background = '';
457
+ dot.style.animation = '';
458
+ currentRunId = null;
459
+ currentApprovalId = null;
460
+ if (sseSource) { sseSource.close(); sseSource = null; }
461
+
462
+ document.getElementById('ambient').style.opacity = '1';
463
+ document.getElementById('ambient').style.pointerEvents = 'all';
464
+ document.getElementById('btn-start').disabled = false;
465
+ setStep(0);
466
+ setStatus('Back to ambient. Loop complete. ✓');
467
+ }, 800);
468
+ }
469
+
470
+ function delay(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
471
+ </script>
472
+ </body>
473
+ </html>