loopengine 0.1.2 → 0.1.4

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.
@@ -0,0 +1,1271 @@
1
+ // A local dev playground: a single self-contained HTML page (no build
2
+ // step, no framework, no CDN) served by adapters/http.ts at GET
3
+ // /playground. It's a browser client on top of the *existing*
4
+ // /agents/:name/messages/stream route — nothing here talks to runAgent,
5
+ // SessionStore, or ActAuth directly; it only renders the same SSE events
6
+ // that route already emits, live, instead of leaving `curl -N` as the
7
+ // only way to see the loop actually think.
8
+ //
9
+ // Embedded as a TS string (not a separate .html file) so tsc's ordinary
10
+ // compile emits it straight into dist/adapters/ alongside http.js — no
11
+ // asset-copy step needed in the build or the Dockerfile.
12
+ //
13
+ // The inline <script> below is deliberately written without backticks or
14
+ // ${} — it lives inside this outer TS template literal, so a nested
15
+ // template literal would need every backtick/${ escaped relative to the
16
+ // outer one. Plain string concatenation sidesteps that class of bug
17
+ // entirely rather than relying on getting every escape right.
18
+ import { devUiCss } from './dev-ui-styles.js';
19
+ export const playgroundHtml = `<!doctype html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="utf-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1">
24
+ <title>LoopEngine Playground</title>
25
+ <style>${devUiCss}
26
+ body {
27
+ display: flex;
28
+ flex-direction: column;
29
+ height: 100vh;
30
+ }
31
+ header {
32
+ padding: 10px 16px;
33
+ border-bottom: 1px solid light-dark(#ddd, #333);
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 12px;
37
+ flex-wrap: wrap;
38
+ }
39
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; }
40
+ #agentCaption {
41
+ font-size: 12px;
42
+ color: light-dark(#666, #999);
43
+ max-width: 360px;
44
+ overflow: hidden;
45
+ text-overflow: ellipsis;
46
+ white-space: nowrap;
47
+ }
48
+ #configLink { font-size: 12px; white-space: nowrap; text-decoration: none; }
49
+ #sessionLabel {
50
+ margin-left: auto;
51
+ font-size: 12px;
52
+ font-family: ui-monospace, monospace;
53
+ color: light-dark(#666, #999);
54
+ }
55
+ #sessionLabel.copyable { cursor: pointer; }
56
+ #sessionLabel.copyable:hover { text-decoration: underline; }
57
+ main {
58
+ flex: 1;
59
+ display: flex;
60
+ min-height: 0;
61
+ }
62
+ .pane {
63
+ flex: 1;
64
+ display: flex;
65
+ flex-direction: column;
66
+ min-width: 0;
67
+ border-right: 1px solid light-dark(#ddd, #333);
68
+ }
69
+ .pane:last-child { border-right: none; }
70
+ .sessions-pane { flex: 0 0 200px; }
71
+ .sessions-pane .pane-body { padding: 6px; gap: 2px; }
72
+ .session-item {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 4px;
76
+ border-radius: 6px;
77
+ padding: 6px 8px;
78
+ cursor: pointer;
79
+ }
80
+ .session-item:hover { background: light-dark(#f0f0f1, #26262b); }
81
+ .session-item.active { background: light-dark(#dbeafe, #1e3a5f); }
82
+ .session-item .preview {
83
+ flex: 1;
84
+ min-width: 0;
85
+ font-size: 12px;
86
+ overflow: hidden;
87
+ text-overflow: ellipsis;
88
+ white-space: nowrap;
89
+ }
90
+ .session-item .session-time { font-size: 10px; color: light-dark(#999, #777); flex-shrink: 0; }
91
+ .session-item .session-remove {
92
+ flex-shrink: 0;
93
+ border: none;
94
+ background: none;
95
+ padding: 0 2px;
96
+ font-size: 13px;
97
+ line-height: 1;
98
+ color: light-dark(#999, #777);
99
+ display: none;
100
+ }
101
+ .session-item:hover .session-remove { display: block; }
102
+ .session-item .session-remove:hover { color: light-dark(#991b1b, #f87171); }
103
+ .pane h2 {
104
+ font-size: 12px;
105
+ text-transform: uppercase;
106
+ letter-spacing: 0.04em;
107
+ color: light-dark(#666, #999);
108
+ margin: 0;
109
+ padding: 8px 12px;
110
+ border-bottom: 1px solid light-dark(#eee, #2a2a2e);
111
+ }
112
+ .pane-body {
113
+ flex: 1;
114
+ overflow-y: auto;
115
+ padding: 10px 12px;
116
+ display: flex;
117
+ flex-direction: column;
118
+ gap: 8px;
119
+ }
120
+ .msg { max-width: 90%; }
121
+ .msg-label {
122
+ font-size: 10px;
123
+ text-transform: uppercase;
124
+ color: light-dark(#999, #777);
125
+ margin-bottom: 2px;
126
+ }
127
+ .msg-body {
128
+ padding: 8px 10px;
129
+ border-radius: 8px;
130
+ white-space: pre-wrap;
131
+ word-break: break-word;
132
+ }
133
+ .msg-user { align-self: flex-end; }
134
+ .msg-user .msg-body { background: light-dark(#dbeafe, #1e3a5f); }
135
+ .msg-assistant .msg-body { background: light-dark(#fff, #2a2a2e); border: 1px solid light-dark(#ddd, #3a3a3e); }
136
+ .msg-error .msg-body { background: light-dark(#fee2e2, #4a1f1f); color: light-dark(#991b1b, #f87171); }
137
+ .msg-stopped .msg-body { font-style: italic; color: light-dark(#666, #999); background: light-dark(#f3f3f4, #26262b); }
138
+ .msg-thinking .msg-body { display: inline-flex; gap: 4px; padding: 11px 10px; }
139
+ .msg-approval .msg-body {
140
+ background: light-dark(#fefce8, #422006);
141
+ border: 1px solid light-dark(#eab308, #a16207);
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 6px;
145
+ }
146
+ .approval-tool { font-family: ui-monospace, monospace; font-weight: 600; }
147
+ .approval-scope { font-size: 11px; color: light-dark(#666, #999); }
148
+ .approval-reason { font-size: 12px; }
149
+ .approval-args {
150
+ font-size: 11px;
151
+ margin: 0;
152
+ background: light-dark(#fff, #26262b);
153
+ border-radius: 6px;
154
+ padding: 6px 8px;
155
+ overflow-x: auto;
156
+ white-space: pre-wrap;
157
+ word-break: break-word;
158
+ }
159
+ .approval-actions { display: flex; gap: 8px; }
160
+ .approval-actions button { font-size: 12px; padding: 5px 12px; }
161
+ .approval-actions .approve { background: light-dark(#dcfce7, #14532d); }
162
+ .approval-actions .deny { background: light-dark(#fee2e2, #450a0a); }
163
+ .approval-status { font-size: 12px; font-style: italic; color: light-dark(#666, #999); }
164
+ .msg-question .msg-body {
165
+ background: light-dark(#eff6ff, #1e293b);
166
+ border: 1px solid light-dark(#3b82f6, #60a5fa);
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 6px;
170
+ }
171
+ .question-text { font-size: 13px; }
172
+ .question-options { display: flex; flex-wrap: wrap; gap: 6px; }
173
+ .question-options button { font-size: 12px; padding: 5px 12px; }
174
+ .question-answer-row { display: flex; gap: 6px; }
175
+ .question-answer-row input { flex: 1; font-size: 12px; }
176
+ .question-answer-row button { font-size: 12px; padding: 5px 12px; }
177
+ .question-status { font-size: 12px; font-style: italic; color: light-dark(#666, #999); }
178
+ .dot {
179
+ width: 6px;
180
+ height: 6px;
181
+ border-radius: 50%;
182
+ background: light-dark(#999, #777);
183
+ animation: dot-blink 1.4s infinite both;
184
+ }
185
+ .dot:nth-child(2) { animation-delay: 0.2s; }
186
+ .dot:nth-child(3) { animation-delay: 0.4s; }
187
+ @keyframes dot-blink { 0%, 80%, 100% { opacity: 0.2; } 40% { opacity: 1; } }
188
+ .empty-hint { padding: 10px 2px; }
189
+ .event {
190
+ border-left: 3px solid light-dark(#ccc, #444);
191
+ padding: 4px 8px;
192
+ font-size: 12px;
193
+ }
194
+ .event-label { font-weight: 600; font-family: ui-monospace, monospace; }
195
+ .event-data {
196
+ margin: 2px 0 0;
197
+ font-family: ui-monospace, monospace;
198
+ font-size: 11px;
199
+ white-space: pre-wrap;
200
+ word-break: break-word;
201
+ color: light-dark(#444, #aaa);
202
+ }
203
+ .event-actauth { border-left-color: #f59e0b; }
204
+ .event-toollane { border-left-color: #3b82f6; }
205
+ .event-budget { border-left-color: #8b5cf6; }
206
+ .event-prompt { border-left-color: #ef4444; }
207
+ .event-skill { border-left-color: #10b981; }
208
+ .event-loop { border-left-color: #6b7280; }
209
+ .event-approval { border-left-color: #eab308; }
210
+ .event-question { border-left-color: #3b82f6; }
211
+ .composer {
212
+ border-top: 1px solid light-dark(#ddd, #333);
213
+ padding: 10px 12px;
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 8px;
217
+ }
218
+ .send-row { display: flex; gap: 8px; }
219
+ #messageInput { flex: 1; resize: vertical; height: 90px; }
220
+ #messageInput:disabled, #sendButton:disabled { cursor: not-allowed; }
221
+ details summary { cursor: pointer; font-size: 12px; color: light-dark(#666, #999); }
222
+ .advanced-hint { margin: 6px 0 0; font-size: 11px; }
223
+ .advanced-fields { display: flex; gap: 8px; margin-top: 6px; }
224
+ .advanced-fields > div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
225
+ .advanced-fields label { font-size: 11px; color: light-dark(#666, #999); }
226
+ .advanced-fields textarea { height: 50px; font-family: ui-monospace, monospace; font-size: 11px; resize: vertical; }
227
+ @media (max-width: 720px) {
228
+ main { flex-direction: column; }
229
+ .pane { border-right: none; border-bottom: 1px solid light-dark(#ddd, #333); min-height: 160px; }
230
+ .pane:last-child { border-bottom: none; }
231
+ .sessions-pane { flex-basis: auto; }
232
+ }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <nav class="topnav">
237
+ <a href="/agents">Agents</a>
238
+ <a href="/agents/config">Config</a>
239
+ <a href="/playground" class="active">Playground</a>
240
+ </nav>
241
+ <header>
242
+ <h1>LoopEngine Playground</h1>
243
+ <select id="agentSelect"></select>
244
+ <span id="agentCaption"></span>
245
+ <button id="newConversationButton" type="button">New conversation</button>
246
+ <a id="configLink" href="/agents/config">Agent config &rarr;</a>
247
+ <span id="sessionLabel">session: (new)</span>
248
+ </header>
249
+ <main>
250
+ <section class="pane sessions-pane">
251
+ <h2>Sessions</h2>
252
+ <div class="pane-body" id="sessionsPane"></div>
253
+ </section>
254
+ <section class="pane">
255
+ <h2>Chat</h2>
256
+ <div class="pane-body" id="chatPane"></div>
257
+ <div class="composer">
258
+ <details>
259
+ <summary>Advanced</summary>
260
+ <p class="advanced-hint muted">For agents whose sessionIdFor/tenantFor need something beyond a plain sessionId — e.g. customer-service reads customerEmail from the body.</p>
261
+ <div class="advanced-fields">
262
+ <div>
263
+ <label for="extraHeadersInput">Extra headers (one "key: value" per line)</label>
264
+ <textarea id="extraHeadersInput" placeholder="x-api-key: acme-trusted-key"></textarea>
265
+ </div>
266
+ <div>
267
+ <label for="extraBodyInput">Extra body fields (JSON)</label>
268
+ <textarea id="extraBodyInput" placeholder='{"customerEmail": "a@example.com"}'></textarea>
269
+ </div>
270
+ </div>
271
+ </details>
272
+ <div class="send-row">
273
+ <textarea id="messageInput" placeholder="Message&hellip; (Enter to send, Shift+Enter for a new line)"></textarea>
274
+ <button id="sendButton" type="button">Send</button>
275
+ </div>
276
+ </div>
277
+ </section>
278
+ <section class="pane">
279
+ <h2>Loop events</h2>
280
+ <div class="pane-body" id="timelinePane"></div>
281
+ </section>
282
+ </main>
283
+ <script>
284
+ (function () {
285
+ var agentSelect = document.getElementById('agentSelect');
286
+ var agentCaption = document.getElementById('agentCaption');
287
+ var configLink = document.getElementById('configLink');
288
+ var sessionLabel = document.getElementById('sessionLabel');
289
+ var chatPane = document.getElementById('chatPane');
290
+ var timelinePane = document.getElementById('timelinePane');
291
+ var sessionsPane = document.getElementById('sessionsPane');
292
+ var messageInput = document.getElementById('messageInput');
293
+ var sendButton = document.getElementById('sendButton');
294
+ var newConversationButton = document.getElementById('newConversationButton');
295
+ var extraHeadersInput = document.getElementById('extraHeadersInput');
296
+ var extraBodyInput = document.getElementById('extraBodyInput');
297
+
298
+ var currentAgents = [];
299
+ var sessionId = null;
300
+ var isSending = false;
301
+ var thinkingEl = null;
302
+ // Set right before a message is sent, consumed by handleFrame's own
303
+ // 'session' branch once the server echoes back which sessionId this
304
+ // turn actually used (brand new or resumed) — that's the only point
305
+ // this code knows for certain "this session id is real and this is
306
+ // what the user just said to it," the two things rememberSession needs.
307
+ var lastSentMessage = null;
308
+
309
+ // Keyed by tool_use id — a 'tool:started' card (see appendToolCallCard
310
+ // below) parks its status <div> here so the later 'tool:result' event
311
+ // for the exact same call (run-agent.ts emits both with the same id)
312
+ // can update it in place instead of appending a second, redundant card.
313
+ // Cleared on every conversation switch (resetConversation/resumeSession)
314
+ // since a card from a previous session/turn is never a valid update
315
+ // target for one that happens to reuse an id namespace.
316
+ var toolCallCardsById = {};
317
+
318
+ // A page refresh only ever loses *this tab's* JS state — the actual
319
+ // conversation is durably stored server-side (see session-store.ts).
320
+ // What's missing after a refresh is just "which session ids did I
321
+ // start," so a browser-local list of {agent, sessionId, preview,
322
+ // updatedAt} is enough to rebuild a resumable list; GET
323
+ // /agents/:name/sessions/:id (resumeSession below) rehydrates the full
324
+ // conversation from the real store once one's picked.
325
+ var SESSIONS_STORAGE_KEY = 'loopengine-playground-sessions';
326
+ var MAX_SAVED_SESSIONS = 50;
327
+
328
+ function clearEmptyHint(pane) {
329
+ var hint = pane.querySelector('.empty-hint');
330
+ if (hint) hint.remove();
331
+ }
332
+
333
+ function setEmptyHint(pane, text) {
334
+ pane.innerHTML = '';
335
+ var p = document.createElement('p');
336
+ p.className = 'empty-hint muted';
337
+ p.textContent = text;
338
+ pane.appendChild(p);
339
+ }
340
+
341
+ function appendChatMessage(role, text) {
342
+ clearEmptyHint(chatPane);
343
+ var div = document.createElement('div');
344
+ div.className = 'msg msg-' + role;
345
+ var label = document.createElement('div');
346
+ label.className = 'msg-label';
347
+ label.textContent = role;
348
+ var body = document.createElement('div');
349
+ body.className = 'msg-body';
350
+ body.textContent = text;
351
+ div.appendChild(label);
352
+ div.appendChild(body);
353
+ chatPane.appendChild(div);
354
+ chatPane.scrollTop = chatPane.scrollHeight;
355
+ return div;
356
+ }
357
+
358
+ // Only ever called for a resumeContext'd card (see decide()/answer()
359
+ // below, both gated on it the same way) — a live SSE-pushed card has no
360
+ // resumeContext and never calls this, since its own connection already
361
+ // independently shows whatever comes next via handleFrame. Renders
362
+ // exactly what that same live connection would have shown for this
363
+ // response: another pending card, or the final text — the decide/
364
+ // answer response itself already says which (see adapters/http.ts's own
365
+ // raceAndRespond), so there's nothing left to poll or replay history
366
+ // for. Replaced pollForCompletion, which used to re-fetch and replay
367
+ // session history to find out the same thing.
368
+ function applyDecisionResult(respBody, resumeContext) {
369
+ if (respBody.pending) {
370
+ if (respBody.type === 'question') appendQuestionCard(respBody, resumeContext);
371
+ else appendApprovalCard(respBody, resumeContext);
372
+ return;
373
+ }
374
+ var doneEl = appendChatMessage('assistant', respBody.text);
375
+ if (respBody.stopReason) doneEl.classList.add('msg-stopped');
376
+ }
377
+
378
+ // A 'ask' decision arrived mid-turn (see run-agent.ts's gate.evaluate)
379
+ // and the server's default approver is now a tracked WebchatApprover (see
380
+ // web/web-approver.ts's createTrackedApprover) instead of a blocking
381
+ // terminal prompt — it parked this one and pushed it straight onto this
382
+ // exact SSE connection via onPending, so it can be decided right here
383
+ // instead of on a page
384
+ // nobody's watching. The rest of the turn (toollane:result, done, ...)
385
+ // keeps streaming into this same response once decide() resolves it.
386
+ function appendApprovalCard(data, resumeContext) {
387
+ clearEmptyHint(chatPane);
388
+ var div = document.createElement('div');
389
+ div.className = 'msg msg-assistant msg-approval';
390
+ var label = document.createElement('div');
391
+ label.className = 'msg-label';
392
+ label.textContent = 'approval needed';
393
+ var body = document.createElement('div');
394
+ body.className = 'msg-body';
395
+
396
+ var tool = document.createElement('div');
397
+ tool.className = 'approval-tool';
398
+ tool.textContent = data.tool;
399
+
400
+ var scope = document.createElement('div');
401
+ scope.className = 'approval-scope';
402
+ scope.textContent = data.scope.tenant + '/' + data.scope.environment + '/' + data.scope.agent;
403
+
404
+ var reason = document.createElement('div');
405
+ reason.className = 'approval-reason';
406
+ reason.textContent = data.reason;
407
+
408
+ var pre = document.createElement('pre');
409
+ pre.className = 'approval-args';
410
+ pre.textContent = JSON.stringify(data.args, null, 2);
411
+
412
+ var actions = document.createElement('div');
413
+ actions.className = 'approval-actions';
414
+ var approveBtn = document.createElement('button');
415
+ approveBtn.type = 'button';
416
+ approveBtn.className = 'approve';
417
+ approveBtn.textContent = 'Approve';
418
+ var denyBtn = document.createElement('button');
419
+ denyBtn.type = 'button';
420
+ denyBtn.className = 'deny';
421
+ denyBtn.textContent = 'Deny';
422
+ actions.appendChild(approveBtn);
423
+ actions.appendChild(denyBtn);
424
+
425
+ body.appendChild(tool);
426
+ body.appendChild(scope);
427
+ body.appendChild(reason);
428
+ body.appendChild(pre);
429
+ body.appendChild(actions);
430
+ div.appendChild(label);
431
+ div.appendChild(body);
432
+ chatPane.appendChild(div);
433
+ chatPane.scrollTop = chatPane.scrollHeight;
434
+
435
+ function decide(approved) {
436
+ approveBtn.disabled = true;
437
+ denyBtn.disabled = true;
438
+ // Live connection only: shown *synchronously*, before fetch() even
439
+ // starts — not inside its .then(). adapters/http.ts's own decide
440
+ // route now races the rest of the turn the same way POST /messages
441
+ // does (see raceAndRespond), so this response and the SSE 'done'/
442
+ // 'approval:pending' event for the very same outcome can land in
443
+ // either order — confirmed live. Calling showThinking() here is
444
+ // guaranteed to happen before either one, since nothing async can
445
+ // run before this synchronous click handler finishes; putting it in
446
+ // the .then() instead meant that whenever the SSE frame won the
447
+ // race and called removeThinking() *first*, this call landed after
448
+ // and left an orphaned thinking indicator nothing would ever clear
449
+ // — the "stuck on pending" bug.
450
+ if (!resumeContext) showThinking();
451
+ fetch('/approvals/' + encodeURIComponent(data.id) + '/' + (approved ? 'approve' : 'deny'), { method: 'POST' })
452
+ .then(function (r) {
453
+ return r.json().then(function (respBody) {
454
+ if (!r.ok) throw new Error(respBody.error || ('HTTP ' + r.status));
455
+ return respBody;
456
+ });
457
+ })
458
+ .then(function (respBody) {
459
+ actions.remove();
460
+ var status = document.createElement('div');
461
+ status.className = 'approval-status';
462
+ status.textContent = approved ? 'Approved.' : 'Denied.';
463
+ body.appendChild(status);
464
+ // Resumed: no live connection to show what's next on its own,
465
+ // so use the response directly (see applyDecisionResult).
466
+ // Live: nothing further to do here — the SSE stream (which may
467
+ // well have already fired by now) is what shows whatever comes
468
+ // next; this response was only ever needed to confirm the
469
+ // decision was recorded.
470
+ if (resumeContext) applyDecisionResult(respBody, resumeContext);
471
+ })
472
+ .catch(function (err) {
473
+ approveBtn.disabled = false;
474
+ denyBtn.disabled = false;
475
+ if (!resumeContext) removeThinking();
476
+ alert('Could not record decision: ' + err.message);
477
+ });
478
+ }
479
+
480
+ approveBtn.addEventListener('click', function () { decide(true); });
481
+ denyBtn.addEventListener('click', function () { decide(false); });
482
+ }
483
+
484
+ // The model called the system ask_user tool (see
485
+ // system-tools/ask_user.ts) — same "parked and pushed onto this exact
486
+ // SSE connection" mechanism appendApprovalCard uses, just answered with
487
+ // an arbitrary string instead of a fixed allow/deny. Suggested options
488
+ // (if the model gave any) render as one-click buttons; free text is
489
+ // always available too, since the tool never restricts the answer to
490
+ // just those.
491
+ function appendQuestionCard(data, resumeContext) {
492
+ clearEmptyHint(chatPane);
493
+ var div = document.createElement('div');
494
+ div.className = 'msg msg-assistant msg-question';
495
+ var label = document.createElement('div');
496
+ label.className = 'msg-label';
497
+ label.textContent = 'answer needed';
498
+ var body = document.createElement('div');
499
+ body.className = 'msg-body';
500
+
501
+ var text = document.createElement('div');
502
+ text.className = 'question-text';
503
+ text.textContent = data.question;
504
+ body.appendChild(text);
505
+
506
+ var optionsRow = null;
507
+ if (data.options && data.options.length) {
508
+ optionsRow = document.createElement('div');
509
+ optionsRow.className = 'question-options';
510
+ for (var i = 0; i < data.options.length; i++) {
511
+ (function (option) {
512
+ var btn = document.createElement('button');
513
+ btn.type = 'button';
514
+ btn.textContent = option;
515
+ btn.addEventListener('click', function () { answer(option); });
516
+ optionsRow.appendChild(btn);
517
+ })(data.options[i]);
518
+ }
519
+ body.appendChild(optionsRow);
520
+ }
521
+
522
+ var answerRow = document.createElement('div');
523
+ answerRow.className = 'question-answer-row';
524
+ var input = document.createElement('input');
525
+ input.type = 'text';
526
+ input.placeholder = 'Type an answer\\u2026';
527
+ var sendBtn = document.createElement('button');
528
+ sendBtn.type = 'button';
529
+ sendBtn.textContent = 'Send';
530
+ answerRow.appendChild(input);
531
+ answerRow.appendChild(sendBtn);
532
+ body.appendChild(answerRow);
533
+
534
+ div.appendChild(label);
535
+ div.appendChild(body);
536
+ chatPane.appendChild(div);
537
+ chatPane.scrollTop = chatPane.scrollHeight;
538
+ input.focus();
539
+
540
+ function answer(value) {
541
+ if (!value) return;
542
+ input.disabled = true;
543
+ sendBtn.disabled = true;
544
+ if (optionsRow) optionsRow.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
545
+ // See appendApprovalCard's own decide() for why this fires
546
+ // synchronously, before fetch(), instead of inside its .then() —
547
+ // same race against the SSE stream, same fix.
548
+ if (!resumeContext) showThinking();
549
+ fetch('/questions/' + encodeURIComponent(data.id) + '/answer', {
550
+ method: 'POST',
551
+ headers: { 'content-type': 'application/json' },
552
+ body: JSON.stringify({ answer: value }),
553
+ })
554
+ .then(function (r) {
555
+ return r.json().then(function (respBody) {
556
+ if (!r.ok) throw new Error(respBody.error || ('HTTP ' + r.status));
557
+ return respBody;
558
+ });
559
+ })
560
+ .then(function (respBody) {
561
+ answerRow.remove();
562
+ if (optionsRow) optionsRow.remove();
563
+ var status = document.createElement('div');
564
+ status.className = 'question-status';
565
+ status.textContent = 'Answered: ' + value;
566
+ body.appendChild(status);
567
+ if (resumeContext) applyDecisionResult(respBody, resumeContext);
568
+ })
569
+ .catch(function (err) {
570
+ input.disabled = false;
571
+ sendBtn.disabled = false;
572
+ if (optionsRow) optionsRow.querySelectorAll('button').forEach(function (b) { b.disabled = false; });
573
+ if (!resumeContext) removeThinking();
574
+ alert('Could not send answer: ' + err.message);
575
+ });
576
+ }
577
+
578
+ sendBtn.addEventListener('click', function () { answer(input.value.trim()); });
579
+ input.addEventListener('keydown', function (e) {
580
+ if (e.key === 'Enter') answer(input.value.trim());
581
+ });
582
+ }
583
+
584
+ // Every tool call reconstructed from stored history (see
585
+ // renderHistoryMessage below) — success, denied, or skipped alike — same
586
+ // look as a live/resumed appendApprovalCard, minus Approve/Deny (already
587
+ // resolved either way) and minus scope (not something a tool_result's
588
+ // plain string content carries, and not worth threading
589
+ // agent/tenant/environment all the way through just to show three words
590
+ // nobody's questioning by this point). Without this, a refresh
591
+ // collapsed a denied call down to one bare "Denied: <tool> (<reason>)"
592
+ // line and a successful one down to nothing at all — confirmed live
593
+ // that refreshing after approving firecrawl_FIRECRAWL_SCRAPE left no
594
+ // way to see what URL had even been requested, or what it returned.
595
+ //
596
+ // Same yellow .msg-approval styling regardless of outcome (approved,
597
+ // denied, skipped, or error) — statusText is the only thing that
598
+ // changes; deliberately not recolored per outcome afterward.
599
+ //
600
+ // Labeled the same as the live card this same event would have shown
601
+ // ("approval needed"/appendApprovalCard, "answer needed"/
602
+ // appendQuestionCard) — not a distinct "tool call" label — so live and
603
+ // refreshed views of the exact same event read the same way instead of
604
+ // looking like two different kinds of thing.
605
+ function appendToolCallCard(toolName, args, detailText, statusText, id) {
606
+ clearEmptyHint(chatPane);
607
+ var div = document.createElement('div');
608
+ div.className = 'msg msg-assistant msg-approval';
609
+ var label = document.createElement('div');
610
+ label.className = 'msg-label';
611
+ label.textContent = toolName === 'system_ask_user' ? 'answer needed' : 'approval needed';
612
+ var body = document.createElement('div');
613
+ body.className = 'msg-body';
614
+
615
+ var tool = document.createElement('div');
616
+ tool.className = 'approval-tool';
617
+ tool.textContent = toolName;
618
+ body.appendChild(tool);
619
+
620
+ if (detailText) {
621
+ var reason = document.createElement('div');
622
+ reason.className = 'approval-reason';
623
+ reason.textContent = detailText;
624
+ body.appendChild(reason);
625
+ }
626
+
627
+ if (args !== undefined) {
628
+ var pre = document.createElement('pre');
629
+ pre.className = 'approval-args';
630
+ pre.textContent = JSON.stringify(args, null, 2);
631
+ body.appendChild(pre);
632
+ }
633
+
634
+ var status = document.createElement('div');
635
+ status.className = 'approval-status';
636
+ status.textContent = statusText;
637
+ body.appendChild(status);
638
+
639
+ div.appendChild(label);
640
+ div.appendChild(body);
641
+ chatPane.appendChild(div);
642
+ chatPane.scrollTop = chatPane.scrollHeight;
643
+ // Only a 'tool:started' card (the one phase with a real "later update"
644
+ // coming) registers itself here — see updateToolCallCard below.
645
+ if (id) toolCallCardsById[id] = status;
646
+ return div;
647
+ }
648
+
649
+ // Turns a 'tool:started' card's "Running…" placeholder into its real
650
+ // outcome in place, rather than appending a second card right under it
651
+ // — same call, same id, just the second half of the same event pushed
652
+ // by run-agent.ts once execution actually finishes. Returns whether an
653
+ // in-place update happened; the caller falls back to a fresh
654
+ // appendToolCallCard when it didn't (a denied/skipped call, which never
655
+ // gets a 'tool:started' card in the first place, or an interactively-
656
+ // asked call this handleFrame chose not to render one for at all —
657
+ // see wasAskedInteractively below).
658
+ function updateToolCallCard(id, statusText) {
659
+ var status = id && toolCallCardsById[id];
660
+ if (!status) return false;
661
+ delete toolCallCardsById[id];
662
+ status.textContent = statusText;
663
+ return true;
664
+ }
665
+
666
+ // run-agent.ts is a pure engine now: 'tool:started'/'tool:result' fire
667
+ // unconditionally for every call, whether or not a human was actually
668
+ // asked live via an approval card (see appendApprovalCard). Deciding
669
+ // whether that duplicates something already on screen is a rendering
670
+ // call, made here instead. actauth's own Gate only ever appends this
671
+ // exact suffix to a decision's reason when the rule it matched said
672
+ // 'ask' in the first place (see actauth's own gate.ts) - the one
673
+ // reliable way to tell "a human was actually asked, live" apart from a
674
+ // straight allow/deny rule that never asked anyone anything. Without
675
+ // this check, every interactively-approved or -denied call shows up
676
+ // twice: once as the approval card itself, once more as a redundant
677
+ // second "approval needed" card right under it.
678
+ function wasAskedInteractively(detailText) {
679
+ return !!detailText && (detailText.indexOf(' \\u2014 human approved') !== -1 || detailText.indexOf(' \\u2014 human denied') !== -1);
680
+ }
681
+
682
+ function showThinking() {
683
+ var div = appendChatMessage('assistant', '');
684
+ div.classList.add('msg-thinking');
685
+ div.querySelector('.msg-body').innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
686
+ thinkingEl = div;
687
+ }
688
+
689
+ function removeThinking() {
690
+ if (thinkingEl) {
691
+ thinkingEl.remove();
692
+ thinkingEl = null;
693
+ }
694
+ }
695
+
696
+ function eventCategory(eventName) {
697
+ var idx = eventName.indexOf(':');
698
+ return idx === -1 ? '' : 'event-' + eventName.slice(0, idx);
699
+ }
700
+
701
+ function loadSavedSessions() {
702
+ try {
703
+ var raw = localStorage.getItem(SESSIONS_STORAGE_KEY);
704
+ var parsed = raw ? JSON.parse(raw) : [];
705
+ return Array.isArray(parsed) ? parsed : [];
706
+ } catch (err) {
707
+ return [];
708
+ }
709
+ }
710
+
711
+ function saveSavedSessions(list) {
712
+ try {
713
+ localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(list));
714
+ } catch (err) {
715
+ // best-effort — private browsing / a full quota just means this
716
+ // session won't be in the list after a refresh, not a hard failure.
717
+ }
718
+ }
719
+
720
+ // Most-recently-used first, one entry per {agent, sessionId} (a resumed
721
+ // session that gets a new message moves back to the top instead of
722
+ // duplicating), capped so this can't grow without bound over a long
723
+ // browser lifetime.
724
+ function rememberSession(agent, id, preview) {
725
+ var list = loadSavedSessions().filter(function (s) { return !(s.agent === agent && s.sessionId === id); });
726
+ list.unshift({ agent: agent, sessionId: id, preview: preview, updatedAt: Date.now() });
727
+ if (list.length > MAX_SAVED_SESSIONS) list = list.slice(0, MAX_SAVED_SESSIONS);
728
+ saveSavedSessions(list);
729
+ renderSessionsPane();
730
+ }
731
+
732
+ function forgetSession(agent, id) {
733
+ saveSavedSessions(loadSavedSessions().filter(function (s) { return !(s.agent === agent && s.sessionId === id); }));
734
+ renderSessionsPane();
735
+ }
736
+
737
+ // Same "browser-local, best-effort" fallback rememberSession's own
738
+ // preview field already is (see its doc comment) — just for the
739
+ // in-flight turn's *assistant* text instead of the user's own message.
740
+ // Written every time a live 'assistant:text' event arrives (see
741
+ // handleFrame below) and cleared the moment the turn actually finishes
742
+ // ('done'/'error'), so a resume finds it set only while genuinely
743
+ // waiting on a still-pending approval/question — exactly the window
744
+ // resumeSession's own history fetch can't see into yet (see its own
745
+ // doc comment on why the *user's* message needs the same treatment).
746
+ function updateSessionField(agent, id, field, value) {
747
+ var list = loadSavedSessions();
748
+ for (var i = 0; i < list.length; i++) {
749
+ if (list[i].agent === agent && list[i].sessionId === id) {
750
+ list[i][field] = value;
751
+ saveSavedSessions(list);
752
+ return;
753
+ }
754
+ }
755
+ }
756
+
757
+ function formatRelativeTime(ms) {
758
+ var minutes = Math.round((Date.now() - ms) / 60000);
759
+ if (minutes < 1) return 'now';
760
+ if (minutes < 60) return minutes + 'm';
761
+ var hours = Math.round(minutes / 60);
762
+ if (hours < 24) return hours + 'h';
763
+ return Math.round(hours / 24) + 'd';
764
+ }
765
+
766
+ function renderSessionsPane() {
767
+ var agent = agentSelect.value;
768
+ var list = loadSavedSessions().filter(function (s) { return s.agent === agent; });
769
+ sessionsPane.textContent = '';
770
+ if (!list.length) {
771
+ setEmptyHint(sessionsPane, 'No saved sessions yet.');
772
+ return;
773
+ }
774
+ for (var i = 0; i < list.length; i++) {
775
+ var item = list[i];
776
+ var div = document.createElement('div');
777
+ div.className = 'session-item' + (item.sessionId === sessionId ? ' active' : '');
778
+ var preview = document.createElement('span');
779
+ preview.className = 'preview';
780
+ preview.textContent = item.preview || '(empty)';
781
+ var time = document.createElement('span');
782
+ time.className = 'session-time';
783
+ time.textContent = formatRelativeTime(item.updatedAt);
784
+ var removeBtn = document.createElement('button');
785
+ removeBtn.type = 'button';
786
+ removeBtn.className = 'session-remove';
787
+ removeBtn.textContent = '\\u00d7';
788
+ removeBtn.title = 'Remove from this list';
789
+ div.appendChild(preview);
790
+ div.appendChild(time);
791
+ div.appendChild(removeBtn);
792
+ (function (item) {
793
+ div.addEventListener('click', function () { resumeSession(item); });
794
+ removeBtn.addEventListener('click', function (ev) {
795
+ ev.stopPropagation();
796
+ forgetSession(item.agent, item.sessionId);
797
+ });
798
+ })(item);
799
+ sessionsPane.appendChild(div);
800
+ }
801
+ }
802
+
803
+ // Every tool_use/tool_result pair reconstructs as a tool call card (see
804
+ // appendToolCallCard above) — approved and successful, denied, or
805
+ // skipped alike, not just the denied/skipped subset this used to be
806
+ // limited to. A refresh used to leave a successful call with zero trace
807
+ // it ever happened (that used to be "what the Loop events pane is
808
+ // for," but that pane only ever covers the *live* run that produced it,
809
+ // never a resumed one) — confirmed live that resuming a session after
810
+ // approving a tool call left no way to see what it was even called
811
+ // with, or what it returned. toolCallsById correlates a tool_result
812
+ // back to which tool it's for (by tool_use_id) across the two separate
813
+ // messages that always bundle them — threaded in from resumeSession's
814
+ // own render loop below, one map per session render.
815
+ function renderHistoryMessage(msg, toolCallsById) {
816
+ if (Array.isArray(msg.content)) {
817
+ for (var i = 0; i < msg.content.length; i++) {
818
+ var block = msg.content[i];
819
+ if (block.type === 'tool_use') {
820
+ toolCallsById[block.id] = { name: block.name, input: block.input };
821
+ } else if (block.type === 'tool_result' && typeof block.content === 'string') {
822
+ var call = toolCallsById[block.tool_use_id];
823
+ var toolName = call ? call.name : 'a tool call';
824
+ var toolArgs = call ? call.input : undefined;
825
+ // Plain prefix checks, not a regex — this whole file is one
826
+ // outer TS template literal (see its own top-of-file doc
827
+ // comment), so an unrecognized backslash escape gets silently
828
+ // dropped when TS compiles it, and a two-character regex class
829
+ // meant to match any character including newlines quietly
830
+ // turned into a class that only matches two literal letters in
831
+ // the actually served script — confirmed by extracting and
832
+ // inspecting the real served script, not just re-typing the
833
+ // intended pattern in a standalone test file (which doesn't go
834
+ // through this file's own compile step, so never would have
835
+ // caught this). That silently broke every denied/skipped
836
+ // reconstruction earlier this session, the whole time.
837
+ var deniedPrefix = 'denied: ';
838
+ var skippedPrefix = 'skipped: ';
839
+ if (!block.is_error) {
840
+ // block.reason (see run-agent.ts's own ModelContentBlock.reason)
841
+ // is the actauth decision's own "matched rule '...'" text —
842
+ // the tool's actual return value is deliberately not shown
843
+ // here (that's the Loop events pane's job for the live run
844
+ // that produced it); this card is about *why it was allowed*,
845
+ // not what it returned.
846
+ appendToolCallCard(toolName, toolArgs, block.reason, 'Approved.');
847
+ } else if (block.content.indexOf(deniedPrefix) === 0) {
848
+ appendToolCallCard(toolName, toolArgs, block.content.slice(deniedPrefix.length), 'Denied.');
849
+ } else if (block.content.indexOf(skippedPrefix) === 0) {
850
+ appendToolCallCard(toolName, toolArgs, block.content.slice(skippedPrefix.length), 'Skipped.');
851
+ } else {
852
+ appendToolCallCard(toolName, toolArgs, block.content, 'Error.');
853
+ }
854
+ }
855
+ }
856
+ }
857
+
858
+ if (typeof msg.content === 'string') {
859
+ appendChatMessage(msg.role, msg.content);
860
+ return;
861
+ }
862
+ if (!Array.isArray(msg.content)) return;
863
+ var text = msg.content
864
+ .filter(function (b) { return b.type === 'text'; })
865
+ .map(function (b) { return b.text; })
866
+ .join('\\n');
867
+ if (text) appendChatMessage(msg.role, text);
868
+ }
869
+
870
+ function resumeSession(item) {
871
+ if (isSending || item.sessionId === sessionId) return;
872
+ agentSelect.value = item.agent;
873
+ updateCaption();
874
+ sessionId = item.sessionId;
875
+ sessionLabel.textContent = 'session: ' + sessionId;
876
+ sessionLabel.title = 'Click to copy session id';
877
+ sessionLabel.classList.add('copyable');
878
+ chatPane.textContent = '';
879
+ toolCallCardsById = {};
880
+ setEmptyHint(timelinePane, 'Loop events (tool calls, permission checks, budget checks) will appear here as the agent runs.');
881
+ renderSessionsPane();
882
+
883
+ fetch('/agents/' + encodeURIComponent(item.agent) + '/sessions/' + encodeURIComponent(item.sessionId))
884
+ .then(function (r) { return r.json(); })
885
+ .then(function (data) {
886
+ var history = data.history || [];
887
+ var lastRenderedUserText = null;
888
+ var toolCallsById = {};
889
+ for (var i = 0; i < history.length; i++) {
890
+ renderHistoryMessage(history[i], toolCallsById);
891
+ if (history[i].role === 'user' && typeof history[i].content === 'string') lastRenderedUserText = history[i].content;
892
+ }
893
+ if (!history.length) setEmptyHint(chatPane, 'This session has no messages yet.');
894
+ // The turn that's currently pending (see checkForPendingItems
895
+ // below), if there is one, hasn't been persisted at all yet —
896
+ // withSession only appends a whole turn once it fully completes,
897
+ // human-wait included (see session-store.ts's own withSession) —
898
+ // so the very message that triggered it wouldn't show up above,
899
+ // making the pending card look like it came out of nowhere. The
900
+ // sidebar already has it locally regardless (rememberSession
901
+ // saves it the moment it's sent), so fall back to that instead.
902
+ if (item.preview && item.preview !== lastRenderedUserText) {
903
+ clearEmptyHint(chatPane);
904
+ appendChatMessage('user', item.preview);
905
+
906
+ // Same reasoning, one message later: the turn's own assistant
907
+ // text (its "I'll do X" alongside the tool_use an approval/
908
+ // question card is about) hasn't persisted yet either, for the
909
+ // exact same reason the user's own message above hasn't — only
910
+ // reachable inside this branch since it can only be genuinely
911
+ // pending when the user message itself is too (withSession
912
+ // persists a whole turn atomically, never half of one).
913
+ // pendingAssistantText is written live by handleFrame's own
914
+ // 'assistant:text' branch and cleared the moment the turn
915
+ // actually finishes, so a stale leftover here can't happen —
916
+ // it's only ever set while this exact window is still open.
917
+ // Without this, resuming while an approval/question is pending
918
+ // showed only the bare card, with the reasoning it was
919
+ // responding to nowhere on the page until the card was decided.
920
+ if (item.pendingAssistantText) appendChatMessage('assistant', item.pendingAssistantText);
921
+ }
922
+ checkForPendingItems(item.agent, item.sessionId);
923
+ })
924
+ .catch(function (err) {
925
+ setEmptyHint(chatPane, 'Could not load this session: ' + err.message);
926
+ });
927
+ }
928
+
929
+ function checkForPendingItems(agent, sessionId) {
930
+ var resumeContext = { agent: agent, sessionId: sessionId };
931
+ fetch('/agents/' + encodeURIComponent(agent) + '/sessions/' + encodeURIComponent(sessionId) + '/questions')
932
+ .then(function (r) { return r.json(); })
933
+ .then(function (data) {
934
+ var questions = data.questions || [];
935
+ for (var i = 0; i < questions.length; i++) appendQuestionCard(questions[i], resumeContext);
936
+ })
937
+ .catch(function () {});
938
+ fetch('/agents/' + encodeURIComponent(agent) + '/sessions/' + encodeURIComponent(sessionId) + '/approvals')
939
+ .then(function (r) { return r.json(); })
940
+ .then(function (data) {
941
+ var approvals = data.approvals || [];
942
+ for (var i = 0; i < approvals.length; i++) appendApprovalCard(approvals[i], resumeContext);
943
+ })
944
+ .catch(function () {});
945
+ }
946
+
947
+ function appendTimelineEntry(eventName, data) {
948
+ clearEmptyHint(timelinePane);
949
+ var div = document.createElement('div');
950
+ div.className = 'event ' + eventCategory(eventName);
951
+ var label = document.createElement('div');
952
+ label.className = 'event-label';
953
+ label.textContent = eventName;
954
+ var pre = document.createElement('pre');
955
+ pre.className = 'event-data';
956
+ // Every LoopEvent's own 'type' field already duplicates eventName
957
+ // (the SSE event name above it) - stripped here so the JSON blob
958
+ // shows only what's specific to this event, not the label twice.
959
+ var displayData = data;
960
+ if (data && typeof data === 'object' && 'type' in data) {
961
+ displayData = Object.assign({}, data);
962
+ delete displayData.type;
963
+ }
964
+ pre.textContent = JSON.stringify(displayData, null, 2);
965
+ div.appendChild(label);
966
+ div.appendChild(pre);
967
+ timelinePane.appendChild(div);
968
+ timelinePane.scrollTop = timelinePane.scrollHeight;
969
+ }
970
+
971
+ function parseExtraHeaders(text) {
972
+ var result = {};
973
+ var lines = text.split('\\n');
974
+ for (var i = 0; i < lines.length; i++) {
975
+ var line = lines[i].trim();
976
+ if (!line) continue;
977
+ var idx = line.indexOf(':');
978
+ if (idx === -1) continue;
979
+ var key = line.slice(0, idx).trim();
980
+ var value = line.slice(idx + 1).trim();
981
+ if (key) result[key] = value;
982
+ }
983
+ return result;
984
+ }
985
+
986
+ function parseExtraBody(text) {
987
+ var trimmed = text.trim();
988
+ if (!trimmed) return {};
989
+ try {
990
+ return JSON.parse(trimmed);
991
+ } catch (err) {
992
+ appendChatMessage('error', 'Extra body fields must be valid JSON: ' + err.message);
993
+ return null;
994
+ }
995
+ }
996
+
997
+ function updateCaption() {
998
+ var name = agentSelect.value;
999
+ if (!currentAgents.length) {
1000
+ agentCaption.textContent = 'No agents registered — add one under agents/.';
1001
+ configLink.style.display = 'none';
1002
+ return;
1003
+ }
1004
+ configLink.style.display = '';
1005
+ var agent = null;
1006
+ for (var i = 0; i < currentAgents.length; i++) {
1007
+ if (currentAgents[i].name === name) { agent = currentAgents[i]; break; }
1008
+ }
1009
+ agentCaption.textContent = agent ? agent.systemPrompt : '';
1010
+ configLink.href = '/agents/config' + (name ? '?agent=' + encodeURIComponent(name) : '');
1011
+ }
1012
+
1013
+ function updateSendButtonState() {
1014
+ sendButton.disabled = isSending || !messageInput.value.trim() || !agentSelect.value;
1015
+ }
1016
+
1017
+ function setSending(sending) {
1018
+ isSending = sending;
1019
+ var hasAgents = currentAgents.length > 0;
1020
+ messageInput.disabled = sending || !hasAgents;
1021
+ agentSelect.disabled = sending || !hasAgents;
1022
+ newConversationButton.disabled = sending;
1023
+ sendButton.textContent = sending ? 'Sending\\u2026' : 'Send';
1024
+ updateSendButtonState();
1025
+ }
1026
+
1027
+ function resetConversation() {
1028
+ sessionId = null;
1029
+ toolCallCardsById = {};
1030
+ removeThinking();
1031
+ sessionLabel.textContent = 'session: (new)';
1032
+ sessionLabel.classList.remove('copyable');
1033
+ sessionLabel.title = '';
1034
+ var name = agentSelect.value;
1035
+ setEmptyHint(chatPane, name ? 'No messages yet \\u2014 say hi to ' + name + '.' : 'No messages yet \\u2014 pick an agent above to get started.');
1036
+ setEmptyHint(timelinePane, 'Loop events (tool calls, permission checks, budget checks) will appear here as the agent runs.');
1037
+ renderSessionsPane();
1038
+ }
1039
+
1040
+ function loadAgents() {
1041
+ fetch('/agents')
1042
+ .then(function (r) { return r.json(); })
1043
+ .then(function (data) {
1044
+ currentAgents = data.agents || [];
1045
+ agentSelect.textContent = '';
1046
+ for (var i = 0; i < currentAgents.length; i++) {
1047
+ var opt = document.createElement('option');
1048
+ opt.value = currentAgents[i].name;
1049
+ opt.textContent = currentAgents[i].name;
1050
+ agentSelect.appendChild(opt);
1051
+ }
1052
+ // Deep-linked from the agents list or config page (?agent=name) —
1053
+ // preselect it if it's a real, currently-registered agent.
1054
+ var requested = new URLSearchParams(location.search).get('agent');
1055
+ if (requested && currentAgents.some(function (a) { return a.name === requested; })) {
1056
+ agentSelect.value = requested;
1057
+ }
1058
+ var hasAgents = currentAgents.length > 0;
1059
+ agentSelect.disabled = !hasAgents;
1060
+ messageInput.disabled = !hasAgents;
1061
+ messageInput.placeholder = hasAgents
1062
+ ? 'Message\\u2026 (Enter to send, Shift+Enter for a new line)'
1063
+ : 'No agents registered';
1064
+ updateCaption();
1065
+ resetConversation();
1066
+ updateSendButtonState();
1067
+ if (hasAgents) messageInput.focus();
1068
+ })
1069
+ .catch(function (err) {
1070
+ agentCaption.textContent = 'Could not load agents: ' + err.message;
1071
+ resetConversation();
1072
+ });
1073
+ }
1074
+
1075
+ function handleFrame(frame) {
1076
+ var lines = frame.split('\\n');
1077
+ var eventName = 'message';
1078
+ var dataLine = '';
1079
+ for (var i = 0; i < lines.length; i++) {
1080
+ if (lines[i].indexOf('event: ') === 0) eventName = lines[i].slice(7);
1081
+ else if (lines[i].indexOf('data: ') === 0) dataLine = lines[i].slice(6);
1082
+ }
1083
+ var data;
1084
+ try {
1085
+ data = JSON.parse(dataLine);
1086
+ } catch (err) {
1087
+ data = dataLine;
1088
+ }
1089
+ if (eventName === 'session') {
1090
+ sessionId = data.sessionId;
1091
+ sessionLabel.textContent = 'session: ' + sessionId;
1092
+ sessionLabel.title = 'Click to copy session id';
1093
+ sessionLabel.classList.add('copyable');
1094
+ if (lastSentMessage) {
1095
+ rememberSession(agentSelect.value, sessionId, lastSentMessage);
1096
+ lastSentMessage = null;
1097
+ }
1098
+ } else if (eventName === 'done') {
1099
+ removeThinking();
1100
+ var doneEl = appendChatMessage('assistant', data.text);
1101
+ // stopReason (see run-agent.ts's own RunAgentResult) marks this as
1102
+ // a synthetic notice, not something the model actually said — a
1103
+ // denied tool call stopping the loop right there, same as
1104
+ // max_turns already could. Visually distinct so it doesn't read
1105
+ // like a normal finished answer.
1106
+ if (data.stopReason) doneEl.classList.add('msg-stopped');
1107
+ if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', null);
1108
+ } else if (eventName === 'error') {
1109
+ removeThinking();
1110
+ appendChatMessage('error', data.error);
1111
+ if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', null);
1112
+ } else if (eventName === 'assistant:text') {
1113
+ removeThinking();
1114
+ appendChatMessage('assistant', data.text);
1115
+ showThinking();
1116
+ if (sessionId) updateSessionField(agentSelect.value, sessionId, 'pendingAssistantText', data.text);
1117
+ } else if (eventName === 'tool:started') {
1118
+ // Fired the instant any call is decided, auto-allowed or
1119
+ // interactively asked alike (see run-agent.ts's own 'tool:started'
1120
+ // log call - it emits this unconditionally now, no notion of
1121
+ // "already shown elsewhere"). An interactively-asked call already
1122
+ // has its own approval card showing this decision (see
1123
+ // appendApprovalCard/wasAskedInteractively above) - no redundant
1124
+ // 'Running…' card needed for it, unlike an auto-allowed call which
1125
+ // had nothing shown until now.
1126
+ removeThinking();
1127
+ if (!wasAskedInteractively(data.detailText)) {
1128
+ appendToolCallCard(data.tool, data.args, data.detailText, 'Running\\u2026', data.id);
1129
+ }
1130
+ showThinking();
1131
+ appendTimelineEntry(eventName, data);
1132
+ } else if (eventName === 'tool:result') {
1133
+ // A tool call's own resolution (see run-agent.ts's own 'tool:result'
1134
+ // log call, also unconditional now) - auto-allowed, approved,
1135
+ // denied, or skipped alike. An interactively-approved call that
1136
+ // then executed successfully already has its own approval card
1137
+ // saying "Approved." - redundant to show again. A rejected
1138
+ // execution is new information that card never had (it only ever
1139
+ // showed the *decision*, not what running the call actually did),
1140
+ // so it still gets shown regardless.
1141
+ removeThinking();
1142
+ if (!wasAskedInteractively(data.detailText) || data.statusText === 'Error.') {
1143
+ // A call that got its own 'tool:started' card above updates it in
1144
+ // place (updateToolCallCard); one that didn't (denied/skipped, or
1145
+ // the rejected-after-interactive-approval case just above) gets a
1146
+ // fresh card instead - same fallback renderHistoryMessage's replay
1147
+ // of stored history already relies on for a resumed session, which
1148
+ // never sees 'tool:started' at all.
1149
+ if (!updateToolCallCard(data.id, data.statusText)) {
1150
+ appendToolCallCard(data.tool, data.args, data.detailText, data.statusText, data.id);
1151
+ }
1152
+ }
1153
+ showThinking();
1154
+ appendTimelineEntry(eventName, data);
1155
+ } else if (eventName === 'approval:pending') {
1156
+ removeThinking();
1157
+ appendApprovalCard(data);
1158
+ appendTimelineEntry(eventName, data);
1159
+ } else if (eventName === 'question:pending') {
1160
+ removeThinking();
1161
+ appendQuestionCard(data);
1162
+ appendTimelineEntry(eventName, data);
1163
+ } else {
1164
+ appendTimelineEntry(eventName, data);
1165
+ }
1166
+ }
1167
+
1168
+ function sendMessage() {
1169
+ if (isSending) return;
1170
+ var agent = agentSelect.value;
1171
+ var message = messageInput.value.trim();
1172
+ if (!agent || !message) return;
1173
+
1174
+ var extraHeaders = parseExtraHeaders(extraHeadersInput.value);
1175
+ var extraBody = parseExtraBody(extraBodyInput.value);
1176
+ if (extraBody === null) return;
1177
+
1178
+ var body = { message: message };
1179
+ if (sessionId) body.sessionId = sessionId;
1180
+ for (var key in extraBody) body[key] = extraBody[key];
1181
+
1182
+ var headers = { 'content-type': 'application/json' };
1183
+ for (var hk in extraHeaders) headers[hk] = extraHeaders[hk];
1184
+
1185
+ appendChatMessage('user', message);
1186
+ lastSentMessage = message;
1187
+ messageInput.value = '';
1188
+ setSending(true);
1189
+ showThinking();
1190
+
1191
+ function finish() {
1192
+ removeThinking();
1193
+ setSending(false);
1194
+ messageInput.focus();
1195
+ }
1196
+
1197
+ fetch('/agents/' + encodeURIComponent(agent) + '/messages/stream', {
1198
+ method: 'POST',
1199
+ headers: headers,
1200
+ body: JSON.stringify(body),
1201
+ })
1202
+ .then(function (response) {
1203
+ var contentType = response.headers.get('content-type') || '';
1204
+ if (contentType.indexOf('text/event-stream') === -1) {
1205
+ return response.json()
1206
+ .catch(function () { return { error: 'HTTP ' + response.status }; })
1207
+ .then(function (data) {
1208
+ appendChatMessage('error', data.error || ('HTTP ' + response.status));
1209
+ finish();
1210
+ });
1211
+ }
1212
+
1213
+ var reader = response.body.getReader();
1214
+ var decoder = new TextDecoder();
1215
+ var buffer = '';
1216
+
1217
+ function pump() {
1218
+ return reader.read().then(function (chunk) {
1219
+ if (chunk.done) {
1220
+ buffer += decoder.decode();
1221
+ finish();
1222
+ return;
1223
+ }
1224
+ buffer += decoder.decode(chunk.value, { stream: true });
1225
+ var idx;
1226
+ while ((idx = buffer.indexOf('\\n\\n')) !== -1) {
1227
+ var frame = buffer.slice(0, idx);
1228
+ buffer = buffer.slice(idx + 2);
1229
+ handleFrame(frame);
1230
+ }
1231
+ return pump();
1232
+ });
1233
+ }
1234
+
1235
+ return pump();
1236
+ })
1237
+ .catch(function (err) {
1238
+ appendChatMessage('error', 'Network error: ' + err.message);
1239
+ finish();
1240
+ });
1241
+ }
1242
+
1243
+ agentSelect.addEventListener('change', function () {
1244
+ updateCaption();
1245
+ resetConversation();
1246
+ updateSendButtonState();
1247
+ });
1248
+ messageInput.addEventListener('input', updateSendButtonState);
1249
+ newConversationButton.addEventListener('click', resetConversation);
1250
+ sendButton.addEventListener('click', sendMessage);
1251
+ messageInput.addEventListener('keydown', function (e) {
1252
+ if (e.key === 'Enter' && !e.shiftKey) {
1253
+ e.preventDefault();
1254
+ sendMessage();
1255
+ }
1256
+ });
1257
+ sessionLabel.addEventListener('click', function () {
1258
+ if (!sessionId || !navigator.clipboard) return;
1259
+ navigator.clipboard.writeText(sessionId).then(function () {
1260
+ var original = 'session: ' + sessionId;
1261
+ sessionLabel.textContent = 'copied!';
1262
+ setTimeout(function () { sessionLabel.textContent = original; }, 1000);
1263
+ });
1264
+ });
1265
+
1266
+ loadAgents();
1267
+ })();
1268
+ </script>
1269
+ </body>
1270
+ </html>
1271
+ `;