lampson 0.1.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, lamps (your own tool plugins), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,8 @@
21
21
  "lamps/",
22
22
  "chat.syn",
23
23
  "web.syn",
24
+ "hub.tpl.syn",
25
+ "cli.syn",
24
26
  "lampson.ps1",
25
27
  "lampson.sh",
26
28
  "lampson.cmd",
@@ -0,0 +1,12 @@
1
+ /* hub: la pantalla de workspaces (public/hub.html) — solo cabecera y zona central, sin paneles laterales */
2
+ body.hub { grid-template-columns:1fr; grid-template-areas:"top" "main"; }
3
+ body.hub main { grid-template-rows:1fr; }
4
+ body.hub #stage { display:flex; align-items:center; padding:20px 40px; }
5
+ .wsgrid { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); gap:14px; margin-top:8px; }
6
+ .card.ws { display:block; text-decoration:none; color:inherit; cursor:pointer; transition:border-color .12s; }
7
+ .card.ws:hover { border-color:var(--ink-3); }
8
+ .card.ws.on { border-color:var(--accent); }
9
+ .card.ws.new { border-style:dashed; }
10
+ .card.ws .nm { font:600 16px/1.3 var(--serif); color:var(--ink); margin:0 0 4px; }
11
+ .card.ws .pth { font:400 11px/1.4 var(--mono); color:var(--ink-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0 0 6px; }
12
+ .card.ws .meta { font:400 11px/1.4 var(--mono); color:var(--ink-3); margin:0; }
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Lampson · workspaces</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <link rel="stylesheet" href="/css/tokens.css">
8
+ <link rel="stylesheet" href="/css/layout.css">
9
+ <link rel="stylesheet" href="/css/chat.css">
10
+ <link rel="stylesheet" href="/css/panel.css">
11
+ <link rel="stylesheet" href="/css/hub.css">
12
+ <script>(function(){try{var t=localStorage.getItem('lampson.theme');if(t!=='light'&&t!=='dark'){t='dark';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
13
+ </head>
14
+ <body class="hub">
15
+ <header>
16
+ <div class="hrow">
17
+ <span class="brand">lampson<span class="proj">workspaces</span></span>
18
+ <span class="spacer"></span>
19
+ <button class="theme-toggle" id="theme" type="button" title="cambiar tema"></button>
20
+ </div>
21
+ </header>
22
+ <main>
23
+ <div id="stage">
24
+ <div class="empty">
25
+ <p class="eyebrow">workspaces</p>
26
+ <h1>¿En qué proyecto trabajamos?</h1>
27
+ <p class="lead">Cada workspace es una carpeta de tu disco con su propio agente: sesiones, tareas programadas, lámparas, MCP. Corren en procesos separados, así podés tener varios abiertos a la vez. Los que tienen tareas encendidas siguen vivos aunque cierres todo.</p>
28
+ <div class="wsgrid" id="wsGrid"></div>
29
+ <p class="lead" style="margin-top:22px">Desde una terminal: <code>cd mi-proyecto &amp;&amp; lampson</code> (terminal) o <code>lampson --web</code> (esta web, en ese workspace).</p>
30
+ </div>
31
+ </div>
32
+ </main>
33
+ <script src="/js/panel.js"></script>
34
+ <script src="/js/hub.js"></script>
35
+ </body>
36
+ </html>
package/public/index.html CHANGED
@@ -31,6 +31,7 @@
31
31
  <span class="hpath" id="hpath"></span>
32
32
  <select id="agent" title="perfil de agente: qué tools puede usar"></select>
33
33
  <select id="perm" title="qué pasa con un comando peligroso (rm -rf, git push --force, sudo…)"><option value="ask">permisos: preguntar</option><option value="yolo">permisos: permitir todo</option><option value="strict">permisos: denegar</option></select>
34
+ <span class="pill on" id="wsPill" style="display:none" title="cambiar de workspace">workspace ▾</span>
34
35
  <span class="pill" id="lamps" title="lámparas: plugins de tools que vos encendés · clic para ver y activar">lámparas</span>
35
36
  <span class="pill" id="model" title="proveedor y modelo · clic para cambiarlos o cargar una API key">…</span>
36
37
  </div>
@@ -121,6 +122,7 @@
121
122
  <script src="/js/schedules.js"></script>
122
123
  <script src="/js/approvals.js"></script>
123
124
  <script src="/js/config.js"></script>
125
+ <script src="/js/workspaces.js"></script>
124
126
  <script src="/js/update.js"></script>
125
127
  <script src="/js/sessions.js"></script>
126
128
  <script src="/js/chat.js"></script>
@@ -1,7 +1,7 @@
1
1
  // agents.js — subagentes (tool delegate): lista + log vivo por hijo
2
2
  let agentOpen = null, agentTimer = null, agentPaint = null;
3
3
  async function loadAgents() {
4
- let r; try { r = await (await fetch('/api/agents')).json(); } catch (e) { return; }
4
+ let r; try { r = await (await fetch(BASE + '/api/agents')).json(); } catch (e) { return; }
5
5
  const box = $('#agentsBox'); box.innerHTML = '';
6
6
  const list = (r.agents || []).slice().reverse(); const act = list.filter(a => a.status === 'running').length;
7
7
  $('#agentCount').textContent = list.length ? (act ? `${act} ● · ${list.length}` : `${list.length}`) : ''; autoSec('agents', act > 0);
@@ -11,14 +11,14 @@ async function loadAgents() {
11
11
  const d = document.createElement('div'); d.className = 'p' + (run ? ' run' : '') + (agentOpen === a.id ? ' active' : ''); d.title = `${a.status} · ${a.steps} pasos · ${a.tokens} tokens\n${a.brief}`;
12
12
  d.innerHTML = `<span class="st"></span><span class="nm">${esc(a.id)}</span><span class="cm">${esc(String(a.brief || '').split('\n')[0].slice(0, 90))}</span>` + (run ? '<button title="parar: devuelve el informe parcial">■</button>' : '');
13
13
  d.onclick = () => openAgent(a.id);
14
- const b = d.querySelector('button'); if (b) b.onclick = async (ev) => { ev.stopPropagation(); await api('/api/agents/stop', { id: a.id }); setTimeout(loadAgents, 1500); };
14
+ const b = d.querySelector('button'); if (b) b.onclick = async (ev) => { ev.stopPropagation(); await api(BASE + '/api/agents/stop', { id: a.id }); setTimeout(loadAgents, 1500); };
15
15
  box.appendChild(d);
16
16
  }
17
17
  }
18
18
  async function openAgent(id) {
19
19
  agentOpen = id; procOpen = null; document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active'));
20
20
  const paint = async () => {
21
- let r; try { r = await (await fetch('/api/agents/log?id=' + encodeURIComponent(id))).json(); } catch (e) { return false; }
21
+ let r; try { r = await (await fetch(BASE + '/api/agents/log?id=' + encodeURIComponent(id))).json(); } catch (e) { return false; }
22
22
  const meta = (r.agent || {});
23
23
  $('#vpath').textContent = 'subagente ' + id; $('#vmeta').textContent = meta.status === 'running' ? `● corriendo · ${meta.steps} pasos · log en vivo` : `○ ${meta.status || 'terminado'} · ${meta.steps ?? '?'} pasos · ${meta.tokens ?? '?'} tokens`;
24
24
  const pre = $('#vbody'); pre.className = 'log'; pre.textContent = (r.log || '(sin salida todavía)') + (meta.status && meta.status !== 'running' && meta.text ? '\n\n── informe ──\n' + meta.text : '');
package/public/js/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // app.js — arranque: config de la cabecera y primera carga de cada sección (se carga último)
2
2
  async function loadCfg() {
3
- try { cfg = await (await fetch('/api/config')).json(); } catch (e) { cfg = {}; }
3
+ try { cfg = await (await fetch(BASE + '/api/config')).json(); } catch (e) { cfg = {}; }
4
4
  const ws = cfg.workspace || '';
5
5
  $('#proj').textContent = ws.split(/[\\/]/).filter(Boolean).pop() || 'workspace'; $('#proj').title = ws;
6
6
  $('#hpath').textContent = ws; $('#hpath').title = ws;
@@ -3,7 +3,7 @@
3
3
  // configurados). Las del chat de esta pestaña ya tienen su tarjeta en la conversación; igual aparecen en la lista.
4
4
  const apprSeen = new Set();
5
5
  async function loadApprovals() {
6
- let r; try { r = await (await fetch('/api/approvals')).json(); } catch (e) { return; }
6
+ let r; try { r = await (await fetch(BASE + '/api/approvals')).json(); } catch (e) { return; }
7
7
  const list = r.approvals || [], pend = list.filter(a => a.status === 'pending');
8
8
  const box = $('#apprBox'); box.innerHTML = '';
9
9
  $('#apprCount').textContent = pend.length ? `${pend.length} ●` : ''; autoSec('approvals', pend.length > 0);
@@ -12,7 +12,7 @@ async function loadApprovals() {
12
12
  const d = document.createElement('div'); d.className = 'p appr'; const src = a.source === 'chat' ? 'chat' : ('⏰ ' + a.source.replace(/^schedule:?/, ''));
13
13
  d.innerHTML = `<span class="st ${a.status === 'pending' ? 'busy' : ''}"></span><span class="nm">${esc(src)}</span><span class="cm">${a.status === 'pending' ? 'vence ' + esc(fmtWhen(a.expires_at)) : esc(a.status)}</span><span class="msg">${esc(a.message)}</span><span class="why">${esc(a.why)}</span>`
14
14
  + (a.status === 'pending' ? `<span class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></span>` : '');
15
- d.querySelectorAll('button').forEach(b => b.onclick = async () => { d.querySelectorAll('button').forEach(x => x.disabled = true); await api('/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); setTimeout(loadApprovals, 600); });
15
+ d.querySelectorAll('button').forEach(b => b.onclick = async () => { d.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); setTimeout(loadApprovals, 600); });
16
16
  box.appendChild(d);
17
17
  }
18
18
  if (r.public_url === '' && pend.length) { const n = document.createElement('div'); n.className = 'daemon'; n.textContent = 'para aprobar desde el móvil: URL pública + webhook en ⚙'; box.appendChild(n); }
@@ -20,7 +20,7 @@ async function loadApprovals() {
20
20
  for (const a of pend) {
21
21
  if (a.source === 'chat' || apprSeen.has(a.id)) continue; apprSeen.add(a.id);
22
22
  const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(a.why)} · ${esc(a.source.replace(/^schedule:?/, 'tarea programada '))}</div><code>${esc(a.message)}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`);
23
- el.dataset.id = a.id; el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api('/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); });
23
+ el.dataset.id = a.id; el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: a.id, decision: b.dataset.ok === '1' }); });
24
24
  }
25
25
  for (const a of list) { if (a.status === 'pending') continue; const el = [...log.querySelectorAll('.approval')].find(x => x.dataset.id === a.id); if (el && el.querySelector('.btns button')) el.querySelector('.btns').innerHTML = a.status === 'approved' ? '<span style="color:var(--str)">✓ permitido</span>' : (a.status === 'expired' ? '<span class="denied">✗ sin respuesta, denegado</span>' : '<span class="denied">✗ denegado</span>'); }
26
26
  }
package/public/js/chat.js CHANGED
@@ -48,7 +48,7 @@ async function send(message) {
48
48
  add('user', esc(message) + imagesHtml(images));
49
49
  const thinking = add('working', '<span class="dot"></span><span>pensando…</span>');
50
50
  try {
51
- const res = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ session, message, agent: $('#agent').value || 'build', permission: $('#perm').value || 'ask', images }), signal: aborter.signal });
51
+ const res = await fetch(BASE + '/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ session, message, agent: $('#agent').value || 'build', permission: $('#perm').value || 'ask', images }), signal: aborter.signal });
52
52
  if (!res.ok || !res.body) { add('denied', 'error HTTP ' + res.status + ' ' + esc(await res.text())); }
53
53
  else {
54
54
  const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = '';
@@ -77,7 +77,7 @@ function handle(chunk, thinking) {
77
77
  // lámpara recién creada: avisar en el chat con un acceso directo al switch (el modelo no sabe cómo se enciende en esta UI)
78
78
  if (d.call && d.call.name === 'lamp' && /^lamp '([^']+)' created/.test(out)) { const nm = out.match(/^lamp '([^']+)' created/)[1]; const m = add('meta', `☼ lámpara <b>${esc(nm)}</b> creada — está apagada: <a href="#" class="lampgo">encenderla en «lámparas»</a>`); m.querySelector('.lampgo').onclick = (ev) => { ev.preventDefault(); openLamps(nm); }; $('#lamps').classList.add('new'); setTimeout(() => $('#lamps').classList.remove('new'), 4000); }
79
79
  const el = pending || add('step', '<span class="ic">→</span>'); pending = null; thinking.querySelector('span:last-child').textContent = 'pensando…'; const ic = el.querySelector('.ic'); if (ic) { ic.textContent = bad ? '✗' : '✓'; ic.className = 'ic ' + (bad ? 'bad' : 'ok'); } el.insertAdjacentHTML('beforeend', resultHtml(out, bad)); }
80
- else if (k === 'approval_request') { const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(d.why)}</div><code>${esc(describe({name: d.name, args: d.args}))}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`); el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api('/api/approve', { id: d.id, decision: b.dataset.ok === '1' }); }); el.dataset.id = d.id; }
80
+ else if (k === 'approval_request') { const el = add('approval', `<div class="card"><div class="why">⚠ ${esc(d.why)}</div><code>${esc(describe({name: d.name, args: d.args}))}</code><div class="btns"><button class="primary" data-ok="1">Permitir</button><button data-ok="0">Denegar</button></div></div>`); el.querySelectorAll('button').forEach(b => b.onclick = async () => { el.querySelectorAll('button').forEach(x => x.disabled = true); await api(BASE + '/api/approve', { id: d.id, decision: b.dataset.ok === '1' }); }); el.dataset.id = d.id; }
81
81
  else if (k === 'approval_result') { const el = [...log.querySelectorAll('.approval')].find(x => x.dataset.id === d.id); if (el) el.querySelector('.btns').innerHTML = d.approved ? '<span style="color:var(--str)">✓ permitido</span>' : (d.timeout ? '<span class="denied">✗ sin respuesta, denegado</span>' : '<span class="denied">✗ denegado</span>'); }
82
82
  else if (k === 'mode') { $('#agent').value = d.agent; $('#perm').value = d.permission; if (d.has_key === false) { add('denied', `‼ no hay API key para ${esc(d.provider)} — cargala en la ventana de modelo (clic en «${esc(d.provider)} · ${esc(d.model)}» arriba)`); } }
83
83
  else if (k === 'tool_denied') { const el = pending || add('step', '<span class="ic">✗</span>'); el.insertAdjacentHTML('beforeend', `<span class="denied"> ✗ ${esc(d.reason)}</span>`); }
@@ -17,7 +17,7 @@ function cfgField(v, key, label, placeholder, ds, type) {
17
17
  return `<label class="col">${label}<input name="${key}" type="${type || 'text'}" spellcheck="false" autocomplete="off" placeholder="${esc(placeholder)}" value="${esc(v[key] || '')}" ${v[key + '_from_env'] ? 'disabled title="fijado en .env (LAMPSON_' + key.toUpperCase() + '): editalo ahí"' : ''}><span class="ds">${ds}</span></label>`;
18
18
  }
19
19
  async function openCfg(section, onboarding) {
20
- let r; try { r = await (await fetch('/api/settings/values')).json(); } catch (e) { r = { values: {} }; }
20
+ let r; try { r = await (await fetch(BASE + '/api/settings/values')).json(); } catch (e) { r = { values: {} }; }
21
21
  const v = r.values || {};
22
22
  Panel.open({
23
23
  id: 'config', eyebrow: 'configuración', title: onboarding ? 'Bienvenido a Lampson' : 'Configuración', sub: 'lampson/.lampson/config.json', layout: 'browse', noClose: !!onboarding,
@@ -30,6 +30,7 @@ async function openCfg(section, onboarding) {
30
30
  detail: (s) => {
31
31
  if (s.id === 'general') return `<div class="dhead"><span class="nm serif">General</span></div><div class="dform"><p class="lead">Se guarda en <code>lampson/.lampson/config.json</code> (local). Un valor fijado en <code>.env</code> gana y se muestra bloqueado.</p>`
32
32
  + cfgField(v, 'tz', 'zona horaria', '-03:00', 'para «daily 09:00» y los horarios de las tareas programadas · vacío = la del sistema' + (typeof r.tz_detected === 'number' ? ` (ahora: ${fmtOff(r.tz_detected)})` : ''))
33
+ + cfgField(v, 'idle_hours', 'apagar workspaces inactivos tras', '4', 'horas sin uso tras las que el hub apaga el proceso de un workspace (vuelve a arrancar al abrirlo, en 1-2 s) - 0 = nunca - los que tienen tareas programadas encendidas o politica siempre vivo no se apagan')
33
34
  + `<div class="pfoot"><button class="primary" data-save>Guardar</button><span class="derr"></span></div></div>`;
34
35
  if (s.id === 'approvals') return `<div class="dhead"><span class="nm serif">Aprobaciones a distancia</span></div><div class="dform"><p class="lead">Cuando una tarea programada (o el chat) necesita tu permiso, además de aparecer acá puede avisarte a tu canal con <b>links de un solo uso</b> para permitir o denegar desde el teléfono.</p>`
35
36
  + cfgField(v, 'public_url', 'URL pública', 'https://lampson.midominio.com', 'por dónde se llega a este lampson desde afuera (túnel, VPS, edge con TLS); con esto cada aprobación trae links <code>/approve/&lt;id&gt;/&lt;token&gt;?d=yes|no</code>')
@@ -51,11 +52,12 @@ async function openCfg(section, onboarding) {
51
52
  const save = box.querySelector('[data-save]');
52
53
  if (save) save.onclick = async () => {
53
54
  const body = {};
54
- for (const key of ['tz', 'public_url', 'webhook_url']) { const el = box.querySelector(`[name="${key}"]`); if (el && !el.disabled) body[key] = el.value.trim(); }
55
+ for (const key of ['tz', 'public_url', 'webhook_url', 'idle_hours']) { const el = box.querySelector(`[name="${key}"]`); if (el && !el.disabled) body[key] = el.value.trim(); }
55
56
  const sec = box.querySelector('[name="webhook_secret"]'); if (sec && sec.value.trim()) body.webhook_secret = sec.value.trim();
56
57
  if (body.tz && !/^[+-]?\d{1,2}(:?\d{2})?$/.test(body.tz)) { err('zona horaria: usá -03:00 o +0200'); return; }
58
+ if (body.idle_hours && !/^[0-9]+([.][0-9]+)?$/.test(body.idle_hours)) { err('inactividad: horas (0 = nunca)'); return; }
57
59
  err('guardando…');
58
- const r2 = await api('/api/settings/values', body);
60
+ const r2 = await api(BASE + '/api/settings/values', body);
59
61
  if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
60
62
  Object.assign(v, r2.data.values || {}); err('✓ guardado'); add('meta', '⚙ configuración guardada'); loadSched(); loadApprovals();
61
63
  };
@@ -67,7 +69,7 @@ async function openCfg(section, onboarding) {
67
69
  f('model').value = setupSel === cfg.provider ? (cfg.model || p.model || '') : (p.model || ''); f('model').placeholder = p.model || '';
68
70
  // modelos válidos según la API del proveedor (evita tipear "DeepSeek-V4-Pro" cuando la API quiere "deepseek-v4-pro")
69
71
  const dl = box.querySelector('#modelList'); dl.innerHTML = ''; f('model').title = '';
70
- if (p.has_key || setupSel === 'ollama') fetch('/api/models?provider=' + encodeURIComponent(setupSel)).then(r => r.json()).then(d => { if (setupSel !== p.name) return; if (d.error) { f('model').title = 'no pude listar modelos: ' + d.error; return; } dl.innerHTML = (d.models || []).map(m => `<option value="${esc(m)}">`).join(''); f('model').title = (d.models || []).length ? 'modelos disponibles: ' + d.models.join(', ') : ''; }).catch(() => {});
72
+ if (p.has_key || setupSel === 'ollama') fetch(BASE + '/api/models?provider=' + encodeURIComponent(setupSel)).then(r => r.json()).then(d => { if (setupSel !== p.name) return; if (d.error) { f('model').title = 'no pude listar modelos: ' + d.error; return; } dl.innerHTML = (d.models || []).map(m => `<option value="${esc(m)}">`).join(''); f('model').title = (d.models || []).length ? 'modelos disponibles: ' + d.models.join(', ') : ''; }).catch(() => {});
71
73
  f('key').value = ''; f('key').disabled = setupSel === 'ollama';
72
74
  f('key').placeholder = setupSel === 'ollama' ? 'ollama no necesita key' : (p.has_key ? 'hay una key guardada · pegá otra para reemplazarla' : 'pegala acá');
73
75
  };
@@ -80,7 +82,7 @@ async function openCfg(section, onboarding) {
80
82
  if (setupSel !== 'ollama' && !p.has_key && !body.key) { err('falta la API key'); return; }
81
83
  saveP.disabled = true;
82
84
  try {
83
- const r2 = await api('/api/settings', body);
85
+ const r2 = await api(BASE + '/api/settings', body);
84
86
  if (!r2.ok) { err(r2.data.error || ('error ' + r2.status)); return; }
85
87
  const d = r2.data; cfg.providers = d.providers; cfg.provider = d.provider; cfg.model = d.model; cfg.vision = d.vision; cfg.configured = d.configured; paintModel(); paintAttach();
86
88
  if (onboarding) { Panel.close(); if (log.querySelector('.empty')) empty(); } else { err('✓ guardado'); Panel.detail(); }
package/public/js/core.js CHANGED
@@ -1,5 +1,8 @@
1
1
  // core.js — estado compartido y helpers que usan todos los módulos (se carga primero)
2
2
  const $ = s => document.querySelector(s);
3
+ // detrás del hub la UI vive en /w/<slug>/ y su API en /w/<slug>/api/…; standalone (sin hub) BASE = ''
4
+ const BASE = (location.pathname.match(/^\/w\/[^/]+/) || [''])[0];
5
+ const WS_SLUG = BASE ? BASE.slice(3) : '';
3
6
  const log = $('#log');
4
7
  let session = new URLSearchParams(location.search).get('session') || localStorage.getItem('lampson.session') || '';
5
8
  let busy = false; let cfg = {};
@@ -4,7 +4,7 @@
4
4
  // recargar por timer; los timers quedan como respaldo lento por si el stream se cae (reconexión con backoff).
5
5
  let events = null, evBackoff = 1000;
6
6
  function connectEvents() {
7
- try { events = new EventSource('/api/events'); } catch (e) { events = null; return; }
7
+ try { events = new EventSource(BASE + '/api/events'); } catch (e) { events = null; return; }
8
8
  events.onopen = () => { evBackoff = 1000; };
9
9
  events.addEventListener('event', e => {
10
10
  let ev; try { ev = JSON.parse(e.data); } catch { return; }
@@ -0,0 +1,40 @@
1
+ // hub.js — la pantalla de workspaces (public/hub.html): tarjetas + el mismo Panel de workspaces que usa la UI de un
2
+ // workspace (js/workspaces.js) para crear/encender/apagar. Sin core.js: acá no hay chat.
3
+ const $ = s => document.querySelector(s);
4
+ const BASE = '', WS_SLUG = '';
5
+ function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
6
+ async function api(path, body) { const r = await fetch(path, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); let data = {}; try { data = await r.json(); } catch (e) {} return { ok: r.ok, status: r.status, data }; }
7
+ function fmtWhen(ts) { if (!ts) return '—'; return new Date(ts * 1000).toLocaleString(undefined, { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
8
+ function inlineConfirm(el, label, onYes) {
9
+ const prev = el.innerHTML; el.classList.add('ask');
10
+ el.innerHTML = `<span class="q">${esc(label)}</span> <span class="yes">sí</span> · <span class="no">no</span>`;
11
+ const restore = () => { el.innerHTML = prev; el.classList.remove('ask'); };
12
+ el.querySelector('.yes').onclick = (ev) => { ev.stopPropagation(); onYes(); };
13
+ el.querySelector('.no').onclick = (ev) => { ev.stopPropagation(); restore(); };
14
+ setTimeout(() => { if (el.classList.contains('ask')) restore(); }, 6000);
15
+ }
16
+ // tema
17
+ const SUN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.7v2.1M12 19.2v2.1M2.7 12h2.1M19.2 12h2.1M5.3 5.3l1.5 1.5M17.2 17.2l1.5 1.5M18.7 5.3l-1.5 1.5M6.8 17.2l-1.5 1.5"/></svg>';
18
+ const MOON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20.2 14.6A8.6 8.6 0 0 1 9.4 3.8a8.6 8.6 0 1 0 10.8 10.8z"/></svg>';
19
+ function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; }
20
+ $('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); };
21
+ paintTheme();
22
+ // tarjetas
23
+ async function paintGrid() {
24
+ const grid = $('#wsGrid'); let r; try { r = await (await fetch(BASE + '/api/workspaces')).json(); } catch (e) { grid.innerHTML = '<div class="card"><p>el hub no responde</p></div>'; return; }
25
+ wsList = r.workspaces || []; wsIdle = r.idle_hours;
26
+ grid.innerHTML = '';
27
+ for (const w of wsList) {
28
+ const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ''); c.href = w.url;
29
+ c.innerHTML = `<h3>${w.alive ? '● vivo' : '○ apagado'}${w.schedules_on ? ' · ⏰ ' + w.schedules_on : ''}</h3><p class="nm">${esc(w.name)}</p><p class="pth">${esc(w.path)}</p><p class="meta">último uso ${esc(fmtWhen(w.last_used))}</p>`;
30
+ grid.appendChild(c);
31
+ }
32
+ const add = document.createElement('a'); add.className = 'card ws new'; add.href = '#'; add.innerHTML = '<h3>+</h3><p class="nm">nuevo workspace</p><p class="pth">elegí una carpeta de tu disco</p>';
33
+ add.onclick = (e) => { e.preventDefault(); openWorkspaces('__new'); };
34
+ grid.appendChild(add);
35
+ const all = document.createElement('a'); all.className = 'card ws'; all.href = '#'; all.innerHTML = '<h3>⌕</h3><p class="nm">gestionar</p><p class="pth">encender, apagar, política de vida, quitar</p>';
36
+ all.onclick = (e) => { e.preventDefault(); openWorkspaces(); };
37
+ grid.appendChild(all);
38
+ }
39
+ paintGrid();
40
+ setInterval(paintGrid, 10000);
@@ -2,7 +2,7 @@
2
2
  // switch, tools con formulario generado del schema, correr, borrar) y la ayuda «?».
3
3
  let lampsData = [], lampsMeta = { global: 'lamps/', project: '.lampson/lamps/' };
4
4
  async function fetchLamps() {
5
- let r; try { r = await (await fetch('/api/lamps')).json(); } catch (e) { return lampsData; }
5
+ let r; try { r = await (await fetch(BASE + '/api/lamps')).json(); } catch (e) { return lampsData; }
6
6
  lampsData = r.lamps || []; lampsMeta = { global: r.global || 'lamps/', project: r.project || '.lampson/lamps/' };
7
7
  const on = lampsData.filter(l => l.enabled).length;
8
8
  const pill = $('#lamps'); pill.textContent = lampsData.length ? `lámparas ${on}/${lampsData.length}` : 'lámparas'; pill.classList.toggle('on', on > 0);
@@ -81,7 +81,7 @@ function openLamps(selectName) {
81
81
  const errEl = box.querySelector('[data-err]');
82
82
  box.querySelector('.sw input').onchange = async (ev) => {
83
83
  errEl.textContent = '';
84
- const r = await api('/api/lamps/toggle', { name: l.name, enabled: ev.target.checked });
84
+ const r = await api(BASE + '/api/lamps/toggle', { name: l.name, enabled: ev.target.checked });
85
85
  if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); ev.target.checked = !ev.target.checked; return; }
86
86
  add('meta', '☼ ' + esc(r.data.result || '')); loadLamps(); loadSched();
87
87
  };
@@ -92,7 +92,7 @@ function openLamps(selectName) {
92
92
  const st = tdiv.querySelector('.st'), out = tdiv.querySelector('.out'); const got = lampArgs(tdiv, props, required);
93
93
  if (got.error) { st.textContent = got.error; return; }
94
94
  st.textContent = 'corriendo…'; out.style.display = 'none'; b.disabled = true;
95
- const r = await api('/api/lamps/run', { tool: 'lamp_' + l.name + '_' + t.name, args: got.args });
95
+ const r = await api(BASE + '/api/lamps/run', { tool: 'lamp_' + l.name + '_' + t.name, args: got.args });
96
96
  st.textContent = r.ok ? '' : (r.data.error || 'error ' + r.status);
97
97
  if (r.ok) { out.textContent = r.data.output; out.style.display = ''; }
98
98
  b.disabled = false;
@@ -100,7 +100,7 @@ function openLamps(selectName) {
100
100
  }
101
101
  const del = box.querySelector('.dfoot .del');
102
102
  if (del) del.onclick = () => inlineConfirm(del, `¿borrar ${l.name} del disco?`, async () => {
103
- const r = await api('/api/lamps/remove', { name: l.name });
103
+ const r = await api(BASE + '/api/lamps/remove', { name: l.name });
104
104
  if (!r.ok) { errEl.textContent = r.data.error || ('error ' + r.status); return; }
105
105
  add('meta', '☼ ' + esc(r.data.result || '')); loadLamps();
106
106
  });
package/public/js/lsp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // lsp.js — language servers: sección del panel lateral + vista browse del Panel («+ agregar» = fila con el formulario)
2
2
  let lspServers = [], lspPresets = {}, lspGlobalFile = '.lampson/lsp.json';
3
3
  async function fetchLsp() {
4
- let r; try { r = await (await fetch('/api/lsp')).json(); } catch (e) { return lspServers; }
4
+ let r; try { r = await (await fetch(BASE + '/api/lsp')).json(); } catch (e) { return lspServers; }
5
5
  lspServers = r.servers || []; lspPresets = r.presets || {}; lspGlobalFile = r.global || lspGlobalFile;
6
6
  return lspServers;
7
7
  }
@@ -53,14 +53,14 @@ function openLsp(selectName) {
53
53
  if (!body.name || !body.command || !Object.keys(body.languages).length) { err('falta nombre, comando o extensiones (.ext=languageId)'); return; }
54
54
  }
55
55
  err('guardando…');
56
- const r = await api('/api/lsp/add', body);
56
+ const r = await api(BASE + '/api/lsp/add', body);
57
57
  if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
58
58
  add('meta', '⌁ ' + esc(r.data.result || 'server agregado')); await loadLsp(); Panel.selectKey(body.name);
59
59
  };
60
60
  return;
61
61
  }
62
62
  const del = box.querySelector('.dfoot .del');
63
- del.onclick = () => inlineConfirm(del, `¿quitar ${s.name}?`, async () => { const r = await api('/api/lsp/remove', { name: s.name }); if (!r.ok) { err(r.data.error || 'error'); return; } add('meta', '⌁ ' + esc(r.data.result || '')); loadLsp(); });
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
64
  }
65
65
  }
66
66
  });
package/public/js/mcp.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // derecha; «+ conectar» es una fila más cuyo detalle es el formulario)
3
3
  let mcpServers = [], mcpFiles = { global: '.lampson/mcp.json', project: '' };
4
4
  async function fetchMcp() {
5
- let r; try { r = await (await fetch('/api/mcp')).json(); } catch (e) { return mcpServers; }
5
+ let r; try { r = await (await fetch(BASE + '/api/mcp')).json(); } catch (e) { return mcpServers; }
6
6
  mcpServers = r.servers || []; mcpFiles = { global: r.global || '.lampson/mcp.json', project: r.project || '' };
7
7
  return mcpServers;
8
8
  }
@@ -50,7 +50,7 @@ function openMcp(selectName) {
50
50
  const env = {};
51
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
52
  err('conectando…');
53
- const r = await api('/api/mcp/add', { name, command, env, scope: f('scope').value });
53
+ const r = await api(BASE + '/api/mcp/add', { name, command, env, scope: f('scope').value });
54
54
  if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
55
55
  add('meta', '⌁ ' + esc(r.data.result || 'server conectado')); await loadMcp(); Panel.selectKey(name);
56
56
  };
@@ -58,7 +58,7 @@ function openMcp(selectName) {
58
58
  return;
59
59
  }
60
60
  const del = box.querySelector('.dfoot .del');
61
- del.onclick = () => inlineConfirm(del, `¿quitar ${s.name}?`, async () => { const r = await api('/api/mcp/remove', { name: s.name }); if (!r.ok) { err(r.data.error || 'error'); return; } add('meta', '⌁ ' + esc(r.data.result || '')); loadMcp(); });
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
62
  }
63
63
  }
64
64
  });
@@ -1,7 +1,7 @@
1
1
  // memory.js — notas que el agente guarda sobre este proyecto (tool memory)
2
2
  let memOpen = null;
3
3
  async function loadMemory() {
4
- let r; try { r = await (await fetch('/api/memory')).json(); } catch (e) { return; }
4
+ let r; try { r = await (await fetch(BASE + '/api/memory')).json(); } catch (e) { return; }
5
5
  const box = $('#memory'); box.innerHTML = '';
6
6
  const notes = r.notes || [];
7
7
  $('#memCount').textContent = notes.length || ''; autoSec('memory', notes.length > 0);
@@ -10,7 +10,7 @@ async function loadMemory() {
10
10
  }
11
11
  async function openMemory(name) {
12
12
  memOpen = name; procOpen = null; clearInterval(procTimer);
13
- const r = await (await fetch('/api/memory/note?name=' + encodeURIComponent(name))).json();
13
+ const r = await (await fetch(BASE + '/api/memory/note?name=' + encodeURIComponent(name))).json();
14
14
  if (r.error) { add('denied', esc(r.error)); return; }
15
15
  showText('memoria · ' + name + '.md', 'notas del agente sobre este proyecto (editables a mano en lampson/memory/)', r.content || '(vacía)', false);
16
16
  loadMemory();
@@ -1,7 +1,7 @@
1
1
  // procs.js — procesos gestionados por el agente (tool process) y puertos en escucha de la máquina
2
2
  let procOpen = null, procTimer = null;
3
3
  async function loadPorts() {
4
- let r; try { r = await (await fetch('/api/ports')).json(); } catch (e) { return; }
4
+ let r; try { r = await (await fetch(BASE + '/api/ports')).json(); } catch (e) { return; }
5
5
  const box = $('#ports'); box.innerHTML = '';
6
6
  const list = (r.ports || []).filter(p => p.port !== 8080 || p.name !== 'synsema.exe');
7
7
  $('#portCount').textContent = list.length || ''; autoSec('ports', list.length > 0);
@@ -10,13 +10,13 @@ async function loadPorts() {
10
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
11
  const what = p.command ? p.command.replace(/^"?[A-Za-z]:\\[^"]*\\([^"\\]+)"?/, '$1') : p.name;
12
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('/api/ports/kill', { pid: p.pid }); loadPorts(); }); setTimeout(() => { if (!holder.classList.contains('ask')) loadPorts(); }, 6100); };
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
14
  box.appendChild(d);
15
15
  }
16
16
  }
17
17
  async function loadProcs() {
18
18
  loadPorts();
19
- let r; try { r = await (await fetch('/api/proc')).json(); } catch (e) { return; }
19
+ let r; try { r = await (await fetch(BASE + '/api/proc')).json(); } catch (e) { return; }
20
20
  const box = $('#procs'); box.innerHTML = '';
21
21
  const list = r.processes || []; const act = list.filter(p => p.running).length;
22
22
  $('#procCount').textContent = list.length ? (act ? `${act} ● · ${list.length}` : `${list.length}`) : '';
@@ -26,20 +26,20 @@ async function loadProcs() {
26
26
  const d = document.createElement('div'); d.className = 'p' + (p.running ? ' run' : '') + (procOpen === p.name ? ' active' : ''); d.title = p.command;
27
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
28
  d.onclick = () => openProc(p.name);
29
- const b = d.querySelector('button'); if (b) b.onclick = async (ev) => { ev.stopPropagation(); await api('/api/proc/stop', { name: p.name }); loadProcs(); if (procOpen === p.name) 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
30
  box.appendChild(d);
31
31
  }
32
32
  }
33
33
  async function openProc(name) {
34
34
  procOpen = name; document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active'));
35
- const r = await (await fetch('/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
35
+ const r = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
36
36
  showText('proceso ' + name, r.running ? '● corriendo · log en vivo' : '○ terminado', r.log || '(sin salida todavía)', true);
37
37
  clearInterval(procTimer); if (r.running && !events) procTimer = setInterval(() => refreshProcViewer(name), 2000); // sin stream de eventos: respaldo por timer
38
38
  loadProcs();
39
39
  }
40
40
  async function refreshProcViewer(name) {
41
41
  if (procOpen !== name || $('#viewer').style.display === 'none') { clearInterval(procTimer); return; }
42
- const x = await (await fetch('/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
42
+ const x = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
43
43
  $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
44
44
  if (!x.running) { clearInterval(procTimer); loadProcs(); }
45
45
  }
@@ -4,7 +4,7 @@
4
4
  // terminal, tick cada 30 s) o con `lampson --daemon start` con todo cerrado.
5
5
  let schedData = [], schedLamps = [], schedTick = 30, schedMountOk = true, schedLiveTimer = null;
6
6
  async function fetchSched() {
7
- let r; try { r = await (await fetch('/api/schedules')).json(); } catch (e) { return schedData; }
7
+ let r; try { r = await (await fetch(BASE + '/api/schedules')).json(); } catch (e) { return schedData; }
8
8
  schedData = r.tasks || []; schedLamps = r.lamps || []; schedTick = r.tick || 30; schedMountOk = r.mount_ok !== false;
9
9
  return schedData;
10
10
  }
@@ -47,11 +47,11 @@ function openSched(selectId) {
47
47
  emptyHtml: 'nada coincide',
48
48
  detail: async (t) => {
49
49
  if (t === SCHED_NEW) return schedForm();
50
- let r; try { r = await (await fetch('/api/schedules/log?id=' + encodeURIComponent(t.id) + '&tail=300')).json(); } catch (e) { r = {}; }
50
+ let r; try { r = await (await fetch(BASE + '/api/schedules/log?id=' + encodeURIComponent(t.id) + '&tail=300')).json(); } catch (e) { r = {}; }
51
51
  // r.task = el registro crudo (plan como objeto, session, history fresco); t = el resumen (plan y horarios en texto)
52
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
53
  let live = '';
54
- if (task.running && task.session) { try { const tr = await (await fetch('/api/trace?session=' + encodeURIComponent(task.session) + '&tail=80')).json(); live = tr.trace || '(arrancando…)'; } catch (e) { live = '(arrancando…)'; } }
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
55
  else if (task.running) live = task.action.type === 'prompt' ? 'arrancando el agente…' : 'corriendo ' + task.action.type + '…';
56
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
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>
@@ -66,11 +66,11 @@ function openSched(selectId) {
66
66
  wire: (t, box) => {
67
67
  const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
68
68
  if (t === SCHED_NEW) { schedFormWire(box, err); return; }
69
- box.querySelector('[data-run]').onclick = async () => { const r = await api('/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('/api/schedules/toggle', { id: t.id, enabled: !t.enabled }); if (!r.ok) { err(r.data.error || 'error'); return; } loadSched(); };
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
71
  box.querySelectorAll('[data-session]').forEach(a => a.onclick = (e) => { e.preventDefault(); Panel.close(); open(a.dataset.session); });
72
72
  const del = box.querySelector('.dfoot .del');
73
- del.onclick = () => inlineConfirm(del, `¿quitar ${t.name}?`, async () => { await api('/api/schedules/remove', { id: t.id }); loadSched(); });
73
+ del.onclick = () => inlineConfirm(del, `¿quitar ${t.name}?`, async () => { await api(BASE + '/api/schedules/remove', { id: t.id }); loadSched(); });
74
74
  const live = box.querySelector('[data-live]'); if (live) live.scrollTop = live.scrollHeight;
75
75
  clearTimeout(schedLiveTimer); if (t.running) schedLiveTimer = setTimeout(() => { if (Panel.is('schedules')) Panel.refresh(); }, 3000);
76
76
  }
@@ -110,7 +110,7 @@ function schedFormWire(box, err) {
110
110
  const body = { name: f('name').value.trim(), when: f('when').value.trim(), action, permission: f('permission').value, notify: f('notify').value.trim() };
111
111
  if (!body.when) { err('falta cuándo'); return; }
112
112
  err('programando…');
113
- const r = await api('/api/schedules/add', body);
113
+ const r = await api(BASE + '/api/schedules/add', body);
114
114
  if (!r.ok) { err(r.data.error || ('error ' + r.status)); return; }
115
115
  add('meta', '⏰ ' + esc(r.data.result || 'programada')); setSec('sched', true); await loadSched(); if (r.data.task) Panel.selectKey(r.data.task.id);
116
116
  };
@@ -1,9 +1,9 @@
1
1
  // sessions.js — sesiones: las últimas 4 en el panel, «ver todas» abre el Panel (browse) con búsqueda en
2
2
  // títulos y contenido (GET /api/sessions/search?q=) y una previsualización de la conversación; traza.
3
3
  function forgetSession(id) { if (id === session) { session = ''; localStorage.removeItem('lampson.session'); log.innerHTML = ''; showPane('log'); empty(); } }
4
- async function deleteSession(id) { await api('/api/sessions/delete', { id }); forgetSession(id); loadSessions(); }
4
+ async function deleteSession(id) { await api(BASE + '/api/sessions/delete', { id }); forgetSession(id); loadSessions(); }
5
5
  async function loadSessions() {
6
- let r; try { r = await (await fetch('/api/sessions')).json(); } catch (e) { return; }
6
+ let r; try { r = await (await fetch(BASE + '/api/sessions')).json(); } catch (e) { return; }
7
7
  const box = $('#sessions'); box.innerHTML = '';
8
8
  $('#sessCount').textContent = r.sessions.length || '';
9
9
  if (!r.sessions.length) { box.innerHTML = '<div class="s none">todavía ninguna</div>'; return; }
@@ -23,10 +23,10 @@ function openSessionsPanel() {
23
23
  browse: {
24
24
  placeholder: 'buscar en títulos y en lo que se habló… (↑↓ · Enter abre)', listWidth: '360px', nodot: true,
25
25
  key: s => s.id,
26
- load: async (q) => (await (await fetch('/api/sessions/search?q=' + encodeURIComponent(q) + '&limit=300')).json()).sessions || [],
26
+ load: async (q) => (await (await fetch(BASE + '/api/sessions/search?q=' + encodeURIComponent(q) + '&limit=300')).json()).sessions || [],
27
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
28
  detail: async (s, q) => {
29
- const d = await (await fetch('/api/sessions/' + encodeURIComponent(s.id))).json();
29
+ const d = await (await fetch(BASE + '/api/sessions/' + encodeURIComponent(s.id))).json();
30
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
31
  const steps = (d.messages || []).filter(m => m.role === 'tool').length;
32
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>`
@@ -48,7 +48,7 @@ function openSessionsPanel() {
48
48
  }
49
49
  async function open(id) {
50
50
  session = id; localStorage.setItem('lampson.session', id); log.innerHTML = ''; log.classList.remove('hero'); showPane('log'); loadTodo();
51
- const r = await fetch('/api/sessions/' + id); if (!r.ok) { session = ''; empty(); return loadSessions(); }
51
+ const r = await fetch(BASE + '/api/sessions/' + id); if (!r.ok) { session = ''; empty(); return loadSessions(); }
52
52
  const d = await r.json();
53
53
  const queue = []; // pasos sin resultado todavía, en orden (varias tool calls por turno)
54
54
  for (const m of d.messages) {
@@ -64,6 +64,6 @@ $('#new').onclick = () => { session = ''; localStorage.removeItem('lampson.sessi
64
64
  // traza legible de una sesión (.lampson/trace/<sid>.log)
65
65
  async function openTrace(id) {
66
66
  procOpen = null; agentOpen = null;
67
- let r; try { r = await (await fetch('/api/trace?session=' + encodeURIComponent(id) + '&tail=600')).json(); } catch (e) { return; }
67
+ let r; try { r = await (await fetch(BASE + '/api/trace?session=' + encodeURIComponent(id) + '&tail=600')).json(); } catch (e) { return; }
68
68
  showText('traza ' + id, r.file || '', r.trace || '(sin traza todavía: se escribe a medida que el agente trabaja)', true);
69
69
  }
@@ -29,7 +29,7 @@ function openTerm() {
29
29
  }
30
30
  });
31
31
  $('#tmeta').textContent = 'conectando…';
32
- termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/term'); termWs.binaryType = 'arraybuffer';
32
+ termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + '/api/term'); termWs.binaryType = 'arraybuffer';
33
33
  const send = (o) => { if (termWs && termWs.readyState === 1) termWs.send(JSON.stringify(o)); };
34
34
  termWs.onopen = () => { $('#termpane').classList.add('live'); send({ type: 'resize', cols: term.cols, rows: term.rows }); term.focus(); };
35
35
  termWs.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
package/public/js/todo.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // todo.js — lista de tareas del agente en la sesión actual (tool todo)
2
2
  async function loadTodo() {
3
- let r; try { r = await (await fetch('/api/todo' + (session ? '?session=' + encodeURIComponent(session) : ''))).json(); } catch (e) { return; }
3
+ let r; try { r = await (await fetch(BASE + '/api/todo' + (session ? '?session=' + encodeURIComponent(session) : ''))).json(); } catch (e) { return; }
4
4
  const box = $('#todoBox'); box.innerHTML = '';
5
5
  const items = r.items || []; const open = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length;
6
6
  $('#todoCount').textContent = items.length ? (open ? `${open} · ${items.length}` : `${items.length}`) : ''; autoSec('todo', open > 0);