lampson 0.2.8 → 0.2.9

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.
Files changed (78) hide show
  1. package/.env.example +10 -1
  2. package/README.md +8 -3
  3. package/bin/lampson.js +1 -1
  4. package/chat.syn +111 -7
  5. package/cli.syn +143 -7
  6. package/edge.syn +26 -0
  7. package/hub.tpl.syn +127 -6
  8. package/lampson.ps1 +43 -2
  9. package/lampson.sh +40 -2
  10. package/lib/agents.syn +27 -6
  11. package/lib/approvals.syn +2 -2
  12. package/lib/auth.syn +274 -0
  13. package/lib/computer.syn +2000 -0
  14. package/lib/computer_judge.syn +410 -0
  15. package/lib/fs.syn +19 -3
  16. package/lib/git.syn +2 -2
  17. package/lib/judge.syn +78 -0
  18. package/lib/line.syn +2 -2
  19. package/lib/loop.syn +36 -4
  20. package/lib/lsp.syn +4 -4
  21. package/lib/mcp.syn +20 -4
  22. package/lib/md.syn +11 -11
  23. package/lib/origin.syn +67 -0
  24. package/lib/permission.syn +21 -2
  25. package/lib/prompt.syn +9 -1
  26. package/lib/provider.syn +70 -20
  27. package/lib/schedule.syn +12 -12
  28. package/lib/session.syn +5 -5
  29. package/lib/settings.syn +65 -1
  30. package/lib/skills.syn +22 -6
  31. package/lib/tools/bash.syn +1 -1
  32. package/lib/tools/common.syn +1 -1
  33. package/lib/tools/fetch.syn +98 -71
  34. package/lib/tools/grep.syn +1 -1
  35. package/lib/tools/memo.syn +3 -3
  36. package/lib/tools/proc.syn +38 -20
  37. package/lib/tools/qr.js +26 -0
  38. package/lib/tools/url.syn +5 -5
  39. package/lib/tools.syn +23 -2
  40. package/lib/trace.syn +2 -2
  41. package/lib/tree.syn +29 -13
  42. package/lib/update.syn +1 -1
  43. package/lib/workspaces.syn +202 -36
  44. package/package.json +3 -2
  45. package/public/apple-touch-icon.png +0 -0
  46. package/public/badge-96.png +0 -0
  47. package/public/css/code.css +48 -0
  48. package/public/css/hub.css +9 -0
  49. package/public/css/login.css +16 -0
  50. package/public/css/panel.css +30 -0
  51. package/public/css/tokens.css +1 -1
  52. package/public/hub.html +11 -1
  53. package/public/icon-192.png +0 -0
  54. package/public/icon-512.png +0 -0
  55. package/public/icon-maskable-512.png +0 -0
  56. package/public/icons-src/badge.svg +12 -0
  57. package/public/icons-src/icon-maskable.svg +15 -0
  58. package/public/icons-src/icon.svg +13 -0
  59. package/public/index.html +14 -3
  60. package/public/js/agents.js +1 -1
  61. package/public/js/chat.js +1 -1
  62. package/public/js/config.js +145 -2
  63. package/public/js/core.js +4 -2
  64. package/public/js/devices.js +80 -0
  65. package/public/js/editor.js +232 -0
  66. package/public/js/login.js +78 -0
  67. package/public/js/procs.js +1 -1
  68. package/public/js/pwa.js +5 -0
  69. package/public/js/tree.js +12 -4
  70. package/public/login.html +43 -0
  71. package/public/manifest.webmanifest +16 -0
  72. package/public/offline.html +24 -0
  73. package/public/sw.js +63 -0
  74. package/public/vendor/highlight.min.js +1343 -0
  75. package/public/vendor/qrcode.js +2297 -0
  76. package/skills/computer-use/SKILL.md +178 -0
  77. package/skills/lampson/SKILL.md +5 -0
  78. package/web.syn +67 -6
@@ -0,0 +1,232 @@
1
+ // editor.js — editor de archivos del visor: un textarea transparente (caret, selección, teclado nativo) sobre un
2
+ // <pre> con el mismo texto resaltado por highlight.js (vendor/highlight.min.js = build "common" + powershell +
3
+ // dockerfile; Synsema se registra acá, traducido de la gramática TextMate oficial del repo kitecosmic/synsema,
4
+ // editors/vscode/syntaxes/synsema.tmLanguage.json). El gutter con los números va aparte y sigue el scroll.
5
+ //
6
+ // Guardar: Ctrl+S o el botón → POST /api/fs {op:"write"} (lib/fs.syn: solo archivos que existen, dentro del
7
+ // workspace). Antes de guardar se relee el archivo: si cambió en disco (el agente lo editó mientras tanto) se
8
+ // avisa y se elige sobrescribir o recargar, nunca se pisa a ciegas. Cambiar de archivo con cambios sin guardar
9
+ // no los pierde: quedan como borrador en memoria y vuelven al reabrirlo (se pierden al recargar la página, con aviso).
10
+ // Solo lectura: archivos truncados por la API (> 200k) y binarios — guardar un recorte destruiría el archivo.
11
+
12
+ const LANG_BY_EXT = {
13
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
14
+ css: 'css', scss: 'scss', less: 'less', html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', svelte: 'xml', xsl: 'xml', plist: 'xml',
15
+ json: 'json', jsonc: 'json', md: 'markdown', markdown: 'markdown', py: 'python', rs: 'rust', go: 'go',
16
+ sh: 'bash', bash: 'bash', zsh: 'bash', env: 'bash', ps1: 'powershell', psm1: 'powershell', psd1: 'powershell',
17
+ yml: 'yaml', yaml: 'yaml', sql: 'sql', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp', cs: 'csharp',
18
+ java: 'java', kt: 'kotlin', kts: 'kotlin', swift: 'swift', rb: 'ruby', php: 'php', lua: 'lua', r: 'r', pl: 'perl',
19
+ ini: 'ini', toml: 'ini', cfg: 'ini', conf: 'ini', properties: 'ini', diff: 'diff', patch: 'diff', graphql: 'graphql', gql: 'graphql',
20
+ wasm: 'wasm', wat: 'wasm', syn: 'synsema', fsyn: 'synsema', txt: 'plaintext', log: 'plaintext', csv: 'plaintext', lock: 'plaintext',
21
+ prisma: 'prisma', tsbuildinfo: 'json', map: 'json', webmanifest: 'json', jsonl: 'json', har: 'json', mdx: 'markdown', proto: 'protobuf',
22
+ bat: 'dos', cmd: 'dos', psql: 'sql', pgsql: 'sql', mysql: 'sql', m: 'objectivec', csproj: 'xml', xaml: 'xml', astro: 'xml', ejs: 'xml', hbs: 'xml',
23
+ // nombres de fence habituales en el chat que no son extensiones
24
+ javascript: 'javascript', typescript: 'typescript', python: 'python', rust: 'rust', shell: 'bash', powershell: 'powershell',
25
+ synsema: 'synsema', html5: 'xml', dockerfile: 'dockerfile', makefile: 'makefile', text: 'plaintext', plaintext: 'plaintext'
26
+ };
27
+ const LANG_BY_NAME = { dockerfile: 'dockerfile', makefile: 'makefile', gnumakefile: 'makefile', '.env': 'bash', '.env.example': 'bash', '.gitignore': 'plaintext', '.gitattributes': 'plaintext', '.npmrc': 'ini', '.editorconfig': 'ini', '.nvmrc': 'plaintext', '.babelrc': 'json', '.eslintrc': 'json', '.prettierrc': 'json', '.swcrc': 'json', 'nginx.conf': 'nginx', procfile: 'plaintext' };
28
+ function langOf(path) {
29
+ const base = String(path || '').split('/').pop().toLowerCase();
30
+ if (LANG_BY_NAME[base]) return LANG_BY_NAME[base];
31
+ if (base.startsWith('.env')) return 'bash'; // .env.local, .env.production…
32
+ if (base.startsWith('dockerfile')) return 'dockerfile'; // Dockerfile.dev
33
+ const i = base.lastIndexOf('.'); const ext = i >= 0 ? base.slice(i + 1) : '';
34
+ return LANG_BY_EXT[ext] || 'plaintext';
35
+ }
36
+ // código en un bloque ``` del chat (core.js md()): resaltado si el fence trae un lenguaje conocido
37
+ function hlCode(code, lang) {
38
+ if (typeof hljs === 'undefined') return esc(code);
39
+ const l = String(lang || '').toLowerCase(); const name = LANG_BY_EXT[l] || (l && hljs.getLanguage(l) ? l : '');
40
+ if (!name || name === 'plaintext' || code.length > 60000) return esc(code);
41
+ try { return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; } catch (e) { return esc(code); }
42
+ }
43
+
44
+ // ---- Synsema para highlight.js (misma partición que el tmLanguage: control, tipos, seguridad, agentes, LLM,
45
+ // humano, observabilidad, serve, módulos; builtins solo cuando se llaman; task/type/agent + nombre = definición) ----
46
+ function synsemaGrammar(hljs) {
47
+ const KEYWORDS = [
48
+ 'when', 'otherwise', 'each', 'while', 'repeat', 'match', 'is', 'try', 'recover', 'give', 'stop', 'then', 'and_then', 'return',
49
+ 'let', 'be', 'set', 'to', 'task', 'type', 'agent', 'state',
50
+ 'and', 'or', 'not', 'in', 'of', 'as', 'with',
51
+ 'spawn', 'share', 'observe', 'signal', 'wait_for',
52
+ 'analyze', 'decide', 'reason', 'generate',
53
+ 'approve', 'confirm', 'ask', 'show',
54
+ 'trace', 'log', 'measure', 'checkpoint',
55
+ 'serve', 'on', 'route', 'auth', 'errors', 'requires', 'expect', 'max_body', 'max_streams', 'stream', 'send', 'static', 'cache', 'fallback', 'cors', 'describe', 'private', 'from', 'domain', 'tls', 'redirect', 'proxy', 'host', 'paged', 'per', 'rate_limit', 'unlimited', 'none', 'mount', 'at',
56
+ 'use', 'export', 'routes', 'enum'
57
+ ];
58
+ const SECURITY = ['require', 'allow', 'deny', 'sandbox', 'intent', 'invariant', 'verify'];
59
+ const BUILTINS = ['print', 'text', 'length', 'append', 'range', 'contains', 'split', 'join', 'type_of', 'matches', 'find_all', 'capture', 'replace_re', 'fetch', 'read_file', 'write_file', 'http', 'http_get', 'http_post', 'sql', 'sql_exec', 'sql_tables', 'db_open', 'db_close', 'format_time', 'parse_time', 'date_parts', 'now', 'sleep', 'apply', 'where', 'reduce', 'collect', 'transform', 'sort_by', 'group_by', 'every', 'some', 'flatten', 'parallel_map', 'chunk', 'remember', 'recall', 'forget_memory', 'add_rule', 'check_rules', 'get_rules', 'memory_summary', 'create_progress', 'start_step', 'complete_step', 'fail_step', 'resume_point', 'progress_display', 'progress_percent', 'ok', 'created', 'not_found', 'fail', 'html', 'respond', 'render', 'page', 'heading', 'prose', 'list', 'ordered_list', 'link', 'image', 'section', 'code', 'raw', 'content'];
60
+ const NUMBER = { scope: 'number', match: /\b[0-9][0-9_]*(\.[0-9][0-9_]*)?([eE][+-]?[0-9]+)?\b/ };
61
+ const CALL = { scope: 'built_in', match: new RegExp('\\b(' + BUILTINS.join('|') + ')(?=\\s*\\()') };
62
+ // {expr} dentro de un string: termina en }, pero también ante la comilla que cierra el string o al final de la
63
+ // línea, así una llave sin cerrar no arrastra el color
64
+ const subst = (quote) => ({ scope: 'subst', begin: /\{/, end: new RegExp('\\}|(?=' + quote + ')|$'), keywords: { keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] }, contains: [NUMBER, CALL] });
65
+ return {
66
+ name: 'Synsema', aliases: ['syn', 'fsyn'],
67
+ keywords: { $pattern: /[A-Za-z_][A-Za-z0-9_]*/, keyword: KEYWORDS, literal: ['true', 'false', 'nothing'] },
68
+ contains: [
69
+ hljs.COMMENT('--', '$'),
70
+ // los strings no cruzan líneas: end también en $ (hljs compila con la bandera m). Sin esto una comilla o un
71
+ // backtick sin cerrar pintaban el resto del archivo como string (visto 2026-09-05 con un ` suelto en un .syn)
72
+ { scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('"')] },
73
+ { scope: 'string', begin: /`/, end: /`|$/, contains: [{ scope: 'char.escape', match: /\\./ }, subst('`')] },
74
+ { match: [/\b(task|type|agent)\b/, /\s+/, /[A-Za-z_][A-Za-z0-9_]*/], scope: { 1: 'keyword', 3: 'title.function' } },
75
+ { scope: 'keyword.security', match: new RegExp('\\b(' + SECURITY.join('|') + ')\\b') },
76
+ CALL,
77
+ NUMBER
78
+ ]
79
+ };
80
+ }
81
+ // Prisma (schema.prisma): highlight.js no lo trae. Bloques model/enum/datasource/generator, tipos escalares,
82
+ // atributos @id/@@map, env("…"), comentarios // y ///.
83
+ function prismaGrammar(hljs) {
84
+ return {
85
+ name: 'Prisma', aliases: ['prisma'],
86
+ keywords: {
87
+ keyword: ['model', 'enum', 'type', 'view', 'datasource', 'generator'],
88
+ built_in: ['String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Unsupported'],
89
+ literal: ['true', 'false', 'null']
90
+ },
91
+ contains: [
92
+ hljs.COMMENT('///', '$'), hljs.COMMENT('//', '$'),
93
+ { scope: 'string', begin: /"/, end: /"|$/, contains: [{ scope: 'char.escape', match: /\\./ }] },
94
+ { match: [/\b(model|enum|type|view|datasource|generator)\b/, /\s+/, /[A-Za-z_]\w*/], scope: { 1: 'keyword', 3: 'title.class' } },
95
+ { scope: 'attribute', match: /@@?[A-Za-z_][\w.]*/ },
96
+ { scope: 'built_in', match: /\benv(?=\()/ },
97
+ { scope: 'number', match: /\b\d+(\.\d+)?\b/ }
98
+ ]
99
+ };
100
+ }
101
+ if (typeof hljs !== 'undefined') { hljs.registerLanguage('synsema', synsemaGrammar); hljs.registerLanguage('prisma', prismaGrammar); }
102
+
103
+ // ---- estado ----
104
+ const ED = { path: '', loaded: null, lang: 'plaintext', dirty: false, readonly: false, saving: false };
105
+ const drafts = new Map(); // path → texto sin guardar (al cambiar de archivo no se pierde nada)
106
+ const eta = $('#eta'), ehl = $('#ehl'), egut = $('#egut'), edBox = $('#editor');
107
+ const HL_MAX = 120000; // más que esto se muestra sin resaltar (hljs tardaría en cada tecla)
108
+ const HL_SYNC_MAX = 30000; // hasta acá el espejo se redibuja en el mismo tick que la tecla; después con debounce corto
109
+
110
+ function edVisible() { return !edBox.hidden && $('#viewer').style.display !== 'none' && !!ED.path; }
111
+ function edDirty() { return ED.dirty; }
112
+
113
+ // abre la respuesta de GET /api/file en el editor (tree.js openFile). Un borrador guardado gana al disco.
114
+ function edOpen(d) {
115
+ edStash();
116
+ ED.path = d.path; ED.loaded = String(d.content ?? ''); ED.lang = langOf(d.path); ED.saving = false;
117
+ const binary = /\u0000/.test(ED.loaded);
118
+ ED.readonly = !!d.truncated || binary;
119
+ const draft = drafts.get(ED.path);
120
+ eta.value = !ED.readonly && draft != null && draft !== ED.loaded ? draft : ED.loaded;
121
+ ED.dirty = eta.value !== ED.loaded;
122
+ eta.readOnly = ED.readonly;
123
+ $('#vbody').hidden = true; edBox.hidden = false; $('#viewer').classList.add('edit');
124
+ edRender(); edMeta(d.truncated ? 'archivo muy grande · solo lectura' : binary ? 'binario · solo lectura' : '');
125
+ eta.scrollTop = 0; eta.scrollLeft = 0; edSync();
126
+ if (!ED.readonly) eta.focus({ preventScroll: true });
127
+ }
128
+ function edStash() { if (!ED.path) return; if (ED.dirty) drafts.set(ED.path, eta.value); else drafts.delete(ED.path); }
129
+ // cierra el editor (✕ del visor, tras confirmar si había cambios); el visor vuelve a su <pre> de texto
130
+ function edClose(discard) {
131
+ if (discard) drafts.delete(ED.path); else edStash();
132
+ ED.path = ''; ED.loaded = null; ED.dirty = false;
133
+ edBox.hidden = true; $('#vbody').hidden = false; $('#viewer').classList.remove('edit');
134
+ }
135
+ // el visor va a mostrar texto plano (logs de procesos/agentes, showText): el editor se esconde sin perder borradores
136
+ function viewerText() { if (!edBox.hidden) edClose(false); }
137
+
138
+ function edRender() {
139
+ const src = eta.value; let html;
140
+ if (src.length > HL_MAX || ED.lang === 'plaintext' || typeof hljs === 'undefined') html = esc(src);
141
+ else { try { html = hljs.highlight(src, { language: ED.lang, ignoreIllegals: true }).value; } catch (e) { html = esc(src); } }
142
+ // el espacio final: un <pre> no pinta la última línea vacía, el textarea sí; así ambos miden lo mismo
143
+ ehl.firstElementChild.innerHTML = html + '\n ';
144
+ const n = src.split('\n').length;
145
+ if (egut.childElementCount !== n) { let g = ''; for (let i = 1; i <= n; i++) g += '<span>' + i + '</span>'; egut.innerHTML = g; }
146
+ }
147
+ function edSync() { ehl.scrollTop = eta.scrollTop; ehl.scrollLeft = eta.scrollLeft; egut.style.transform = 'translateY(' + (-eta.scrollTop) + 'px)'; }
148
+ function edMeta(note, bad) {
149
+ const n = eta.value.split('\n').length;
150
+ const parts = [ED.lang === 'plaintext' ? 'texto' : ED.lang, n + (n === 1 ? ' línea' : ' líneas')];
151
+ if (ED.dirty) parts.push('<b class="dirty">● sin guardar</b>');
152
+ if (note) parts.push(`<span class="${bad ? 'err' : 'note'}">${esc(note)}</span>`);
153
+ $('#vmeta').innerHTML = parts.join(' · ');
154
+ const b = $('#vsave'); b.hidden = ED.readonly; b.disabled = !ED.dirty || ED.saving;
155
+ }
156
+
157
+ // ---- guardar ----
158
+ async function edSave(force) {
159
+ if (!edVisible() || ED.readonly || ED.saving) return;
160
+ const content = eta.value, path = ED.path;
161
+ if (!ED.dirty && !force) return;
162
+ ED.saving = true; edMeta('guardando…');
163
+ try {
164
+ if (!force) { // ¿cambió en disco desde que lo abrimos? (el agente pudo escribirlo mientras tanto)
165
+ const r0 = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d0 = await r0.json();
166
+ if (r0.ok && String(d0.content) !== ED.loaded) { ED.saving = false; edConflict(); return; }
167
+ }
168
+ const r = await api(BASE + '/api/fs', { op: 'write', path, content });
169
+ if (!r.ok) { ED.saving = false; edMeta(r.data.error || 'no se pudo guardar', true); return; }
170
+ if (ED.path === path) { ED.loaded = content; ED.dirty = eta.value !== content; } // el usuario pudo seguir tipeando
171
+ drafts.delete(path); ED.saving = false;
172
+ edMeta('guardado'); setTimeout(() => { if (ED.path === path && !ED.dirty) edMeta(); }, 1800);
173
+ if (typeof treeChanged === 'function') treeChanged();
174
+ } catch (e) { ED.saving = false; edMeta('no se pudo guardar: ' + e.message, true); }
175
+ }
176
+ function edConflict() {
177
+ $('#vmeta').innerHTML = `<span class="warn">cambió en disco mientras lo editabas</span> · <a href="#" id="edover">sobrescribir</a> · <a href="#" id="edreload">recargar (pierde tus cambios)</a>`;
178
+ $('#edover').onclick = ev => { ev.preventDefault(); edSave(true); };
179
+ $('#edreload').onclick = ev => { ev.preventDefault(); drafts.delete(ED.path); ED.dirty = false; openFile(ED.path); };
180
+ }
181
+ // una tool del agente pudo tocar el archivo abierto (chat.js): sin cambios locales se recarga solo conservando el
182
+ // scroll; con cambios locales se avisa y el conflicto se resuelve al guardar
183
+ async function edTouched(path) {
184
+ if (!edVisible()) return;
185
+ const p = path ? String(path).replace(/\\/g, '/').replace(/^\.\//, '') : '';
186
+ if (p && p !== ED.path) return;
187
+ try {
188
+ const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(ED.path)); const d = await r.json();
189
+ if (!r.ok || String(d.content) === ED.loaded) return;
190
+ if (ED.dirty) { edMeta('el agente cambió este archivo en disco · al guardar podés sobrescribir o recargar', true); return; }
191
+ const st = eta.scrollTop, sl = eta.scrollLeft;
192
+ ED.loaded = String(d.content); eta.value = ED.loaded; ED.readonly = !!d.truncated; eta.readOnly = ED.readonly;
193
+ edRender(); edMeta('actualizado: el agente lo cambió'); eta.scrollTop = st; eta.scrollLeft = sl; edSync();
194
+ setTimeout(() => { if (!ED.dirty) edMeta(); }, 2500);
195
+ } catch (e) {}
196
+ }
197
+
198
+ // ---- teclado y eventos ----
199
+ function edInsert(t) {
200
+ if (!document.execCommand || !document.execCommand('insertText', false, t)) { // execCommand conserva el undo del navegador
201
+ eta.setRangeText(t, eta.selectionStart, eta.selectionEnd, 'end'); eta.dispatchEvent(new Event('input'));
202
+ }
203
+ }
204
+ eta.addEventListener('input', () => {
205
+ ED.dirty = eta.value !== ED.loaded;
206
+ if (eta.value.length > HL_SYNC_MAX) debounce('edhl', edRender, 40); else edRender();
207
+ edMeta(); edSync();
208
+ });
209
+ eta.addEventListener('scroll', edSync);
210
+ eta.addEventListener('keydown', ev => {
211
+ if (ED.readonly) return;
212
+ if (ev.key === 'Tab' && !ev.ctrlKey && !ev.altKey) {
213
+ ev.preventDefault();
214
+ const v = eta.value, s = eta.selectionStart, e = eta.selectionEnd;
215
+ if (s === e || !v.slice(s, e).includes('\n')) { if (!ev.shiftKey) edInsert(' '); return; } // 4 espacios: la indentación de Synsema
216
+ // varias líneas seleccionadas: indentar / desindentar el bloque
217
+ const ls = v.lastIndexOf('\n', s - 1) + 1; let le = v.indexOf('\n', e - 1); if (le < 0) le = v.length;
218
+ const lines = v.slice(ls, le).split('\n');
219
+ const out = lines.map(l => ev.shiftKey ? l.replace(/^( |\t| {1,3})/, '') : ' ' + l).join('\n');
220
+ eta.setSelectionRange(ls, le); edInsert(out); eta.setSelectionRange(ls, ls + out.length);
221
+ } else if (ev.key === 'Enter' && !ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
222
+ ev.preventDefault();
223
+ const v = eta.value, s = eta.selectionStart; const ls = v.lastIndexOf('\n', s - 1) + 1;
224
+ const ind = (v.slice(ls, s).match(/^[ \t]*/) || [''])[0];
225
+ edInsert('\n' + ind); // conserva la indentación de la línea anterior
226
+ } else if (ev.key === 'Escape') { eta.blur(); }
227
+ });
228
+ document.addEventListener('keydown', ev => {
229
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && ev.key.toLowerCase() === 's' && edVisible()) { ev.preventDefault(); edSave(); }
230
+ });
231
+ $('#vsave').onclick = () => edSave();
232
+ window.addEventListener('beforeunload', ev => { if (ED.dirty || drafts.size) { ev.preventDefault(); ev.returnValue = ''; } });
@@ -0,0 +1,78 @@
1
+ // login.js — /login (passkey de un dispositivo ya emparejado) y /pair#<código> (registrar una passkey nueva).
2
+ // Los binarios de WebAuthn viajan en base64url: el server manda challenge / user.id / ids así, y recibe la
3
+ // credencial en la forma de PublicKeyCredential.toJSON() (armada a mano: toJSON no está en todos los Safari).
4
+ const $ = s => document.querySelector(s);
5
+ const b64uToBuf = (s) => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };
6
+ const bufToB64u = (buf) => { const u = new Uint8Array(buf); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); };
7
+ function say(text, kind) { const m = $('#msg'); m.textContent = text || ''; m.className = 'msg' + (kind ? ' ' + kind : ''); }
8
+ async function post(path, body) { const r = await fetch(path, { 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 }; }
9
+ function credJSON(c) {
10
+ const r = c.response, out = { id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {}, response: { clientDataJSON: bufToB64u(r.clientDataJSON) } };
11
+ if (c.authenticatorAttachment) out.authenticatorAttachment = c.authenticatorAttachment;
12
+ if (r.attestationObject) { out.response.attestationObject = bufToB64u(r.attestationObject); if (r.getTransports) out.response.transports = r.getTransports(); }
13
+ if (r.authenticatorData) out.response.authenticatorData = bufToB64u(r.authenticatorData);
14
+ if (r.signature) out.response.signature = bufToB64u(r.signature);
15
+ if (r.userHandle) out.response.userHandle = bufToB64u(r.userHandle);
16
+ return out;
17
+ }
18
+ function why(e) {
19
+ if (!e) return 'algo falló';
20
+ if (e.name === 'NotAllowedError') return 'cancelado o sin respuesta — volvé a intentar';
21
+ if (e.name === 'InvalidStateError') return 'este dispositivo ya tiene una passkey de lampson para este dominio — entrá desde /login';
22
+ if (e.name === 'SecurityError') return 'el navegador rechazó el dominio: las passkeys necesitan https (o localhost)';
23
+ return (e.name ? e.name + ': ' : '') + (e.message || String(e));
24
+ }
25
+ function guessName() {
26
+ const ua = navigator.userAgent;
27
+ const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'dispositivo';
28
+ const br = /EdgA?\//.test(ua) ? 'Edge' : /SamsungBrowser/.test(ua) ? 'Samsung' : /CriOS|Chrome\//.test(ua) ? 'Chrome' : /FxiOS|Firefox\//.test(ua) ? 'Firefox' : /Safari\//.test(ua) ? 'Safari' : '';
29
+ return br ? os + ' · ' + br : os;
30
+ }
31
+ const next = () => { const n = new URLSearchParams(location.search).get('next'); return n && n.startsWith('/') && !n.startsWith('//') ? n : '/'; };
32
+ const supported = () => window.isSecureContext && window.PublicKeyCredential && navigator.credentials;
33
+
34
+ async function doPair(code) {
35
+ const btn = $('#doPair'); btn.disabled = true; say('preparando…');
36
+ try {
37
+ const o = await post('/api/auth/register/options', { code, name: $('#devName').value.trim() || guessName() });
38
+ if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
39
+ const pk = o.data.publicKey;
40
+ pk.challenge = b64uToBuf(pk.challenge); pk.user.id = b64uToBuf(pk.user.id);
41
+ say('seguí las instrucciones del dispositivo…');
42
+ const cred = await navigator.credentials.create({ publicKey: pk });
43
+ const r = await post('/api/auth/register', { code, challenge_id: o.data.challenge_id, name: $('#devName').value.trim() || guessName(), credential: credJSON(cred) });
44
+ if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
45
+ say('listo · entrando…', 'ok');
46
+ history.replaceState(null, '', '/pair'); // el código ya no sirve: que no quede en el historial
47
+ setTimeout(() => { location.href = '/'; }, 500);
48
+ } catch (e) { say(why(e), 'err'); btn.disabled = false; }
49
+ }
50
+
51
+ async function doLogin() {
52
+ const btn = $('#doLogin'); btn.disabled = true; say('');
53
+ try {
54
+ const o = await post('/api/auth/login/options', {});
55
+ if (!o.ok) { say(o.data.error || ('error ' + o.status), 'err'); btn.disabled = false; return; }
56
+ if (!o.data.known) { say('no hay dispositivos emparejados para este dominio: emparejá este desde la PC', 'err'); btn.disabled = false; return; }
57
+ const pk = o.data.publicKey;
58
+ pk.challenge = b64uToBuf(pk.challenge); pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ type: c.type, id: b64uToBuf(c.id) }));
59
+ const cred = await navigator.credentials.get({ publicKey: pk });
60
+ const r = await post('/api/auth/login', { challenge_id: o.data.challenge_id, credential: credJSON(cred) });
61
+ if (!r.ok) { say(r.data.error || ('error ' + r.status), 'err'); btn.disabled = false; return; }
62
+ say('entrando…', 'ok'); location.href = next();
63
+ } catch (e) { say(why(e), 'err'); btn.disabled = false; }
64
+ }
65
+
66
+ (async () => {
67
+ const code = location.pathname === '/pair' ? decodeURIComponent(location.hash.slice(1)) : '';
68
+ if (code) {
69
+ $('#pair').hidden = false; $('#devName').value = guessName();
70
+ $('#doPair').onclick = () => doPair(code);
71
+ } else {
72
+ // ¿ya adentro? (esta PC o una sesión viva) → directo al hub
73
+ try { const me = await (await fetch('/api/auth/me')).json(); if (me.authenticated) { location.href = next(); return; } } catch (e) {}
74
+ $('#login').hidden = false; $('#doLogin').onclick = doLogin;
75
+ if (location.pathname === '/pair') say('este link no trae código: escaneá el QR de nuevo', 'err');
76
+ }
77
+ if (!supported()) { say('este navegador no puede usar passkeys aquí: hace falta https (el túnel) y un navegador actual', 'err'); document.querySelectorAll('button.primary').forEach(b => b.disabled = true); }
78
+ })();
@@ -44,6 +44,6 @@ async function openProc(name) {
44
44
  async function refreshProcViewer(name) {
45
45
  if (procOpen !== name || $('#viewer').style.display === 'none') { clearInterval(procTimer); return; }
46
46
  const x = await (await fetch(BASE + '/api/proc/log?name=' + encodeURIComponent(name) + '&tail=400')).json();
47
- $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
47
+ viewerText(); $('#vbody').textContent = x.log || ''; $('#vmeta').textContent = x.running ? '● corriendo · log en vivo' : '○ terminado';
48
48
  if (!x.running) { clearInterval(procTimer); loadProcs(); }
49
49
  }
@@ -0,0 +1,5 @@
1
+ // pwa.js — registra el service worker (instalable en el celular y en el escritorio). Solo en contexto seguro:
2
+ // https (túnel, VPS) o localhost; por http en la LAN el navegador no lo permite y la app sigue como sitio.
3
+ if ('serviceWorker' in navigator && window.isSecureContext) {
4
+ window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {}); });
5
+ }
package/public/js/tree.js CHANGED
@@ -241,15 +241,23 @@ function showMenu(x, y, e, only) {
241
241
  if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
242
242
  }
243
243
 
244
+ // abre el archivo en el editor (js/editor.js): colores por tipo de archivo, Ctrl+S guarda. El <pre> #vbody queda para logs.
244
245
  async function openFile(path, row) {
245
246
  document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
246
247
  const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
247
- if (!r.ok) { add('denied', esc(d.error || 'error')); return; }
248
- $('#vpath').textContent = d.path; $('#vmeta').textContent = d.lines + ' líneas';
249
- $('#vbody').className = ''; $('#vbody').innerHTML = String(d.content).split('\n').map((l, i) => `<span class="ln">${i + 1}</span>${esc(l)}`).join('\n');
248
+ if (!r.ok) { viewerNotice(path, 'no se pudo abrir', String(d.error || 'error')); return; }
249
+ if (!row) { const ar = rowOf(d.path); if (ar) ar.classList.add('active'); }
250
+ if (d.binary) { viewerNotice(d.path, 'binario · ' + fmtSize(d.size), 'Este archivo no es texto (base de datos, imagen, fuente, ejecutable…): no se puede mostrar ni editar acá.' + (d.error ? '\n\n' + d.error : '')); return; }
251
+ $('#vpath').textContent = d.path; $('#vbody').className = '';
252
+ edOpen(d);
250
253
  showPane('viewer'); $('#stage').scrollTop = 0;
251
254
  }
255
+ // avisos del visor (binario, error al abrir): en el propio visor, no como mensaje en el chat
256
+ function viewerNotice(path, meta, text) { showText(path, meta, text, false); }
257
+ function fmtSize(n) { n = Number(n) || 0; return n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
252
258
  // el árbol se refresca solo cuando una tool pudo crear/borrar archivos, con un pequeño debounce; ↻ lo fuerza a mano
253
259
  function treeChanged() { debounce('tree', loadTree, 400); }
254
260
  $('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
255
- $('#vclose').onclick = () => { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; };
261
+ function closeViewer(discard) { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; edClose(discard); const x = $('#vclose'); x.classList.remove('ask'); x.textContent = '✕ cerrar'; }
262
+ // con cambios sin guardar, el ✕ pregunta antes (mismo patrón en línea que borrar en el árbol)
263
+ $('#vclose').onclick = () => { if (edVisible() && edDirty()) { inlineConfirm($('#vclose'), '¿descartar cambios?', () => closeViewer(true)); return; } closeViewer(false); };
@@ -0,0 +1,43 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#131412">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
+ <link rel="manifest" href="/manifest.webmanifest">
11
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
12
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png">
13
+ <title>Lampson · entrar</title>
14
+ <link rel="stylesheet" href="/css/tokens.css">
15
+ <link rel="stylesheet" href="/css/login.css">
16
+ <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>
17
+ </head>
18
+ <body class="auth">
19
+ <main class="authbox">
20
+ <img src="/favicon.svg" alt="" class="logo" width="48" height="48">
21
+ <!-- login: dispositivo ya emparejado -->
22
+ <section id="login" hidden>
23
+ <p class="eyebrow">lampson</p>
24
+ <h1>Entrar</h1>
25
+ <p class="lead">Usá la passkey de este dispositivo: huella, cara o el bloqueo de pantalla.</p>
26
+ <button class="primary" id="doLogin">Entrar con passkey</button>
27
+ <p class="hint">¿Dispositivo nuevo? En la PC donde corre lampson abrí <b>dispositivos</b> → <b>emparejar</b> y escaneá el QR.</p>
28
+ </section>
29
+ <!-- emparejar: llegó desde el QR (/pair#<código>) -->
30
+ <section id="pair" hidden>
31
+ <p class="eyebrow">emparejar dispositivo</p>
32
+ <h1>Conectar con tu PC</h1>
33
+ <p class="lead">Vas a crear una passkey para este dispositivo. Después entrás solo con la huella o la cara, sin contraseñas.</p>
34
+ <label class="field">nombre del dispositivo<input id="devName" maxlength="60" autocomplete="off" spellcheck="false"></label>
35
+ <button class="primary" id="doPair">Crear passkey</button>
36
+ <p class="hint">El código del QR vale 5 minutos y sirve una sola vez.</p>
37
+ </section>
38
+ <p class="msg" id="msg" role="status"></p>
39
+ </main>
40
+ <script src="/js/pwa.js"></script>
41
+ <script src="/js/login.js"></script>
42
+ </body>
43
+ </html>
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "/",
3
+ "name": "Lampson",
4
+ "short_name": "Lampson",
5
+ "description": "Tu agente de código: workspaces, sesiones y tareas desde el celular.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#131412",
10
+ "theme_color": "#131412",
11
+ "icons": [
12
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
13
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
14
+ { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
15
+ ]
16
+ }
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#131412">
7
+ <title>Lampson · sin conexión</title>
8
+ <link rel="stylesheet" href="/css/tokens.css">
9
+ <style>
10
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:24px; }
11
+ .box { max-width:420px; }
12
+ h1 { font:600 22px/1.3 var(--serif); margin:0 0 8px; }
13
+ p { color:var(--ink-2); margin:0 0 16px; }
14
+ button { font:400 13px var(--mono); padding:8px 14px; background:transparent; color:var(--accent); border:1px solid var(--accent); border-radius:var(--r); }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <div class="box">
19
+ <h1>Tu PC no responde</h1>
20
+ <p>Lampson corre en tu PC: tiene que estar prendida, con lampson y el túnel andando (<code>lampson --install</code> lo arranca solo al iniciar sesión). Si estás sin señal, revisá tu conexión.</p>
21
+ <button onclick="location.reload()">reintentar</button>
22
+ </div>
23
+ </body>
24
+ </html>
package/public/sw.js ADDED
@@ -0,0 +1,63 @@
1
+ // sw.js — service worker de la PWA de lampson (base: el scaffold de `synsema init --pwa`).
2
+ // Red primero para todo: lampson cambia seguido y es una herramienta en vivo; mostrar una versión vieja de la UI
3
+ // sería peor que avisar. El caché solo sirve para abrir sin red: íconos, CSS/JS/fuentes y una página «sin conexión».
4
+ // Nunca se cachea el API (/api/…, /w/<slug>/api/…) ni una respuesta que falló, ni las páginas con datos del usuario.
5
+ const CACHE = "lampson-shell-v2";
6
+ const SHELL = ["/offline.html", "/manifest.webmanifest", "/icon-192.png", "/favicon.svg", "/css/tokens.css"];
7
+
8
+ self.addEventListener("install", (e) => {
9
+ e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
10
+ });
11
+
12
+ self.addEventListener("activate", (e) => {
13
+ e.waitUntil(
14
+ caches.keys()
15
+ .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
16
+ .then(() => self.clients.claim())
17
+ );
18
+ });
19
+
20
+ const tunnelDown = (res) => res.headers.get("x-synsema-tunnel") === "offline" || [502, 503, 504, 521, 522, 523, 524, 530].includes(res.status);
21
+ const isApi = (p) => p.startsWith("/api/") || /^\/w\/[^/]+\/(api|approve)\//.test(p);
22
+ const isAsset = (p) => /^\/(css|js|fonts|vendor)\//.test(p) || /\.(png|svg|webmanifest|woff2)$/.test(p);
23
+
24
+ self.addEventListener("fetch", (e) => {
25
+ const url = new URL(e.request.url);
26
+ if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
27
+ if (isApi(url.pathname)) return; // SSE, WebSocket y JSON van directo a la red, sin tocar
28
+ if (e.request.mode === "navigate") {
29
+ // PC apagada o lampson caído: sin red → error de fetch; detrás de un túnel, el túnel SÍ responde, con su propia
30
+ // página de error (Cloudflare: 530/1033, 502, 504; un túnel de la plataforma marca X-Synsema-Tunnel: offline).
31
+ // En los dos casos, la pantalla de lampson. El hub nunca responde una página con esos códigos.
32
+ e.respondWith(fetch(e.request).then((res) => {
33
+ if (tunnelDown(res)) return caches.match("/offline.html").then((c) => c || res);
34
+ return res;
35
+ }).catch(() => caches.match("/offline.html")));
36
+ return;
37
+ }
38
+ if (isAsset(url.pathname)) {
39
+ e.respondWith(fetch(e.request).then(async (res) => {
40
+ if (res.ok) { const c = await caches.open(CACHE); await c.put(e.request, res.clone()); }
41
+ return res;
42
+ }).catch(async () => (await caches.match(e.request)) || Response.error()));
43
+ }
44
+ });
45
+
46
+ // push (para después: avisos de aprobaciones y de tareas terminadas)
47
+ self.addEventListener("push", (e) => {
48
+ let data = {};
49
+ try { data = e.data ? e.data.json() : {}; } catch (_) { data = { body: e.data ? e.data.text() : "" }; }
50
+ const opts = { body: data.body || "", icon: "/icon-192.png", badge: "/badge-96.png", data };
51
+ if (data.tag) { opts.tag = String(data.tag); opts.renotify = true; }
52
+ e.waitUntil(self.registration.showNotification(data.title || "Lampson", opts));
53
+ });
54
+
55
+ self.addEventListener("notificationclick", (e) => {
56
+ e.notification.close();
57
+ const url = (e.notification.data && e.notification.data.url) || "/";
58
+ e.waitUntil(clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
59
+ const win = wins.find((w) => "focus" in w);
60
+ if (win) { win.postMessage({ type: "notificationclick", url }); return win.focus(); }
61
+ return clients.openWindow(url);
62
+ }));
63
+ });