lampson 0.2.3 → 0.2.5

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.
@@ -1,6 +1,20 @@
1
- // terminal.js — terminal real: xterm.js ↔ WebSocket /api/term ↔ pty en el servidor
2
- // Frames binarios = bytes del pty; texto JSON = control (hello / exit). Cerrar el panel cierra el socket y mata el shell.
3
- let term = null, termWs = null, termFit = null;
1
+ // terminal.js — terminales reales: xterm.js ↔ WebSocket /api/term ↔ un pty por terminal en el servidor.
2
+ // Frames de texto: "o" + salida del pty · "c" + JSON de control (hello / exit). Conviven varias como
3
+ // pestañas (cada una con su shell); minimizar esconde el panel con todas vivas y el botón del encabezado
4
+ // lo trae de vuelta; ✕ mata la shell de la activa (la última cierra el panel).
5
+ // La shell NO vive en el socket sino en un agente del servidor (lib/term.syn): recargar la página no la
6
+ // mata — al cargar pedimos /api/terms y nos reenganchamos por id, con replay de lo último que imprimió.
7
+ const MAX_TERMS = 4;
8
+ let terms = []; // [{id, el, term, ws, fit, ro, box, meta, live}]
9
+ let termAt = -1; // índice de la activa en terms
10
+ // qué estaba abierto antes del F5 (el panel y la pestaña activa; las shells las sabe el servidor)
11
+ function saveTermUi() {
12
+ try {
13
+ localStorage.setItem('lampson.term.open', termShown() ? '1' : '0');
14
+ localStorage.setItem('lampson.term.at', String(termAt));
15
+ localStorage.setItem('lampson.term.max', $('#termpane').classList.contains('max') ? '1' : '0');
16
+ } catch (e) {}
17
+ }
4
18
  function termTheme() {
5
19
  const s = getComputedStyle(document.documentElement); const v = n => s.getPropertyValue(n).trim();
6
20
  return { background: v('--paper'), foreground: v('--ink'), cursor: v('--accent'), cursorAccent: v('--paper'), selectionBackground: v('--sel'),
@@ -8,11 +22,56 @@ function termTheme() {
8
22
  yellow: v('--amber'), brightYellow: v('--amber'), blue: v('--term-blue'), brightBlue: v('--accent'), magenta: v('--rubric'), brightMagenta: v('--rubric'),
9
23
  cyan: v('--accent'), brightCyan: v('--accent'), white: v('--ink-2'), brightWhite: v('--ink') };
10
24
  }
11
- function openTerm() {
12
- if (term) { showPane('term'); termFit.fit(); term.focus(); return; }
13
- showPane('term'); procOpen = null; clearInterval(procTimer);
14
- term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
15
- termFit = new FitAddon.FitAddon(); term.loadAddon(termFit); term.open($('#xterm')); termFit.fit();
25
+ // ojo: el display inicial lo pone el CSS, no el style inline — mirando sólo el inline, «está abierto»
26
+ // daba true antes del primer showPane() y la restauración se creía abierta
27
+ const termShown = () => getComputedStyle($('#termpane')).display !== 'none';
28
+ // Un solo lugar donde se ajusta el tamaño. fit() hace clear() + resize() del terminal y dispara un
29
+ // resize del pty (el shell repinta entero), así que sólo se llama cuando la caja cambió DE VERDAD:
30
+ // sin esa guarda, el ResizeObserver se realimenta con su propio ajuste y el terminal parpadea.
31
+ function fitTerm(t) {
32
+ if (!t || !t.fit || !termShown()) return;
33
+ const box = t.el.clientWidth + 'x' + t.el.clientHeight;
34
+ if (!t.el.clientWidth || !t.el.clientHeight || box === t.box) return;
35
+ t.box = box;
36
+ requestAnimationFrame(() => { if (t.term && t.fit) try { t.fit.fit(); } catch (e) {} }); // fuera del callback del observer
37
+ }
38
+ // pestañas: una por terminal (siempre visibles, también con una sola: así se ve que hay numeración)
39
+ function paintTabs() {
40
+ const tabs = $('#ttabs'); tabs.innerHTML = '';
41
+ terms.forEach((t, i) => {
42
+ const b = document.createElement('span');
43
+ b.className = 'ttab' + (i === termAt ? ' on' : '') + (t.live ? '' : ' dead');
44
+ b.textContent = String(i + 1);
45
+ b.title = 'terminal ' + (i + 1) + (t.meta ? ' · ' + t.meta : '') + (t.live ? '' : ' (shell terminada)');
46
+ b.onclick = () => activateTerm(i); tabs.appendChild(b);
47
+ });
48
+ const add = $('#tnew'); add.disabled = terms.length >= MAX_TERMS;
49
+ add.title = add.disabled ? 'máximo ' + MAX_TERMS + ' terminales abiertas' : 'abrir otra terminal: una shell nueva en el workspace';
50
+ const t = terms[termAt];
51
+ $('#tmeta').textContent = t ? t.meta : '';
52
+ $('#termpane').classList.toggle('live', !!(t && t.live));
53
+ $('#term').classList.toggle('live', terms.some(x => x.live)); // el botón del encabezado: hay shell viva
54
+ }
55
+ function activateTerm(i) {
56
+ termAt = i;
57
+ terms.forEach((t, k) => { t.el.style.display = k === i ? '' : 'none'; });
58
+ paintTabs(); saveTermUi();
59
+ const t = terms[i];
60
+ if (!t) return;
61
+ t.box = ''; fitTerm(t); t.term.refresh(0, t.term.rows - 1); t.term.focus();
62
+ }
63
+ // id = reengancharse a una shell que ya existe (tras un F5); show = false para reconstruir sin abrir el panel
64
+ function newTerm(id, show) {
65
+ if (terms.length >= MAX_TERMS) return;
66
+ if (show !== false) { showPane('term'); procOpen = null; clearInterval(procTimer); }
67
+ const el = document.createElement('div'); el.className = 'xt'; $('#xterms').appendChild(el);
68
+ const term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
69
+ const fit = new FitAddon.FitAddon(); term.loadAddon(fit);
70
+ const t = { id: id || '', el, term, fit, ws: null, ro: null, box: '', meta: 'conectando…', live: false };
71
+ terms.push(t);
72
+ if (show !== false) termAt = terms.length - 1;
73
+ terms.forEach((x, k) => { x.el.style.display = k === termAt ? '' : 'none'; });
74
+ term.open(el); fitTerm(t);
16
75
  // URLs clickeables (npm run dev imprime http://localhost:3000): link provider mínimo con la API nativa
17
76
  // de xterm v5 — el addon web-links no está vendorizado y no hace falta para http/https
18
77
  const TERM_URL_RE = /https?:\/\/[^\s"'`<>()\[\]{}]*[^\s"'`<>()\[\]{}.,;:!?]/g;
@@ -28,22 +87,98 @@ function openTerm() {
28
87
  cb(links.length ? links : undefined);
29
88
  }
30
89
  });
31
- $('#tmeta').textContent = 'conectando…';
32
- termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + '/api/term'); termWs.binaryType = 'arraybuffer';
33
- const send = (o) => { if (termWs && termWs.readyState === 1) termWs.send(JSON.stringify(o)); };
34
- termWs.onopen = () => { $('#termpane').classList.add('live'); send({ type: 'resize', cols: term.cols, rows: term.rows }); term.focus(); };
35
- termWs.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
90
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + '/api/term' + (id ? '?id=' + encodeURIComponent(id) : ''));
91
+ ws.binaryType = 'arraybuffer'; t.ws = ws;
92
+ const send = (o) => { if (ws.readyState === 1) ws.send(JSON.stringify(o)); };
93
+ t.send = send;
94
+ ws.onopen = () => { t.live = true; paintTabs(); send({ type: 'resize', cols: term.cols, rows: term.rows }); if (termAt === terms.indexOf(t)) term.focus(); };
95
+ ws.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
36
96
  const s = String(e.data);
37
97
  if (s[0] === 'o') { term.write(s.slice(1)); return; }
38
98
  let m; try { m = JSON.parse(s.slice(1)); } catch { return; }
39
- if (m.type === 'hello') $('#tmeta').textContent = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`;
40
- if (m.type === 'exit') { term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`); $('#termpane').classList.remove('live'); }
99
+ if (m.type === 'hello') { t.id = m.id || t.id; t.meta = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`; paintTabs(); }
100
+ if (m.type === 'exit') {
101
+ t.live = false; paintTabs();
102
+ if (m.error === 'too_many') { term.write(`\r\n\x1b[2m[ya hay ${MAX_TERMS} terminales abiertas — cerrá una para abrir otra]\x1b[0m\r\n`); return; }
103
+ term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`);
104
+ }
105
+ };
106
+ // el socket se corta pero la shell sigue viva en el servidor: se recupera recargando (o al volver a entrar)
107
+ ws.onclose = () => {
108
+ t.live = false;
109
+ if (t.meta === 'conectando…') t.meta = 'sin conexión';
110
+ else if (!t.killed) term.write('\r\n\x1b[2m[conexión perdida · la shell sigue viva: recargá la página para reengancharte]\x1b[0m\r\n');
111
+ paintTabs();
41
112
  };
42
- termWs.onclose = () => { $('#termpane').classList.remove('live'); if ($('#tmeta').textContent === 'conectando…') $('#tmeta').textContent = 'sin conexión'; };
43
113
  term.onData(d => send({ type: 'in', data: d }));
44
114
  term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows }));
45
- new ResizeObserver(() => { if ($('#termpane').style.display !== 'none') termFit.fit(); }).observe($('#xterm'));
115
+ t.ro = new ResizeObserver(() => fitTerm(t)); t.ro.observe(el);
116
+ paintTabs(); saveTermUi();
117
+ }
118
+ // el botón del encabezado: abre la primera, trae de vuelta el panel minimizado, o lo esconde si está a la vista.
119
+ // Espera a saber qué shells sobrevivieron (termsReady): sin eso, un clic apurado abriría una shell de más.
120
+ function openTerm() {
121
+ termsReady.then(() => {
122
+ if (!terms.length) { newTerm(); return; }
123
+ if (termShown()) { minTerm(); return; }
124
+ showPane('term'); procOpen = null; clearInterval(procTimer);
125
+ activateTerm(termAt < 0 || termAt >= terms.length ? terms.length - 1 : termAt);
126
+ saveTermUi();
127
+ });
128
+ }
129
+ // minimizar: el panel se esconde, las shells siguen vivas (el botón del encabezado queda verde y lo devuelve)
130
+ function minTerm() { maxTerm(false); showPane('log'); saveTermUi(); }
131
+ // pantalla completa: el panel tapa la ventana entera; se vuelve con el mismo botón (❐).
132
+ function maxTerm(on) {
133
+ const pane = $('#termpane');
134
+ const max = on === undefined ? !pane.classList.contains('max') : on;
135
+ pane.classList.toggle('max', max);
136
+ $('#tmax').innerHTML = max ? '&#10064;' : '&#9633;';
137
+ $('#tmax').title = max ? 'volver al tamaño normal' : 'pantalla completa';
138
+ saveTermUi();
139
+ const t = terms[termAt];
140
+ if (t) { t.box = ''; fitTerm(t); t.term.focus(); }
141
+ }
142
+ // repintar las terminales abiertas al cambiar de tema (lo llama theme.js; ojo: «term» a secas es el BOTÓN
143
+ // del encabezado — los id del HTML son globales — así que el repintado tiene que pasar por acá)
144
+ function termsRetheme() { const th = termTheme(); terms.forEach(t => { t.term.options.theme = th; }); }
145
+ // cerrar: sólo la terminal activa. Hay que PEDIR la muerte de la shell ({type:"kill"}): irse del socket
146
+ // ya no la mata (ese es el precio, y la gracia, de que sobreviva a un F5). La última cierra el panel.
147
+ function closeTerm() {
148
+ const t = terms[termAt];
149
+ if (!t) { showPane('log'); return; }
150
+ t.killed = true;
151
+ if (t.ro) t.ro.disconnect();
152
+ if (t.ws) try { t.send({ type: 'kill' }); t.ws.close(); } catch (e) {}
153
+ t.term.dispose(); t.el.remove();
154
+ terms.splice(termAt, 1);
155
+ if (!terms.length) { termAt = -1; paintTabs(); maxTerm(false); showPane('log'); saveTermUi(); return; }
156
+ activateTerm(Math.min(termAt, terms.length - 1));
157
+ }
158
+ // al cargar: las shells que sobrevivieron al F5 vuelven como pestañas (el panel sólo si estaba abierto)
159
+ async function restoreTerms() {
160
+ let data; try { data = await (await fetch(BASE + '/api/terms')).json(); } catch (e) { return; }
161
+ const live = (data && data.terminals) || [];
162
+ if (!live.length) return;
163
+ // leer ANTES de crear nada: cada newTerm guarda el estado y pisaría lo que dejó la sesión anterior
164
+ let open = false, at = 0, max = false;
165
+ try {
166
+ open = localStorage.getItem('lampson.term.open') === '1';
167
+ at = +(localStorage.getItem('lampson.term.at') || 0);
168
+ max = localStorage.getItem('lampson.term.max') === '1';
169
+ } catch (e) {}
170
+ const yaAbrio = terms.length > 0; // alguien abrió una mientras preguntábamos: no le movemos la vista
171
+ for (const info of live) newTerm(info.id, false);
172
+ if (yaAbrio) { paintTabs(); return; }
173
+ termAt = Math.max(0, Math.min(at, terms.length - 1));
174
+ if (open) { showPane('term'); procOpen = null; clearInterval(procTimer); if (max) maxTerm(true); activateTerm(termAt); }
175
+ else { terms.forEach((t, k) => { t.el.style.display = k === termAt ? '' : 'none'; }); paintTabs(); }
46
176
  }
47
- function closeTerm() { if (termWs) { try { termWs.close(); } catch (e) {} } if (term) term.dispose(); term = null; termWs = null; termFit = null; $('#xterm').innerHTML = ''; $('#termpane').classList.remove('live'); showPane('log'); }
177
+ const termsReady = restoreTerms(); // se lanza al cargar; openTerm la espera
48
178
  $('#term').onclick = openTerm;
179
+ $('#tnew').onclick = newTerm;
180
+ $('#tmin').onclick = minTerm;
181
+ $('#tmax').onclick = () => maxTerm();
49
182
  $('#tclose').onclick = closeTerm;
183
+ // Sin atajo para salir de pantalla completa: Esc es del shell (vim, menús) y robársela rompería el terminal.
184
+ // El botón ❐ queda a la vista en la cabecera, que es lo único que se dibuja fuera del área del terminal.
@@ -2,5 +2,5 @@
2
2
  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>';
3
3
  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>';
4
4
  function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; $('#theme').title = light ? 'Cambiar a tema oscuro' : 'Cambiar a tema claro'; }
5
- $('#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(); if (typeof term !== 'undefined' && term) term.options.theme = termTheme(); };
5
+ $('#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(); if (typeof termsRetheme === 'function') termsRetheme(); };
6
6
  paintTheme();
@@ -4,8 +4,9 @@ let wsList = [], wsIdle = 4;
4
4
  // abierto por el puerto de un proceso (:808N) las URLs de otros workspaces tienen que ir al hub (:8080), no a este proceso
5
5
  const HUB_BASE = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080' : '';
6
6
  function wsUrl(w) { return HUB_BASE + w.url; }
7
- async function fetchWorkspaces() {
8
- try { const r = await (await fetch('/api/workspaces')).json(); wsList = r.workspaces || []; wsIdle = r.idle_hours; return true; } catch (e) { wsList = []; return false; }
7
+ // fresh=true: saltear la caché del hub (después de encender/apagar)
8
+ async function fetchWorkspaces(fresh) {
9
+ try { const r = await (await fetch('/api/workspaces' + (fresh ? '?fresh=1' : ''))).json(); wsList = r.workspaces || []; wsIdle = r.idle_hours; return true; } catch (e) { wsList = []; return false; }
9
10
  }
10
11
  async function paintWorkspacePill() {
11
12
  const pill = $('#wsPill'); if (!pill) return;
@@ -22,12 +23,12 @@ function openWorkspaces(selectSlug) {
22
23
  browse: {
23
24
  placeholder: 'buscar workspace…', listWidth: '300px', key: w => w.slug,
24
25
  load: async (q) => { await fetchWorkspaces(); const list = wsList.filter(w => !q || (w.name + ' ' + w.path).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, WS_NEW]; },
25
- render: (w) => w === WS_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">nuevo workspace…</div><div class="meta">elegí una carpeta</div></div>` : `<span class="dot">${w.alive ? '●' : '○'}</span><div><div class="nm">${esc(w.name)}${w.slug === WS_SLUG ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(w.path)}</div></div>`,
26
+ render: (w) => w === WS_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">nuevo workspace…</div><div class="meta">elegí una carpeta</div></div>` : `<span class="dot ${w.alive ? 'on' : 'off'}">${w.alive ? '●' : '○'}</span><div><div class="nm">${esc(w.name)}${w.slug === WS_SLUG ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(w.path)}</div></div>`,
26
27
  count: (rows) => { const n = rows.filter(r => r !== WS_NEW).length, a = rows.filter(r => r !== WS_NEW && r.alive).length; return `${n} workspace${n === 1 ? '' : 's'} · ${a} vivo${a === 1 ? '' : 's'}`; },
27
28
  emptyHtml: 'nada coincide', emptyDetail: 'Todavía no hay workspaces. Creá uno con «nuevo workspace…» o abrí <code>lampson</code> en la carpeta de un proyecto.',
28
- detail: (w) => w === WS_NEW ? wsNewForm() : `<div class="dhead"><span class="nm">${esc(w.name)}</span><span class="meta">${w.alive ? '● vivo' : '○ apagado'} · ${esc(w.policy)}</span></div>
29
+ detail: (w) => w === WS_NEW ? wsNewForm() : `<div class="dhead"><span class="nm">${esc(w.name)}</span><span class="meta"><b class="st ${w.alive ? 'on' : 'off'}">${w.alive ? '● vivo' : (w.paused ? '○ apagado a mano' : '○ apagado')}</b> · ${esc(w.policy)}</span></div>
29
30
  <div class="dcap">${esc(w.path)}</div>
30
- <div class="ddesc">${w.schedules_on ? `${w.schedules_on} tarea${w.schedules_on === 1 ? '' : 's'} programada${w.schedules_on === 1 ? '' : 's'} encendida${w.schedules_on === 1 ? '' : 's'} (lo mantienen vivo). ` : ''}Último uso: ${esc(fmtWhen(w.last_used))}.</div>
31
+ <div class="ddesc">${w.schedules_on ? `${w.schedules_on} tarea${w.schedules_on === 1 ? '' : 's'} programada${w.schedules_on === 1 ? '' : 's'} encendida${w.schedules_on === 1 ? '' : 's'} ${w.alive ? '(lo mantienen vivo)' : (w.paused ? '— en pausa hasta que lo enciendas' : '')}. ` : ''}${!w.alive && w.paused ? 'Lo apagaste a mano: no se enciende solo hasta ▶ encender o abrir <code>lampson</code> en la carpeta. ' : ''}Último uso: ${esc(fmtWhen(w.last_used))}.</div>
31
32
  <div class="dacts"><button class="primary" data-open>${w.slug === WS_SLUG ? 'Ya estás acá' : 'Abrir'}</button>${w.alive ? '<button data-stop>■ apagar</button>' : '<button data-start>▶ encender</button>'}</div>
32
33
  <div class="dform" style="margin-top:14px"><label>vida <select name="policy"><option value="auto" ${w.policy === 'auto' ? 'selected' : ''}>auto — vivo mientras se use (${wsIdle === 0 ? 'nunca se apaga' : 'se apaga tras ' + wsIdle + ' h sin uso'}) o tenga tareas</option><option value="always" ${w.policy === 'always' ? 'selected' : ''}>siempre vivo</option><option value="off" ${w.policy === 'off' ? 'selected' : ''}>apagado — solo cuando lo abrís</option></select></label><span class="ds">las horas de inactividad se cambian en ⚙ → General</span></div>
33
34
  <div class="dfoot"><span>/w/${esc(w.slug)} · :${w.port} (solo loopback)</span><span class="del">quitar del registro</span></div><div class="derr"></div>`,
@@ -35,8 +36,11 @@ function openWorkspaces(selectSlug) {
35
36
  const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
36
37
  if (w === WS_NEW) { wsNewWire(box, err); return; }
37
38
  box.querySelector('[data-open]').onclick = () => { if (w.slug !== WS_SLUG) location.href = wsUrl(w); else Panel.close(); };
38
- const st = box.querySelector('[data-start]'); if (st) st.onclick = async () => { err('arrancando…'); const r = await api('/api/workspaces/start', { slug: w.slug }); err(r.data.ok ? '' : 'no respondió a tiempo'); Panel.refresh(); };
39
- const sp = box.querySelector('[data-stop]'); if (sp) sp.onclick = async () => { await api('/api/workspaces/stop', { slug: w.slug }); setTimeout(() => Panel.refresh(), 800); };
39
+ // esperar a que el hub vea el estado nuevo (matar/arrancar un proceso tarda unos segundos): sin esto el refresco
40
+ // a los 0,8 s todavía mostraba «vivo» y el botón parecía no hacer nada
41
+ const waitFor = async (alive, secs) => { const t0 = Date.now(); while (Date.now() - t0 < secs * 1000) { await fetchWorkspaces(true); const x = wsList.find(y => y.slug === w.slug); if (x && x.alive === alive) return true; await new Promise(r => setTimeout(r, 700)); } return false; };
42
+ const st = box.querySelector('[data-start]'); if (st) st.onclick = async () => { st.disabled = true; st.textContent = '⟳ encendiendo…'; err(''); const r = await api('/api/workspaces/start', { slug: w.slug }); const ok = r.data.ok || await waitFor(true, 6); err(ok ? '' : 'no respondió a tiempo · mirá .lampson/ws/' + w.slug + '/.lampson/web.log'); Panel.refresh(); };
43
+ const sp = box.querySelector('[data-stop]'); if (sp) sp.onclick = async () => { sp.disabled = true; sp.textContent = '⟳ apagando…'; err(''); await api('/api/workspaces/stop', { slug: w.slug }); const ok = await waitFor(false, 10); if (ok && w.slug === WS_SLUG) { location.href = HUB_BASE + '/'; return; } err(ok ? '' : 'sigue respondiendo · probá de nuevo'); Panel.refresh(); };
40
44
  box.querySelector('[name="policy"]').onchange = async (ev) => { const r = await api('/api/workspaces/policy', { slug: w.slug, policy: ev.target.value }); if (!r.ok) err(r.data.error || 'error'); };
41
45
  const del = box.querySelector('.dfoot .del');
42
46
  del.onclick = () => inlineConfirm(del, `¿quitar ${w.name} del registro?`, async () => { const r = await api('/api/workspaces/remove', { slug: w.slug }); if (!r.ok) { err(r.data.error || 'error'); return; } if (w.slug === WS_SLUG) location.href = HUB_BASE + '/'; else Panel.refresh(); });
@@ -3,7 +3,7 @@ name: synsema
3
3
  description: Writing, checking, running and testing Synsema (.syn) code — syntax reflexes, capabilities, live processes / pseudo-terminals, and the runtime traps that cost hours. Load before touching any .syn file.
4
4
  ---
5
5
 
6
- # Synsema quick reference (v0.6.12)
6
+ # Synsema quick reference (v0.6.13)
7
7
 
8
8
  > Curated 10 KB summary for the agent (the full reference is ~450 KB and lives in the user's editor
9
9
  > skill). Kept in sync by hand with each `synsema update`; if `synsema --version` is newer than the
@@ -14,6 +14,13 @@ description: Writing, checking, running and testing Synsema (.syn) code — synt
14
14
  `synsema test file.syn` (runs `test "..."` blocks) · `synsema serve file.syn` (HTTP server) ·
15
15
  `synsema update` (self-update; then refresh the AI skill with the command it prints).
16
16
  - Errors carry `file:line` and a suggestion. Read them; they are usually right.
17
+ - **Read the repo without opening files (v0.6.13+)**: `synsema code outline` (project map: intent,
18
+ symbols, imports per file), `routes [path]` (the table each `serve` publishes: method, path, auth,
19
+ stream/socket/proxy, response kind, capabilities), `refs <name>` (every use, through module aliases),
20
+ `symbol <name>`, `caps` (declared vs. effective vs. `missing`, with the `require` to add), `check`,
21
+ `search <text>`, `deps`. Add `--json` for scripts. Same eight tools over MCP: `synsema code --mcp`
22
+ (server `synsema-code`, static — it never talks to a running server). `outline` before opening a
23
+ `.syn`, `refs` before renaming, `check` after every edit.
17
24
 
18
25
  ## Syntax reflexes (Python → Synsema)
19
26
  - `let x be 5` / `set x to 6` (no `=`) · `-- comment` · `when / otherwise when / otherwise` (no colons)
package/web.syn CHANGED
@@ -64,6 +64,7 @@ use "./lib/schedule.syn" as schedule
64
64
  use "./lib/sched_run.syn" as sched_run
65
65
  use "./lib/approvals.syn" as approvals
66
66
  use "./lib/permission.syn" as permission
67
+ use "./lib/term.syn" as term
67
68
 
68
69
  let APPROVAL_TIMEOUT be 180
69
70
 
@@ -86,25 +87,10 @@ task as_text(x)
86
87
  give decode(x, "utf8_lossy")
87
88
  give x
88
89
 
89
- -- shell para el terminal web: [cmd, args]. Windows: pwsh si está, si no PowerShell 5; unix: bash de login.
90
- -- En pwsh, los directorios de `ls` salen sin fondo azul (el default de $PSStyle se lee mal en un tema oscuro).
91
- task term_shell()
92
- when env("OS", "") == "Windows_NT"
93
- try
94
- run("pwsh", ["-NoLogo", "-Command", "exit"], 10)
95
- give ["pwsh", ["-NoLogo", "-NoExit", "-Command", "$PSStyle.FileInfo.Directory = $PSStyle.Foreground.BrightBlue"]]
96
- recover err
97
- give ["powershell", ["-NoLogo"]]
98
- give ["bash", ["-l"]]
99
-
100
- -- cwd del terminal: la ruta REAL del proyecto (LAMPSON_WORKSPACE), no la junction ./workspace —
101
- -- así el prompt muestra dónde estás de verdad y coincide con el header.
102
- task term_cwd()
103
- give env("LAMPSON_WORKSPACE", "workspace")
104
-
105
90
  task boot(profile, mode, ask_fn, pname, model)
106
91
  let cfg be provider.config_for(pname, model)
107
- let p be when contains(agents.PROFILES, profile) then profile otherwise "build"
92
+ -- worker es interno de delegate: por la API tampoco se elige como perfil de sesión
93
+ let p be when contains(agents.names(), profile) then profile otherwise "build"
108
94
  let opts be loop.default_opts(agents.registry_for(p), agents.catalog_for(p), ask_fn)
109
95
  set opts to loop.with_steps(opts, agents.steps_for(p))
110
96
  set opts to loop.with_inbox(opts, agents.parent_inbox, "")
@@ -209,39 +195,61 @@ serve on 8080
209
195
  route "GET /w/:slug/api/tree" requires auth
210
196
  give tree.tree(6)
211
197
 
212
- -- Terminal real en el navegador: un shell dentro de un pseudo-terminal (pty) por conexión WebSocket.
213
- -- Navegador servidor: texto JSON {type: "in", data: teclas} | {type: "resize", cols, rows}.
214
- -- Servidor navegador: frames de texto con prefijo: "o" + salida cruda del pty (ANSI incluido, xterm.js
215
- -- la dibuja) | "c" + JSON de control ({type: hello|exit}).
216
- -- El shell vive lo que vive el socket: cerrar el panel lo mata (el runtime no deja huérfanos).
198
+ -- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
199
+ -- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
200
+ -- la pestaña se reengancha con ?id=<id> y recibe el replay de lo último que imprimió.
201
+ -- Navegador servidor: {type: "in", data} | {type: "resize", cols, rows} | {type: "kill"}.
202
+ -- Servidor navegador: "o" + salida cruda del pty | "c" + JSON de control (hello | exit).
203
+ -- Sin ningún socket enganchado por 30 min, el supervisor recoge la shell; todo muere con lampson.
217
204
  route "GET /w/:slug/api/term" requires auth
218
205
  socket
219
206
  state_set("lampson:last_used", now())
220
- let sh be term_shell()
221
- let p be proc_spawn(sh[0], sh[1], {"cwd": term_cwd(), "pty": true, "cols": 120, "rows": 32})
222
- ws_send(socket, "c" + json_encode({"type": "hello", "shell": sh[0], "pid": proc_stats(p)["pid"], "cwd": term_cwd()}))
223
- let open be true
224
- while open
225
- let ev be select({"ui": socket, "sh": p}, 600)
226
- when ev == nothing
227
- set open to proc_status(p) == "running"
228
- otherwise when ev["name"] == "ui"
229
- when ev["type"] == "close"
230
- set open to false
231
- otherwise when ev["type"] == "binary"
232
- proc_send(p, ev["data"])
207
+ let id be when contains(query, "id") then text(query.id) otherwise ""
208
+ when id == "" or not term.alive(id)
209
+ set id to term.start(120, 32) -- nothing = ya hay MAX_TERMS abiertas
210
+ when id == nothing
211
+ ws_send(socket, "c" + json_encode({"type": "exit", "code": -1, "error": "too_many"}))
212
+ otherwise
213
+ let st be term.state(id)
214
+ ws_send(socket, "c" + json_encode({"type": "hello", "id": id, "shell": st["shell"], "pid": st["pid"], "cwd": st["cwd"]}))
215
+ let sub be bus_subscribe("term.out." + id)
216
+ -- el replay vuelve marcado con este id de socket: otra pestaña abierta no lo repite
217
+ let me be text(floor(now() * 1000)) + "-" + text(floor(random() * 100000))
218
+ term.ctl(id, {"k": "replay", "to": me})
219
+ while true
220
+ let ev be select({"ui": socket, "sh": sub}, 20)
221
+ when ev == nothing
222
+ when not term.alive(id)
223
+ stop
224
+ term.ctl(id, {"k": "ping"}) -- «sigo acá»: sin pings se recoge la shell
225
+ otherwise when ev["name"] == "ui"
226
+ when ev["type"] == "close"
227
+ stop
228
+ otherwise when ev["type"] == "binary"
229
+ term.ctl(id, {"k": "in", "d": as_text(ev["data"])})
230
+ otherwise
231
+ let m be json_decode(ev["data"])
232
+ when m["type"] == "in"
233
+ term.ctl(id, {"k": "in", "d": m["data"]})
234
+ otherwise when m["type"] == "resize"
235
+ term.ctl(id, {"k": "resize", "cols": m["cols"], "rows": m["rows"]})
236
+ otherwise when m["type"] == "kill"
237
+ term.kill(id) -- cerrar la pestaña SÍ mata la shell, a pedido
233
238
  otherwise
234
- let m be json_decode(ev["data"])
235
- when m["type"] == "in"
236
- proc_send(p, m["data"])
237
- otherwise when m["type"] == "resize"
238
- proc_resize(p, floor(m["cols"]), floor(m["rows"]))
239
- otherwise when ev["type"] == "exit"
240
- ws_send(socket, "c" + json_encode({"type": "exit", "code": ev["data"]["exit_code"]}))
241
- set open to false
242
- otherwise
243
- ws_send(socket, "o" + as_text(ev["data"]))
244
- proc_close(p)
239
+ let d be ev["data"]
240
+ when d["k"] == "exit"
241
+ ws_send(socket, "c" + json_encode({"type": "exit", "code": d["code"]}))
242
+ stop
243
+ otherwise when d["k"] == "replay"
244
+ when d["to"] == me and d["d"] != ""
245
+ ws_send(socket, "o" + d["d"])
246
+ otherwise
247
+ ws_send(socket, "o" + d["d"])
248
+ bus_unsubscribe(sub)
249
+
250
+ -- las terminales vivas: la UI las repinta como pestañas al cargar (después de un F5 siguen ahí)
251
+ route "GET /w/:slug/api/terms" requires auth
252
+ give {"terminals": term.list(), "max": term.MAX_TERMS}
245
253
 
246
254
  route "GET /w/:slug/api/update" requires auth
247
255
  give update.check()