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