loopengine 0.1.3 → 0.1.5

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,1807 @@
1
+ // A local dev page: a single self-contained HTML page (no build step, no
2
+ // framework, no CDN) served by adapters/http.ts at GET /agents/config. It's
3
+ // a browser client on top of the existing GET /agents, GET
4
+ // /agents/:name/config, and GET/POST/DELETE /agents/:name/gateway-tools
5
+ // routes — nothing here talks to runAgent, SessionStore, or ActAuth
6
+ // directly; it only renders/writes through what those routes already
7
+ // resolve, so what's shown here is always what a real request would
8
+ // actually get (see describeAgent in adapters/http.ts).
9
+ //
10
+ // Once an agent is picked, its detail pane is a submenu — Overview /
11
+ // Actauth / Gateway tools — rather than one long scrolling page. Gateway
12
+ // tools used to be its own top-level page (adapters/gateway-tools-page.ts,
13
+ // now removed) with its own agent-picker sidebar; folded in here instead
14
+ // so there's one place to pick an agent and one place to see everything
15
+ // about it, not two parallel agent pickers for two halves of the same
16
+ // picture. The Gateway tools tab's own content is fetched lazily, only
17
+ // when that tab is actually opened — unlike Overview/Actauth (both free,
18
+ // pulled from the same /config fetch already made to open the agent at
19
+ // all), resolving gateway tools means actually connecting to each one
20
+ // (see gateway-tools.ts's describeGatewayTools), a real cost not worth
21
+ // paying for an agent whose gateway tools nobody looked at this visit.
22
+ //
23
+ // Embedded as a TS string (not a separate .html file), same reasoning as
24
+ // web/playground.ts: tsc's ordinary compile emits it straight into
25
+ // dist/adapters/ alongside http.js, no asset-copy step needed. Plain string
26
+ // concatenation, not a nested template literal, for the same
27
+ // backtick/${}-escaping reason playground.ts's own header comment explains.
28
+ import { devUiCss } from './dev-ui-styles.js';
29
+ export const agentsConfigPageHtml = `<!doctype html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="utf-8">
33
+ <meta name="viewport" content="width=device-width, initial-scale=1">
34
+ <title>LoopEngine Agents</title>
35
+ <style>${devUiCss}
36
+ body {
37
+ display: flex;
38
+ flex-direction: column;
39
+ height: 100vh;
40
+ }
41
+ /* Outer Agents/Models/Gateways section sidebar — matches
42
+ web/global-config-page.ts's own left nav, so this page (with
43
+ Agents pre-selected) and /config (with Models pre-selected) read as
44
+ two views of the same shell rather than unrelated pages. Models and
45
+ Gateways are real links out to /config?section=..., not client-side
46
+ panels rendered here too — that data/logic already lives in
47
+ global-config-page.ts, duplicating it here would just be two places
48
+ it could drift apart. Named .section-sidebar (not .sidebar) to avoid
49
+ colliding with nav.sidebar right below, which is the agent picker,
50
+ not this new outer one. */
51
+ .page-body { flex: 1; display: flex; min-height: 0; }
52
+ nav.section-sidebar {
53
+ width: 150px;
54
+ flex-shrink: 0;
55
+ border-right: 1px solid light-dark(#ddd, #333);
56
+ padding: 6px;
57
+ }
58
+ nav.section-sidebar a {
59
+ display: block;
60
+ border-radius: 6px;
61
+ padding: 8px 10px;
62
+ font-size: 13px;
63
+ text-decoration: none;
64
+ color: inherit;
65
+ }
66
+ nav.section-sidebar a:hover { background: light-dark(#eee, #26262b); }
67
+ nav.section-sidebar a.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
68
+ .layout { flex: 1; display: flex; min-height: 0; }
69
+ nav.sidebar {
70
+ width: 220px;
71
+ flex-shrink: 0;
72
+ border-right: 1px solid light-dark(#ddd, #333);
73
+ overflow-y: auto;
74
+ display: flex;
75
+ flex-direction: column;
76
+ }
77
+ #agentList { list-style: none; margin: 0; padding: 6px; flex: 1; }
78
+ #agentList li {
79
+ padding: 8px 10px;
80
+ border-radius: 6px;
81
+ cursor: pointer;
82
+ font-size: 13px;
83
+ font-family: ui-monospace, monospace;
84
+ }
85
+ #agentList li:hover { background: light-dark(#eee, #26262b); }
86
+ #agentList li.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
87
+ main {
88
+ flex: 1;
89
+ overflow-y: auto;
90
+ padding: 20px 28px;
91
+ }
92
+ #empty { color: light-dark(#666, #999); font-size: 13px; }
93
+ h2 { font-size: 20px; margin: 0 0 4px; font-family: ui-monospace, monospace; }
94
+ .tabs {
95
+ display: flex;
96
+ gap: 18px;
97
+ margin: 14px 0 20px;
98
+ border-bottom: 1px solid light-dark(#ddd, #333);
99
+ }
100
+ .tabs button {
101
+ background: none;
102
+ border: none;
103
+ border-bottom: 2px solid transparent;
104
+ padding: 8px 2px;
105
+ font-size: 13px;
106
+ cursor: pointer;
107
+ color: light-dark(#666, #999);
108
+ }
109
+ .tabs button:hover { color: light-dark(#1a1a1e, #e8e8ea); }
110
+ .tabs button.active {
111
+ color: light-dark(#1a1a1e, #e8e8ea);
112
+ border-bottom-color: light-dark(#1d4ed8, #60a5fa);
113
+ font-weight: 600;
114
+ }
115
+ section { margin-bottom: 22px; }
116
+ section h3 {
117
+ font-size: 12px;
118
+ text-transform: uppercase;
119
+ letter-spacing: 0.04em;
120
+ color: light-dark(#666, #999);
121
+ margin: 0 0 8px;
122
+ border-bottom: 1px solid light-dark(#eee, #2a2a2e);
123
+ padding-bottom: 4px;
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ }
128
+ .hint-btn {
129
+ font-size: 11px;
130
+ text-transform: none;
131
+ letter-spacing: normal;
132
+ padding: 2px 8px;
133
+ }
134
+ .http-tool-subsection {
135
+ margin-top: 14px;
136
+ padding-top: 14px;
137
+ border-top: 1px dashed light-dark(#eee, #2a2a2e);
138
+ }
139
+ .http-tool-subsection h4 {
140
+ font-size: 12px;
141
+ text-transform: uppercase;
142
+ letter-spacing: 0.04em;
143
+ color: light-dark(#666, #999);
144
+ margin: 0 0 8px;
145
+ }
146
+ pre {
147
+ background: light-dark(#fff, #26262b);
148
+ border: 1px solid light-dark(#ddd, #3a3a3e);
149
+ border-radius: 6px;
150
+ padding: 10px 12px;
151
+ font-size: 12px;
152
+ white-space: pre-wrap;
153
+ word-break: break-word;
154
+ margin: 0;
155
+ }
156
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
157
+ th, td {
158
+ text-align: left;
159
+ padding: 6px 8px;
160
+ border-bottom: 1px solid light-dark(#eee, #2a2a2e);
161
+ vertical-align: top;
162
+ }
163
+ th { color: light-dark(#666, #999); font-weight: 600; }
164
+ .decision-allow { color: light-dark(#166534, #86efac); }
165
+ .decision-ask { color: light-dark(#92400e, #fcd34d); }
166
+ .decision-deny { color: light-dark(#991b1b, #f87171); }
167
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; }
168
+ .kv dt { color: light-dark(#666, #999); }
169
+ .kv dd { margin: 0; font-family: ui-monospace, monospace; }
170
+ .error { color: light-dark(#991b1b, #f87171); font-size: 13px; }
171
+ .hint { font-size: 11px; color: light-dark(#666, #999); }
172
+ #playgroundLink { font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: normal; text-decoration: none; margin-left: 8px; }
173
+ .source {
174
+ border: 1px solid light-dark(#ddd, #3a3a3e);
175
+ border-radius: 8px;
176
+ padding: 12px 14px;
177
+ margin-bottom: 10px;
178
+ }
179
+ .source-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
180
+ .source-head h4 { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
181
+ .status-ok { color: light-dark(#166534, #86efac); }
182
+ .status-error { color: light-dark(#991b1b, #f87171); }
183
+ .tool-list { margin: 8px 0 0; padding-left: 0; font-size: 12px; }
184
+ .tool-list li {
185
+ margin-bottom: 2px;
186
+ display: flex;
187
+ align-items: baseline;
188
+ gap: 8px;
189
+ }
190
+ .delete-btn {
191
+ flex-shrink: 0;
192
+ font-size: 12px;
193
+ color: light-dark(#991b1b, #f87171);
194
+ background: light-dark(#fee2e2, #3a1f1f);
195
+ border-color: light-dark(#f3b4b4, #6b3232);
196
+ }
197
+ .delete-btn:hover { background: light-dark(#fecaca, #4a1f1f); }
198
+ /* Explicit rather than relying on inherited font-size: .edit-rule-btn
199
+ sits inside a 12px table, but .edit-skill-btn sits in .source-head
200
+ (no font-size of its own, so it'd otherwise inherit the page's
201
+ larger default) — without this the two Edit buttons render at
202
+ visibly different sizes despite being the same component. */
203
+ .edit-rule-btn, .edit-skill-btn { font-size: 12px; }
204
+ /* Sits inside a section h3, which is uppercase/letter-spaced for its
205
+ own heading text — without resetting both here, the button's own
206
+ label ("Edit") would inherit that and render as "EDIT" with the
207
+ heading's wide letter-spacing, not a normal-looking button. */
208
+ .edit-btn { font-size: 11px; text-transform: none; letter-spacing: normal; margin-left: 6px; }
209
+ /* Flex items default to min-width: auto, which means a long,
210
+ unbreakable token (a slug like GITHUB_LIST_REPOSITORIES_FOR_THE_
211
+ AUTHENTICATED_USER has no spaces for the browser to wrap at) simply
212
+ doesn't shrink to fit — it overflows the row, and the row's
213
+ ancestor forms, instead of wrapping. min-width: 0 lets it actually
214
+ shrink to the available space; overflow-wrap lets it break mid-word
215
+ once there's nowhere left to wrap normally. */
216
+ .tool-picker-label { flex: 1; min-width: 0; overflow-wrap: break-word; }
217
+ form.add-source {
218
+ border: 1px solid light-dark(#ddd, #3a3a3e);
219
+ border-radius: 8px;
220
+ padding: 14px;
221
+ display: grid;
222
+ gap: 10px;
223
+ max-width: 480px;
224
+ }
225
+ form.add-source label { font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
226
+ form.add-source input, form.add-source select, form.add-source textarea { width: 100%; }
227
+ form.add-source textarea { font-family: ui-monospace, monospace; min-height: 60px; }
228
+ /* Overrides form.add-source's shared max-width/textarea defaults for
229
+ the skill editor specifically — id selectors beat the class-level
230
+ rules above without needing to touch what every other add-source
231
+ form (gateway tools, actauth rules) looks like. A skill's
232
+ description/body are real prose/markdown, not the short one-liners
233
+ the shared 480px/60px defaults were sized for. */
234
+ #skillForm { max-width: 720px; }
235
+ #skillDescInput { font-family: inherit; min-height: 48px; resize: vertical; }
236
+ #skillBodyInput { min-height: 280px; resize: vertical; }
237
+ /* Same "field label" look form.add-source label gets, reproduced on a
238
+ <div> instead of a <label> — a <label> wrapping the Write/Preview
239
+ toggle buttons would make clicking them also fire the label's own
240
+ default click-forwarding behavior, aimed at whatever form control
241
+ the label wraps first (the textarea), which isn't what a toggle
242
+ button click should do. */
243
+ .body-field { font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
244
+ .body-field-head { display: flex; align-items: center; justify-content: space-between; }
245
+ .md-toggle { display: flex; gap: 4px; }
246
+ .md-toggle-btn { padding: 2px 8px; font-size: 11px; background: transparent; }
247
+ .md-toggle-btn.active { background: light-dark(#e8e8ea, #333338); font-weight: 600; }
248
+ .markdown-body {
249
+ border: 1px solid light-dark(#ccc, #444);
250
+ border-radius: 6px;
251
+ padding: 10px 14px;
252
+ min-height: 280px;
253
+ max-height: 480px;
254
+ overflow-y: auto;
255
+ }
256
+ .markdown-body h1, .markdown-body h2 {
257
+ border-bottom: 1px solid light-dark(#eee, #333338);
258
+ padding-bottom: 4px;
259
+ }
260
+ .markdown-body pre {
261
+ background: light-dark(#f6f8fa, #26262b);
262
+ padding: 10px 12px;
263
+ border-radius: 6px;
264
+ overflow-x: auto;
265
+ }
266
+ .markdown-body code {
267
+ background: light-dark(#eee, #2a2a2e);
268
+ padding: 1px 5px;
269
+ border-radius: 4px;
270
+ font-family: ui-monospace, monospace;
271
+ font-size: 12px;
272
+ }
273
+ .markdown-body pre code { background: none; padding: 0; }
274
+ .tool-picker-controls {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 8px;
278
+ margin: 4px 0;
279
+ }
280
+ .tool-picker-controls button { font-size: 11px; padding: 3px 8px; }
281
+ .tool-picker-list {
282
+ max-height: 240px;
283
+ overflow-y: auto;
284
+ border: 1px solid light-dark(#ddd, #3a3a3e);
285
+ border-radius: 6px;
286
+ padding: 6px 8px;
287
+ }
288
+ /* "form.add-source label { flex-direction: column; }" above is meant
289
+ for the form's own top-level fields (Provider/App/Name/...), each a
290
+ <label> wrapping a heading over its input — but .tool-picker-item is
291
+ also a <label> inside that same form, so that rule matches it too,
292
+ and (higher specificity: form+class+label vs. just one class here)
293
+ wins over flex-direction on this rule alone, stacking the checkbox
294
+ under the text instead of beside it. "label.tool-picker-item" adds
295
+ the element back in to match that specificity and override it. */
296
+ form.add-source label.tool-picker-item {
297
+ display: flex;
298
+ flex-direction: row;
299
+ align-items: flex-start;
300
+ justify-content: space-between;
301
+ gap: 8px;
302
+ padding: 5px 2px;
303
+ font-size: 12px;
304
+ border-bottom: 1px solid light-dark(#eee, #2a2a2e);
305
+ }
306
+ .tool-picker-item:last-child { border-bottom: none; }
307
+ /* Higher specificity than "form.add-source input { width: 100%; }" —
308
+ without this, that rule wins (one more element in its selector) and
309
+ stretches the checkbox to the full row width, which visually
310
+ overlaps the description text under it instead of sitting beside it. */
311
+ form.add-source input[type="checkbox"] { width: auto; flex-shrink: 0; margin-top: 2px; }
312
+ </style>
313
+ </head>
314
+ <body>
315
+ <nav class="topnav">
316
+ <a href="/agents">Agents</a>
317
+ <a href="/agents/config" class="active">Config</a>
318
+ <a href="/playground">Playground</a>
319
+ </nav>
320
+ <div class="page-body">
321
+ <nav class="section-sidebar">
322
+ <a href="/agents/config" class="active">Agents</a>
323
+ <a href="/config?section=models">Models</a>
324
+ <a href="/config?section=gateways">Gateways</a>
325
+ </nav>
326
+ <div class="layout">
327
+ <nav class="sidebar">
328
+ <ul id="agentList"></ul>
329
+ </nav>
330
+ <main>
331
+ <div id="empty">Pick an agent to see its config.</div>
332
+ <div id="detail" style="display:none"></div>
333
+ </main>
334
+ </div>
335
+ </div>
336
+ <script>
337
+ (function () {
338
+ var agentList = document.getElementById('agentList');
339
+ var empty = document.getElementById('empty');
340
+ var detail = document.getElementById('detail');
341
+ var currentName = null;
342
+ var currentTab = 'overview';
343
+ // Which agent's Gateway tools tab has already been fetched — reset on
344
+ // every agent switch so a stale agent's sources are never shown under
345
+ // a new one, and re-fetched the next time that tab is opened.
346
+ var gatewayLoadedFor = null;
347
+ // Same idea, for the Actauth tab's own separately-fetched GET
348
+ // .../actauth (see renderActauthTabPlaceholder's own doc comment).
349
+ var actauthLoadedFor = null;
350
+ // The full /agents/:name/config response the currently-open agent was
351
+ // last rendered from — kept around so refreshActauthDependentPanels/
352
+ // refreshSkillsDependentPanels below can re-fetch and re-render just
353
+ // the panels that read cfg (Overview, Skills) after a change, without
354
+ // touching the Tools/Actauth tabs' own already-current state.
355
+ var currentCfg = null;
356
+
357
+ function decisionClass(decision) {
358
+ return 'decision-' + decision;
359
+ }
360
+
361
+ function escapeHtml(s) {
362
+ return String(s).replace(/[&<>"']/g, function (c) {
363
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
364
+ });
365
+ }
366
+
367
+ function badge(label) {
368
+ return '<span class="badge ' + (label === 'custom' ? 'badge-custom' : 'badge-default') + '">' + escapeHtml(label) + '</span>';
369
+ }
370
+
371
+ // A small, deliberately non-CommonMark markdown-to-HTML renderer for
372
+ // the skill Body field's Preview toggle (see wireSkillsHandlers) — this
373
+ // page stays a self-contained string, no build step, no CDN (same
374
+ // reasoning as devUiCss's own header comment), so pulling in a real
375
+ // markdown library isn't an option without breaking that. Covers what
376
+ // an actual SKILL.md body realistically uses (headings, bold/italic,
377
+ // inline code, fenced code blocks, links, lists, rules, paragraphs) —
378
+ // good enough for "does this read the way it will on GitHub", not a
379
+ // spec-complete parser. Escapes first, so no markdown source can inject
380
+ // raw HTML into the preview.
381
+ function renderMarkdownPreview(md) {
382
+ var lines = escapeHtml(md).split('\\n');
383
+ var html = '';
384
+ var inCode = false;
385
+ var codeBuffer = '';
386
+ var listType = null;
387
+ var paragraph = [];
388
+
389
+ // This whole page is one outer TS template literal AND its output is
390
+ // itself served as JS source text a browser then parses again — two
391
+ // layers of "escape sequences get processed", not one. The unicode
392
+ // escape used for the backtick patterns below survives both layers fine, since
393
+ // it's a real, recognized escape at both of them. A star/bracket/
394
+ // paren/s/d/dot escape (\*, \[, \s, \d, \.) is *not* a recognized
395
+ // escape at either layer, so each layer that touches it silently
396
+ // drops the backslash and keeps just the character — confirmed by
397
+ // extracting and executing the actually-served page during
398
+ // development, where every regex built the "obvious" way here ended
399
+ // up with its escapes stripped entirely, sometimes still parsing
400
+ // (silently wrong) and sometimes throwing ("nothing to repeat").
401
+ // Building these particular patterns from a runtime backslash
402
+ // character instead sidesteps the whole problem: there is no
403
+ // backslash-followed-by-letter sequence anywhere in this source for
404
+ // either layer to misinterpret.
405
+ var bs = String.fromCharCode(92);
406
+ var inlineCodePattern = new RegExp('\\u0060([^\\u0060]+)\\u0060', 'g');
407
+ var fencePattern = new RegExp('^\\u0060\\u0060\\u0060');
408
+ var boldPattern = new RegExp(bs + '*' + bs + '*([^*]+)' + bs + '*' + bs + '*', 'g');
409
+ var italicPattern = new RegExp(bs + '*([^*]+)' + bs + '*', 'g');
410
+ var linkPattern = new RegExp(bs + '[([^' + bs + ']]+)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
411
+ var headingPattern = new RegExp('^(#{1,6})' + bs + 's+(.*)$');
412
+ var hrPattern = new RegExp('^(---|' + bs + '*' + bs + '*' + bs + '*)' + bs + 's*$');
413
+ var ulPattern = new RegExp('^[-*]' + bs + 's+(.*)$');
414
+ var olPattern = new RegExp('^' + bs + 'd+' + bs + '.' + bs + 's+(.*)$');
415
+ function inlineFormat(text) {
416
+ return text
417
+ .replace(inlineCodePattern, '<code>$1</code>')
418
+ .replace(boldPattern, '<strong>$1</strong>')
419
+ .replace(italicPattern, '<em>$1</em>')
420
+ .replace(linkPattern, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
421
+ }
422
+ function flushParagraph() {
423
+ if (paragraph.length) {
424
+ html += '<p>' + inlineFormat(paragraph.join(' ')) + '</p>';
425
+ paragraph = [];
426
+ }
427
+ }
428
+ function closeList() {
429
+ if (listType) {
430
+ html += '</' + listType + '>';
431
+ listType = null;
432
+ }
433
+ }
434
+
435
+ for (var i = 0; i < lines.length; i++) {
436
+ var line = lines[i];
437
+ if (fencePattern.test(line)) {
438
+ if (!inCode) {
439
+ flushParagraph();
440
+ closeList();
441
+ inCode = true;
442
+ codeBuffer = '';
443
+ } else {
444
+ html += '<pre><code>' + codeBuffer + '</code></pre>';
445
+ inCode = false;
446
+ }
447
+ continue;
448
+ }
449
+ if (inCode) {
450
+ codeBuffer += line + '\\n';
451
+ continue;
452
+ }
453
+
454
+ var heading = line.match(headingPattern);
455
+ if (heading) {
456
+ flushParagraph();
457
+ closeList();
458
+ var level = heading[1].length;
459
+ html += '<h' + level + '>' + inlineFormat(heading[2]) + '</h' + level + '>';
460
+ continue;
461
+ }
462
+ if (hrPattern.test(line)) {
463
+ flushParagraph();
464
+ closeList();
465
+ html += '<hr>';
466
+ continue;
467
+ }
468
+ var ul = line.match(ulPattern);
469
+ var ol = line.match(olPattern);
470
+ if (ul || ol) {
471
+ flushParagraph();
472
+ var wantType = ul ? 'ul' : 'ol';
473
+ if (listType !== wantType) {
474
+ closeList();
475
+ html += '<' + wantType + '>';
476
+ listType = wantType;
477
+ }
478
+ html += '<li>' + inlineFormat((ul || ol)[1]) + '</li>';
479
+ continue;
480
+ }
481
+ closeList();
482
+
483
+ if (line.trim() === '') {
484
+ flushParagraph();
485
+ continue;
486
+ }
487
+ paragraph.push(line.trim());
488
+ }
489
+ flushParagraph();
490
+ closeList();
491
+ return html || '<p class="hint">Nothing to preview yet.</p>';
492
+ }
493
+
494
+ // ---- Overview tab ----
495
+
496
+ function renderTools(tools) {
497
+ if (!tools.length) return '<p class="muted">No tools.</p>';
498
+ var rows = tools.map(function (t) {
499
+ return '<tr><td><code>' + escapeHtml(t.name) + '</code></td>' +
500
+ '<td>' + escapeHtml(t.description) + '</td>' +
501
+ '<td>' + (t.safe ? 'yes' : 'no') + '</td></tr>';
502
+ }).join('');
503
+ return '<table><thead><tr><th>Name</th><th>Description</th><th>Parallel-safe</th></tr></thead><tbody>' + rows + '</tbody></table>';
504
+ }
505
+
506
+ // Overview's own tools table, unlike the Tools tab's three separately-
507
+ // headed sections (Local tools / Gateway Tools / Agent as Tools), is
508
+ // one flat list (cfg.tools) — without a Type column there's no way to
509
+ // tell, at a glance, where a given tool actually comes from. Built
510
+ // from cfg.localTools/cfg.gatewayTools/cfg.agentAsTools directly
511
+ // (same three arrays the Tools tab renders) rather than cfg.tools, so
512
+ // each row can be tagged with which one it came from — same order as
513
+ // the Tools tab's own sections, for consistency. Deliberately excludes
514
+ // cfg.systemTools/cfg.systemSkills — infrastructure every agent gets
515
+ // automatically (see run-agent.ts's systemTools/systemSkillsDir), not
516
+ // something an operator configured, so this page hides them rather
517
+ // than clutter the per-agent view with tools/skills that aren't
518
+ // actually specific to this agent.
519
+ function renderToolsWithType(cfg) {
520
+ var rows = [];
521
+ function addRows(tools, type) {
522
+ for (var i = 0; i < tools.length; i++) {
523
+ var t = tools[i];
524
+ rows.push(
525
+ '<tr><td><code>' + escapeHtml(t.name) + '</code></td>' +
526
+ '<td>' + escapeHtml(t.description) + '</td>' +
527
+ '<td>' + escapeHtml(type) + '</td>' +
528
+ '<td>' + (t.safe ? 'yes' : 'no') + '</td></tr>',
529
+ );
530
+ }
531
+ }
532
+ addRows(cfg.localTools, 'Local');
533
+ addRows(cfg.gatewayTools, 'Gateway');
534
+ addRows(cfg.agentAsTools, 'Agent as Tool');
535
+ if (!rows.length) return '<p class="muted">No tools.</p>';
536
+ return '<table><thead><tr><th>Name</th><th>Description</th><th>Type</th><th>Parallel-safe</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>';
537
+ }
538
+
539
+ function renderSkills(skills) {
540
+ if (!skills.length) return '<p class="muted">No skills.</p>';
541
+ var rows = skills.map(function (s) {
542
+ return '<tr><td><code>' + escapeHtml(s.name) + '</code></td>' +
543
+ '<td>' + escapeHtml(s.description) + '</td></tr>';
544
+ }).join('');
545
+ return '<table><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>';
546
+ }
547
+
548
+ function renderOverviewHtml(cfg) {
549
+ var systemPromptSection =
550
+ '<h3>System prompt <button type="button" class="edit-btn" id="editSystemPromptBtn">Edit</button></h3>' +
551
+ '<pre id="systemPromptDisplay">' + escapeHtml(cfg.systemPrompt) + '</pre>' +
552
+ '<form class="add-source" id="systemPromptForm" style="display:none">' +
553
+ '<textarea name="systemPrompt">' + escapeHtml(cfg.systemPrompt) + '</textarea>' +
554
+ '<button type="submit">Save</button>' +
555
+ '<button type="button" id="cancelSystemPromptBtn">Cancel</button>' +
556
+ '<div class="error" id="systemPromptError"></div>' +
557
+ '</form>';
558
+
559
+ // Only editable when cfg.model is the resolved {provider, model,
560
+ // maxTokens} object — a custom createModelCall (the string case) has
561
+ // no config.model at all for agent-file-admin.ts's editAgentFile to
562
+ // find and edit; see its own doc comment for why it refuses rather
563
+ // than guessing at that case.
564
+ var modelSection;
565
+ if (typeof cfg.model === 'string') {
566
+ modelSection = '<h3>Model</h3><p class="muted">' + escapeHtml(cfg.model) + '</p>';
567
+ } else {
568
+ modelSection =
569
+ '<h3>Model <button type="button" class="edit-btn" id="editModelBtn">Edit</button></h3>' +
570
+ '<dl class="kv" id="modelDisplay">' +
571
+ '<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
572
+ '<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
573
+ '<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
574
+ '</dl>' +
575
+ '<form class="add-source" id="modelForm" style="display:none">' +
576
+ '<label>Provider' +
577
+ '<select name="provider">' +
578
+ ['anthropic', 'openai', 'deepseek'].map(function (p) {
579
+ return '<option value="' + p + '"' + (p === cfg.model.provider ? ' selected' : '') + '>' + p + '</option>';
580
+ }).join('') +
581
+ '</select>' +
582
+ '</label>' +
583
+ '<label>Model name <span class="hint">(required for openai/deepseek; defaults to claude-sonnet-5 for anthropic)</span>' +
584
+ '<input type="text" name="modelName" value="' + escapeHtml(cfg.model.model || '') + '">' +
585
+ '</label>' +
586
+ '<button type="submit">Save</button>' +
587
+ '<button type="button" id="cancelModelBtn">Cancel</button>' +
588
+ '<div class="error" id="modelError"></div>' +
589
+ '</form>';
590
+ }
591
+
592
+ return '<section>' + systemPromptSection + '</section>' +
593
+ '<section>' + modelSection + '</section>' +
594
+ '<section><h3>Skills (' + cfg.skills.length + ')</h3>' + renderSkills(cfg.skills) + '</section>' +
595
+ '<section><h3>Tools (' + cfg.tools.length + ')</h3>' + renderToolsWithType(cfg) + '</section>' +
596
+ '<section><h3>ActAuth</h3>' + renderRules(cfg.permissions) + '</section>' +
597
+ '<section><h3>Hooks</h3><dl class="kv">' +
598
+ '<dt>sessionIdFor</dt><dd>' + badge(cfg.sessionIdFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.sessionIdFor) + '</span></dd>' +
599
+ '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
600
+ '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
601
+ '</dl></section>' +
602
+ '<section><h3>Limits &amp; budgets</h3><dl class="kv">' +
603
+ '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
604
+ '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
605
+ '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
606
+ '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
607
+ '</dl></section>';
608
+ }
609
+
610
+ // Wires the System prompt / Model Edit buttons rendered by
611
+ // renderOverviewHtml above — called every time that HTML gets (re)set
612
+ // (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
613
+ // same pattern every other tab's own wireXHandlers already follows.
614
+ // PUT /agents/:name persists via agent-file-admin.ts's editAgentFile
615
+ // (see its own doc comment for why some agents' index.ts files can't
616
+ // be safely edited this way at all — no editModelBtn is rendered for
617
+ // those in the first place) and applies the change live; the response
618
+ // is the freshly-resolved config, so this re-renders the whole
619
+ // Overview panel from it directly instead of a second GET.
620
+ function wireOverviewHandlers(name) {
621
+ var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
622
+ if (!overviewPanel) return;
623
+
624
+ var editSystemPromptBtn = overviewPanel.querySelector('#editSystemPromptBtn');
625
+ if (editSystemPromptBtn) {
626
+ var systemPromptDisplay = overviewPanel.querySelector('#systemPromptDisplay');
627
+ var systemPromptForm = overviewPanel.querySelector('#systemPromptForm');
628
+ var systemPromptError = overviewPanel.querySelector('#systemPromptError');
629
+
630
+ editSystemPromptBtn.addEventListener('click', function () {
631
+ systemPromptDisplay.style.display = 'none';
632
+ editSystemPromptBtn.style.display = 'none';
633
+ systemPromptForm.style.display = '';
634
+ systemPromptForm.querySelector('textarea').focus();
635
+ });
636
+ overviewPanel.querySelector('#cancelSystemPromptBtn').addEventListener('click', function () {
637
+ systemPromptForm.style.display = 'none';
638
+ systemPromptDisplay.style.display = '';
639
+ editSystemPromptBtn.style.display = '';
640
+ systemPromptError.textContent = '';
641
+ });
642
+ systemPromptForm.addEventListener('submit', function (ev) {
643
+ ev.preventDefault();
644
+ systemPromptError.textContent = '';
645
+ var value = systemPromptForm.querySelector('textarea[name="systemPrompt"]').value;
646
+ var submitBtn = systemPromptForm.querySelector('button[type="submit"]');
647
+ submitBtn.disabled = true;
648
+ fetch('/agents/' + encodeURIComponent(name), {
649
+ method: 'PUT',
650
+ headers: { 'content-type': 'application/json' },
651
+ body: JSON.stringify({ systemPrompt: value }),
652
+ })
653
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
654
+ .then(function (result) {
655
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
656
+ currentCfg = result.body;
657
+ overviewPanel.innerHTML = renderOverviewHtml(result.body);
658
+ wireOverviewHandlers(name);
659
+ })
660
+ .catch(function (err) {
661
+ systemPromptError.textContent = err.message;
662
+ submitBtn.disabled = false;
663
+ });
664
+ });
665
+ }
666
+
667
+ var editModelBtn = overviewPanel.querySelector('#editModelBtn');
668
+ if (editModelBtn) {
669
+ var modelDisplay = overviewPanel.querySelector('#modelDisplay');
670
+ var modelForm = overviewPanel.querySelector('#modelForm');
671
+ var modelError = overviewPanel.querySelector('#modelError');
672
+
673
+ editModelBtn.addEventListener('click', function () {
674
+ modelDisplay.style.display = 'none';
675
+ editModelBtn.style.display = 'none';
676
+ modelForm.style.display = '';
677
+ });
678
+ overviewPanel.querySelector('#cancelModelBtn').addEventListener('click', function () {
679
+ modelForm.style.display = 'none';
680
+ modelDisplay.style.display = '';
681
+ editModelBtn.style.display = '';
682
+ modelError.textContent = '';
683
+ });
684
+ modelForm.addEventListener('submit', function (ev) {
685
+ ev.preventDefault();
686
+ modelError.textContent = '';
687
+ var data = new FormData(modelForm);
688
+ var submitBtn = modelForm.querySelector('button[type="submit"]');
689
+ submitBtn.disabled = true;
690
+ var body = { model: { provider: data.get('provider') } };
691
+ var modelName = data.get('modelName');
692
+ if (modelName && modelName.trim()) body.model.model = modelName;
693
+ fetch('/agents/' + encodeURIComponent(name), {
694
+ method: 'PUT',
695
+ headers: { 'content-type': 'application/json' },
696
+ body: JSON.stringify(body),
697
+ })
698
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
699
+ .then(function (result) {
700
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
701
+ currentCfg = result.body;
702
+ overviewPanel.innerHTML = renderOverviewHtml(result.body);
703
+ wireOverviewHandlers(name);
704
+ })
705
+ .catch(function (err) {
706
+ modelError.textContent = err.message;
707
+ submitBtn.disabled = false;
708
+ });
709
+ });
710
+ }
711
+ }
712
+
713
+ // ---- Overview's own Actauth section: read-only, from cfg.permissions
714
+ // (already resolved by run-agent.ts's own loadRules — 3-segment
715
+ // scopePattern, "when" conditions included). The dedicated Actauth tab
716
+ // below is a *different* view: editable, and reading/writing the raw
717
+ // 2-segment scope actauth.yml itself has, via a separately-fetched
718
+ // GET /agents/:name/actauth — see renderActauthTabPlaceholder. Keeping
719
+ // these two independent avoids a scope-format mismatch: round-tripping
720
+ // cfg.permissions' own expanded 3-segment scopePattern back through an
721
+ // edit would permanently "expand" a hand-authored 2-segment rule the
722
+ // moment anyone touched it. ----
723
+
724
+ function renderRules(permissions) {
725
+ var header = '<dl class="kv">' +
726
+ '<dt>Source</dt><dd>' + escapeHtml(permissions.source) + '</dd>' +
727
+ '<dt>Default decision</dt><dd class="' + decisionClass(permissions.defaultDecision) + '">' + escapeHtml(permissions.defaultDecision) + '</dd>' +
728
+ '</dl>';
729
+ if (!permissions.rules.length) return header + '<p class="muted">No explicit rules — every tool call falls through to the default decision above.</p>';
730
+ var rows = permissions.rules.map(function (r) {
731
+ return '<tr>' +
732
+ '<td><code>' + escapeHtml(r.scopePattern) + '</code></td>' +
733
+ '<td><code>' + escapeHtml(r.tool) + '</code></td>' +
734
+ '<td class="' + decisionClass(r.decision) + '">' + escapeHtml(r.decision) + '</td>' +
735
+ '<td>' + (r.when ? '<pre>' + escapeHtml(JSON.stringify(r.when, null, 2)) + '</pre>' : '') + '</td>' +
736
+ '</tr>';
737
+ }).join('');
738
+ return header + '<table style="margin-top:10px"><thead><tr><th>Scope</th><th>Tool</th><th>Decision</th><th>When</th></tr></thead><tbody>' + rows + '</tbody></table>';
739
+ }
740
+
741
+ // ---- Actauth tab: editable — add/edit/delete rules and set
742
+ // default_decision via actauth-admin.ts, working directly against the
743
+ // file's own raw scope strings (see the comment above). Lazily loaded
744
+ // the same way Gateway Tools is (see loadActauthTab/actauthLoadedFor):
745
+ // not because reading it is expensive here (it's a plain file read,
746
+ // unlike describeGatewayTools' live reconnects), just to avoid a
747
+ // redundant fetch on every agent switch for a tab that isn't always
748
+ // opened. ----
749
+
750
+ function renderActauthTabPlaceholder() {
751
+ return '<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading&hellip;</p></div></section>';
752
+ }
753
+
754
+ function renderActauthRuleRow(r) {
755
+ var actionsHtml = r.name
756
+ ? '<button type="button" class="edit-rule-btn" data-name="' + escapeHtml(r.name) + '">Edit</button> ' +
757
+ '<button type="button" class="delete-btn delete-rule-btn" data-name="' + escapeHtml(r.name) + '" title="Remove this rule" aria-label="Remove this rule">Delete</button>'
758
+ // A rule with no "name" in the YAML has nothing this tab's
759
+ // update/removeActauthRule (both keyed by name) can address — left
760
+ // visible, but not editable here.
761
+ : '<span class="hint">unnamed — edit actauth.yml directly</span>';
762
+ return '<tr>' +
763
+ '<td><code>' + escapeHtml(r.name || '') + '</code></td>' +
764
+ '<td><code>' + escapeHtml(r.scope) + '</code></td>' +
765
+ '<td><code>' + escapeHtml(r.tool) + '</code></td>' +
766
+ '<td class="' + decisionClass(r.decision) + '">' + escapeHtml(r.decision) + '</td>' +
767
+ '<td>' + actionsHtml + '</td>' +
768
+ '</tr>';
769
+ }
770
+
771
+ function renderActauthConfigHtml(cfg) {
772
+ var rulesHtml = cfg.rules.length
773
+ ? '<table><thead><tr><th>Name</th><th>Scope</th><th>Tool</th><th>Decision</th><th></th></tr></thead><tbody>' +
774
+ cfg.rules.map(renderActauthRuleRow).join('') + '</tbody></table>'
775
+ : '<p class="hint">No explicit rules — every tool call falls through to the default decision.</p>';
776
+
777
+ return '<section><h3>Default decision</h3>' +
778
+ '<form class="add-source" id="defaultDecisionForm" style="max-width:220px">' +
779
+ '<label>Applies when no rule matches' +
780
+ '<select name="defaultDecision" id="defaultDecisionSelect">' +
781
+ '<option value="allow"' + (cfg.defaultDecision === 'allow' ? ' selected' : '') + '>allow</option>' +
782
+ '<option value="ask"' + (cfg.defaultDecision === 'ask' ? ' selected' : '') + '>ask</option>' +
783
+ '<option value="deny"' + (cfg.defaultDecision === 'deny' ? ' selected' : '') + '>deny</option>' +
784
+ '</select>' +
785
+ '</label>' +
786
+ '<button type="submit">Save</button>' +
787
+ '<div id="defaultDecisionError" class="error"></div>' +
788
+ '</form></section>' +
789
+ '<section><h3>Rules (' + cfg.rules.length + ')</h3>' + rulesHtml + '</section>' +
790
+ '<section><h3 id="ruleFormHeading">Add a rule</h3>' +
791
+ '<form class="add-source" id="ruleForm">' +
792
+ '<label>Name' +
793
+ '<input name="name" id="ruleNameInput" required placeholder="my-rule-name">' +
794
+ '</label>' +
795
+ '<label>Scope <span class="hint">(tenant/environment — e.g. "default/production" or "*/*"; the agent segment is appended automatically)</span>' +
796
+ '<input name="scope" id="ruleScopeInput" required placeholder="default/production">' +
797
+ '</label>' +
798
+ '<label>Tool' +
799
+ '<input name="tool" id="ruleToolInput" required placeholder="write_file">' +
800
+ '</label>' +
801
+ '<label>Decision' +
802
+ '<select name="decision" id="ruleDecisionInput">' +
803
+ '<option value="allow">allow</option>' +
804
+ '<option value="ask" selected>ask</option>' +
805
+ '<option value="deny">deny</option>' +
806
+ '</select>' +
807
+ '</label>' +
808
+ '<button type="submit" id="ruleSubmitBtn">Add</button>' +
809
+ '<button type="button" id="ruleCancelBtn" style="display:none">Cancel</button>' +
810
+ '<div id="ruleFormError" class="error"></div>' +
811
+ '</form></section>';
812
+ }
813
+
814
+ function actauthContentEl() {
815
+ return detail.querySelector('#actauthContent');
816
+ }
817
+
818
+ function wireActauthHandlers(name, cfg) {
819
+ var content = actauthContentEl();
820
+ if (!content) return;
821
+ var editingRuleName = null;
822
+
823
+ function ruleByName(ruleName) {
824
+ for (var i = 0; i < cfg.rules.length; i++) {
825
+ if (cfg.rules[i].name === ruleName) return cfg.rules[i];
826
+ }
827
+ return null;
828
+ }
829
+
830
+ function resetRuleForm() {
831
+ var form = content.querySelector('#ruleForm');
832
+ form.reset();
833
+ content.querySelector('#ruleNameInput').disabled = false;
834
+ content.querySelector('#ruleFormHeading').textContent = 'Add a rule';
835
+ content.querySelector('#ruleSubmitBtn').textContent = 'Add';
836
+ content.querySelector('#ruleCancelBtn').style.display = 'none';
837
+ content.querySelector('#ruleFormError').textContent = '';
838
+ editingRuleName = null;
839
+ }
840
+
841
+ var deleteButtons = content.querySelectorAll('.delete-rule-btn');
842
+ for (var i = 0; i < deleteButtons.length; i++) {
843
+ deleteButtons[i].addEventListener('click', function (ev) {
844
+ var btn = ev.currentTarget;
845
+ var ruleName = btn.getAttribute('data-name');
846
+ if (!confirm('Remove rule "' + ruleName + '"?')) return;
847
+ btn.disabled = true;
848
+ fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(ruleName), { method: 'DELETE' })
849
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
850
+ .then(function (result) {
851
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
852
+ applyActauthConfig(name, result.body);
853
+ refreshOverviewPanel(name);
854
+ })
855
+ .catch(function (err) {
856
+ alert('Could not remove: ' + err.message);
857
+ btn.disabled = false;
858
+ });
859
+ });
860
+ }
861
+
862
+ var editButtons = content.querySelectorAll('.edit-rule-btn');
863
+ for (var i = 0; i < editButtons.length; i++) {
864
+ editButtons[i].addEventListener('click', function (ev) {
865
+ var ruleName = ev.currentTarget.getAttribute('data-name');
866
+ var rule = ruleByName(ruleName);
867
+ if (!rule) return;
868
+ content.querySelector('#ruleNameInput').value = rule.name;
869
+ content.querySelector('#ruleNameInput').disabled = true;
870
+ content.querySelector('#ruleScopeInput').value = rule.scope;
871
+ content.querySelector('#ruleToolInput').value = rule.tool;
872
+ content.querySelector('#ruleDecisionInput').value = rule.decision;
873
+ content.querySelector('#ruleFormHeading').textContent = 'Edit rule "' + rule.name + '"';
874
+ content.querySelector('#ruleSubmitBtn').textContent = 'Save';
875
+ content.querySelector('#ruleCancelBtn').style.display = '';
876
+ content.querySelector('#ruleFormError').textContent = '';
877
+ editingRuleName = rule.name;
878
+ content.querySelector('#ruleForm').scrollIntoView({ block: 'nearest' });
879
+ });
880
+ }
881
+
882
+ content.querySelector('#ruleCancelBtn').addEventListener('click', resetRuleForm);
883
+
884
+ var ruleForm = content.querySelector('#ruleForm');
885
+ ruleForm.addEventListener('submit', function (ev) {
886
+ ev.preventDefault();
887
+ var errorEl = content.querySelector('#ruleFormError');
888
+ errorEl.textContent = '';
889
+ var data = new FormData(ruleForm);
890
+ var scope = data.get('scope');
891
+ var tool = data.get('tool');
892
+ var decision = data.get('decision');
893
+ var submitBtn = content.querySelector('#ruleSubmitBtn');
894
+ submitBtn.disabled = true;
895
+
896
+ var request = editingRuleName
897
+ ? fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(editingRuleName), {
898
+ method: 'PUT',
899
+ headers: { 'content-type': 'application/json' },
900
+ body: JSON.stringify({ scope: scope, tool: tool, decision: decision }),
901
+ })
902
+ : fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules', {
903
+ method: 'POST',
904
+ headers: { 'content-type': 'application/json' },
905
+ body: JSON.stringify({ name: data.get('name'), scope: scope, tool: tool, decision: decision }),
906
+ });
907
+
908
+ request
909
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
910
+ .then(function (result) {
911
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
912
+ applyActauthConfig(name, result.body);
913
+ refreshOverviewPanel(name);
914
+ })
915
+ .catch(function (err) {
916
+ errorEl.textContent = err.message;
917
+ submitBtn.disabled = false;
918
+ });
919
+ });
920
+
921
+ var defaultForm = content.querySelector('#defaultDecisionForm');
922
+ defaultForm.addEventListener('submit', function (ev) {
923
+ ev.preventDefault();
924
+ var errorEl = content.querySelector('#defaultDecisionError');
925
+ errorEl.textContent = '';
926
+ var decision = content.querySelector('#defaultDecisionSelect').value;
927
+ fetch('/agents/' + encodeURIComponent(name) + '/actauth/default-decision', {
928
+ method: 'PUT',
929
+ headers: { 'content-type': 'application/json' },
930
+ body: JSON.stringify({ decision: decision }),
931
+ })
932
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
933
+ .then(function (result) {
934
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
935
+ applyActauthConfig(name, result.body);
936
+ refreshOverviewPanel(name);
937
+ })
938
+ .catch(function (err) {
939
+ errorEl.textContent = err.message;
940
+ });
941
+ });
942
+ }
943
+
944
+ function applyActauthConfig(name, cfg) {
945
+ var content = actauthContentEl();
946
+ if (!content) return;
947
+ content.innerHTML = renderActauthConfigHtml(cfg);
948
+ wireActauthHandlers(name, cfg);
949
+ actauthLoadedFor = name;
950
+ }
951
+
952
+ function loadActauthTab(name) {
953
+ var content = actauthContentEl();
954
+ if (!content) return;
955
+ content.innerHTML = '<p class="hint">Loading&hellip;</p>';
956
+ fetch('/agents/' + encodeURIComponent(name) + '/actauth')
957
+ .then(function (r) { return r.json(); })
958
+ .then(function (cfg) {
959
+ if (currentName !== name) return;
960
+ applyActauthConfig(name, cfg);
961
+ })
962
+ .catch(function (err) {
963
+ if (currentName !== name) return;
964
+ content.innerHTML = '<p class="error">Could not load actauth rules: ' + escapeHtml(err.message) + '</p>';
965
+ });
966
+ }
967
+
968
+ // ---- Skills tab: editable — add/edit/delete SKILL.md files via
969
+ // skills-admin.ts, restricted (see that file's own doc comment) to
970
+ // flat, non-nested skill ids. Unlike the Tools tab's Gateway Tools
971
+ // section, this doesn't need its own lazy-loaded endpoint — cfg.skills
972
+ // (SkillGarden's own index: name + description, no body) is already
973
+ // free from the same /config fetch that opened the agent; only Edit
974
+ // needs a further per-skill GET, to pull the body that index doesn't
975
+ // carry. ----
976
+
977
+ function renderSkillCard(s) {
978
+ return '<div class="source">' +
979
+ '<div class="source-head">' +
980
+ '<h4>' + escapeHtml(s.name) + '</h4>' +
981
+ '<span>' +
982
+ '<button type="button" class="edit-skill-btn" data-id="' + escapeHtml(s.name) + '">Edit</button> ' +
983
+ '<button type="button" class="delete-btn delete-skill-btn" data-id="' + escapeHtml(s.name) + '" title="Delete this skill" aria-label="Delete this skill">Delete</button>' +
984
+ '</span>' +
985
+ '</div>' +
986
+ '<p class="hint">' + escapeHtml(s.description) + '</p>' +
987
+ '</div>';
988
+ }
989
+
990
+ function renderSkillsTabHtml(cfg) {
991
+ var listHtml = cfg.skills.length ? cfg.skills.map(renderSkillCard).join('') : '<p class="hint">No skills yet.</p>';
992
+ return '<section><h3>Skills dirs</h3><dl class="kv">' +
993
+ '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
994
+ '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
995
+ '</dl></section>' +
996
+ '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
997
+ '<section><h3 id="skillFormHeading">Add a skill</h3>' +
998
+ '<form class="add-source" id="skillForm">' +
999
+ '<label>Id <span class="hint">(lowercase, hyphen-separated — becomes the SKILL.md folder name; nested skills aren\\'t editable here)</span>' +
1000
+ '<input name="id" id="skillIdInput" required pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="summarize-files">' +
1001
+ '</label>' +
1002
+ '<label>Description <span class="hint">(what should make the agent reach for this? newlines are collapsed to spaces on save)</span>' +
1003
+ '<textarea name="description" id="skillDescInput" required></textarea>' +
1004
+ '</label>' +
1005
+ '<div class="body-field">' +
1006
+ '<div class="body-field-head">' +
1007
+ '<span>Body <span class="hint">(markdown)</span></span>' +
1008
+ '<span class="md-toggle">' +
1009
+ '<button type="button" class="md-toggle-btn active" id="skillBodyWriteBtn">Write</button>' +
1010
+ '<button type="button" class="md-toggle-btn" id="skillBodyPreviewBtn">Preview</button>' +
1011
+ '</span>' +
1012
+ '</div>' +
1013
+ '<textarea name="body" id="skillBodyInput" required></textarea>' +
1014
+ '<div class="markdown-body" id="skillBodyPreview" style="display:none"></div>' +
1015
+ '</div>' +
1016
+ '<button type="submit" id="skillSubmitBtn">Add</button>' +
1017
+ '<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
1018
+ '<div id="skillFormError" class="error"></div>' +
1019
+ '</form></section>';
1020
+ }
1021
+
1022
+ function skillsPanelEl() {
1023
+ return detail.querySelector('[data-tab-panel="skills"]');
1024
+ }
1025
+
1026
+ function wireSkillsHandlers(name) {
1027
+ var panel = skillsPanelEl();
1028
+ if (!panel) return;
1029
+ var editingSkillId = null;
1030
+
1031
+ // Write/Preview toggle for the Body field — same idea as GitHub's own
1032
+ // issue/PR editor: the textarea and the rendered preview show/hide
1033
+ // each other, the textarea's value is the only source of truth (the
1034
+ // preview is just a render of it on demand, not a second place edits
1035
+ // could get made), so switching back to Write can never lose anything
1036
+ // typed before switching to Preview.
1037
+ var bodyTextarea = panel.querySelector('#skillBodyInput');
1038
+ var bodyPreview = panel.querySelector('#skillBodyPreview');
1039
+ var bodyWriteBtn = panel.querySelector('#skillBodyWriteBtn');
1040
+ var bodyPreviewBtn = panel.querySelector('#skillBodyPreviewBtn');
1041
+
1042
+ function showBodyWrite() {
1043
+ bodyTextarea.style.display = '';
1044
+ bodyPreview.style.display = 'none';
1045
+ bodyWriteBtn.classList.add('active');
1046
+ bodyPreviewBtn.classList.remove('active');
1047
+ }
1048
+ function showBodyPreview() {
1049
+ bodyPreview.innerHTML = renderMarkdownPreview(bodyTextarea.value);
1050
+ bodyTextarea.style.display = 'none';
1051
+ bodyPreview.style.display = 'block';
1052
+ bodyPreviewBtn.classList.add('active');
1053
+ bodyWriteBtn.classList.remove('active');
1054
+ }
1055
+ bodyWriteBtn.addEventListener('click', showBodyWrite);
1056
+ bodyPreviewBtn.addEventListener('click', showBodyPreview);
1057
+
1058
+ function resetSkillForm() {
1059
+ var form = panel.querySelector('#skillForm');
1060
+ form.reset();
1061
+ panel.querySelector('#skillIdInput').disabled = false;
1062
+ panel.querySelector('#skillFormHeading').textContent = 'Add a skill';
1063
+ panel.querySelector('#skillSubmitBtn').textContent = 'Add';
1064
+ panel.querySelector('#skillCancelBtn').style.display = 'none';
1065
+ panel.querySelector('#skillFormError').textContent = '';
1066
+ editingSkillId = null;
1067
+ showBodyWrite();
1068
+ }
1069
+
1070
+ var deleteButtons = panel.querySelectorAll('.delete-skill-btn');
1071
+ for (var i = 0; i < deleteButtons.length; i++) {
1072
+ deleteButtons[i].addEventListener('click', function (ev) {
1073
+ var btn = ev.currentTarget;
1074
+ var id = btn.getAttribute('data-id');
1075
+ if (!confirm('Delete skill "' + id + '"? This removes its SKILL.md.')) return;
1076
+ btn.disabled = true;
1077
+ fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id), { method: 'DELETE' })
1078
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1079
+ .then(function (result) {
1080
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1081
+ refreshSkillsDependentPanels(name);
1082
+ })
1083
+ .catch(function (err) {
1084
+ alert('Could not delete: ' + err.message);
1085
+ btn.disabled = false;
1086
+ });
1087
+ });
1088
+ }
1089
+
1090
+ var editButtons = panel.querySelectorAll('.edit-skill-btn');
1091
+ for (var i = 0; i < editButtons.length; i++) {
1092
+ editButtons[i].addEventListener('click', function (ev) {
1093
+ var id = ev.currentTarget.getAttribute('data-id');
1094
+ var errorEl = panel.querySelector('#skillFormError');
1095
+ errorEl.textContent = '';
1096
+ fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id))
1097
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1098
+ .then(function (result) {
1099
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1100
+ panel.querySelector('#skillIdInput').value = result.body.id;
1101
+ panel.querySelector('#skillIdInput').disabled = true;
1102
+ panel.querySelector('#skillDescInput').value = result.body.description;
1103
+ panel.querySelector('#skillBodyInput').value = result.body.body;
1104
+ panel.querySelector('#skillFormHeading').textContent = 'Edit skill "' + result.body.id + '"';
1105
+ panel.querySelector('#skillSubmitBtn').textContent = 'Save';
1106
+ panel.querySelector('#skillCancelBtn').style.display = '';
1107
+ editingSkillId = result.body.id;
1108
+ showBodyWrite();
1109
+ panel.querySelector('#skillForm').scrollIntoView({ block: 'nearest' });
1110
+ })
1111
+ .catch(function (err) {
1112
+ errorEl.textContent = 'Could not load skill: ' + err.message;
1113
+ });
1114
+ });
1115
+ }
1116
+
1117
+ panel.querySelector('#skillCancelBtn').addEventListener('click', resetSkillForm);
1118
+
1119
+ var form = panel.querySelector('#skillForm');
1120
+ form.addEventListener('submit', function (ev) {
1121
+ ev.preventDefault();
1122
+ var errorEl = panel.querySelector('#skillFormError');
1123
+ errorEl.textContent = '';
1124
+ var data = new FormData(form);
1125
+ // writeSkill (both routes go through the same PUT) creates-or-
1126
+ // replaces — so "Add" with an id that already exists just
1127
+ // overwrites it, same as editing it directly would.
1128
+ var id = editingSkillId || data.get('id');
1129
+ var submitBtn = panel.querySelector('#skillSubmitBtn');
1130
+ submitBtn.disabled = true;
1131
+ fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id), {
1132
+ method: 'PUT',
1133
+ headers: { 'content-type': 'application/json' },
1134
+ body: JSON.stringify({ description: data.get('description'), body: data.get('body') }),
1135
+ })
1136
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1137
+ .then(function (result) {
1138
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1139
+ refreshSkillsDependentPanels(name);
1140
+ })
1141
+ .catch(function (err) {
1142
+ errorEl.textContent = err.message;
1143
+ submitBtn.disabled = false;
1144
+ });
1145
+ });
1146
+ }
1147
+
1148
+ // ---- Tools tab: three sections, one per where a tool actually comes
1149
+ // from -- Local tools (agents/name/tools/), Agent as Tools (subagents,
1150
+ // via agentAsTool), and Gateway Tools (gateway-tools.ts's registry,
1151
+ // this section ported from the old standalone /agents/gateway-tools
1152
+ // page). Local tools/Agent as Tools render immediately from cfg --
1153
+ // already fetched to open the agent at all, no extra cost. Gateway
1154
+ // Tools alone loads lazily, only once this tab is opened (see
1155
+ // loadGatewayTab): unlike the other two, resolving it means actually
1156
+ // connecting to each registered source (see gateway-tools.ts's
1157
+ // describeGatewayTools), a real cost not worth paying up front. ----
1158
+
1159
+ function renderToolsTabHtml(cfg) {
1160
+ return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
1161
+ '<div class="http-tool-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1162
+ '</section>' +
1163
+ '<section id="gatewayToolsSection">' +
1164
+ '<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
1165
+ '<div id="gatewayToolsContent"><p class="hint">Loading&hellip;</p></div>' +
1166
+ '</section>' +
1167
+ '<section><h3>Agent as Tools (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
1168
+ }
1169
+
1170
+ // ---- Create an HTTP tool: generates a real agents/:name/tools/<tool>.ts
1171
+ // file from this form (loopengine's own createHttpTool), never arbitrary
1172
+ // code — only one shape, a parameterized HTTP call. See
1173
+ // web/http-tool-admin.ts's own header comment for why this is the line
1174
+ // this repo draws instead of, say, storing code in a SKILL.md and
1175
+ // executing it at request time. ----
1176
+
1177
+ var httpToolFieldRowCount = 0;
1178
+ var httpToolHeaderRowCount = 0;
1179
+
1180
+ function renderHttpToolFieldRow(index) {
1181
+ return '<div class="http-tool-field-row" data-row="' + index + '">' +
1182
+ '<input type="text" class="http-tool-field-name" placeholder="field_name" pattern="[a-zA-Z_][a-zA-Z0-9_]*">' +
1183
+ '<select class="http-tool-field-type">' +
1184
+ '<option value="string">string</option>' +
1185
+ '<option value="number">number</option>' +
1186
+ '<option value="boolean">boolean</option>' +
1187
+ '</select>' +
1188
+ '<input type="text" class="http-tool-field-description" placeholder="description (optional)">' +
1189
+ '<label class="http-tool-inline-label"><input type="checkbox" class="http-tool-field-required" checked> required</label>' +
1190
+ '<button type="button" class="delete-btn http-tool-remove-row" title="Remove this field" aria-label="Remove this field">Delete</button>' +
1191
+ '</div>';
1192
+ }
1193
+
1194
+ function renderHttpToolHeaderRow(index) {
1195
+ return '<div class="http-tool-header-row" data-row="' + index + '">' +
1196
+ '<input type="text" class="http-tool-header-key" placeholder="Header-Name">' +
1197
+ '<input type="text" class="http-tool-header-value" placeholder="value, {field}, or {{ENV_VAR}}">' +
1198
+ '<button type="button" class="delete-btn http-tool-remove-row" title="Remove this header" aria-label="Remove this header">Delete</button>' +
1199
+ '</div>';
1200
+ }
1201
+
1202
+ function renderHttpToolFormHtml() {
1203
+ httpToolFieldRowCount = 0;
1204
+ httpToolHeaderRowCount = 0;
1205
+ return '<form class="add-source" id="httpToolForm">' +
1206
+ '<label>Name <span class="hint">(snake_case, e.g. lookup_order_status)</span>' +
1207
+ '<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
1208
+ '</label>' +
1209
+ '<label>Description' +
1210
+ '<input type="text" name="description" required placeholder="What this tool does">' +
1211
+ '</label>' +
1212
+ '<label>Fields' +
1213
+ '<div id="httpToolFieldRows"></div>' +
1214
+ '<button type="button" id="httpToolAddFieldBtn" class="hint-btn">Add field</button>' +
1215
+ '</label>' +
1216
+ '<label>Method &amp; URL <span class="hint">(use {field} for a field defined above)</span>' +
1217
+ '<div class="http-tool-method-url">' +
1218
+ '<select name="method">' +
1219
+ '<option value="GET">GET</option>' +
1220
+ '<option value="POST">POST</option>' +
1221
+ '<option value="PUT">PUT</option>' +
1222
+ '<option value="PATCH">PATCH</option>' +
1223
+ '<option value="DELETE">DELETE</option>' +
1224
+ '</select>' +
1225
+ '<input type="text" name="url" required placeholder="https://api.example.com/orders/{orderId}">' +
1226
+ '</div>' +
1227
+ '</label>' +
1228
+ '<label>Headers <span class="hint">(a value may reference {field} or {{ENV_VAR}} &mdash; never paste a real secret here)</span>' +
1229
+ '<div id="httpToolHeaderRows"></div>' +
1230
+ '<button type="button" id="httpToolAddHeaderBtn" class="hint-btn">Add header</button>' +
1231
+ '</label>' +
1232
+ '<label class="http-tool-inline-label"><input type="checkbox" name="sendFieldsAsJsonBody"> Send fields as a JSON body (POST/PUT/PATCH only)</label>' +
1233
+ '<label>Response JSON path <span class="hint">(optional &mdash; e.g. data.status; leave blank to return the raw response text)</span>' +
1234
+ '<input type="text" name="responseJsonPath" placeholder="data.status">' +
1235
+ '</label>' +
1236
+ '<label>Grant permission <span class="hint">(optional &mdash; leave blank to leave this tool at actauth&#39;s own default, typically deny)</span>' +
1237
+ '<select name="decision">' +
1238
+ '<option value="">(leave unset)</option>' +
1239
+ '<option value="allow">allow</option>' +
1240
+ '<option value="ask">ask</option>' +
1241
+ '<option value="deny">deny</option>' +
1242
+ '</select>' +
1243
+ '</label>' +
1244
+ '<button type="submit" id="httpToolSubmitBtn">Create tool</button>' +
1245
+ '<div id="httpToolError" class="error"></div>' +
1246
+ '</form>';
1247
+ }
1248
+
1249
+ function wireHttpToolRemoveButton(row) {
1250
+ var btn = row.querySelector('.http-tool-remove-row');
1251
+ btn.addEventListener('click', function () {
1252
+ row.parentNode.removeChild(row);
1253
+ });
1254
+ }
1255
+
1256
+ function wireHttpToolFormHandlers(name) {
1257
+ var form = detail.querySelector('#httpToolForm');
1258
+ if (!form) return;
1259
+
1260
+ var fieldRows = detail.querySelector('#httpToolFieldRows');
1261
+ var headerRows = detail.querySelector('#httpToolHeaderRows');
1262
+ var addFieldBtn = detail.querySelector('#httpToolAddFieldBtn');
1263
+ var addHeaderBtn = detail.querySelector('#httpToolAddHeaderBtn');
1264
+ var errorEl = detail.querySelector('#httpToolError');
1265
+ var submitBtn = detail.querySelector('#httpToolSubmitBtn');
1266
+
1267
+ addFieldBtn.addEventListener('click', function () {
1268
+ fieldRows.insertAdjacentHTML('beforeend', renderHttpToolFieldRow(httpToolFieldRowCount++));
1269
+ wireHttpToolRemoveButton(fieldRows.lastElementChild);
1270
+ });
1271
+ addHeaderBtn.addEventListener('click', function () {
1272
+ headerRows.insertAdjacentHTML('beforeend', renderHttpToolHeaderRow(httpToolHeaderRowCount++));
1273
+ wireHttpToolRemoveButton(headerRows.lastElementChild);
1274
+ });
1275
+
1276
+ form.addEventListener('submit', function (ev) {
1277
+ ev.preventDefault();
1278
+ errorEl.textContent = '';
1279
+
1280
+ var fields = [];
1281
+ var fieldRowEls = fieldRows.querySelectorAll('.http-tool-field-row');
1282
+ for (var i = 0; i < fieldRowEls.length; i++) {
1283
+ var row = fieldRowEls[i];
1284
+ var fieldName = row.querySelector('.http-tool-field-name').value.trim();
1285
+ if (!fieldName) continue;
1286
+ fields.push({
1287
+ name: fieldName,
1288
+ type: row.querySelector('.http-tool-field-type').value,
1289
+ description: row.querySelector('.http-tool-field-description').value.trim() || undefined,
1290
+ required: row.querySelector('.http-tool-field-required').checked,
1291
+ });
1292
+ }
1293
+
1294
+ var headers = [];
1295
+ var headerRowEls = headerRows.querySelectorAll('.http-tool-header-row');
1296
+ for (var j = 0; j < headerRowEls.length; j++) {
1297
+ var hrow = headerRowEls[j];
1298
+ var headerKey = hrow.querySelector('.http-tool-header-key').value.trim();
1299
+ if (!headerKey) continue;
1300
+ headers.push({ key: headerKey, value: hrow.querySelector('.http-tool-header-value').value });
1301
+ }
1302
+
1303
+ var formData = new FormData(form);
1304
+ var payload = {
1305
+ name: formData.get('name'),
1306
+ description: formData.get('description'),
1307
+ fields: fields,
1308
+ method: formData.get('method'),
1309
+ url: formData.get('url'),
1310
+ headers: headers,
1311
+ sendFieldsAsJsonBody: formData.get('sendFieldsAsJsonBody') === 'on',
1312
+ responseJsonPath: formData.get('responseJsonPath') ? formData.get('responseJsonPath') : undefined,
1313
+ decision: formData.get('decision') ? formData.get('decision') : undefined,
1314
+ };
1315
+
1316
+ submitBtn.disabled = true;
1317
+ fetch('/agents/' + encodeURIComponent(name) + '/tools/http', {
1318
+ method: 'POST',
1319
+ headers: { 'content-type': 'application/json' },
1320
+ body: JSON.stringify(payload),
1321
+ })
1322
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1323
+ .then(function (result) {
1324
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1325
+ selectAgent(name);
1326
+ })
1327
+ .catch(function (err) {
1328
+ errorEl.textContent = err.message;
1329
+ submitBtn.disabled = false;
1330
+ });
1331
+ });
1332
+ }
1333
+
1334
+ // Removing a source used to be one "Remove" button per source, at the
1335
+ // bottom of the card — dropping every tool it produces at once, with
1336
+ // no way to remove just one. Each slug now gets its own remove icon
1337
+ // instead, right where it's listed — a source disappears on its own,
1338
+ // via removeGatewayToolSlug, once its last slug is gone (see that
1339
+ // function's own doc comment).
1340
+ function renderGatewaySource(s) {
1341
+ var errorHtml = s.status === 'error' ? '<p class="error">' + escapeHtml(s.error) + '</p>' : '';
1342
+ var rowsHtml = !s.entry.slugs.length
1343
+ ? '<p class="hint">No tools registered.</p>'
1344
+ : '<ul class="tool-list">' + s.entry.slugs.map(function (slug, i) {
1345
+ var tool = s.status === 'ok' ? s.tools[i] : null;
1346
+ var label = tool
1347
+ ? '<code>' + escapeHtml(tool.name) + '</code> &mdash; ' + escapeHtml(tool.description)
1348
+ : '<code>' + escapeHtml(s.entry.name + '_' + slug) + '</code>';
1349
+ return '<li><span class="tool-picker-label">' + label + '</span>' +
1350
+ ' <button type="button" class="delete-btn" data-source="' + escapeHtml(s.entry.name) + '" data-slug="' + escapeHtml(slug) + '" title="Remove this tool" aria-label="Remove this tool">Delete</button>' +
1351
+ '</li>';
1352
+ }).join('') + '</ul>';
1353
+
1354
+ return '<div class="source">' +
1355
+ '<div class="source-head">' +
1356
+ '<h4>' + escapeHtml(s.entry.name) + ' <span class="hint">(' + escapeHtml(s.entry.provider) + ')</span></h4>' +
1357
+ '<span class="' + (s.status === 'ok' ? 'status-ok' : 'status-error') + '">' + escapeHtml(s.status) + '</span>' +
1358
+ '</div>' +
1359
+ errorHtml +
1360
+ rowsHtml +
1361
+ '</div>';
1362
+ }
1363
+
1364
+ function renderGatewayHtml(sources) {
1365
+ var listHtml = sources.length
1366
+ ? sources.map(renderGatewaySource).join('')
1367
+ : '<p class="hint">No gateway tools registered yet.</p>';
1368
+
1369
+ // App + tool picker instead of freeform slug entry — pulled from
1370
+ // GET /composio/connections and GET /composio/tools?toolkit=X (see
1371
+ // wireGatewayHandlers below), so adding a source means checking boxes
1372
+ // for what's already connected and available, not needing to already
1373
+ // know a toolkit's exact slug strings by heart.
1374
+ return '<section><div id="sourceList">' + listHtml + '</div></section>' +
1375
+ '<section><h3>Add a gateway tool</h3>' +
1376
+ '<form class="add-source" id="addForm">' +
1377
+ '<label>Provider' +
1378
+ '<select name="provider"><option value="composio">Composio</option></select>' +
1379
+ '</label>' +
1380
+ '<label>App <span class="hint">(already connected via composio link)</span>' +
1381
+ '<select name="toolkit" id="toolkitSelect" required><option value="">Loading apps&hellip;</option></select>' +
1382
+ '</label>' +
1383
+ '<label>Name <span class="hint">(namespaces every tool it produces, e.g. "gh")</span>' +
1384
+ '<input name="name" required pattern="[a-z0-9_]+" placeholder="gh">' +
1385
+ '</label>' +
1386
+ '<label>Tools' +
1387
+ '<input type="text" id="toolFilter" placeholder="Filter by name or slug&hellip;">' +
1388
+ '<div class="tool-picker-controls">' +
1389
+ '<button type="button" id="selectAllToolsBtn" disabled>Select all</button>' +
1390
+ '<span class="hint" id="selectedCount"></span>' +
1391
+ '</div>' +
1392
+ '<div class="tool-picker-list" id="toolPickerList"><p class="hint">Pick an app above first.</p></div>' +
1393
+ '</label>' +
1394
+ '<label>Grant permission <span class="hint">(optional — leave blank to leave every new tool at actauth\\'s own default, typically deny)</span>' +
1395
+ '<select name="decision">' +
1396
+ '<option value="">(leave unset)</option>' +
1397
+ '<option value="auto" selected>auto — allow read-only tools, ask for the rest</option>' +
1398
+ '<option value="allow">allow</option>' +
1399
+ '<option value="ask">ask</option>' +
1400
+ '<option value="deny">deny</option>' +
1401
+ '</select>' +
1402
+ '</label>' +
1403
+ '<button type="submit" id="addSubmitBtn" disabled>Add</button>' +
1404
+ '<div id="addError" class="error"></div>' +
1405
+ '</form></section>';
1406
+ }
1407
+
1408
+ function gatewayContentEl() {
1409
+ return detail.querySelector('#gatewayToolsContent');
1410
+ }
1411
+
1412
+ function wireGatewayHandlers(name) {
1413
+ var content = gatewayContentEl();
1414
+ var removeToolButtons = content.querySelectorAll('.delete-btn');
1415
+ for (var i = 0; i < removeToolButtons.length; i++) {
1416
+ removeToolButtons[i].addEventListener('click', function (ev) {
1417
+ var btn = ev.currentTarget;
1418
+ var sourceName = btn.getAttribute('data-source');
1419
+ var slug = btn.getAttribute('data-slug');
1420
+ btn.disabled = true;
1421
+ fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools/' + encodeURIComponent(sourceName) + '/' + encodeURIComponent(slug), { method: 'DELETE' })
1422
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1423
+ .then(function (result) {
1424
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1425
+ // Same shortcut as the add-form's own success handler — the
1426
+ // DELETE response already carries the freshly-resolved
1427
+ // sources, so applying it directly skips a second,
1428
+ // redundant live-reconnect round trip for the same data.
1429
+ applyGatewaySources(name, result.body.sources || []);
1430
+ // Removing a tool also drops its auto-seeded actauth rule
1431
+ // (see removeGatewayToolSlug) — keep Overview/Actauth in
1432
+ // sync with that.
1433
+ refreshActauthDependentPanels(name);
1434
+ })
1435
+ .catch(function (err) {
1436
+ alert('Could not remove: ' + err.message);
1437
+ btn.disabled = false;
1438
+ });
1439
+ });
1440
+ }
1441
+
1442
+ var form = content.querySelector('#addForm');
1443
+ var toolkitSelect = content.querySelector('#toolkitSelect');
1444
+ var nameInput = form.querySelector('input[name="name"]');
1445
+ var toolFilter = content.querySelector('#toolFilter');
1446
+ var toolPickerList = content.querySelector('#toolPickerList');
1447
+ var selectAllBtn = content.querySelector('#selectAllToolsBtn');
1448
+ var selectedCountEl = content.querySelector('#selectedCount');
1449
+ var submitBtn = content.querySelector('#addSubmitBtn');
1450
+ var currentTools = [];
1451
+ // Tracks whichever toolkit slug the Name field was last auto-filled
1452
+ // with, so switching apps updates an untouched Name along with it —
1453
+ // but the moment a person types their own value in, it stops
1454
+ // clobbering that on the next app switch.
1455
+ var nameAutoFilledAs = '';
1456
+
1457
+ function updateSelectedCount() {
1458
+ var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
1459
+ var checked = toolPickerList.querySelectorAll('input[name="slugs"]:checked').length;
1460
+ selectedCountEl.textContent = checked ? checked + ' of ' + boxes.length + ' selected' : '';
1461
+ }
1462
+
1463
+ function renderToolPicker(tools, filterText) {
1464
+ var q = (filterText || '').toLowerCase();
1465
+ var filtered = !q
1466
+ ? tools
1467
+ : tools.filter(function (t) {
1468
+ return t.slug.toLowerCase().indexOf(q) !== -1 || t.name.toLowerCase().indexOf(q) !== -1 || t.description.toLowerCase().indexOf(q) !== -1;
1469
+ });
1470
+ if (!filtered.length) {
1471
+ toolPickerList.innerHTML = '<p class="hint">No matching tools.</p>';
1472
+ updateSelectedCount();
1473
+ return;
1474
+ }
1475
+ toolPickerList.innerHTML = filtered.map(function (t) {
1476
+ return '<label class="tool-picker-item">' +
1477
+ '<span class="tool-picker-label"><strong>' + escapeHtml(t.name) + '</strong> <span class="hint">' + escapeHtml(t.slug) + '</span>' +
1478
+ '<br><span class="hint">' + escapeHtml(t.description) + '</span></span>' +
1479
+ '<input type="checkbox" name="slugs" value="' + escapeHtml(t.slug) + '">' +
1480
+ '</label>';
1481
+ }).join('');
1482
+ var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
1483
+ for (var i = 0; i < boxes.length; i++) {
1484
+ boxes[i].addEventListener('change', updateSelectedCount);
1485
+ }
1486
+ updateSelectedCount();
1487
+ }
1488
+
1489
+ // Acts only on the currently-filtered/visible tools, not every tool
1490
+ // the toolkit has — filtering to "issue" and hitting Select all is
1491
+ // meant to select the issue-related tools shown, not silently pull
1492
+ // in everything else the toolkit offers too. Toggles: if everything
1493
+ // visible is already checked, this unchecks it instead.
1494
+ selectAllBtn.addEventListener('click', function () {
1495
+ var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
1496
+ if (!boxes.length) return;
1497
+ var allChecked = Array.prototype.every.call(boxes, function (b) { return b.checked; });
1498
+ for (var i = 0; i < boxes.length; i++) boxes[i].checked = !allChecked;
1499
+ updateSelectedCount();
1500
+ });
1501
+
1502
+ function loadToolsForToolkit(toolkit) {
1503
+ toolPickerList.innerHTML = '<p class="hint">Loading tools&hellip;</p>';
1504
+ submitBtn.disabled = true;
1505
+ selectAllBtn.disabled = true;
1506
+ fetch('/composio/tools?toolkit=' + encodeURIComponent(toolkit))
1507
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1508
+ .then(function (result) {
1509
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1510
+ currentTools = result.body.tools || [];
1511
+ renderToolPicker(currentTools, toolFilter.value);
1512
+ submitBtn.disabled = false;
1513
+ selectAllBtn.disabled = false;
1514
+ })
1515
+ .catch(function (err) {
1516
+ toolPickerList.innerHTML = '<p class="error">Could not load tools: ' + escapeHtml(err.message) + '</p>';
1517
+ });
1518
+ }
1519
+
1520
+ fetch('/composio/connections')
1521
+ .then(function (r) { return r.json(); })
1522
+ .then(function (data) {
1523
+ var active = (data.connections || []).filter(function (c) { return c.status === 'ACTIVE'; });
1524
+ var toolkits = [];
1525
+ for (var i = 0; i < active.length; i++) {
1526
+ if (toolkits.indexOf(active[i].toolkit) === -1) toolkits.push(active[i].toolkit);
1527
+ }
1528
+ if (!toolkits.length) {
1529
+ toolkitSelect.innerHTML = '<option value="">No connected apps</option>';
1530
+ toolPickerList.innerHTML = '<p class="hint">Run "composio link &lt;toolkit&gt;" on the machine running this server, then reopen this tab.</p>';
1531
+ return;
1532
+ }
1533
+ toolkitSelect.innerHTML = '<option value="">Choose an app&hellip;</option>' +
1534
+ toolkits.map(function (t) { return '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>'; }).join('');
1535
+ })
1536
+ .catch(function (err) {
1537
+ toolkitSelect.innerHTML = '<option value="">Could not load apps</option>';
1538
+ toolPickerList.innerHTML = '<p class="error">Could not load connected apps: ' + escapeHtml(err.message) + '</p>';
1539
+ });
1540
+
1541
+ toolkitSelect.addEventListener('change', function () {
1542
+ var toolkit = toolkitSelect.value;
1543
+ if (!toolkit) {
1544
+ toolPickerList.innerHTML = '<p class="hint">Pick an app above first.</p>';
1545
+ submitBtn.disabled = true;
1546
+ selectAllBtn.disabled = true;
1547
+ selectedCountEl.textContent = '';
1548
+ return;
1549
+ }
1550
+ if (!nameInput.value || nameInput.value === nameAutoFilledAs) {
1551
+ nameInput.value = toolkit;
1552
+ nameAutoFilledAs = toolkit;
1553
+ }
1554
+ loadToolsForToolkit(toolkit);
1555
+ });
1556
+
1557
+ toolFilter.addEventListener('input', function () {
1558
+ renderToolPicker(currentTools, toolFilter.value);
1559
+ });
1560
+
1561
+ form.addEventListener('submit', function (ev) {
1562
+ ev.preventDefault();
1563
+ var addError = content.querySelector('#addError');
1564
+ addError.textContent = '';
1565
+ var checked = form.querySelectorAll('input[name="slugs"]:checked');
1566
+ var slugs = Array.prototype.map.call(checked, function (cb) { return cb.value; });
1567
+ if (!slugs.length) {
1568
+ addError.textContent = 'Pick at least one tool.';
1569
+ return;
1570
+ }
1571
+ var data = new FormData(form);
1572
+ var body = { provider: data.get('provider'), name: data.get('name'), slugs: slugs };
1573
+ var decision = data.get('decision');
1574
+ if (decision) body.decision = decision;
1575
+
1576
+ // Immediate feedback the instant Add is clicked — without this,
1577
+ // the button just sits there through the whole POST (which itself
1578
+ // does a live connect to every registered source, not a quick
1579
+ // write — see gateway-tools.ts's describeGatewayTools), so nothing
1580
+ // visibly happens until it's already done.
1581
+ submitBtn.disabled = true;
1582
+ submitBtn.textContent = 'Adding…';
1583
+
1584
+ fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools', {
1585
+ method: 'POST',
1586
+ headers: { 'content-type': 'application/json' },
1587
+ body: JSON.stringify(body),
1588
+ })
1589
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1590
+ .then(function (result) {
1591
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1592
+ // The POST response already carries the freshly-resolved
1593
+ // sources (handleToolSourcesPost returns exactly what
1594
+ // describeGatewayTools would) — applying it directly instead
1595
+ // of calling loadGatewayTab skips a second, fully-redundant
1596
+ // live-reconnect round trip for the exact same data.
1597
+ applyGatewaySources(name, result.body.sources || []);
1598
+ // A decision (if given) just seeded a new actauth rule — keep
1599
+ // Overview/Actauth in sync with that instead of showing it
1600
+ // only after the agent is re-selected.
1601
+ refreshActauthDependentPanels(name);
1602
+ })
1603
+ .catch(function (err) {
1604
+ addError.textContent = err.message;
1605
+ submitBtn.disabled = false;
1606
+ submitBtn.textContent = 'Add';
1607
+ });
1608
+ });
1609
+ }
1610
+
1611
+ function applyGatewaySources(name, sources) {
1612
+ var content = gatewayContentEl();
1613
+ if (!content) return;
1614
+ content.innerHTML = renderGatewayHtml(sources);
1615
+ wireGatewayHandlers(name);
1616
+ gatewayLoadedFor = name;
1617
+ }
1618
+
1619
+ // force: true bypasses describeGatewayTools' own cache (see its own
1620
+ // doc comment) — the "Refresh" button's case, for when an operator
1621
+ // actually wants a live re-check (e.g. after reconnecting an account),
1622
+ // not the default tab-open case, which is happy with whatever a prior
1623
+ // load in this same gateway-tools.yml's lifetime already found.
1624
+ function loadGatewayTab(name, force) {
1625
+ var content = gatewayContentEl();
1626
+ if (!content) return;
1627
+ content.innerHTML = '<p class="hint">Loading&hellip;</p>';
1628
+ fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools' + (force ? '?refresh=1' : ''))
1629
+ .then(function (r) { return r.json(); })
1630
+ .then(function (data) {
1631
+ if (currentName !== name) return;
1632
+ applyGatewaySources(name, data.sources || []);
1633
+ })
1634
+ .catch(function (err) {
1635
+ if (currentName !== name) return;
1636
+ content.innerHTML = '<p class="error">Could not load gateway tools: ' + escapeHtml(err.message) + '</p>';
1637
+ });
1638
+ }
1639
+
1640
+ // ---- Tab switching + agent selection ----
1641
+
1642
+ function switchTab(tab) {
1643
+ currentTab = tab;
1644
+ var buttons = detail.querySelectorAll('.tabs button');
1645
+ for (var i = 0; i < buttons.length; i++) {
1646
+ buttons[i].classList.toggle('active', buttons[i].dataset.tab === tab);
1647
+ }
1648
+ var panels = detail.querySelectorAll('.tab-panel');
1649
+ for (var i = 0; i < panels.length; i++) {
1650
+ panels[i].style.display = panels[i].dataset.tabPanel === tab ? 'block' : 'none';
1651
+ }
1652
+ if (tab === 'tools' && gatewayLoadedFor !== currentName) {
1653
+ loadGatewayTab(currentName);
1654
+ }
1655
+ if (tab === 'actauth' && actauthLoadedFor !== currentName) {
1656
+ loadActauthTab(currentName);
1657
+ }
1658
+ }
1659
+
1660
+ // Refreshes just Overview's own summary — used after anything that
1661
+ // changes tools/skills/permissions but already has its own tab
1662
+ // reflecting the change directly from the response (applyGatewaySources,
1663
+ // applyActauthConfig, refreshSkillsDependentPanels's own Skills half),
1664
+ // so only Overview is left needing a re-fetch.
1665
+ function refreshOverviewPanel(name) {
1666
+ fetch('/agents/' + encodeURIComponent(name) + '/config')
1667
+ .then(function (r) { return r.json(); })
1668
+ .then(function (cfg) {
1669
+ if (currentName !== name) return;
1670
+ currentCfg = cfg;
1671
+ var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
1672
+ if (overviewPanel) {
1673
+ overviewPanel.innerHTML = renderOverviewHtml(cfg);
1674
+ wireOverviewHandlers(name);
1675
+ }
1676
+ })
1677
+ .catch(function () {
1678
+ // Best-effort — the tab that triggered this already reflects the
1679
+ // change either way, this is only about keeping Overview in sync.
1680
+ });
1681
+ }
1682
+
1683
+ // Adding or removing a gateway tool changes actauth.yml (a decision
1684
+ // seeds/drops a rule for it — see gateway-tools.ts's addGatewayTool/
1685
+ // removeGatewayToolSlug) but Overview's own Actauth section and the
1686
+ // dedicated Actauth tab were both rendered once, from whatever cfg
1687
+ // /agents/:name/config (and, for the Actauth tab, GET .../actauth)
1688
+ // returned when the agent/tab was first opened — without this, a rule
1689
+ // added or removed from the Tools tab wouldn't show up there until the
1690
+ // agent was re-selected or the page reloaded. Only re-loads the
1691
+ // Actauth tab if it's actually been opened already (actauthLoadedFor)
1692
+ // — otherwise it'll load fresh, gateway-tool-seeded rules included,
1693
+ // the first time it is.
1694
+ function refreshActauthDependentPanels(name) {
1695
+ refreshOverviewPanel(name);
1696
+ if (actauthLoadedFor === name) loadActauthTab(name);
1697
+ }
1698
+
1699
+ // Skills tab isn't lazily loaded like Gateway Tools/Actauth (see its
1700
+ // own header comment — it's already free from cfg, no live connection
1701
+ // needed) — so refreshing it after an add/edit/delete just means
1702
+ // re-rendering both it and Overview from a fresh /config fetch.
1703
+ function refreshSkillsDependentPanels(name) {
1704
+ fetch('/agents/' + encodeURIComponent(name) + '/config')
1705
+ .then(function (r) { return r.json(); })
1706
+ .then(function (cfg) {
1707
+ if (currentName !== name) return;
1708
+ currentCfg = cfg;
1709
+ var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
1710
+ var skillsPanel = skillsPanelEl();
1711
+ if (overviewPanel) {
1712
+ overviewPanel.innerHTML = renderOverviewHtml(cfg);
1713
+ wireOverviewHandlers(name);
1714
+ }
1715
+ if (skillsPanel) {
1716
+ skillsPanel.innerHTML = renderSkillsTabHtml(cfg);
1717
+ wireSkillsHandlers(name);
1718
+ }
1719
+ })
1720
+ .catch(function () {});
1721
+ }
1722
+
1723
+ function renderDetail(cfg) {
1724
+ currentCfg = cfg;
1725
+ detail.innerHTML =
1726
+ '<h2>' + escapeHtml(cfg.name) +
1727
+ ' <a id="playgroundLink" href="/playground?agent=' + encodeURIComponent(cfg.name) + '">Open in playground &rarr;</a></h2>' +
1728
+ '<div class="tabs">' +
1729
+ '<button class="tab" data-tab="overview">Overview</button>' +
1730
+ '<button class="tab" data-tab="skills">Skills</button>' +
1731
+ '<button class="tab" data-tab="tools">Tools</button>' +
1732
+ '<button class="tab" data-tab="actauth">ActAuth</button>' +
1733
+ '</div>' +
1734
+ '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
1735
+ '<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
1736
+ '<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
1737
+ '<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>';
1738
+
1739
+ var buttons = detail.querySelectorAll('.tabs button');
1740
+ for (var i = 0; i < buttons.length; i++) {
1741
+ buttons[i].addEventListener('click', function (ev) { switchTab(ev.currentTarget.dataset.tab); });
1742
+ }
1743
+
1744
+ wireSkillsHandlers(cfg.name);
1745
+ wireOverviewHandlers(cfg.name);
1746
+ wireHttpToolFormHandlers(cfg.name);
1747
+ var gatewayRefreshBtn = detail.querySelector('#gatewayToolsRefreshBtn');
1748
+ if (gatewayRefreshBtn) {
1749
+ gatewayRefreshBtn.addEventListener('click', function () { loadGatewayTab(cfg.name, true); });
1750
+ }
1751
+
1752
+ empty.style.display = 'none';
1753
+ detail.style.display = 'block';
1754
+ switchTab(currentTab);
1755
+ }
1756
+
1757
+ function selectAgent(name) {
1758
+ currentName = name;
1759
+ gatewayLoadedFor = null;
1760
+ actauthLoadedFor = null;
1761
+ var items = agentList.querySelectorAll('li');
1762
+ for (var i = 0; i < items.length; i++) {
1763
+ items[i].classList.toggle('active', items[i].dataset.name === name);
1764
+ }
1765
+ detail.innerHTML = '<p class="muted">Loading&hellip;</p>';
1766
+ empty.style.display = 'none';
1767
+ detail.style.display = 'block';
1768
+ fetch('/agents/' + encodeURIComponent(name) + '/config')
1769
+ .then(function (r) { return r.json(); })
1770
+ .then(function (cfg) {
1771
+ if (currentName !== name) return;
1772
+ renderDetail(cfg);
1773
+ })
1774
+ .catch(function (err) {
1775
+ if (currentName !== name) return;
1776
+ detail.innerHTML = '<p class="error">Could not load config: ' + escapeHtml(err.message) + '</p>';
1777
+ });
1778
+ }
1779
+
1780
+ fetch('/agents')
1781
+ .then(function (r) { return r.json(); })
1782
+ .then(function (data) {
1783
+ var agents = data.agents || [];
1784
+ agentList.textContent = '';
1785
+ for (var i = 0; i < agents.length; i++) {
1786
+ (function (name) {
1787
+ var li = document.createElement('li');
1788
+ li.textContent = name;
1789
+ li.dataset.name = name;
1790
+ li.addEventListener('click', function () { selectAgent(name); });
1791
+ agentList.appendChild(li);
1792
+ })(agents[i].name);
1793
+ }
1794
+ if (agents.length) {
1795
+ var requested = new URLSearchParams(location.search).get('agent');
1796
+ var initial = agents.some(function (a) { return a.name === requested; }) ? requested : agents[0].name;
1797
+ selectAgent(initial);
1798
+ }
1799
+ })
1800
+ .catch(function (err) {
1801
+ empty.textContent = 'Could not load agents: ' + err.message;
1802
+ });
1803
+ })();
1804
+ </script>
1805
+ </body>
1806
+ </html>
1807
+ `;