feat(core): add stale permission entries sub-section to K3PT ST4T3

Adds a "stale permission entries" sub-section within the K3PT ST4T3 pane
on /core.html showing agents with explicit capability/tool-group JSON
entries but no live container — typically renamed or manually-deleted agents
whose entries persisted (e.g. the old "root" manager name after rename to
"ruth").

Each ghost agent gets a "✕ clear perms" button that calls
DELETE /api/permissions/{agent} (added in the prior commit). Lazy-loaded
on first K3PT ST4T3 tab activation; auto-refreshes on capabilities_changed
and tool_groups_changed SSE events.

core.js: track liveContainerNames from /api/state.containers; add
renderStalePerms + fetchAndRenderStalePerms; hook tab onShow + SSE handlers.

system-sections.css: new .tombstones-stale-* selectors for the ghost list
rows and error message.

core.html: add #tombstones-stale-perms div inside the K3PT ST4T3 pane;
expand comment to describe both sub-sections.
This commit is contained in:
iris 2026-06-27 16:10:46 +02:00
commit 6ab0757cc6
3 changed files with 133 additions and 1 deletions

View file

@ -20,12 +20,15 @@ let metaInputsState = [];
let metaUpdateRunning = false;
let tombstonesState = [];
let rebuildQueueState = [];
// Live container names — used by the stale-perms ghost filter.
let liveContainerNames = new Set();
function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running;
tombstonesState = (s.tombstones || []).slice();
rebuildQueueState = (s.rebuild_queue || []).slice();
liveContainerNames = new Set((s.containers || []).map((c) => c.name));
}
// ─── meta inputs ──────────────────────────────────────────────────────────
@ -447,6 +450,79 @@ function renderTombstones(s) {
root.append(ul);
}
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit capability / tool-group JSON entries but no live
// container (e.g. renamed agents like the old "root" manager name). Lazy-
// loaded on first "kept" tab activation; refreshed when perm data changes.
let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries'));
root.append(el('p', { class: 'meta' },
'agents with explicit capability or tool-group entries but no live container '
+ '(typically renamed or manually-deleted agents whose JSON entries persisted).'));
const errP = el('p', { class: 'tombstones-stale-err', hidden: true });
const ul = el('ul', { class: 'tombstones-stale-list' });
for (const name of ghosts) {
const li = el('li', { class: 'tombstones-stale-row' });
li.append(el('span', { class: 'tombstones-stale-name' }, name));
li.append(el('span', { class: 'badge badge-muted' }, 'stale perms'));
const btn = el('button', {
type: 'button',
class: 'btn btn-destroy',
title: 'remove explicit capability and tool-group entries for ' + name,
}, '✕ clear perms');
btn.addEventListener('click', async () => {
btn.disabled = true;
errP.hidden = true;
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg;
errP.hidden = false;
btn.disabled = false;
return;
}
} catch (err) {
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
errP.hidden = false;
btn.disabled = false;
return;
}
await fetchAndRenderStalePerms();
});
li.append(btn);
ul.append(li);
}
root.append(ul, errP);
}
async function fetchAndRenderStalePerms() {
const root = $('tombstones-stale-perms');
if (!root) return;
try {
const [capsResp, tgResp] = await Promise.all([
fetch('/api/capabilities'),
fetch('/api/tool-groups'),
]);
const capsData = capsResp.ok ? await capsResp.json() : { assignments: {} };
const tgData = tgResp.ok ? await tgResp.json() : { assignments: {} };
const explicit = new Set([
...Object.keys(capsData.assignments || {}),
...Object.keys(tgData.assignments || {}),
]);
const ghosts = [...explicit].filter((n) => !liveContainerNames.has(n)).sort();
renderStalePerms(root, ghosts);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'failed to load perm data: ' + err));
}
stalePermsLoaded = true;
}
// ─── container load (live cgroup poll while the LOAD sub-tab is open) ──────
let containerLoadTimer = null;
@ -602,6 +678,14 @@ const SSE_HANDLERS = {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
capabilities_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
@ -635,6 +719,9 @@ async function init() {
onShow: (id) => {
if (id === 'load') startContainerLoadPolling();
else stopContainerLoadPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms();
},
});