lampson 0.1.3 → 0.2.0
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.
- package/.env.example +9 -0
- package/README.md +87 -4
- package/bin/lampson.js +1 -1
- package/chat.syn +133 -0
- package/cli.syn +68 -0
- package/hub.tpl.syn +127 -0
- package/lampson.ps1 +77 -51
- package/lampson.sh +68 -22
- package/lib/agents.syn +1 -1
- package/lib/approvals.syn +179 -0
- package/lib/lsp.syn +10 -1
- package/lib/mcp.syn +10 -1
- package/lib/permission.syn +18 -0
- package/lib/sched_run.syn +161 -0
- package/lib/schedule.syn +660 -0
- package/lib/session.syn +38 -1
- package/lib/settings.syn +66 -3
- package/lib/skills.syn +2 -0
- package/lib/tools.syn +94 -2
- package/lib/workspaces.syn +555 -0
- package/package.json +3 -1
- package/public/css/chat.css +56 -0
- package/public/css/hub.css +12 -0
- package/public/css/layout.css +97 -0
- package/public/css/panel.css +125 -0
- package/public/css/sidebar.css +80 -0
- package/public/css/tokens.css +57 -0
- package/public/hub.html +36 -0
- package/public/index.html +51 -1197
- package/public/js/agents.js +31 -0
- package/public/js/app.js +21 -0
- package/public/js/approvals.js +26 -0
- package/public/js/chat.js +92 -0
- package/public/js/config.js +100 -0
- package/public/js/core.js +91 -0
- package/public/js/events.js +31 -0
- package/public/js/hub.js +40 -0
- package/public/js/lamps.js +112 -0
- package/public/js/lsp.js +81 -0
- package/public/js/mcp.js +74 -0
- package/public/js/memory.js +17 -0
- package/public/js/panel.js +101 -0
- package/public/js/procs.js +45 -0
- package/public/js/schedules.js +118 -0
- package/public/js/sessions.js +69 -0
- package/public/js/sidebar.js +33 -0
- package/public/js/terminal.js +49 -0
- package/public/js/theme.js +6 -0
- package/public/js/todo.js +10 -0
- package/public/js/tree.js +53 -0
- package/public/js/update.js +14 -0
- package/public/js/workspaces.js +83 -0
- package/skills/lampson/SKILL.md +21 -0
- package/skills/synsema/SKILL.md +4 -1
- package/web.syn +165 -55
package/public/js/lsp.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// lsp.js — language servers: sección del panel lateral + vista browse del Panel («+ agregar» = fila con el formulario)
|
|
2
|
+
let lspServers = [], lspPresets = {}, lspGlobalFile = '.lampson/lsp.json';
|
|
3
|
+
async function fetchLsp() {
|
|
4
|
+
let r; try { r = await (await fetch(BASE + '/api/lsp')).json(); } catch (e) { return lspServers; }
|
|
5
|
+
lspServers = r.servers || []; lspPresets = r.presets || {}; lspGlobalFile = r.global || lspGlobalFile;
|
|
6
|
+
return lspServers;
|
|
7
|
+
}
|
|
8
|
+
async function loadLsp() {
|
|
9
|
+
const list = await fetchLsp();
|
|
10
|
+
const box = $('#lspBox'); box.innerHTML = '';
|
|
11
|
+
const ready = list.filter(s => s.status === 'ready').length;
|
|
12
|
+
$('#lspCount').textContent = list.length ? (ready ? `${ready} ● · ${list.length}` : `${list.length}`) : ''; autoSec('lsp', list.some(s => s.status === 'error' || s.status === 'exited'));
|
|
13
|
+
if (!list.length) { const e = document.createElement('div'); e.className = 'p none'; e.style.cursor = 'default'; e.textContent = 'sin language servers'; box.appendChild(e); }
|
|
14
|
+
for (const sv of list) {
|
|
15
|
+
const d = document.createElement('div'); d.className = 'p' + (sv.status === 'ready' ? ' run' : '');
|
|
16
|
+
d.title = `${sv.scope} · $ ${sv.command}` + (sv.error ? `\n${sv.error}` : '');
|
|
17
|
+
d.innerHTML = `<span class="st"></span><span class="nm">${esc(sv.name)}</span><span class="cm">${esc(sv.status)} · ${esc((sv.extensions || []).join(' '))}${sv.error ? ' · ' + esc(sv.error) : ''}</span>`;
|
|
18
|
+
d.onclick = () => openLsp(sv.name);
|
|
19
|
+
box.appendChild(d);
|
|
20
|
+
}
|
|
21
|
+
const addRow = document.createElement('div'); addRow.className = 'p'; addRow.innerHTML = `<span class="nm" style="color:var(--accent)">+ agregar un language server…</span>`;
|
|
22
|
+
addRow.title = 'preset (typescript, python, rust, go, css, html) o tu propio comando · también podés editar ' + lspGlobalFile;
|
|
23
|
+
addRow.onclick = () => openLsp('__new');
|
|
24
|
+
box.appendChild(addRow);
|
|
25
|
+
if (Panel.is('lsp')) Panel.refresh();
|
|
26
|
+
}
|
|
27
|
+
const LSP_NEW = { name: '__new' };
|
|
28
|
+
function openLsp(selectName) {
|
|
29
|
+
Panel.open({
|
|
30
|
+
id: 'lsp', eyebrow: 'lsp', title: 'Language servers', sub: 'arrancan en la primera consulta del agente', layout: 'browse', select: selectName || null,
|
|
31
|
+
browse: {
|
|
32
|
+
placeholder: 'buscar…', listWidth: '260px', key: s => s.name,
|
|
33
|
+
load: async (q) => { const all = await fetchLsp(); const list = all.filter(s => !q || (s.name + ' ' + s.scope + ' ' + (s.extensions || []).join(' ')).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, LSP_NEW]; },
|
|
34
|
+
render: (s) => s === LSP_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">agregar un language server…</div><div class="meta">preset o comando propio</div></div>` : `<span class="dot">${s.status === 'ready' ? '●' : (s.status === 'error' || s.status === 'exited' ? '!' : '○')}</span><div><div class="nm">${esc(s.name)}</div><div class="meta">${esc(s.scope)} · ${esc(s.status)} · ${esc((s.extensions || []).join(' '))}</div></div>`,
|
|
35
|
+
count: (rows) => { const n = rows.filter(r => r !== LSP_NEW).length; return `${n} server${n === 1 ? '' : 's'}`; },
|
|
36
|
+
emptyHtml: 'nada coincide',
|
|
37
|
+
detail: (s) => s === LSP_NEW ? lspForm() : `<div class="dhead"><span class="nm">${esc(s.name)}</span><span class="meta">${esc(s.scope)} · ${esc(s.status)}</span></div>
|
|
38
|
+
<div class="dcap">$ ${esc(s.command)}</div>${s.error ? `<div class="dcap err">${esc(s.error)}</div>` : ''}
|
|
39
|
+
<div class="ddesc">Extensiones: ${(s.extensions || []).map(e => `<code>${esc(e)}</code>`).join(' ') || '—'}. El agente lo usa con la tool <code>lsp</code>: <code>symbols</code> (estructura de un archivo sin leerlo), <code>definition</code>, <code>references</code>, <code>hover</code>. ${s.status === 'idle' ? 'Está en espera: arranca en la primera consulta.' : ''}</div>
|
|
40
|
+
<div class="dfoot"><span>${esc(s.scope === 'project' ? '.lampson/lsp.json' : lspGlobalFile)}</span><span class="del">quitar</span></div><div class="derr"></div>`,
|
|
41
|
+
wire: (s, box) => {
|
|
42
|
+
const err = (t) => { const e = box.querySelector('.derr'); if (e) e.textContent = t || ''; };
|
|
43
|
+
if (s === LSP_NEW) {
|
|
44
|
+
const f = n => box.querySelector(`[name="${n}"]`);
|
|
45
|
+
const changed = () => { const k = f('preset').value; const p = lspPresets[k]; box.querySelector('[data-custom]').style.display = k ? 'none' : ''; box.querySelector('[data-install]').textContent = p ? `$ ${p.command} ${(p.args || []).join(' ')}` + (p.install ? ` · si no está instalado: ${p.install}` : '') : ''; };
|
|
46
|
+
f('preset').onchange = changed; changed();
|
|
47
|
+
box.querySelector('[data-go]').onclick = async () => {
|
|
48
|
+
const k = f('preset').value; const body = { scope: f('scope').value };
|
|
49
|
+
if (k) body.name = k;
|
|
50
|
+
else {
|
|
51
|
+
body.name = f('name').value.trim(); body.command = f('command').value.trim(); body.languages = {};
|
|
52
|
+
for (const tok of f('ext').value.trim().split(/\s+/).filter(Boolean)) { const i = tok.indexOf('='); if (i > 0) body.languages[tok.slice(0, i)] = tok.slice(i + 1); }
|
|
53
|
+
if (!body.name || !body.command || !Object.keys(body.languages).length) { err('falta nombre, comando o extensiones (.ext=languageId)'); return; }
|
|
54
|
+
}
|
|
55
|
+
err('guardando…');
|
|
56
|
+
const r = await api(BASE + '/api/lsp/add', body);
|
|
57
|
+
if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
|
|
58
|
+
add('meta', '⌁ ' + esc(r.data.result || 'server agregado')); await loadLsp(); Panel.selectKey(body.name);
|
|
59
|
+
};
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const del = box.querySelector('.dfoot .del');
|
|
63
|
+
del.onclick = () => inlineConfirm(del, `¿quitar ${s.name}?`, async () => { const r = await api(BASE + '/api/lsp/remove', { name: s.name }); if (!r.ok) { err(r.data.error || 'error'); return; } add('meta', '⌁ ' + esc(r.data.result || '')); loadLsp(); });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function lspForm() {
|
|
69
|
+
const presets = Object.keys(lspPresets).map(k => `<option value="${esc(k)}">${esc(k)} — ${esc(Object.keys(lspPresets[k].languages || {}).join(' '))}</option>`).join('') + '<option value="">otro — mi propio comando</option>';
|
|
70
|
+
return `<div class="dhead"><span class="nm serif">Agregar un language server</span></div><div class="dform">
|
|
71
|
+
<p class="lead">El mismo proceso que usa tu editor para «ir a definición». No se instala nada acá: elegí un preset (con <code>npx</code> se baja solo) o poné tu comando. Arranca en la primera consulta.</p>
|
|
72
|
+
<label>preset <select name="preset">${presets}</select></label>
|
|
73
|
+
<div data-custom style="display:none">
|
|
74
|
+
<label>nombre <input name="name" spellcheck="false" autocomplete="off" placeholder="mylang"></label>
|
|
75
|
+
<label>comando <input name="command" spellcheck="false" autocomplete="off" placeholder="my-language-server --stdio"></label>
|
|
76
|
+
<label>extensiones <input name="ext" spellcheck="false" autocomplete="off" placeholder=".ml=ocaml .mli=ocaml"></label>
|
|
77
|
+
</div>
|
|
78
|
+
<p class="lead" data-install></p>
|
|
79
|
+
<label>alcance <select name="scope"><option value="global">global — todos los proyectos</option><option value="project">proyecto — solo este repo</option></select></label>
|
|
80
|
+
<div class="pfoot"><button class="primary" data-go>Agregar</button><span class="derr"></span></div></div>`;
|
|
81
|
+
}
|
package/public/js/mcp.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// mcp.js — servers MCP: sección del panel lateral + vista browse del Panel (servers a la izquierda, detalle a la
|
|
2
|
+
// derecha; «+ conectar» es una fila más cuyo detalle es el formulario)
|
|
3
|
+
let mcpServers = [], mcpFiles = { global: '.lampson/mcp.json', project: '' };
|
|
4
|
+
async function fetchMcp() {
|
|
5
|
+
let r; try { r = await (await fetch(BASE + '/api/mcp')).json(); } catch (e) { return mcpServers; }
|
|
6
|
+
mcpServers = r.servers || []; mcpFiles = { global: r.global || '.lampson/mcp.json', project: r.project || '' };
|
|
7
|
+
return mcpServers;
|
|
8
|
+
}
|
|
9
|
+
async function loadMcp() {
|
|
10
|
+
const list = await fetchMcp();
|
|
11
|
+
const box = $('#mcpBox'); box.innerHTML = '';
|
|
12
|
+
const ready = list.filter(s => s.status === 'ready').length;
|
|
13
|
+
$('#mcpCount').textContent = list.length ? (ready ? `${ready} ● · ${list.length}` : `${list.length}`) : ''; autoSec('mcp', list.some(s => s.status === 'error'));
|
|
14
|
+
if (!list.length) { const e = document.createElement('div'); e.className = 'p none'; e.style.cursor = 'default'; e.textContent = 'sin servers conectados'; box.appendChild(e); }
|
|
15
|
+
for (const sv of list) {
|
|
16
|
+
const d = document.createElement('div'); d.className = 'p' + (sv.status === 'ready' ? ' run' : '');
|
|
17
|
+
d.title = `${sv.scope} · $ ${sv.command}` + (sv.error ? `\n${sv.error}` : '') + (sv.tools.length ? `\ntools: ${sv.tools.join(', ')}` : '');
|
|
18
|
+
d.innerHTML = `<span class="st"></span><span class="nm">${esc(sv.name)}</span><span class="cm">${esc(sv.status)} · ${sv.tools.length} tools${sv.error ? ' · ' + esc(sv.error) : ''}</span>`;
|
|
19
|
+
d.onclick = () => openMcp(sv.name);
|
|
20
|
+
box.appendChild(d);
|
|
21
|
+
}
|
|
22
|
+
const addRow = document.createElement('div'); addRow.className = 'p'; addRow.innerHTML = `<span class="nm" style="color:var(--accent)">+ conectar un server…</span>`;
|
|
23
|
+
addRow.title = 'también podés editar a mano ' + mcpFiles.global + ' (global) o ' + mcpFiles.project + ' (proyecto), o pedírselo al agente';
|
|
24
|
+
addRow.onclick = () => openMcp('__new');
|
|
25
|
+
box.appendChild(addRow);
|
|
26
|
+
if (Panel.is('mcp')) Panel.refresh();
|
|
27
|
+
}
|
|
28
|
+
const MCP_NEW = { name: '__new' };
|
|
29
|
+
function openMcp(selectName) {
|
|
30
|
+
Panel.open({
|
|
31
|
+
id: 'mcp', eyebrow: 'mcp', title: 'Servers MCP', sub: 'formato Claude Code / Cursor', layout: 'browse', select: selectName || null,
|
|
32
|
+
browse: {
|
|
33
|
+
placeholder: 'buscar server o tool…', listWidth: '260px', key: s => s.name,
|
|
34
|
+
load: async (q) => { const all = await fetchMcp(); const list = all.filter(s => !q || (s.name + ' ' + s.scope + ' ' + (s.tools || []).join(' ')).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, MCP_NEW]; },
|
|
35
|
+
render: (s) => s === MCP_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">conectar un server…</div><div class="meta">${esc(mcpFiles.global)}</div></div>` : `<span class="dot">${s.status === 'ready' ? '●' : (s.status === 'error' ? '!' : '○')}</span><div><div class="nm">${esc(s.name)}</div><div class="meta">${esc(s.scope)} · ${esc(s.status)} · ${(s.tools || []).length} tools</div></div>`,
|
|
36
|
+
count: (rows, q) => `${rows.filter(r => r !== MCP_NEW).length} server${rows.filter(r => r !== MCP_NEW).length === 1 ? '' : 's'}`,
|
|
37
|
+
emptyHtml: 'nada coincide',
|
|
38
|
+
detail: (s) => s === MCP_NEW ? mcpForm() : `<div class="dhead"><span class="nm">${esc(s.name)}</span><span class="meta">${esc(s.scope)} · ${esc(s.status)}</span></div>
|
|
39
|
+
<div class="dcap">$ ${esc(s.command)}</div>${s.error ? `<div class="dcap err">${esc(s.error)}</div>` : ''}
|
|
40
|
+
<div class="ddesc">${(s.tools || []).length ? 'Tools en el catálogo del agente: ' + (s.tools || []).map(t => `<code>mcp_${esc(s.name)}_${esc(t)}</code>`).join(' ') : 'sin tools todavía (el server no terminó de arrancar o falló)'}</div>
|
|
41
|
+
<div class="dfoot"><span>${esc(s.scope === 'project' ? mcpFiles.project : mcpFiles.global)}</span><span class="del">quitar</span></div><div class="derr"></div>`,
|
|
42
|
+
wire: (s, box) => {
|
|
43
|
+
const err = (t) => { const e = box.querySelector('.derr'); if (e) e.textContent = t || ''; };
|
|
44
|
+
if (s === MCP_NEW) {
|
|
45
|
+
const f = n => box.querySelector(`[name="${n}"]`);
|
|
46
|
+
f('scope').onchange = () => { box.querySelector('[data-file]').textContent = f('scope').value === 'project' ? mcpFiles.project : mcpFiles.global; };
|
|
47
|
+
box.querySelector('[data-go]').onclick = async () => {
|
|
48
|
+
const name = f('name').value.trim(), command = f('command').value.trim();
|
|
49
|
+
if (!name || !command) { err('falta nombre o comando'); return; }
|
|
50
|
+
const env = {};
|
|
51
|
+
for (const tok of f('env').value.trim().split(/\s+/).filter(Boolean)) { const i = tok.indexOf('='); if (i > 0) env[tok.slice(0, i)] = tok.slice(i + 1); }
|
|
52
|
+
err('conectando…');
|
|
53
|
+
const r = await api(BASE + '/api/mcp/add', { name, command, env, scope: f('scope').value });
|
|
54
|
+
if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
|
|
55
|
+
add('meta', '⌁ ' + esc(r.data.result || 'server conectado')); await loadMcp(); Panel.selectKey(name);
|
|
56
|
+
};
|
|
57
|
+
f('name').focus();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const del = box.querySelector('.dfoot .del');
|
|
61
|
+
del.onclick = () => inlineConfirm(del, `¿quitar ${s.name}?`, async () => { const r = await api(BASE + '/api/mcp/remove', { name: s.name }); if (!r.ok) { err(r.data.error || 'error'); return; } add('meta', '⌁ ' + esc(r.data.result || '')); loadMcp(); });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function mcpForm() {
|
|
67
|
+
return `<div class="dhead"><span class="nm serif">Conectar un server MCP</span></div><div class="dform">
|
|
68
|
+
<p class="lead">El comando arranca el server por stdio. Queda guardado en <code data-file>${esc(mcpFiles.global)}</code> y sus tools entran al catálogo del agente como <code>mcp_<server>_<tool></code> desde el próximo turno.</p>
|
|
69
|
+
<label>nombre <input name="name" spellcheck="false" autocomplete="off" placeholder="github"></label>
|
|
70
|
+
<label>comando <input name="command" spellcheck="false" autocomplete="off" placeholder="npx -y @modelcontextprotocol/server-github"></label>
|
|
71
|
+
<label>env <input name="env" spellcheck="false" autocomplete="off" placeholder="opcional: GITHUB_TOKEN=… · separá con espacios"></label>
|
|
72
|
+
<label>alcance <select name="scope"><option value="global">global — todos los proyectos</option><option value="project">proyecto — solo este repo</option></select></label>
|
|
73
|
+
<div class="pfoot"><button class="primary" data-go>Conectar</button><span class="derr"></span></div></div>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// memory.js — notas que el agente guarda sobre este proyecto (tool memory)
|
|
2
|
+
let memOpen = null;
|
|
3
|
+
async function loadMemory() {
|
|
4
|
+
let r; try { r = await (await fetch(BASE + '/api/memory')).json(); } catch (e) { return; }
|
|
5
|
+
const box = $('#memory'); box.innerHTML = '';
|
|
6
|
+
const notes = r.notes || [];
|
|
7
|
+
$('#memCount').textContent = notes.length || ''; autoSec('memory', notes.length > 0);
|
|
8
|
+
if (!notes.length) { box.innerHTML = '<div class="m none">sin notas todavía</div>'; return; }
|
|
9
|
+
for (const n of notes) { const d = document.createElement('div'); d.className = 'm' + (memOpen === n.name ? ' active' : ''); d.title = n.title; d.innerHTML = `<b>${esc(n.name)}</b><span class="t">${esc(n.title)}</span>`; d.onclick = () => openMemory(n.name); box.appendChild(d); }
|
|
10
|
+
}
|
|
11
|
+
async function openMemory(name) {
|
|
12
|
+
memOpen = name; procOpen = null; clearInterval(procTimer);
|
|
13
|
+
const r = await (await fetch(BASE + '/api/memory/note?name=' + encodeURIComponent(name))).json();
|
|
14
|
+
if (r.error) { add('denied', esc(r.error)); return; }
|
|
15
|
+
showText('memoria · ' + name + '.md', 'notas del agente sobre este proyecto (editables a mano en lampson/memory/)', r.content || '(vacía)', false);
|
|
16
|
+
loadMemory();
|
|
17
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// panel.js — EL componente modal de la app. Un cascarón y tres layouts; cada vista aporta solo su contenido.
|
|
2
|
+
//
|
|
3
|
+
// Panel.open({ eyebrow, title, sub, size: 'lg'|'md'|'sm', layout: 'browse'|'tabs'|'form',
|
|
4
|
+
// headActions: [{label, title, onClick}], // botones chicos en la cabecera (p.ej. «?»)
|
|
5
|
+
// noClose: bool, // sin ✕/Esc (onboarding)
|
|
6
|
+
// onClose(), select: clave de la fila a dejar elegida al abrir (browse),
|
|
7
|
+
// browse: { placeholder, listWidth, load(q) → rows, render(row, q) → html de la fila, key(row) → id estable,
|
|
8
|
+
// nodot: true (filas sin la columna del punto), detail(row, q) → html del panel derecho (puede ser async), wire(row, box) → engancha handlers,
|
|
9
|
+
// onPick(row) → Enter / doble clic, count(rows, q) → texto del pie, emptyHtml, help: html },
|
|
10
|
+
// tabs: { tabs: [{id, label, html}], footer: html, wire(box), initial },
|
|
11
|
+
// form: { html, footer: html, wire(box) } })
|
|
12
|
+
// Panel.close() · Panel.refresh() (browse: recarga la lista conservando la selección) · Panel.detail() (re-render
|
|
13
|
+
// del detalle) · Panel.selectKey(k) · Panel.box (el elemento) · Panel.err(texto) (línea de error del pie) · Panel.is(id)
|
|
14
|
+
//
|
|
15
|
+
// Teclado en browse: ↑↓ mueven la selección (el detalle sigue), Enter = onPick, Esc cierra. Un solo panel abierto.
|
|
16
|
+
const Panel = (() => {
|
|
17
|
+
let cur = null, rows = [], sel = 0, timer = null, seq = 0, overlay = null, pendingKey = null;
|
|
18
|
+
const ICON_X = '✕';
|
|
19
|
+
function ensure() {
|
|
20
|
+
if (overlay) return overlay;
|
|
21
|
+
overlay = document.createElement('div'); overlay.className = 'modal'; overlay.id = 'panel'; overlay.style.display = 'none';
|
|
22
|
+
overlay.innerHTML = '<div class="panel"></div>';
|
|
23
|
+
document.body.appendChild(overlay);
|
|
24
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay && cur && !cur.noClose) close(); });
|
|
25
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur && !cur.noClose) close(); });
|
|
26
|
+
return overlay;
|
|
27
|
+
}
|
|
28
|
+
const box = () => ensure().querySelector('.panel');
|
|
29
|
+
function head(spec) {
|
|
30
|
+
const acts = (spec.headActions || []).map((a, i) => `<button class="pghost" data-ha="${i}" title="${esc(a.title || '')}">${esc(a.label)}</button>`).join('');
|
|
31
|
+
return `<div class="phead"><span class="eyebrow">${esc(spec.eyebrow || '')}</span><h3>${esc(spec.title || '')}</h3><span class="sub">${esc(spec.sub || '')}</span><span class="spacer"></span>${acts}${spec.noClose ? '' : `<button class="pghost" data-close title="cerrar (Esc)">${ICON_X}</button>`}</div>`;
|
|
32
|
+
}
|
|
33
|
+
function open(spec) {
|
|
34
|
+
cur = spec; rows = []; sel = 0; seq++; pendingKey = spec.select != null ? spec.select : null;
|
|
35
|
+
const b = box(); b.className = 'panel ' + (spec.size || (spec.layout === 'browse' ? 'lg' : 'md'));
|
|
36
|
+
let body = '';
|
|
37
|
+
if (spec.layout === 'browse') {
|
|
38
|
+
const w = spec.browse.listWidth ? `style="--list-w:${spec.browse.listWidth}"` : '';
|
|
39
|
+
body = `<div class="browse" ${w}><div class="blist"><input class="bsearch" spellcheck="false" autocomplete="off" placeholder="${esc(spec.browse.placeholder || 'buscar…')}"><div class="bitems"></div><div class="bcount"></div></div><div class="bdetail"></div></div>` + (spec.browse.help ? `<div class="phelp" style="display:none">${spec.browse.help}</div>` : '');
|
|
40
|
+
} else if (spec.layout === 'tabs') {
|
|
41
|
+
body = `<div class="ptabs">${spec.tabs.tabs.map(t => `<span class="ptab" data-tab="${esc(t.id)}">${esc(t.label)}</span>`).join('')}</div><div class="pbody">${spec.tabs.tabs.map(t => `<div class="pane" data-pane="${esc(t.id)}" style="display:none">${t.html}</div>`).join('')}</div>` + (spec.tabs.footer ? `<div class="pfoot">${spec.tabs.footer}<span class="err"></span></div>` : '');
|
|
42
|
+
} else {
|
|
43
|
+
body = `<div class="pbody">${spec.form.html}</div>` + (spec.form.footer ? `<div class="pfoot">${spec.form.footer}<span class="err"></span></div>` : '');
|
|
44
|
+
}
|
|
45
|
+
b.innerHTML = head(spec) + body;
|
|
46
|
+
const x = b.querySelector('[data-close]'); if (x) x.onclick = close;
|
|
47
|
+
b.querySelectorAll('[data-ha]').forEach(el => el.onclick = () => spec.headActions[Number(el.dataset.ha)].onClick(b));
|
|
48
|
+
ensure().style.display = 'flex';
|
|
49
|
+
if (spec.layout === 'browse') wireBrowse(spec);
|
|
50
|
+
else if (spec.layout === 'tabs') { b.querySelectorAll('.ptab').forEach(t => t.onclick = () => tab(t.dataset.tab)); tab(spec.tabs.initial || spec.tabs.tabs[0].id); if (spec.tabs.wire) spec.tabs.wire(b); }
|
|
51
|
+
else { if (spec.form.wire) spec.form.wire(b); const f = b.querySelector('.pbody input, .pbody textarea, .pbody select'); if (f && spec.form.focus !== false) f.focus(); }
|
|
52
|
+
return b;
|
|
53
|
+
}
|
|
54
|
+
function tab(id) { const b = box(); b.querySelectorAll('.ptab').forEach(t => t.classList.toggle('on', t.dataset.tab === id)); b.querySelectorAll('.pane').forEach(p => p.style.display = p.dataset.pane === id ? '' : 'none'); }
|
|
55
|
+
function close() { const p = cur; cur = null; if (overlay) overlay.style.display = 'none'; clearTimeout(timer); if (p && p.onClose) p.onClose(); }
|
|
56
|
+
function err(t) { const e = box().querySelector('.pfoot .err'); if (e) e.textContent = t || ''; }
|
|
57
|
+
function is(id) { return !!cur && cur.id === id; }
|
|
58
|
+
// ---- browse ----
|
|
59
|
+
function wireBrowse(spec) {
|
|
60
|
+
const b = box(), inp = b.querySelector('.bsearch');
|
|
61
|
+
inp.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(refresh, 220); });
|
|
62
|
+
inp.addEventListener('keydown', (e) => {
|
|
63
|
+
if (!rows.length) return;
|
|
64
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); select((sel + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length); const it = b.querySelectorAll('.bitems .li')[sel]; it && it.scrollIntoView({ block: 'nearest' }); }
|
|
65
|
+
else if (e.key === 'Enter' && spec.browse.onPick) { e.preventDefault(); const r = rows[sel]; if (r) { close(); spec.browse.onPick(r); } }
|
|
66
|
+
});
|
|
67
|
+
inp.focus(); refresh();
|
|
68
|
+
}
|
|
69
|
+
function query() { const i = box().querySelector('.bsearch'); return i ? i.value.trim() : ''; }
|
|
70
|
+
async function refresh() {
|
|
71
|
+
if (!cur || cur.layout !== 'browse') return;
|
|
72
|
+
const spec = cur, q = query(), my = ++seq;
|
|
73
|
+
const keep = pendingKey != null ? pendingKey : (rows[sel] && spec.browse.key ? spec.browse.key(rows[sel]) : null); pendingKey = null;
|
|
74
|
+
let list; try { list = await spec.browse.load(q); } catch (e) { list = []; }
|
|
75
|
+
if (cur !== spec || my !== seq) return;
|
|
76
|
+
rows = list || [];
|
|
77
|
+
sel = 0; if (keep != null && spec.browse.key) { const i = rows.findIndex(r => spec.browse.key(r) === keep); if (i >= 0) sel = i; }
|
|
78
|
+
const items = box().querySelector('.bitems'); items.innerHTML = '';
|
|
79
|
+
if (!rows.length) items.innerHTML = `<div class="bempty">${q ? 'nada coincide con «' + esc(q) + '»' : (spec.browse.emptyHtml || 'nada todavía')}</div>`;
|
|
80
|
+
rows.forEach((r, i) => {
|
|
81
|
+
const d = document.createElement('div'); d.className = 'li' + (spec.browse.nodot ? ' nodot' : '') + (i === sel ? ' sel' : ''); d.innerHTML = spec.browse.render(r, q);
|
|
82
|
+
d.onclick = () => select(i); if (spec.browse.onPick) d.ondblclick = () => { close(); spec.browse.onPick(r); };
|
|
83
|
+
items.appendChild(d);
|
|
84
|
+
});
|
|
85
|
+
box().querySelector('.bcount').textContent = spec.browse.count ? spec.browse.count(rows, q) : `${rows.length}`;
|
|
86
|
+
detail();
|
|
87
|
+
}
|
|
88
|
+
function select(i) { sel = i; box().querySelectorAll('.bitems .li').forEach((x, j) => x.classList.toggle('sel', j === i)); detail(); }
|
|
89
|
+
// seleccionar por clave (spec.browse.key): para abrir el panel ya parado en un elemento («+ nuevo», una tarea)
|
|
90
|
+
function selectKey(k) { if (!cur || cur.layout !== 'browse' || !cur.browse.key) return false; const i = rows.findIndex(r => cur.browse.key(r) === k); if (i < 0) return false; select(i); return true; }
|
|
91
|
+
async function detail() {
|
|
92
|
+
if (!cur || cur.layout !== 'browse') return;
|
|
93
|
+
const spec = cur, my = ++seq, d = box().querySelector('.bdetail'), r = rows[sel];
|
|
94
|
+
if (!r) { d.innerHTML = `<div class="none">${rows.length ? '' : (spec.browse.emptyDetail || '')}</div>`; return; }
|
|
95
|
+
let html; try { html = await spec.browse.detail(r, query()); } catch (e) { html = `<div class="none">${esc(String(e.message || e))}</div>`; }
|
|
96
|
+
if (cur !== spec || my !== seq) return;
|
|
97
|
+
d.innerHTML = html; if (spec.browse.wire) spec.browse.wire(r, d);
|
|
98
|
+
}
|
|
99
|
+
function help(show) { const b = box(); const h = b.querySelector('.phelp'), br = b.querySelector('.browse'); if (!h) return; h.style.display = show ? '' : 'none'; br.style.display = show ? 'none' : ''; }
|
|
100
|
+
return { open, close, refresh, detail, err, is, tab, help, selectKey, get box() { return box(); }, get current() { return cur; }, get selected() { return rows[sel]; } };
|
|
101
|
+
})();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// procs.js — procesos gestionados por el agente (tool process) y puertos en escucha de la máquina
|
|
2
|
+
let procOpen = null, procTimer = null;
|
|
3
|
+
async function loadPorts() {
|
|
4
|
+
let r; try { r = await (await fetch(BASE + '/api/ports')).json(); } catch (e) { return; }
|
|
5
|
+
const box = $('#ports'); box.innerHTML = '';
|
|
6
|
+
const list = (r.ports || []).filter(p => p.port !== 8080 || p.name !== 'synsema.exe');
|
|
7
|
+
$('#portCount').textContent = list.length || ''; autoSec('ports', list.length > 0);
|
|
8
|
+
if (!list.length) { box.innerHTML = '<div class="p none">ninguno</div>'; return; }
|
|
9
|
+
for (const p of list) {
|
|
10
|
+
const d = document.createElement('div'); d.className = 'p'; d.style.cursor = 'default'; d.title = `pid ${p.pid}\n${p.command || '(línea de comando no disponible)'}`;
|
|
11
|
+
const what = p.command ? p.command.replace(/^"?[A-Za-z]:\\[^"]*\\([^"\\]+)"?/, '$1') : p.name;
|
|
12
|
+
d.innerHTML = `<a class="port" href="http://127.0.0.1:${p.port}" target="_blank" rel="noopener" title="abrir http://127.0.0.1:${p.port} en otra pestaña">:${p.port}</a><span class="cm">${esc(what)}</span><button title="matar este proceso (pid ${p.pid}; no lo gestiona lampson)">✕</button>`;
|
|
13
|
+
const kb = d.querySelector('button'); kb.onclick = () => { const holder = document.createElement('span'); holder.className = 'del'; kb.replaceWith(holder); inlineConfirm(holder, `¿matar pid ${p.pid}?`, async () => { await api(BASE + '/api/ports/kill', { pid: p.pid }); loadPorts(); }); setTimeout(() => { if (!holder.classList.contains('ask')) loadPorts(); }, 6100); };
|
|
14
|
+
box.appendChild(d);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function loadProcs() {
|
|
18
|
+
loadPorts();
|
|
19
|
+
let r; try { r = await (await fetch(BASE + '/api/proc')).json(); } catch (e) { return; }
|
|
20
|
+
const box = $('#procs'); box.innerHTML = '';
|
|
21
|
+
const list = r.processes || []; const act = list.filter(p => p.running).length;
|
|
22
|
+
$('#procCount').textContent = list.length ? (act ? `${act} ● · ${list.length}` : `${list.length}`) : '';
|
|
23
|
+
// Procesos queda cerrado salvo que el usuario lo abra (preferencia guardada); el contador avisa
|
|
24
|
+
if (!list.length) { box.innerHTML = '<div class="p none">ninguno</div>'; return; }
|
|
25
|
+
for (const p of list) {
|
|
26
|
+
const d = document.createElement('div'); d.className = 'p' + (p.running ? ' run' : '') + (procOpen === p.name ? ' active' : ''); d.title = p.command;
|
|
27
|
+
d.innerHTML = `<span class="st"></span><span class="nm">${esc(p.name)}</span><span class="cm">$ ${esc(p.command)}</span>` + (p.running ? '<button>■</button>' : '');
|
|
28
|
+
d.onclick = () => openProc(p.name);
|
|
29
|
+
const b = d.querySelector('button'); if (b) b.onclick = async (ev) => { ev.stopPropagation(); await api(BASE + '/api/proc/stop', { name: p.name }); loadProcs(); if (procOpen === p.name) openProc(p.name); };
|
|
30
|
+
box.appendChild(d);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function openProc(name) {
|
|
34
|
+
procOpen = name; document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active'));
|
|
35
|
+
const r = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
|
|
36
|
+
showText('proceso ' + name, r.running ? '● corriendo · log en vivo' : '○ terminado', r.log || '(sin salida todavía)', true);
|
|
37
|
+
clearInterval(procTimer); if (r.running && !events) procTimer = setInterval(() => refreshProcViewer(name), 2000); // sin stream de eventos: respaldo por timer
|
|
38
|
+
loadProcs();
|
|
39
|
+
}
|
|
40
|
+
async function refreshProcViewer(name) {
|
|
41
|
+
if (procOpen !== name || $('#viewer').style.display === 'none') { clearInterval(procTimer); return; }
|
|
42
|
+
const x = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
|
|
43
|
+
$('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
|
|
44
|
+
if (!x.running) { clearInterval(procTimer); loadProcs(); }
|
|
45
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// schedules.js — tareas programadas (lib/schedule.syn): sección del panel lateral + vista browse del Panel
|
|
2
|
+
// (tareas a la izquierda; a la derecha: qué hace, corridas, log, traza en vivo mientras corre, ▶ / ⏻ / quitar;
|
|
3
|
+
// «+ programar» es una fila más cuyo detalle es el formulario). Corren en cualquier lampson abierto (web o
|
|
4
|
+
// terminal, tick cada 30 s) o con `lampson --daemon start` con todo cerrado.
|
|
5
|
+
let schedData = [], schedLamps = [], schedTick = 30, schedMountOk = true, schedLiveTimer = null;
|
|
6
|
+
async function fetchSched() {
|
|
7
|
+
let r; try { r = await (await fetch(BASE + '/api/schedules')).json(); } catch (e) { return schedData; }
|
|
8
|
+
schedData = r.tasks || []; schedLamps = r.lamps || []; schedTick = r.tick || 30; schedMountOk = r.mount_ok !== false;
|
|
9
|
+
return schedData;
|
|
10
|
+
}
|
|
11
|
+
async function loadSched() {
|
|
12
|
+
await fetchSched();
|
|
13
|
+
const box = $('#schedBox'); box.innerHTML = '';
|
|
14
|
+
const on = schedData.filter(t => t.enabled).length, running = schedData.filter(t => t.running).length;
|
|
15
|
+
$('#schedCount').textContent = schedData.length ? (running ? `${running} ● · ${schedData.length}` : `${on} · ${schedData.length}`) : ''; autoSec('sched', running > 0);
|
|
16
|
+
const dm = document.createElement('div'); dm.className = 'daemon' + (schedMountOk ? '' : ' off');
|
|
17
|
+
dm.textContent = schedMountOk ? `corren solas mientras esta web esté abierta · tick cada ${schedTick} s` : '⚠ ./workspace apunta a otro proyecto: tareas en pausa · lampson --daemon restart desde esta carpeta';
|
|
18
|
+
dm.title = 'las tareas programadas corren en este proceso (web o terminal). Para que corran con todo cerrado: lampson --daemon start';
|
|
19
|
+
box.appendChild(dm);
|
|
20
|
+
if (!schedData.length) { const e = document.createElement('div'); e.className = 'p none'; e.style.cursor = 'default'; e.textContent = 'ninguna'; box.appendChild(e); }
|
|
21
|
+
for (const t of schedData) {
|
|
22
|
+
const d = document.createElement('div'); d.className = 'p sched';
|
|
23
|
+
const st = t.running ? 'busy' : (t.enabled ? (t.last_status && t.last_status !== 'ok' ? 'bad' : 'on') : '');
|
|
24
|
+
d.title = `${t.action_text}\npermisos: ${t.permission}` + (t.last_run ? `\núltima: ${t.last_local} · ${t.last_status}: ${t.last_summary}` : '');
|
|
25
|
+
d.innerHTML = `<span class="st ${st}"></span><span class="nm">${esc(t.name)}</span><span class="cm">${esc(t.plan)}</span>`
|
|
26
|
+
+ `<span class="sub">${t.running ? 'corriendo…' : (t.enabled ? 'próxima ' + esc(fmtWhen(t.next_run)) : 'apagada')}${t.last_run ? ' · última ' + esc(t.last_status) : ''} · ${esc(t.action.type)} · ${esc(t.permission)}</span>`;
|
|
27
|
+
d.onclick = () => openSched(t.id);
|
|
28
|
+
box.appendChild(d);
|
|
29
|
+
}
|
|
30
|
+
const addRow = document.createElement('div'); addRow.className = 'p'; addRow.innerHTML = `<span class="nm" style="color:var(--accent)">+ programar una tarea…</span>`;
|
|
31
|
+
addRow.title = 'una lámpara, un comando o el agente con instrucciones · también se lo podés pedir al agente en el chat';
|
|
32
|
+
addRow.onclick = () => openSched('__new');
|
|
33
|
+
box.appendChild(addRow);
|
|
34
|
+
if (Panel.is('schedules')) Panel.refresh();
|
|
35
|
+
}
|
|
36
|
+
const SCHED_NEW = { id: '__new' };
|
|
37
|
+
function schedStatusDot(t) { return t.running ? '◐' : (t.enabled ? (t.last_status && t.last_status !== 'ok' ? '!' : '●') : '○'); }
|
|
38
|
+
function openSched(selectId) {
|
|
39
|
+
Panel.open({
|
|
40
|
+
id: 'schedules', eyebrow: 'programadas', title: 'Tareas programadas', sub: schedMountOk ? `tick cada ${schedTick} s · lampson --daemon start para correr con todo cerrado` : '⚠ ./workspace apunta a otro proyecto: en pausa', layout: 'browse', select: selectId || null,
|
|
41
|
+
onClose: () => clearTimeout(schedLiveTimer),
|
|
42
|
+
browse: {
|
|
43
|
+
placeholder: 'buscar tarea…', listWidth: '300px', key: t => t.id,
|
|
44
|
+
load: async (q) => { const all = await fetchSched(); const list = all.filter(t => !q || (t.name + ' ' + t.plan + ' ' + t.action_text).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, SCHED_NEW]; },
|
|
45
|
+
render: (t) => t === SCHED_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">programar una tarea…</div><div class="meta">lámpara, comando o el agente</div></div>` : `<span class="dot">${schedStatusDot(t)}</span><div><div class="nm">${esc(t.name)}</div><div class="meta">${esc(t.plan)} · ${t.running ? 'corriendo' : (t.enabled ? 'próxima ' + esc(fmtWhen(t.next_run)) : 'apagada')}</div></div>`,
|
|
46
|
+
count: (rows) => { const n = rows.filter(r => r !== SCHED_NEW).length; return `${n} tarea${n === 1 ? '' : 's'}`; },
|
|
47
|
+
emptyHtml: 'nada coincide',
|
|
48
|
+
detail: async (t) => {
|
|
49
|
+
if (t === SCHED_NEW) return schedForm();
|
|
50
|
+
let r; try { r = await (await fetch(BASE + '/api/schedules/log?id=' + encodeURIComponent(t.id) + '&tail=300')).json(); } catch (e) { r = {}; }
|
|
51
|
+
// r.task = el registro crudo (plan como objeto, session, history fresco); t = el resumen (plan y horarios en texto)
|
|
52
|
+
const raw = r.task || t; const task = Object.assign({}, raw, t, { running: raw.running, session: raw.session, history: raw.history, enabled: raw.enabled });
|
|
53
|
+
let live = '';
|
|
54
|
+
if (task.running && task.session) { try { const tr = await (await fetch(BASE + '/api/trace?session=' + encodeURIComponent(task.session) + '&tail=80')).json(); live = tr.trace || '(arrancando…)'; } catch (e) { live = '(arrancando…)'; } }
|
|
55
|
+
else if (task.running) live = task.action.type === 'prompt' ? 'arrancando el agente…' : 'corriendo ' + task.action.type + '…';
|
|
56
|
+
const hist = (task.history || []).slice().reverse().map(h => `<div class="dmsg ${h.status === 'ok' ? '' : 'dm-bad'}"><span class="who">${esc(new Date(h.started * 1000).toLocaleString())} · ${esc(h.status)}${h.late ? ' · atrasada' : ''}${h.steps != null ? ' · ' + h.steps + ' pasos' : ''}${h.session ? ` · <a href="#" data-session="${esc(h.session)}">sesión ⏰</a>` : ''}</span>${esc(h.summary || '')}</div>`).join('');
|
|
57
|
+
return `<div class="dhead"><span class="nm">${esc(task.name)}</span><span class="meta">${esc(task.plan)} · ${task.running ? '● corriendo' : (task.enabled ? 'próxima ' + esc(fmtWhen(task.next_run)) : 'apagada')}</span></div>
|
|
58
|
+
<div class="ddesc">${esc(task.action_text)}</div>
|
|
59
|
+
<div class="dcap">permisos <b>${esc(task.permission)}</b>${task.notify ? ' · avisa a ' + esc(task.notify) : ''}${task.last_run ? ' · última ' + esc(task.last_local) + ' → ' + esc(task.last_status) : ''}</div>
|
|
60
|
+
<div class="dacts"><button class="primary" data-run ${task.running ? 'disabled' : ''}>▶ correr ahora</button><button data-toggle>${task.enabled ? '⏻ apagar' : '○ encender'}</button></div>
|
|
61
|
+
${live ? `<div class="dcap" style="margin-top:12px">corriendo ahora${task.session ? ' · sesión ' + esc(task.session) : ''}</div><pre class="dlog" data-live>${esc(live)}</pre>` : ''}
|
|
62
|
+
${hist ? `<div class="dmsgs">${hist}</div>` : '<div class="none" style="margin-top:12px">sin corridas todavía</div>'}
|
|
63
|
+
${r.log ? `<pre class="dlog">${esc(r.log)}</pre>` : ''}
|
|
64
|
+
<div class="dfoot"><span>.lampson/schedules/${esc(task.id)}.log</span><span class="del">quitar</span></div><div class="derr"></div>`;
|
|
65
|
+
},
|
|
66
|
+
wire: (t, box) => {
|
|
67
|
+
const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
|
|
68
|
+
if (t === SCHED_NEW) { schedFormWire(box, err); return; }
|
|
69
|
+
box.querySelector('[data-run]').onclick = async () => { const r = await api(BASE + '/api/schedules/run', { id: t.id }); add('meta', '⏰ ' + esc(r.data.result || r.data.error || '')); setTimeout(loadSched, 1500); setTimeout(() => Panel.is('schedules') && Panel.detail(), 2500); };
|
|
70
|
+
box.querySelector('[data-toggle]').onclick = async () => { const r = await api(BASE + '/api/schedules/toggle', { id: t.id, enabled: !t.enabled }); if (!r.ok) { err(r.data.error || 'error'); return; } loadSched(); };
|
|
71
|
+
box.querySelectorAll('[data-session]').forEach(a => a.onclick = (e) => { e.preventDefault(); Panel.close(); open(a.dataset.session); });
|
|
72
|
+
const del = box.querySelector('.dfoot .del');
|
|
73
|
+
del.onclick = () => inlineConfirm(del, `¿quitar ${t.name}?`, async () => { await api(BASE + '/api/schedules/remove', { id: t.id }); loadSched(); });
|
|
74
|
+
const live = box.querySelector('[data-live]'); if (live) live.scrollTop = live.scrollHeight;
|
|
75
|
+
clearTimeout(schedLiveTimer); if (t.running) schedLiveTimer = setTimeout(() => { if (Panel.is('schedules')) Panel.refresh(); }, 3000);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function schedForm() {
|
|
81
|
+
const lampOpts = schedLamps.filter(l => l.enabled).flatMap(l => (l.tools || []).map(tl => `<option value="${esc(l.name + '/' + tl)}">${esc(l.name)} · ${esc(tl)}</option>`)).join('') || '<option value="">ninguna lámpara encendida</option>';
|
|
82
|
+
return `<div class="dhead"><span class="nm serif">Programar una tarea</span></div><div class="dform">
|
|
83
|
+
<p class="lead">Corre sola, a la hora que digas, mientras lampson esté abierto (esta web o la terminal); con todo cerrado, <code>lampson --daemon start</code>. También podés pedírsela al agente en el chat: <i>«todos los días a las 9 corré los tests y avisame»</i>.</p>
|
|
84
|
+
<label>nombre <input name="name" spellcheck="false" autocomplete="off" placeholder="tests nocturnos"></label>
|
|
85
|
+
<label>cuándo <input name="when" spellcheck="false" autocomplete="off" placeholder="daily 09:00 · every 6h · mon,wed 08:30 · today 15:14 · once 2026-09-01 10:00 · in 2h"></label>
|
|
86
|
+
<label>qué corre <select name="kind"><option value="prompt">el agente, con estas instrucciones</option><option value="bash">un comando de shell</option><option value="lamp">una tool de una lámpara encendida</option></select></label>
|
|
87
|
+
<div data-kind="prompt">
|
|
88
|
+
<label class="col">instrucciones <textarea name="prompt" rows="4" spellcheck="false" placeholder="Corré la suite de tests. Si algo falla, buscá la causa y proponé el fix en el informe (no lo apliques). Terminá con un resumen corto."></textarea></label>
|
|
89
|
+
<label>perfil <select name="agent"><option value="build">build — puede editar y correr comandos</option><option value="review">review — corre tests, no edita</option><option value="plan">plan — solo lee y propone</option><option value="explore">explore — solo busca</option></select></label>
|
|
90
|
+
<label>permisos <select name="permission"><option value="ask">preguntar — lo peligroso te manda un link y espera</option><option value="strict">denegar — lo peligroso se rechaza y se informa</option><option value="yolo">permitir todo — sin preguntas</option></select></label>
|
|
91
|
+
<p class="lead">Sobre de permisos de una corrida sin nadie mirando. En «preguntar», la aprobación aparece acá y, con URL pública + webhook (⚙), como link en tu canal; sin respuesta en 2 h se deniega. Lo destructivo del sistema se bloquea siempre.</p>
|
|
92
|
+
</div>
|
|
93
|
+
<div data-kind="bash" style="display:none"><label>comando <input name="command" spellcheck="false" autocomplete="off" placeholder="npm test"></label></div>
|
|
94
|
+
<div data-kind="lamp" style="display:none">
|
|
95
|
+
<label>lámpara · tool <select name="lamptool">${lampOpts}</select></label>
|
|
96
|
+
<label>args (JSON) <input name="args" spellcheck="false" autocomplete="off" placeholder='{"who": "cron"}'></label>
|
|
97
|
+
</div>
|
|
98
|
+
<label>avisar a <input name="notify" spellcheck="false" autocomplete="off" placeholder="opcional: URL de webhook que recibe el resultado (JSON)"></label>
|
|
99
|
+
<div class="pfoot"><button class="primary" data-go>Programar</button><span class="derr"></span></div></div>`;
|
|
100
|
+
}
|
|
101
|
+
function schedFormWire(box, err) {
|
|
102
|
+
const f = n => box.querySelector(`[name="${n}"]`);
|
|
103
|
+
const kind = () => { const k = f('kind').value; box.querySelectorAll('[data-kind]').forEach(el => el.style.display = el.dataset.kind === k ? '' : 'none'); };
|
|
104
|
+
f('kind').onchange = kind; kind(); f('name').focus();
|
|
105
|
+
box.querySelector('[data-go]').onclick = async () => {
|
|
106
|
+
const k = f('kind').value; let action;
|
|
107
|
+
if (k === 'prompt') action = { type: 'prompt', prompt: f('prompt').value.trim(), agent: f('agent').value };
|
|
108
|
+
else if (k === 'bash') action = { type: 'bash', command: f('command').value.trim() };
|
|
109
|
+
else { const v = f('lamptool').value; if (!v) { err('encendé una lámpara primero'); return; } const [lamp, tool] = v.split('/'); let args = {}; if (f('args').value.trim()) { try { args = JSON.parse(f('args').value); } catch (e) { err('args: JSON inválido'); return; } } action = { type: 'lamp', lamp, tool, args }; }
|
|
110
|
+
const body = { name: f('name').value.trim(), when: f('when').value.trim(), action, permission: f('permission').value, notify: f('notify').value.trim() };
|
|
111
|
+
if (!body.when) { err('falta cuándo'); return; }
|
|
112
|
+
err('programando…');
|
|
113
|
+
const r = await api(BASE + '/api/schedules/add', body);
|
|
114
|
+
if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
|
|
115
|
+
add('meta', '⏰ ' + esc(r.data.result || 'programada')); setSec('sched', true); await loadSched(); if (r.data.task) Panel.selectKey(r.data.task.id);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
$('#schedAddBtn').onclick = (ev) => { ev.stopPropagation(); openSched('__new'); };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// sessions.js — sesiones: las últimas 4 en el panel, «ver todas» abre el Panel (browse) con búsqueda en
|
|
2
|
+
// títulos y contenido (GET /api/sessions/search?q=) y una previsualización de la conversación; traza.
|
|
3
|
+
function forgetSession(id) { if (id === session) { session = ''; localStorage.removeItem('lampson.session'); log.innerHTML = ''; showPane('log'); empty(); } }
|
|
4
|
+
async function deleteSession(id) { await api(BASE + '/api/sessions/delete', { id }); forgetSession(id); loadSessions(); }
|
|
5
|
+
async function loadSessions() {
|
|
6
|
+
let r; try { r = await (await fetch(BASE + '/api/sessions')).json(); } catch (e) { return; }
|
|
7
|
+
const box = $('#sessions'); box.innerHTML = '';
|
|
8
|
+
$('#sessCount').textContent = r.sessions.length || '';
|
|
9
|
+
if (!r.sessions.length) { box.innerHTML = '<div class="s none">todavía ninguna</div>'; return; }
|
|
10
|
+
const shown = r.sessions.slice(0, 4);
|
|
11
|
+
for (const s of shown) {
|
|
12
|
+
const d = document.createElement('div'); d.className = 's' + (s.id === session ? ' active' : ''); d.innerHTML = '<span class="id">' + esc(s.id.slice(-6)) + '</span><span class="title">' + esc(s.title || '') + '</span><span class="del tr" title="traza: qué hizo el agente en esta sesión (pasos, tools, tiempos, tokens)">≡</span><span class="del" title="borrar esta sesión">✕</span>'; d.title = s.id; d.onclick = () => open(s.id);
|
|
13
|
+
d.querySelector('.tr').onclick = (ev) => { ev.stopPropagation(); openTrace(s.id); };
|
|
14
|
+
d.querySelector('.del:not(.tr)').onclick = (ev) => { ev.stopPropagation(); inlineConfirm(d.querySelector('.del:not(.tr)'), '¿borrar?', () => deleteSession(s.id)); };
|
|
15
|
+
box.appendChild(d);
|
|
16
|
+
}
|
|
17
|
+
if (r.sessions.length > shown.length) { const m = document.createElement('div'); m.className = 's more'; m.innerHTML = `<span class="title">ver todas (${r.sessions.length}) y buscar…</span>`; m.title = 'buscar en títulos y en el contenido de las conversaciones'; m.onclick = openSessionsPanel; box.appendChild(m); }
|
|
18
|
+
}
|
|
19
|
+
$('#sessAll').onclick = (e) => { e.stopPropagation(); openSessionsPanel(); };
|
|
20
|
+
function openSessionsPanel() {
|
|
21
|
+
Panel.open({
|
|
22
|
+
id: 'sessions', eyebrow: 'sesiones', title: 'Todas las sesiones', sub: 'de este workspace', layout: 'browse',
|
|
23
|
+
browse: {
|
|
24
|
+
placeholder: 'buscar en títulos y en lo que se habló… (↑↓ · Enter abre)', listWidth: '360px', nodot: true,
|
|
25
|
+
key: s => s.id,
|
|
26
|
+
load: async (q) => (await (await fetch(BASE + '/api/sessions/search?q=' + encodeURIComponent(q) + '&limit=300')).json()).sessions || [],
|
|
27
|
+
render: (s, q) => `<div><div class="nm serif">${hl(s.title || '(sin título)', q)}</div><div class="meta">${esc(s.id)} · ${esc(fmtIso(s.updated))}</div>` + (s.snippet ? `<div class="snip">${hl(s.snippet, q)}</div>` : '') + '</div>',
|
|
28
|
+
detail: async (s, q) => {
|
|
29
|
+
const d = await (await fetch(BASE + '/api/sessions/' + encodeURIComponent(s.id))).json();
|
|
30
|
+
const msgs = (d.messages || []).filter(m => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string' && m.content.trim() && !/^\[harness\]|^\[Context compacted/.test(m.content)).slice(0, 14);
|
|
31
|
+
const steps = (d.messages || []).filter(m => m.role === 'tool').length;
|
|
32
|
+
return `<div class="dhead"><span class="nm serif">${esc(s.title || '(sin título)')}</span><span class="meta">${esc(s.id)} · ${esc(fmtIso(d.updated))} · ${(d.messages || []).length} mensajes · ${steps} tools</span></div>`
|
|
33
|
+
+ `<div class="dacts"><button class="primary" data-act="open">Abrir</button><button data-act="trace">≡ Traza</button></div>`
|
|
34
|
+
+ `<div class="dmsgs">${msgs.map(m => `<div class="dmsg dm-${m.role}"><span class="who">${m.role === 'user' ? 'vos' : 'agente'}</span>${hl(m.content.length > 500 ? m.content.slice(0, 500) + '…' : m.content, q)}</div>`).join('')}${(d.messages || []).length > msgs.length ? '<div class="none">… (abrila para ver todo)</div>' : ''}</div>`
|
|
35
|
+
+ `<div class="dfoot"><span>.lampson/sessions/${esc(s.id)}.json</span><span class="del">borrar</span></div>`;
|
|
36
|
+
},
|
|
37
|
+
wire: (s, box) => {
|
|
38
|
+
box.querySelector('[data-act="open"]').onclick = () => { Panel.close(); open(s.id); };
|
|
39
|
+
box.querySelector('[data-act="trace"]').onclick = () => { Panel.close(); openTrace(s.id); };
|
|
40
|
+
const del = box.querySelector('.dfoot .del');
|
|
41
|
+
del.onclick = () => inlineConfirm(del, `¿borrar ${s.id}?`, async () => { await deleteSession(s.id); Panel.refresh(); });
|
|
42
|
+
},
|
|
43
|
+
onPick: (s) => open(s.id),
|
|
44
|
+
count: (rows, q) => q ? `${rows.length} coincidencia${rows.length === 1 ? '' : 's'}` : `${rows.length} sesión${rows.length === 1 ? '' : 'es'}`,
|
|
45
|
+
emptyHtml: 'todavía no hay sesiones'
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function open(id) {
|
|
50
|
+
session = id; localStorage.setItem('lampson.session', id); log.innerHTML = ''; log.classList.remove('hero'); showPane('log'); loadTodo();
|
|
51
|
+
const r = await fetch(BASE + '/api/sessions/' + id); if (!r.ok) { session = ''; empty(); return loadSessions(); }
|
|
52
|
+
const d = await r.json();
|
|
53
|
+
const queue = []; // pasos sin resultado todavía, en orden (varias tool calls por turno)
|
|
54
|
+
for (const m of d.messages) {
|
|
55
|
+
// los avisos del harness viajan con role user (lo exige el wire) pero no los escribió el usuario:
|
|
56
|
+
// al recargar se muestran como meta (✉), igual que en vivo — nunca con la etiqueta "vos"
|
|
57
|
+
if (m.role === 'user') { queue.length = 0; const t = String(m.content || ''); if (t.startsWith('[harness]') || t.startsWith('[Context compacted')) add('meta', '✉ ' + esc(t.split('\n')[0].slice(0, 160))); else add('user', esc(m.content) + imagesHtml(m.images)); }
|
|
58
|
+
else if (m.role === 'assistant') { if (m.content) add('assistant', md(m.content)); for (const c of (m.tool_calls || [])) { const el = add('step', `<span class="ic">⚙</span>${cmdHtml(describe(c))}`); wireMore(el); queue.push(el); } }
|
|
59
|
+
else if (m.role === 'tool') { const out = String(m.content); const bad = /^(ERROR|DENIED)/.test(out); const el = queue.shift() || add('step', '<span class="ic">→</span>'); const ic = el.querySelector('.ic'); if (ic) { ic.textContent = bad ? '✗' : '✓'; ic.className = 'ic ' + (bad ? 'bad' : 'ok'); } el.insertAdjacentHTML('beforeend', `<details><summary class="${bad ? 'bad' : ''}">${esc(out.split('\n')[0].slice(0, 140))} <span style="color:var(--ink-3)">(${out.length} chars)</span></summary><pre>${esc(out)}</pre></details>`); }
|
|
60
|
+
}
|
|
61
|
+
loadSessions();
|
|
62
|
+
}
|
|
63
|
+
$('#new').onclick = () => { session = ''; localStorage.removeItem('lampson.session'); log.innerHTML = ''; showPane('log'); empty(); loadSessions(); loadTodo(); };
|
|
64
|
+
// traza legible de una sesión (.lampson/trace/<sid>.log)
|
|
65
|
+
async function openTrace(id) {
|
|
66
|
+
procOpen = null; agentOpen = null;
|
|
67
|
+
let r; try { r = await (await fetch(BASE + '/api/trace?session=' + encodeURIComponent(id) + '&tail=600')).json(); } catch (e) { return; }
|
|
68
|
+
showText('traza ' + id, r.file || '', r.trace || '(sin traza todavía: se escribe a medida que el agente trabaja)', true);
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// sidebar.js — secciones acordeón de los paneles laterales y plegado de cada panel
|
|
2
|
+
function setSec(name, open) {
|
|
3
|
+
const s = document.querySelector(`.sec[data-sec="${name}"]`); if (!s) return;
|
|
4
|
+
s.classList.toggle('open', open);
|
|
5
|
+
s.querySelector('.caret').textContent = open ? '▾' : '▸';
|
|
6
|
+
}
|
|
7
|
+
// sin preferencia del usuario, una sección se abre sola solo si tiene contenido
|
|
8
|
+
function autoSec(name, hasContent) {
|
|
9
|
+
const pref = localStorage.getItem('lampson.sec.' + name);
|
|
10
|
+
setSec(name, pref === null ? hasContent : pref === '1');
|
|
11
|
+
}
|
|
12
|
+
document.querySelectorAll('.sec h2').forEach(h => h.onclick = () => {
|
|
13
|
+
const s = h.closest('.sec'); const open = !s.classList.contains('open');
|
|
14
|
+
localStorage.setItem('lampson.sec.' + s.dataset.sec, open ? '1' : '0');
|
|
15
|
+
setSec(s.dataset.sec, open);
|
|
16
|
+
});
|
|
17
|
+
for (const s of document.querySelectorAll('.sec')) {
|
|
18
|
+
const pref = localStorage.getItem('lampson.sec.' + s.dataset.sec);
|
|
19
|
+
if (pref !== null) setSec(s.dataset.sec, pref === '1');
|
|
20
|
+
}
|
|
21
|
+
// plegar/desplegar: cerrado = barra angosta con su icono; preferencia por lado en localStorage
|
|
22
|
+
function sideSet(which, closed) {
|
|
23
|
+
document.body.classList.toggle(which + '-closed', closed);
|
|
24
|
+
const a = document.querySelector(which === 'l' ? 'aside:not(.right)' : 'aside.right'); if (a) a.classList.toggle('closed', closed);
|
|
25
|
+
try { localStorage.setItem('lampson.side.' + which, closed ? '1' : '0'); } catch (e) {}
|
|
26
|
+
}
|
|
27
|
+
for (const which of ['l', 'r']) {
|
|
28
|
+
const a = document.querySelector(which === 'l' ? 'aside:not(.right)' : 'aside.right'); if (!a) continue;
|
|
29
|
+
a.querySelector('.fold').onclick = () => sideSet(which, true);
|
|
30
|
+
a.querySelector('.rail button').onclick = () => sideSet(which, false);
|
|
31
|
+
let pref = null; try { pref = localStorage.getItem('lampson.side.' + which); } catch (e) {}
|
|
32
|
+
if (pref === '1') sideSet(which, true);
|
|
33
|
+
}
|