@aiwg/cockpit 2026.6.3

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.
Files changed (49) hide show
  1. package/README.md +337 -0
  2. package/bridge/package.json +13 -0
  3. package/bridge/src/public/index.html +395 -0
  4. package/bridge/src/server.mjs +1132 -0
  5. package/bridge/src/smoke.mjs +158 -0
  6. package/contrib/aiwg-core.json +15 -0
  7. package/contrib/contribution.schema.json +69 -0
  8. package/desktop/README.md +42 -0
  9. package/desktop/src-tauri/Cargo.toml +17 -0
  10. package/desktop/src-tauri/build.rs +3 -0
  11. package/desktop/src-tauri/frontend/index.html +11 -0
  12. package/desktop/src-tauri/src/main.rs +55 -0
  13. package/desktop/src-tauri/tauri.conf.json +20 -0
  14. package/package.json +49 -0
  15. package/runtime-docs/README.md +38 -0
  16. package/shell-core/runtime.mjs +45 -0
  17. package/shell-core/smoke.mjs +36 -0
  18. package/vscode/README.md +25 -0
  19. package/vscode/extension.js +59 -0
  20. package/vscode/package.json +29 -0
  21. package/web/dist/assets/index-B5anpdS1.js +67 -0
  22. package/web/dist/assets/index-CP3BF6uZ.css +32 -0
  23. package/web/dist/index.html +14 -0
  24. package/web/index.html +13 -0
  25. package/web/package.json +31 -0
  26. package/web/src/App.test.tsx +237 -0
  27. package/web/src/App.tsx +255 -0
  28. package/web/src/api.ts +20 -0
  29. package/web/src/components/Actions.tsx +60 -0
  30. package/web/src/components/Approvals.tsx +62 -0
  31. package/web/src/components/CapabilitySearch.tsx +73 -0
  32. package/web/src/components/Explore.tsx +41 -0
  33. package/web/src/components/Inventory.tsx +124 -0
  34. package/web/src/components/LaunchInstanceModal.test.tsx +97 -0
  35. package/web/src/components/LaunchInstanceModal.tsx +236 -0
  36. package/web/src/components/Library.tsx +76 -0
  37. package/web/src/components/Running.tsx +60 -0
  38. package/web/src/components/Sessions.test.tsx +125 -0
  39. package/web/src/components/Sessions.tsx +189 -0
  40. package/web/src/components/StartSessionModal.test.tsx +86 -0
  41. package/web/src/components/StartSessionModal.tsx +168 -0
  42. package/web/src/components/Welcome.tsx +474 -0
  43. package/web/src/main.tsx +11 -0
  44. package/web/src/styles.css +254 -0
  45. package/web/src/types.ts +47 -0
  46. package/web/src/useDebounce.ts +10 -0
  47. package/web/src/useSession.ts +220 -0
  48. package/web/src/util.test.ts +30 -0
  49. package/web/src/util.ts +17 -0
@@ -0,0 +1,395 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>AIWG Cockpit</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --bg:#0f1115; --panel:#171a21; --fg:#e6e8ec; --muted:#9aa3b2; --line:#2a2f3a; --ok:#3fb950; --warn:#d29922; --idle:#6b7280; --accent:#818cf8; --err:#f85149; }
9
+ * { box-sizing: border-box; }
10
+ body { margin:0; font:15px/1.5 system-ui, sans-serif; background:var(--bg); color:var(--fg); }
11
+ header { padding:14px 22px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
12
+ header h1 { font-size:18px; margin:0; font-weight:650; }
13
+ header .mark { color:var(--accent); font-size:20px; }
14
+ header .meta { margin-left:auto; color:var(--muted); font-size:13px; }
15
+ [role=tablist] { display:flex; gap:4px; padding:0 22px; border-bottom:1px solid var(--line); }
16
+ [role=tab] { background:none; border:0; border-bottom:2px solid transparent; color:var(--muted); padding:10px 14px; cursor:pointer; font:inherit; }
17
+ [role=tab][aria-selected=true] { color:var(--fg); border-bottom-color:var(--accent); }
18
+ [role=tab]:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
19
+ main { padding:22px; }
20
+ button, select, input { background:var(--panel); color:var(--fg); border:1px solid var(--line); border-radius:7px; padding:7px 12px; font:inherit; }
21
+ button { cursor:pointer; }
22
+ button:hover:not(:disabled) { border-color:var(--accent); }
23
+ button:disabled { opacity:.45; cursor:not-allowed; }
24
+ button:focus-visible, select:focus-visible, input:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
25
+ table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line); border-radius:10px; overflow:hidden; }
26
+ caption { text-align:left; color:var(--muted); padding:10px 14px; font-size:13px; }
27
+ th, td { text-align:left; padding:11px 14px; border-bottom:1px solid var(--line); }
28
+ th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; }
29
+ tr:last-child td { border-bottom:0; }
30
+ code { font:13px ui-monospace, monospace; color:var(--muted); }
31
+ .badge { display:inline-block; padding:2px 8px; border-radius:999px; border:1px solid var(--line); font-size:12px; }
32
+ .badge.controller { border-color:var(--ok); color:var(--ok); }
33
+ .badge.observer { border-color:var(--accent); color:var(--accent); }
34
+ .state { display:inline-flex; align-items:center; gap:7px; }
35
+ .dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; background:var(--idle); }
36
+ .state.running .dot, .state.working .dot { background:var(--ok); } .state.provisioning .dot { background:var(--warn); }
37
+ .empty { color:var(--muted); padding:22px; } .err { color:var(--err); padding:12px 0; }
38
+ .controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
39
+ .controls label { color:var(--muted); font-size:13px; }
40
+ #terminal { background:#0a0c10; border:1px solid var(--line); border-radius:10px; padding:14px; min-height:240px; max-height:48vh; overflow:auto; white-space:pre-wrap; word-break:break-word; font:13px/1.55 ui-monospace, monospace; color:#cdd3de; }
41
+ .inputrow { display:flex; gap:8px; margin-top:12px; }
42
+ .inputrow input { flex:1; font-family:ui-monospace, monospace; }
43
+ .hint { color:var(--muted); font-size:13px; margin:6px 0 0; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <header>
48
+ <span class="mark" aria-hidden="true">◆</span>
49
+ <h1>AIWG&nbsp;Cockpit</h1>
50
+ <span class="meta" id="meta" aria-live="polite"></span>
51
+ <button id="refresh" type="button">Refresh</button>
52
+ </header>
53
+
54
+ <div role="tablist" aria-label="Cockpit views">
55
+ <button role="tab" id="tab-inventory" aria-controls="panel-inventory" aria-selected="true">Inventory</button>
56
+ <button role="tab" id="tab-running" aria-controls="panel-running" aria-selected="false" tabindex="-1">Running</button>
57
+ <button role="tab" id="tab-sessions" aria-controls="panel-sessions" aria-selected="false" tabindex="-1">Sessions</button>
58
+ <button role="tab" id="tab-explore" aria-controls="panel-explore" aria-selected="false" tabindex="-1">Explore</button>
59
+ <button role="tab" id="tab-approvals" aria-controls="panel-approvals" aria-selected="false" tabindex="-1">Approvals</button>
60
+ <button role="tab" id="tab-actions" aria-controls="panel-actions" aria-selected="false" tabindex="-1">Actions</button>
61
+ </div>
62
+
63
+ <main>
64
+ <section id="panel-inventory" role="tabpanel" aria-labelledby="tab-inventory">
65
+ <div id="inventory-view" role="region" aria-label="Agent instance inventory">Loading…</div>
66
+ </section>
67
+
68
+ <section id="panel-running" role="tabpanel" aria-labelledby="tab-running" hidden>
69
+ <div id="running-view" role="region" aria-label="Running agents">Loading…</div>
70
+ </section>
71
+
72
+ <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden>
73
+ <div class="controls">
74
+ <label for="sel-instance">Instance</label>
75
+ <select id="sel-instance"></select>
76
+ <label for="sel-session">Session</label>
77
+ <select id="sel-session"></select>
78
+ <button id="attach-drive" type="button">Attach · drive</button>
79
+ <button id="attach-observe" type="button">Attach · observe</button>
80
+ <button id="btn-keyframe" type="button" disabled>Keyframe</button>
81
+ <button id="btn-replay" type="button" disabled>Reattach + replay</button>
82
+ <button id="btn-detach" type="button" disabled>Detach</button>
83
+ <span id="role-badge"></span>
84
+ </div>
85
+ <div id="terminal" role="log" aria-live="polite" aria-label="Session output"></div>
86
+ <div class="inputrow">
87
+ <input id="cmd" type="text" placeholder="Type to drive the session…" autocomplete="off" disabled aria-label="Session input" />
88
+ <button id="cmd-send" type="button" disabled>Send</button>
89
+ </div>
90
+ <p class="hint" id="session-hint">Pick a session, then attach. The first attach drives; later attaches observe. Output mirrors to every viewer — that's the awareness model.</p>
91
+ </section>
92
+
93
+ <section id="panel-explore" role="tabpanel" aria-labelledby="tab-explore" hidden>
94
+ <form class="controls" id="explore-form">
95
+ <label for="ex-q">Capability</label>
96
+ <input id="ex-q" type="search" placeholder="e.g. deploy production, audit security…" autocomplete="off" style="min-width:280px" />
97
+ <label for="ex-type">Type</label>
98
+ <select id="ex-type">
99
+ <option value="all">all</option><option value="skill">skill</option><option value="agent">agent</option>
100
+ <option value="command">command</option><option value="rule">rule</option><option value="flow">flow</option>
101
+ </select>
102
+ <button type="submit">Discover</button>
103
+ </form>
104
+ <p class="hint">Live from the AIWG registry via <code>aiwg discover</code> — every query re-reads the index, no app restart. The UI is data-driven from the same capability graph the agents use.</p>
105
+ <div style="display:grid;grid-template-columns:minmax(260px,1fr) 1.4fr;gap:16px;align-items:start">
106
+ <div id="ex-results" role="region" aria-label="Discovery results"></div>
107
+ <div id="ex-body" role="region" aria-label="Capability detail"><p class="empty">Select a result to inspect its definition.</p></div>
108
+ </div>
109
+ </section>
110
+
111
+ <section id="panel-approvals" role="tabpanel" aria-labelledby="tab-approvals" hidden>
112
+ <p class="hint">Unified approval inbox — <code>hitl-prompt/v1</code> requests from every stack, in one place. Decisions are operator authorization, not the agent's.</p>
113
+ <div id="approvals-view" role="region" aria-label="Pending approvals">Loading…</div>
114
+ </section>
115
+
116
+ <section id="panel-actions" role="tabpanel" aria-labelledby="tab-actions" hidden>
117
+ <p class="hint">Actions are <strong>contributed declaratively</strong> — first-party and (soon) any installed AIWG extension. Clicking one <strong>injects a command into an agentic session</strong> (the focused one, else it offers a new one); the agent there runs it. The Cockpit never runs the CLI — agents do.</p>
118
+ <div class="controls" id="actions-bar" role="group" aria-label="Contributed actions"></div>
119
+ <div id="action-out" role="region" aria-label="About actions"><p class="empty">Output appears in the session terminal — actions drop a command into a session and the agent executes it.</p></div>
120
+ </section>
121
+ </main>
122
+
123
+ <script type="module">
124
+ // --- view switching (ARIA tabs) ---
125
+ const tabs = [...document.querySelectorAll('[role=tab]')];
126
+ function show(tabId) {
127
+ for (const t of tabs) {
128
+ const sel = t.id === tabId;
129
+ t.setAttribute('aria-selected', String(sel));
130
+ t.tabIndex = sel ? 0 : -1;
131
+ document.getElementById(t.getAttribute('aria-controls')).hidden = !sel;
132
+ }
133
+ if (tabId === 'tab-running') loadRunning();
134
+ if (tabId === 'tab-sessions') loadInstances();
135
+ if (tabId === 'tab-actions') loadActions();
136
+ if (tabId === 'tab-approvals') loadApprovals();
137
+ }
138
+ tabs.forEach((t) => t.addEventListener('click', () => show(t.id)));
139
+
140
+ // shared: POST/DELETE a control-plane call, then re-run a loader
141
+ async function control(path, method, reload) {
142
+ try { await api(path, { method }); } catch (e) { alert(e.message); }
143
+ if (reload) reload();
144
+ }
145
+
146
+ const meta = document.getElementById('meta');
147
+ const fmt = (id) => (id && id.length > 12) ? id.slice(0, 8) + '…' : id;
148
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
149
+ // per-launch token injected by the Bridge for the gated control surface
150
+ const TOKEN = window.__COCKPIT_TOKEN__ || '';
151
+ const api = (u, o = {}) => fetch(u, { ...o, headers: { ...(o.headers || {}), authorization: 'Bearer ' + TOKEN } });
152
+
153
+ // --- Inventory ---
154
+ async function loadInventory() {
155
+ const view = document.getElementById('inventory-view');
156
+ view.textContent = 'Loading…';
157
+ try {
158
+ const inv = await (await api('/api/inventory')).json();
159
+ meta.textContent = `${inv.count} instance(s) · ${new Date(inv.fetched_at).toLocaleTimeString()}`;
160
+ if (!inv.instances.length) { view.innerHTML = '<p class="empty">No instances.</p>'; return; }
161
+ view.innerHTML = `<table><caption>Agent instances (live from the executor)</caption>
162
+ <thead><tr><th scope="col">Instance</th><th scope="col">Runtime</th><th scope="col">Loadout</th><th scope="col">State</th><th scope="col">Tenant</th><th scope="col">Manage</th></tr></thead>
163
+ <tbody>${inv.instances.map((i) => `<tr>
164
+ <td><code title="${esc(i.id)}">${esc(fmt(i.id))}</code></td>
165
+ <td><span class="badge">${esc(i.runtime)}</span></td><td>${esc(i.loadout)}</td>
166
+ <td><span class="state ${esc(i.state)}"><span class="dot" aria-hidden="true"></span>${esc(i.state)}</span></td>
167
+ <td>${esc(i.tenant)}</td>
168
+ <td style="white-space:nowrap">
169
+ ${i.state === 'running'
170
+ ? `<button class="mng" data-act="stop" data-id="${esc(i.id)}" aria-label="Stop ${esc(fmt(i.id))}">Stop</button>`
171
+ : `<button class="mng" data-act="start" data-id="${esc(i.id)}" aria-label="Start ${esc(fmt(i.id))}">Start</button>`}
172
+ <button class="mng" data-act="destroy" data-id="${esc(i.id)}" aria-label="Destroy ${esc(fmt(i.id))}">Destroy</button>
173
+ </td></tr>`).join('')}</tbody></table>`;
174
+ view.querySelectorAll('.mng').forEach((b) => b.addEventListener('click', () => {
175
+ const { act, id } = b.dataset;
176
+ if (act === 'destroy' && !confirm(`Destroy instance ${fmt(id)}? This cannot be undone.`)) return;
177
+ const path = act === 'destroy' ? `/api/instances/${encodeURIComponent(id)}` : `/api/instances/${encodeURIComponent(id)}/${act}`;
178
+ control(path, act === 'destroy' ? 'DELETE' : 'POST', loadInventory);
179
+ }));
180
+ } catch (e) { view.innerHTML = `<p class="err">Could not load inventory: ${esc(e.message)}</p>`; }
181
+ }
182
+
183
+ // --- Running board ---
184
+ async function loadRunning() {
185
+ const view = document.getElementById('running-view');
186
+ view.textContent = 'Loading…';
187
+ try {
188
+ const r = await (await api('/api/running')).json();
189
+ let costLine = '';
190
+ try {
191
+ const c = await (await api('/api/cost')).json();
192
+ costLine = `<p class="hint">Spend across stacks: <strong>$${c.total.usd.toFixed(2)}</strong> · ${(c.total.input_tokens + c.total.output_tokens).toLocaleString()} tokens</p>`;
193
+ } catch { /* cost optional */ }
194
+ if (!r.running.length) { view.innerHTML = costLine + '<p class="empty">Nothing running.</p>'; return; }
195
+ view.innerHTML = costLine + `<table><caption>Running across all stacks — ${r.count} task(s)</caption>
196
+ <thead><tr><th scope="col">Instance</th><th scope="col">Task</th><th scope="col">State</th><th scope="col">Tenant</th><th scope="col">Control</th></tr></thead>
197
+ <tbody>${r.running.map((t) => `<tr>
198
+ <td><code title="${esc(t.instance_id)}">${esc(fmt(t.instance_id))}</code></td>
199
+ <td><code title="${esc(t.task_id)}">${esc(fmt(t.task_id))}</code></td>
200
+ <td><span class="state ${esc(t.state)}"><span class="dot" aria-hidden="true"></span>${esc(t.state)}</span></td>
201
+ <td>${esc(t.tenant)}</td>
202
+ <td><button class="stop" data-i="${esc(t.instance_id)}" data-t="${esc(t.task_id)}" aria-label="Stop task ${esc(fmt(t.task_id))}">Stop</button></td>
203
+ </tr>`).join('')}</tbody></table>`;
204
+ view.querySelectorAll('.stop').forEach((b) => b.addEventListener('click', () =>
205
+ control(`/api/tasks/${encodeURIComponent(b.dataset.i)}/${encodeURIComponent(b.dataset.t)}/cancel`, 'POST', loadRunning)));
206
+ } catch (e) { view.innerHTML = `<p class="err">Could not load running: ${esc(e.message)}</p>`; }
207
+ }
208
+
209
+ // --- Session view (live pty-ws) ---
210
+ const selInstance = document.getElementById('sel-instance');
211
+ const selSession = document.getElementById('sel-session');
212
+ const terminal = document.getElementById('terminal');
213
+ const cmd = document.getElementById('cmd');
214
+ const roleBadge = document.getElementById('role-badge');
215
+ const btn = {
216
+ drive: document.getElementById('attach-drive'), observe: document.getElementById('attach-observe'),
217
+ keyframe: document.getElementById('btn-keyframe'), replay: document.getElementById('btn-replay'),
218
+ detach: document.getElementById('btn-detach'), send: document.getElementById('cmd-send'),
219
+ };
220
+ let ws = null, role = null, lastSeq = 0, attachUrl = null;
221
+ const decode = (b64) => { try { return atob(b64); } catch { return b64; } };
222
+ function write(text) { terminal.textContent += text; terminal.scrollTop = terminal.scrollHeight; }
223
+ function setRole(r) {
224
+ role = r;
225
+ roleBadge.innerHTML = r ? `<span class="badge ${r}">${r}</span>` : '';
226
+ const driving = r === 'controller';
227
+ cmd.disabled = !driving; btn.send.disabled = !driving;
228
+ cmd.placeholder = driving ? 'Type to drive the session…' : 'Observing — input is read-only';
229
+ }
230
+ function setAttached(on) {
231
+ btn.keyframe.disabled = !on; btn.replay.disabled = !on; btn.detach.disabled = !on;
232
+ btn.drive.disabled = on; btn.observe.disabled = on;
233
+ }
234
+
235
+ async function loadInstances() {
236
+ try {
237
+ const inv = await (await api('/api/inventory')).json();
238
+ selInstance.innerHTML = inv.instances.map((i) => `<option value="${esc(i.id)}">${esc(fmt(i.id))} · ${esc(i.loadout)}</option>`).join('');
239
+ await loadSessions();
240
+ } catch (e) { document.getElementById('session-hint').innerHTML = `<span class="err">${esc(e.message)}</span>`; }
241
+ }
242
+ async function loadSessions() {
243
+ const inst = selInstance.value; if (!inst) return;
244
+ try {
245
+ const r = await (await api('/api/sessions?instance=' + encodeURIComponent(inst))).json();
246
+ if (!r.sessions.length) { selSession.innerHTML = '<option value="">— no sessions —</option>'; return; }
247
+ selSession.innerHTML = r.sessions.map((s) => `<option value="${esc(s.attach_url)}">${esc(s.id)} · seq ${s.seq} · ${s.members} viewer(s)</option>`).join('');
248
+ } catch (e) { selSession.innerHTML = `<option value="">${esc(e.message)}</option>`; }
249
+ }
250
+ selInstance.addEventListener('change', loadSessions);
251
+
252
+ function attach(replay) {
253
+ attachUrl = selSession.value;
254
+ if (!attachUrl) return;
255
+ const url = replay ? attachUrl + '?replay_from=' + lastSeq : attachUrl;
256
+ if (replay) terminal.textContent = '';
257
+ ws = new WebSocket(url);
258
+ ws.addEventListener('open', () => setAttached(true));
259
+ ws.addEventListener('close', () => { setAttached(false); setRole(null); });
260
+ ws.addEventListener('error', () => write('\n[connection error]\n'));
261
+ ws.addEventListener('message', (ev) => {
262
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
263
+ switch (m.op) {
264
+ case 'binding_hello': ws.send(JSON.stringify({ op: 'pty.join_session' })); break;
265
+ case 'role_assigned': setRole(m.payload.role); break;
266
+ case 'output': if (m.seq) lastSeq = Math.max(lastSeq, m.seq); write(decode(m.payload.data)); break;
267
+ case 'keyframe':
268
+ for (const f of (m.payload.frames || [])) { if (f.seq) lastSeq = Math.max(lastSeq, f.seq); write(decode(f.payload.data)); }
269
+ if (m.seq) lastSeq = Math.max(lastSeq, m.seq);
270
+ break;
271
+ case 'error': write(`\n[${m.payload.code}]\n`); break;
272
+ }
273
+ });
274
+ }
275
+ function detach() { if (ws) { ws.close(); ws = null; } }
276
+ function sendInput() {
277
+ const text = cmd.value; if (!text || !ws || role !== 'controller') return;
278
+ ws.send(JSON.stringify({ op: 'pty.session_input', payload: { data: btoa(text + '\r\n') } }));
279
+ cmd.value = '';
280
+ }
281
+
282
+ btn.drive.addEventListener('click', () => { terminal.textContent = ''; lastSeq = 0; attach(false); });
283
+ btn.observe.addEventListener('click', () => { terminal.textContent = ''; lastSeq = 0; attach(false); });
284
+ btn.keyframe.addEventListener('click', () => ws && ws.send(JSON.stringify({ op: 'pty.request_keyframe' })));
285
+ btn.replay.addEventListener('click', () => { detach(); setTimeout(() => attach(true), 50); });
286
+ btn.detach.addEventListener('click', detach);
287
+ btn.send.addEventListener('click', sendInput);
288
+ cmd.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendInput(); });
289
+
290
+ // --- Explore (registry-bound, live) ---
291
+ const exResults = document.getElementById('ex-results');
292
+ const exBody = document.getElementById('ex-body');
293
+ async function discover(ev) {
294
+ if (ev) ev.preventDefault();
295
+ const q = document.getElementById('ex-q').value.trim();
296
+ const type = document.getElementById('ex-type').value;
297
+ if (!q) return;
298
+ exResults.innerHTML = '<p class="empty">Searching…</p>';
299
+ try {
300
+ const u = `/api/capabilities?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}&limit=10`;
301
+ const data = await (await api(u)).json();
302
+ const rs = data.results || [];
303
+ if (!rs.length) { exResults.innerHTML = '<p class="empty">No matches.</p>'; return; }
304
+ exResults.innerHTML = `<table><caption>${rs.length} capabilit${rs.length === 1 ? 'y' : 'ies'}</caption><tbody>${rs.map((r, i) => `
305
+ <tr><td>
306
+ <button type="button" class="ex-pick" data-i="${i}" style="width:100%;text-align:left">
307
+ <span class="badge">${esc(r.type)}</span> <strong>${esc(r.name)}</strong>
308
+ <span style="color:var(--muted);font-size:12px"> · ${(r.score ?? 0).toFixed(2)}</span><br>
309
+ <span style="color:var(--muted);font-size:13px">${esc((r.capability || r.title || '').slice(0, 90))}</span>
310
+ </button>
311
+ </td></tr>`).join('')}</tbody></table>`;
312
+ exResults.querySelectorAll('.ex-pick').forEach((b) => b.addEventListener('click', () => showCap(rs[+b.dataset.i])));
313
+ } catch (e) { exResults.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
314
+ }
315
+ async function showCap(r) {
316
+ exBody.innerHTML = '<p class="empty">Loading…</p>';
317
+ try {
318
+ const d = await (await api(`/api/show?type=${encodeURIComponent(r.type)}&name=${encodeURIComponent(r.name)}`)).json();
319
+ exBody.innerHTML = `<div style="margin-bottom:8px"><span class="badge">${esc(r.type)}</span> <strong>${esc(r.name)}</strong></div>
320
+ <div id="terminal" style="max-height:52vh">${esc(d.body)}</div>`;
321
+ } catch (e) { exBody.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
322
+ }
323
+ document.getElementById('explore-form').addEventListener('submit', discover);
324
+
325
+ // --- Approvals (unified HITL inbox) ---
326
+ async function loadApprovals() {
327
+ const view = document.getElementById('approvals-view');
328
+ view.textContent = 'Loading…';
329
+ try {
330
+ const a = await (await api('/api/approvals?status=pending')).json();
331
+ if (!a.approvals.length) { view.innerHTML = '<p class="empty">No pending approvals.</p>'; return; }
332
+ view.innerHTML = `<table><caption>${a.approvals.length} awaiting your decision</caption>
333
+ <thead><tr><th scope="col">Request</th><th scope="col">Risk</th><th scope="col">Instance</th><th scope="col">Decision</th></tr></thead>
334
+ <tbody>${a.approvals.map((p) => `<tr>
335
+ <td>${esc(p.prompt)}</td>
336
+ <td><span class="badge ${p.risk === 'high' ? 'observer' : ''}">${esc(p.risk)}</span></td>
337
+ <td><code title="${esc(p.instance_id)}">${esc(fmt(p.instance_id))}</code></td>
338
+ <td style="white-space:nowrap">
339
+ <button class="apr" data-id="${esc(p.id)}" data-d="approve">Approve</button>
340
+ <button class="apr" data-id="${esc(p.id)}" data-d="deny">Deny</button>
341
+ </td></tr>`).join('')}</tbody></table>`;
342
+ view.querySelectorAll('.apr').forEach((b) => b.addEventListener('click', () =>
343
+ control(`/api/approvals/${encodeURIComponent(b.dataset.id)}?decision=${b.dataset.d}`, 'POST', loadApprovals)));
344
+ } catch (e) { view.innerHTML = `<p class="err">Could not load approvals: ${esc(e.message)}</p>`; }
345
+ }
346
+
347
+ // --- Actions (declaratively contributed) — inject a command INTO a session ---
348
+ // The Cockpit never runs the CLI. An action drops its command into an agentic
349
+ // session (the focused one, else offers a new one) and the agent runs it.
350
+ const actionsBar = document.getElementById('actions-bar');
351
+ let contribActions = [];
352
+ async function loadActions() {
353
+ try {
354
+ const c = await (await api('/api/contributions')).json();
355
+ contribActions = c.actions || [];
356
+ if (!contribActions.length) { actionsBar.innerHTML = '<p class="empty">No contributed actions.</p>'; return; }
357
+ actionsBar.innerHTML = contribActions.map((a) =>
358
+ `<button type="button" class="act" data-id="${esc(a.id)}" title="injects: ${esc(a.inject.command)}">${a.icon ? esc(a.icon) + ' ' : ''}${esc(a.title)}</button>`).join('');
359
+ actionsBar.querySelectorAll('.act').forEach((b) => b.addEventListener('click', () => injectAction(contribActions.find((a) => a.id === b.dataset.id))));
360
+ } catch (e) { actionsBar.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
361
+ }
362
+ function injectAction(action) {
363
+ if (!action) return;
364
+ let command = action.inject.command;
365
+ if (action.inject.needs_args) {
366
+ const extra = prompt(`Arguments for ${action.title}${action.inject.args_hint ? ' (' + action.inject.args_hint + ')' : ''}:`, '');
367
+ if (extra === null) return; // cancelled
368
+ if (extra.trim()) command += ' ' + extra.trim();
369
+ }
370
+ show('tab-sessions'); // actions target the sessions surface
371
+ cmd.value = command;
372
+ const hint = document.getElementById('session-hint');
373
+ if (ws && role === 'controller') {
374
+ sendInput();
375
+ hint.textContent = `Injected "${command}" into the attached session — the agent runs it (the Cockpit never runs the CLI).`;
376
+ } else {
377
+ cmd.focus();
378
+ hint.textContent = `Ready to inject "${command}". Attach to a session (drive), or start one, then Send — the agent runs it.`;
379
+ }
380
+ }
381
+
382
+ document.getElementById('refresh').addEventListener('click', () => {
383
+ const active = tabs.find((t) => t.getAttribute('aria-selected') === 'true').id;
384
+ if (active === 'tab-inventory') loadInventory();
385
+ else if (active === 'tab-running') loadRunning();
386
+ else if (active === 'tab-sessions') loadSessions();
387
+ else if (active === 'tab-approvals') loadApprovals();
388
+ else if (active === 'tab-actions') loadActions();
389
+ else discover();
390
+ });
391
+
392
+ loadInventory();
393
+ </script>
394
+ </body>
395
+ </html>