← back to Agent Cabinet
Make pyramid nodes runnable + openable: /api/source|open|run endpoints + click-drawer (source view, Run in new claude session, Open in editor); 39 scrapers individually runnable; injection-guarded
29b288a52ae646f362c5c6181484a2220b86f999 · 2026-06-16 14:53:28 -0700 · SteveStudio2
Files touched
M pyramid.htmlM server.js
Diff
commit 29b288a52ae646f362c5c6181484a2220b86f999
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue Jun 16 14:53:28 2026 -0700
Make pyramid nodes runnable + openable: /api/source|open|run endpoints + click-drawer (source view, Run in new claude session, Open in editor); 39 scrapers individually runnable; injection-guarded
---
pyramid.html | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
server.js | 72 ++++++++++++++++++++++++++++++++++++
2 files changed, 186 insertions(+), 5 deletions(-)
diff --git a/pyramid.html b/pyramid.html
index 9785230..d89213d 100644
--- a/pyramid.html
+++ b/pyramid.html
@@ -78,6 +78,41 @@
.tip{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);background:#0b0e14;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:10.5px;white-space:nowrap;color:var(--ink);z-index:9;box-shadow:0 4px 14px rgba(0,0,0,.5)}
.foot{color:var(--dim);font-size:11px;padding:0 24px 36px}
.foot code{background:#101725;border:1px solid var(--line);border-radius:4px;padding:1px 5px}
+
+ /* clickable nodes */
+ [data-kind]{cursor:pointer}
+ .agent:hover,.skill:hover,.wf:hover{outline:1px solid #4b5b7a;filter:brightness(1.18)}
+ .officer-node[data-kind]:hover{filter:brightness(1.12)}
+
+ /* drawer */
+ #drawer{position:fixed;top:0;right:0;height:100vh;width:min(560px,94vw);background:#0d1119;border-left:1px solid #243049;
+ box-shadow:-14px 0 50px rgba(0,0,0,.6);transform:translateX(102%);transition:transform .22s cubic-bezier(.4,0,.2,1);z-index:100;display:flex;flex-direction:column}
+ #drawer.open{transform:translateX(0)}
+ .dr-head{padding:16px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
+ .dr-kind{font-size:10px;font-weight:800;letter-spacing:.1em;padding:2px 7px;border-radius:5px}
+ .k-agent{background:#2a2150;color:#c4b5fd;border:1px solid #6d34f4}
+ .k-skill{background:#0f2a1c;color:#86efac;border:1px solid #16a34a}
+ #dr-title{font-size:16px;font-weight:700;flex:1;word-break:break-word}
+ .dr-x{background:transparent;border:1px solid var(--line);color:var(--dim);border-radius:6px;width:28px;height:28px;cursor:pointer;font-size:15px}
+ .dr-x:hover{color:#fff;border-color:#4b5b7a}
+ .dr-actions{display:flex;gap:9px;padding:13px 18px;border-bottom:1px solid var(--line)}
+ .dr-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding:10px;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;border:1px solid}
+ .dr-btn.run{background:#10231a;color:#6ee7b7;border-color:#16a34a}
+ .dr-btn.run:hover{background:#16321f}
+ .dr-btn.open{background:#161d2c;color:#bcd0f0;border-color:#2b3650}
+ .dr-btn.open:hover{background:#1b2435}
+ #dr-body{padding:14px 18px;overflow:auto;flex:1}
+ .dr-path{font:11px ui-monospace,monospace;color:#6b7280;margin-bottom:8px;word-break:break-all}
+ .dr-src{background:#0a0e16;border:1px solid var(--line);border-radius:8px;padding:12px;font:11.5px/1.5 ui-monospace,monospace;color:#c8d2e0;white-space:pre-wrap;word-break:break-word;margin:0}
+ .dr-note{color:var(--dim);font-size:12px;background:#12161f;border:1px dashed #2b3650;border-radius:8px;padding:11px;margin-bottom:10px}
+ .dr-load{color:var(--dim);font-size:12px;padding:8px}
+ .scr-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 8px;border-bottom:1px solid #141b28}
+ .scr-n{font:11.5px ui-monospace,monospace;color:#cfe;word-break:break-all}
+ .mini{background:#161d2c;border:1px solid #2b3650;color:#bcd0f0;border-radius:6px;padding:3px 8px;font-size:11px;cursor:pointer;margin-left:5px}
+ .mini:hover{background:#1b2435}
+ #toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;
+ padding:10px 16px;border-radius:9px;font-size:12.5px;opacity:0;transition:.2s;z-index:120;box-shadow:0 8px 30px rgba(0,0,0,.5);pointer-events:none}
+ #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
@@ -109,8 +144,23 @@
<div class="foot">
Source of truth: <code>~/Projects/agent-cabinet/cabinet.yaml</code> · regenerate with the <code>agent-architect</code> agent.
Agents = subagents an officer delegates to · Skills = leaf skill bundles · Workflows = multi-agent orchestrations.
+ <b style="color:#86efac">Click any node</b> to open its source & run it.
</div>
+<div id="drawer">
+ <div class="dr-head">
+ <span class="dr-kind" id="dr-kind">SKILL</span>
+ <span id="dr-title">—</span>
+ <button class="dr-x" onclick="closePanel()">✕</button>
+ </div>
+ <div class="dr-actions">
+ <button class="dr-btn run" id="dr-run">▶ Run in new session</button>
+ <button class="dr-btn open" id="dr-open">📂 Open in editor</button>
+ </div>
+ <div id="dr-body"></div>
+</div>
+<div id="toast"></div>
+
<script>
const OFF = [
{ id:'vp-operations', nm:'VP Operations', v:'op', col:'#60a5fa',
@@ -119,9 +169,9 @@ const OFF = [
skills:['secrets','domain-setup','cloudflare-manager','open-firewalls','process-hawk'] },
{ id:'vp-dw-commerce', nm:'VP DW Commerce', v:'com', col:'#34d399',
dm:'Catalog · vendor scrapers · Shopify · storefronts',
- agents:['aichat (Big Red)','front-page-steward'],
+ agents:['aichat','front-page-steward'],
skills:['dw-site-build','dw-shopify-theme-optimizer','dw-marketing-copy','dw-seo-optimizer','dw-purchasing-vendor','dw-sku-analyst','dw-legal-compliance','room-setting-generator','commercial-room-setting-generator','shopify-vendor-bulk-updater','weekend-csv-products','tiktok','instagram-post-template','instagram-account-manager','instagram-post-scheduler'],
- scrapers:['1838','arteriors','atomic50','bnwalls','bespoke','brewster','colefax-fowler','colouranddesign','command54','contrado','cowtan','dedar','denovowall','designtex','fabricut','folia','franquemont','hygge','jim-dultz','justin-david','justindavid','knoll','maya-romanoff','mindthegap','muralsource','naturale54','novasuede-updater','osborne','reid-witlin','romo','scalamandre','schumacher-dwsw','stout','timorous','wallquest','york','york-wallcoverings','arc-com','arte-international'] },
+ scrapers:['1838-scraper-manager','arteriors-scraper-manager','atomic50-scraper-manager','bnwalls-scraper-manager','bespoke-scraper-manager','brewster-scraper-manager','colefax-fowler-colorway-scraper','colouranddesign-scraper-manager','command54-scraper-manager','contrado-scraper-manager','cowtan-colorway-scraper','dedar-scraper-manager','denovowall-scraper-manager','designtex-scraper-manager','fabricut-scraper-manager','folia-scraper-manager','franquemont-scraper-manager','hygge-scraper-manager','jim-dultz-scraper-manager','justin-david-scraper','justindavid-scraper-manager','knoll-scraper-manager','maya-romanoff-scraper-manager','mindthegap-scraper-manager','muralsource-scraper-manager','naturale54-scraper-manager','novasuede-updater','osborne-scraper-manager','reid-witlin-scraper-manager','romo-scraper-manager','scalamandre-catalog-scraper','schumacher-dwsw-updater','stout-scraper-manager','timorous-scraper-manager','wallquest-scraper-manager','york-scraper-manager','york-wallcoverings-scraper-manager','arc-com-colorway-scraper','arte-international-colorway-scraper'] },
{ id:'vp-dw-marketing', nm:'VP DW Marketing', v:'mkt', col:'#f472b6',
dm:'Command center · social · video · creative · ad-intel · GA4',
agents:['dw-instagram','dw-tiktok','dw-linkedin','dw-facebook','dw-pinterest','dw-youtube','dw-x-twitter','seo-analyzer','analytics-agent','product-strategist'],
@@ -193,6 +243,7 @@ function build(){
OFF.forEach(o=>{
const col=document.createElement('div'); col.className='officer-col';
const node=document.createElement('div'); node.className='officer-node'; node.style.setProperty('--oc',o.col);
+ node.dataset.kind='subagent'; node.dataset.id=o.id; node.dataset.label=o.nm;
const sc = o.scrapers?o.scrapers.length:0;
node.innerHTML = `<div class="nm">${o.nm}</div><div class="dm">${o.dm}</div>
<div class="ct">${o.agents.length} agents · ${o.skills.length+sc} skills</div>`;
@@ -200,14 +251,17 @@ function build(){
const aL=document.createElement('div'); aL.className='group-label'; aL.textContent='Agents'; col.appendChild(aL);
const aWrap=document.createElement('div'); aWrap.className='agents';
- o.agents.forEach(a=>{ const e=document.createElement('div'); e.className='agent'; e.style.setProperty('--oc',o.col); e.textContent=a; aWrap.appendChild(e); childRefs.push({c:e,p:node,kind:'agent',col:o.col}); });
+ o.agents.forEach(a=>{ const e=document.createElement('div'); e.className='agent'; e.style.setProperty('--oc',o.col); e.textContent=a;
+ e.dataset.kind='subagent'; e.dataset.id=a; e.dataset.label=a; aWrap.appendChild(e); childRefs.push({c:e,p:node,kind:'agent',col:o.col}); });
col.appendChild(aWrap);
const sL=document.createElement('div'); sL.className='group-label'; sL.textContent='Skills'; col.appendChild(sL);
const sWrap=document.createElement('div'); sWrap.className='skills';
- o.skills.forEach(s=>{ const e=document.createElement('div'); e.className='skill'; e.textContent=s; sWrap.appendChild(e); childRefs.push({c:e,p:node,kind:'skill',col:o.col}); });
+ o.skills.forEach(s=>{ const e=document.createElement('div'); e.className='skill'; e.textContent=s;
+ e.dataset.kind='skill'; e.dataset.id=s; e.dataset.label=s; sWrap.appendChild(e); childRefs.push({c:e,p:node,kind:'skill',col:o.col}); });
if(o.scrapers){ const e=document.createElement('div'); e.className='skill scr'; e.style.setProperty('--oc',o.col);
- e.textContent='Vendor Scrapers ×'+o.scrapers.length; e.title=o.scrapers.join(', ');
+ e.textContent='Vendor Scrapers ×'+o.scrapers.length; e.title='Click to open all '+o.scrapers.length+' scraper skills';
+ e.dataset.kind='scrapers'; e.dataset.scrapers=JSON.stringify(o.scrapers);
sWrap.appendChild(e); childRefs.push({c:e,p:node,kind:'skill',col:o.col}); }
col.appendChild(sWrap);
@@ -217,6 +271,7 @@ function build(){
const wfrow=document.getElementById('wfrow');
WF.forEach(w=>{ const e=document.createElement('div'); e.className='wf'; e.style.setProperty('--wc',w.col);
+ e.dataset.kind='skill'; e.dataset.id=w.n; e.dataset.label=w.n;
e.innerHTML=`<div class="wn">${w.n}</div><div class="wd">${w.d}</div><div class="wt">${w.t}</div>`; wfrow.appendChild(e); });
}
@@ -264,7 +319,61 @@ function timeline(){
});
}
+// ── Runnable / openable: click a node → drawer with source + Run + Open ──
+let _toastT;
+function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(_toastT); _toastT=setTimeout(()=>t.classList.remove('show'),2600); }
+function closePanel(){ document.getElementById('drawer').classList.remove('open'); }
+async function runNode(kind,id){
+ toast('Launching '+(kind==='skill'?'/':'@')+id+' …');
+ try{ const r=await(await fetch('/api/run?kind='+kind+'&name='+encodeURIComponent(id))).json();
+ toast(r.ok?('▶ '+id+' — running in a new terminal tab'):('Run failed: '+(r.error||'?'))); }
+ catch(e){ toast('Run failed — is the server (:9766) up & are you on http not file://?'); }
+}
+async function openNode(kind,id){
+ try{ const r=await(await fetch('/api/open?kind='+kind+'&name='+encodeURIComponent(id))).json();
+ toast(r.ok?('📂 opened '+id+' in editor'):(r.error||'no on-disk source')); }
+ catch(e){ toast('Open failed — server down?'); }
+}
+async function openPanel(kind,id,label){
+ const dr=document.getElementById('drawer'); dr.classList.add('open');
+ document.getElementById('dr-title').textContent=label||id;
+ const k=document.getElementById('dr-kind'); k.textContent=kind==='skill'?'SKILL':'AGENT'; k.className='dr-kind '+(kind==='skill'?'k-skill':'k-agent');
+ const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+ run.style.display=''; open.style.display='';
+ run.textContent='▶ Run in new session'; open.textContent='📂 Open in editor';
+ run.onclick=()=>runNode(kind,id); open.onclick=()=>openNode(kind,id);
+ const body=document.getElementById('dr-body'); body.innerHTML='<div class="dr-load">Loading source…</div>';
+ try{
+ const r=await(await fetch('/api/source?kind='+kind+'&name='+encodeURIComponent(id))).json();
+ if(r.exists){
+ body.innerHTML='<div class="dr-path">'+r.path+'</div><pre class="dr-src"></pre>';
+ body.querySelector('.dr-src').textContent=r.content||'(empty)';
+ } else {
+ body.innerHTML='<div class="dr-note">No on-disk source found — likely a plugin/MCP skill or a generic library agent. You can still <b>Run</b> it.</div><div class="dr-path">looked: '+r.path+'</div>';
+ }
+ }catch(e){ body.innerHTML='<div class="dr-note">Couldn’t reach the server. Open this page at <b>http://localhost:9766/pyramid</b> (not file://) so Run/Open work.</div>'; }
+}
+function openScrapers(list){
+ const dr=document.getElementById('drawer'); dr.classList.add('open');
+ document.getElementById('dr-title').textContent='Vendor Scrapers ×'+list.length;
+ const k=document.getElementById('dr-kind'); k.textContent='SKILLS'; k.className='dr-kind k-skill';
+ document.getElementById('dr-run').style.display='none'; document.getElementById('dr-open').style.display='none';
+ const body=document.getElementById('dr-body');
+ body.innerHTML='<div class="dr-note">Each vendor scraper is its own runnable skill.</div>'+
+ list.map(n=>'<div class="scr-row"><span class="scr-n">/'+n+'</span><span>'+
+ '<button class="mini" onclick="runNode(\'skill\',\''+n+'\')">▶ Run</button>'+
+ '<button class="mini" onclick="openNode(\'skill\',\''+n+'\')">📂 Open</button></span></div>').join('');
+}
+function onNodeClick(e){
+ const el=e.target.closest('[data-kind]'); if(!el) return;
+ const kind=el.dataset.kind;
+ if(kind==='scrapers'){ openScrapers(JSON.parse(el.dataset.scrapers||'[]')); return; }
+ openPanel(kind, el.dataset.id, el.dataset.label);
+}
+
legend(); build(); timeline();
+document.getElementById('pyramid').addEventListener('click', onNodeClick);
+document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); });
requestAnimationFrame(()=>requestAnimationFrame(drawLines));
window.addEventListener('resize', ()=>{ requestAnimationFrame(drawLines); });
</script>
diff --git a/server.js b/server.js
index ca117b7..3ed5c96 100644
--- a/server.js
+++ b/server.js
@@ -4,10 +4,52 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
+const os = require('os');
+const { execFile } = require('child_process');
const PORT = 9766;
const ROOT = __dirname;
const CABINET = path.join(ROOT, 'cabinet.yaml');
+const HOME = os.homedir();
+// Strict: agent/skill ids are lowercase kebab/dot. Guards the open/run endpoints
+// against command injection — a name that fails this never reaches the shell.
+const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
+
+// Resolve a node's on-disk source. Agents live in ~/.claude/agents/<name>.md,
+// skills in ~/.claude/skills/<name>/SKILL.md (some skills are flat .md; some
+// "agents" are actually skills, and vice-versa — so we probe both homes).
+function resolveSource(kind, name) {
+ if (!NAME_RE.test(name || '')) return null;
+ const c = [];
+ if (kind === 'skill') {
+ c.push(path.join(HOME, '.claude', 'skills', name, 'SKILL.md'));
+ c.push(path.join(HOME, '.claude', 'skills', name + '.md'));
+ c.push(path.join(HOME, '.claude', 'agents', name + '.md'));
+ } else { // subagent / agent / officer
+ c.push(path.join(HOME, '.claude', 'agents', name + '.md'));
+ c.push(path.join(HOME, '.claude', 'skills', name, 'SKILL.md'));
+ }
+ for (const p of c) { try { if (fs.statSync(p).isFile()) return { path: p, exists: true }; } catch (e) {} }
+ return { path: c[0], exists: false };
+}
+
+function invocationFor(kind, name) {
+ return kind === 'skill' ? '/' + name : 'Use the ' + name + ' subagent — tell it what you need.';
+}
+
+// "Run" = open a NEW interactive claude session (iTerm2 tab, Terminal fallback)
+// seeded with the invocation, so it runs supervised, not headless/autonomous.
+function spawnClaude(kind, name, cb) {
+ if (!NAME_RE.test(name || '')) return cb(new Error('bad name'));
+ const inv = invocationFor(kind, name);
+ const cmd = 'cd ' + HOME + " && claude '" + inv.replace(/'/g, "'\\''") + "'";
+ const esc = cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const useITerm = fs.existsSync('/Applications/iTerm.app');
+ const script = useITerm
+ ? 'tell application "iTerm2"\n activate\n try\n set w to current window\n on error\n set w to (create window with default profile)\n end try\n tell w\n create tab with default profile\n tell current session of current tab to write text "' + esc + '"\n end tell\nend tell'
+ : 'tell application "Terminal"\n activate\n do script "' + esc + '"\nend tell';
+ execFile('osascript', ['-e', script], { timeout: 15000 }, (err) => cb(err, cmd));
+}
function parseYaml(text) {
// Tiny YAML subset parser — good enough for cabinet.yaml's shape.
@@ -468,6 +510,36 @@ function renderGame() {
const server = http.createServer((req, res) => {
if (req.url === '/health') { res.writeHead(200); res.end('ok'); return; }
+
+ const u = new URL(req.url, 'http://localhost');
+ const q = u.searchParams;
+ const J = (obj) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+
+ if (u.pathname === '/pyramid' || u.pathname === '/pyramid/') {
+ try {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(fs.readFileSync(path.join(ROOT, 'pyramid.html'), 'utf8'));
+ } catch (e) { res.writeHead(500); res.end('pyramid.html missing: ' + e.message); }
+ return;
+ }
+ if (u.pathname === '/api/source') {
+ const r = resolveSource(q.get('kind'), q.get('name'));
+ if (!r) return J({ ok: false, error: 'bad name' });
+ let content = '';
+ if (r.exists) { try { content = fs.readFileSync(r.path, 'utf8').slice(0, 40000); } catch (e) {} }
+ return J({ ok: true, kind: q.get('kind'), name: q.get('name'), path: r.path, exists: r.exists, content });
+ }
+ if (u.pathname === '/api/open') {
+ const r = resolveSource(q.get('kind'), q.get('name'));
+ if (!r || !r.exists) return J({ ok: false, error: 'no on-disk source (plugin/MCP skill or generic agent)' });
+ execFile('open', [r.path], (err) => J({ ok: !err, path: r.path, error: err ? String(err) : undefined }));
+ return;
+ }
+ if (u.pathname === '/api/run') {
+ spawnClaude(q.get('kind'), q.get('name'), (err, cmd) => J({ ok: !err, cmd, error: err ? String(err) : undefined }));
+ return;
+ }
+
if (req.url === '/cabinet.yaml') {
try {
const yaml = fs.readFileSync(CABINET, 'utf8');
← 655d59b Add cabinet pyramid visual: President→Officers→Agents→Skills
·
back to Agent Cabinet
·
Pyramid: cross-officer lines — 18 'shared now' (computed fro eb43caf →