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.
- package/.env.example +1 -1
- package/README.md +58 -437
- package/chat.syn +177 -70
- package/hub.tpl.syn +18 -4
- package/lib/line.syn +17 -6
- package/lib/provider.syn +7 -2
- package/lib/term.syn +162 -0
- package/lib/workspaces.syn +70 -6
- package/package.json +1 -1
- package/public/css/chat.css +7 -0
- package/public/css/hub.css +4 -0
- package/public/css/layout.css +36 -6
- package/public/css/panel.css +4 -0
- package/public/css/tokens.css +2 -2
- package/public/favicon.svg +13 -0
- package/public/hub.html +1 -0
- package/public/index.html +3 -2
- package/public/js/events.js +31 -3
- package/public/js/hub.js +9 -1
- package/public/js/panel.js +2 -0
- package/public/js/terminal.js +153 -18
- package/public/js/theme.js +1 -1
- package/public/js/workspaces.js +11 -7
- package/skills/synsema/SKILL.md +8 -1
- package/web.syn +54 -46
package/public/js/terminal.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
// terminal.js —
|
|
2
|
-
// Frames
|
|
3
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const send = (o) => { if (
|
|
34
|
-
|
|
35
|
-
|
|
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')
|
|
40
|
-
if (m.type === 'exit') {
|
|
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(() =>
|
|
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 ? '❐' : '□';
|
|
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
|
-
|
|
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.
|
package/public/js/theme.js
CHANGED
|
@@ -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
|
|
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();
|
package/public/js/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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(); });
|
package/skills/synsema/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
--
|
|
213
|
-
--
|
|
214
|
-
--
|
|
215
|
-
--
|
|
216
|
-
--
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
235
|
-
when
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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()
|