lampson 0.2.7 → 0.2.8

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.
@@ -0,0 +1,220 @@
1
+ -- lib/tools/url.syn — URLs para la tool fetch y para permission.syn (puro, sin capacidades)
2
+ --
3
+ -- parse(url) → {ok, error?, url, scheme, host, port, path, query, origin, dir}
4
+ -- host_class(host) → "public" | "private" | "blocked"
5
+ -- sensitive(url) → motivo (texto) si la URL lleva una credencial, o nothing
6
+ -- resolve(base, href) → URL absoluta de un href (relativo o no), o nothing si no es navegable (#, javascript:)
7
+ --
8
+ -- Política de hosts (tomada de hermes url_safety.py, sin resolver DNS: el runtime no expone un resolver):
9
+ -- * "blocked": endpoints de metadata de la nube (169.254.169.254 y familia, metadata.google.internal).
10
+ -- Nunca son un destino legítimo: se deniegan SIEMPRE, incluso en yolo.
11
+ -- * "private": loopback, redes privadas (10/8, 172.16/12, 192.168/16, CGNAT 100.64/10), link-local,
12
+ -- localhost y sufijos internos. Legítimo para mirar el propio dev server → PIDE aprobación (yolo permite,
13
+ -- strict deniega). Un host numérico ambiguo (entero, hex, octal) cae aquí también.
14
+ -- * "public": el resto. Límite conocido: sin DNS no se detecta un nombre público que resuelve a una IP
15
+ -- privada (DNS rebinding); hermes lo cubre con un resolver, aquí queda documentado.
16
+
17
+ let SENSITIVE_PARAMS be ["access_token", "api_key", "apikey", "auth_token", "authorization", "awsaccesskeyid", "client_secret", "credential", "credentials", "jwt", "password", "passwd", "secret", "session_id", "signature", "token", "private_key", "x_amz_security_token", "x_amz_signature", "x-amz-security-token", "x-amz-signature"]
18
+ let SECRET_RE be "(?i)(\\bsk-(ant-|proj-)?[a-z0-9_-]{24,}|\\bghp_[a-z0-9]{20,}|\\bgithub_pat_[a-z0-9_]{20,}|\\bgho_[a-z0-9]{20,}|\\bglpat-[a-z0-9_-]{16,}|\\bxox[abprs]-[a-z0-9-]{10,}|\\bakia[0-9a-z]{16}\\b|\\baiza[0-9a-z_-]{30,}|-----begin [a-z ]*private key|\\beyj[a-z0-9_-]{20,}\\.eyj[a-z0-9_-]{20,})"
19
+
20
+ task fail(msg)
21
+ give {"ok": false, "error": msg}
22
+
23
+ export task parse(raw)
24
+ when raw == nothing
25
+ give fail("missing URL")
26
+ let u be trim(text(raw))
27
+ -- "https:// host" — el modelo a veces mete un espacio tras el esquema (hermes normalize_url_for_request)
28
+ set u to replace_re(u, "^([A-Za-z][A-Za-z0-9+.-]*://)\\s+", "\\1")
29
+ when u == ""
30
+ give fail("missing URL")
31
+ when length(u) > 2048
32
+ give fail("URL longer than 2048 chars")
33
+ when contains(u, " ") or contains(u, "\n") or contains(u, "\t")
34
+ give fail("URL contains whitespace: " + u)
35
+ let sp be split(u, "://")
36
+ when length(sp) < 2
37
+ give fail("not an absolute http(s) URL: " + u)
38
+ let scheme be lower(sp[0])
39
+ when scheme != "http" and scheme != "https"
40
+ give fail("unsupported scheme '" + scheme + "' (only http and https)")
41
+ let rest be join(slice(sp, 1, length(sp)), "://")
42
+ -- fragmento y query
43
+ let frag_sp be split(rest, "#")
44
+ set rest to frag_sp[0]
45
+ let q_sp be split(rest, "?")
46
+ let query be when length(q_sp) > 1 then join(slice(q_sp, 1, length(q_sp)), "?") otherwise ""
47
+ set rest to q_sp[0]
48
+ -- autoridad y path
49
+ let slash_sp be split(rest, "/")
50
+ let authority be slash_sp[0]
51
+ let path be when length(slash_sp) > 1 then "/" + join(slice(slash_sp, 1, length(slash_sp)), "/") otherwise "/"
52
+ when contains(authority, "@")
53
+ give fail("credentials in the URL are not allowed")
54
+ when authority == ""
55
+ give fail("missing host: " + u)
56
+ let host be lower(authority)
57
+ let port be ""
58
+ when starts_with(host, "[")
59
+ -- IPv6 literal: [::1]:8080
60
+ let close be split(host, "]")
61
+ set host to slice(close[0], 1, length(close[0]))
62
+ when length(close) > 1 and starts_with(close[1], ":")
63
+ set port to slice(close[1], 1, length(close[1]))
64
+ otherwise
65
+ let hp be split(host, ":")
66
+ when length(hp) > 2
67
+ give fail("malformed host: " + authority)
68
+ set host to hp[0]
69
+ when length(hp) == 2
70
+ set port to hp[1]
71
+ when port != "" and not matches(port, "[0-9]{1,5}")
72
+ give fail("malformed port: " + port)
73
+ -- "example.com." (FQDN con punto final) ≡ "example.com"
74
+ while ends_with(host, ".")
75
+ set host to slice(host, 0, length(host) - 1)
76
+ when host == ""
77
+ give fail("missing host: " + u)
78
+ let origin be scheme + "://" + host + (when port != "" then ":" + port otherwise "")
79
+ let dir_parts be split(path, "/")
80
+ let dir be join(slice(dir_parts, 0, length(dir_parts) - 1), "/") + "/"
81
+ give {"ok": true, "url": origin + path + (when query != "" then "?" + query otherwise ""), "scheme": scheme, "host": host, "port": port, "path": path, "query": query, "origin": origin, "dir": dir}
82
+
83
+ -- octetos de una IPv4 en notación decimal con puntos, o nothing
84
+ task ipv4_octets(host)
85
+ when not matches(host, "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}")
86
+ give nothing
87
+ let out be []
88
+ each p in split(host, ".")
89
+ let n be floor(number(p))
90
+ when n > 255
91
+ give nothing
92
+ set out to append(out, n)
93
+ give out
94
+
95
+ task ipv4_class(o)
96
+ let a be o[0]
97
+ let b be o[1]
98
+ when a == 169 and b == 254
99
+ give "blocked"
100
+ when a == 100 and b == 100 and o[2] == 100 and o[3] == 200
101
+ give "blocked"
102
+ when a == 127 or a == 10 or a == 0
103
+ give "private"
104
+ when a == 192 and b == 168
105
+ give "private"
106
+ when a == 172 and b >= 16 and b <= 31
107
+ give "private"
108
+ when a == 100 and b >= 64 and b <= 127
109
+ give "private"
110
+ when a >= 224
111
+ give "private"
112
+ give "public"
113
+
114
+ export task host_class(host)
115
+ let h be lower(text(host))
116
+ when h == "metadata.google.internal" or h == "metadata.goog" or h == "fd00:ec2::254"
117
+ give "blocked"
118
+ let o be ipv4_octets(h)
119
+ when o != nothing
120
+ give ipv4_class(o)
121
+ -- IPv6: loopback, unspecified, ULA fc00::/7, link-local fe80::/10, IPv4 mapeadas ::ffff:a.b.c.d
122
+ when contains(h, ":")
123
+ when h == "::1" or h == "::"
124
+ give "private"
125
+ when starts_with(h, "::ffff:")
126
+ let inner be ipv4_octets(slice(h, 7, length(h)))
127
+ give when inner != nothing then ipv4_class(inner) otherwise "private"
128
+ when starts_with(h, "fc") or starts_with(h, "fd") or starts_with(h, "fe8") or starts_with(h, "fe9") or starts_with(h, "fea") or starts_with(h, "feb")
129
+ give "private"
130
+ give "public"
131
+ when h == "localhost" or ends_with(h, ".localhost") or ends_with(h, ".internal") or ends_with(h, ".local") or ends_with(h, ".home.arpa") or ends_with(h, ".lan")
132
+ give "private"
133
+ -- formas numéricas ambiguas (2130706433, 0x7f000001, 0177.0.0.1): clásicos de bypass → tratar como privado
134
+ when matches(h, "[0-9]+") or matches(h, "0x[0-9a-f]+") or matches(h, "[0-9x.]+")
135
+ give "private"
136
+ give "public"
137
+
138
+ -- %XX → carácter (para inspeccionar una URL con secretos codificados)
139
+ task percent_decode(s)
140
+ let out be s
141
+ each m in find_all(s, "%[0-9A-Fa-f]{2}")
142
+ let ch be nothing
143
+ try
144
+ set ch to json_decode("\"\\u00" + slice(m, 1, 3) + "\"")
145
+ recover e
146
+ set ch to nothing
147
+ when ch != nothing
148
+ set out to replace_text(out, m, ch)
149
+ give out
150
+
151
+ export task sensitive(raw)
152
+ when raw == nothing
153
+ give nothing
154
+ let u be text(raw)
155
+ let decoded be percent_decode(u)
156
+ when length(find_all(u, SECRET_RE)) > 0 or length(find_all(decoded, SECRET_RE)) > 0
157
+ give "an API key or token"
158
+ let p be parse(u)
159
+ when not p["ok"]
160
+ give nothing
161
+ when p["query"] == ""
162
+ give nothing
163
+ each pair in split(p["query"], "&")
164
+ let kv be split(pair, "=")
165
+ let k be lower(trim(percent_decode(kv[0])))
166
+ let v be when length(kv) > 1 then join(slice(kv, 1, length(kv)), "=") otherwise ""
167
+ when v != "" and contains(SENSITIVE_PARAMS, k)
168
+ give "a credential-like query parameter (" + kv[0] + ")"
169
+ give nothing
170
+
171
+ -- "/a/b/../c/./d" → "/a/c/d"
172
+ task normalize_path(path)
173
+ let out be []
174
+ each seg in split(path, "/")
175
+ when seg == ".."
176
+ when length(out) > 0
177
+ set out to slice(out, 0, length(out) - 1)
178
+ otherwise when seg != "." and seg != ""
179
+ set out to append(out, seg)
180
+ give "/" + join(out, "/") + (when ends_with(path, "/") and length(out) > 0 then "/" otherwise "")
181
+
182
+ export task resolve(base, href)
183
+ when href == nothing
184
+ give nothing
185
+ let h be trim(text(href))
186
+ let lh be lower(h)
187
+ when h == "" or starts_with(h, "#") or starts_with(lh, "javascript:") or starts_with(lh, "data:") or starts_with(lh, "mailto:") or starts_with(lh, "tel:")
188
+ give nothing
189
+ when matches(h, "[A-Za-z][A-Za-z0-9+.-]*:.*")
190
+ give h
191
+ let b be parse(base)
192
+ when not b["ok"]
193
+ give h
194
+ when starts_with(h, "//")
195
+ give b["scheme"] + ":" + h
196
+ -- separar query/fragmento del path relativo para normalizar solo el path
197
+ let tail be ""
198
+ let core be h
199
+ let qi be split(h, "?")
200
+ let fi be split(h, "#")
201
+ when length(qi) > 1
202
+ set core to qi[0]
203
+ set tail to "?" + join(slice(qi, 1, length(qi)), "?")
204
+ otherwise when length(fi) > 1
205
+ set core to fi[0]
206
+ set tail to "#" + join(slice(fi, 1, length(fi)), "#")
207
+ when core == ""
208
+ give b["origin"] + b["path"] + tail
209
+ when starts_with(core, "/")
210
+ give b["origin"] + normalize_path(core) + tail
211
+ give b["origin"] + normalize_path(b["dir"] + core) + tail
212
+
213
+ -- nombre de archivo estable para la caché/spill de una URL: fetch-<host>-<digest>
214
+ export task slug(host)
215
+ let s be replace_re(lower(text(host)), "[^a-z0-9._-]", "-")
216
+ when length(s) > 60
217
+ set s to slice(s, 0, 60)
218
+ when s == ""
219
+ give "page"
220
+ give s
package/lib/tools.syn CHANGED
@@ -19,6 +19,7 @@ use "./tools/ls.syn" as t_ls
19
19
  use "./tools/find.syn" as t_find
20
20
  use "./tools/grep.syn" as t_grep
21
21
  use "./tools/bash.syn" as t_bash
22
+ use "./tools/fetch.syn" as t_fetch
22
23
  use "./tools/skill.syn" as t_skill
23
24
  use "./tools/process.syn" as t_process
24
25
  use "./tools/memo.syn" as t_memo
@@ -256,6 +257,7 @@ export task registry()
256
257
  "find": t_find.tool,
257
258
  "grep": t_grep.tool,
258
259
  "bash": t_bash.tool,
260
+ "fetch": t_fetch.tool,
259
261
  "process": t_process.tool,
260
262
  "memory": t_memo.tool,
261
263
  "todo": t_todo.tool,
@@ -266,7 +268,7 @@ export task registry()
266
268
  "schedule": schedule_tool
267
269
  }
268
270
 
269
- export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC]
271
+ export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_fetch.SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, PLUGIN_SPEC, SCHEDULE_SPEC]
270
272
 
271
273
  -- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
272
274
  export task registry_subset(names)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, plugins (your own tools, any language), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,15 @@
9
9
  },
10
10
  "homepage": "https://github.com/kitecosmic/lampson#readme",
11
11
  "bugs": "https://github.com/kitecosmic/lampson/issues",
12
- "keywords": ["agent", "coding-agent", "synsema", "llm", "cli", "lsp", "mcp"],
12
+ "keywords": [
13
+ "agent",
14
+ "coding-agent",
15
+ "synsema",
16
+ "llm",
17
+ "cli",
18
+ "lsp",
19
+ "mcp"
20
+ ],
13
21
  "bin": {
14
22
  "lampson": "bin/lampson.js"
15
23
  },
@@ -82,3 +82,24 @@
82
82
  /* puertos ocupados por lampson: visibles pero sin link ni ✕ */
83
83
  .p .port.own { color:var(--ink-3); font-weight:400; }
84
84
  .p.own .cm { color:var(--ink-3); }
85
+
86
+ /* explorador de archivos: selección, portapapeles, arrastre, renombrar en línea y menú contextual */
87
+ #tree:focus { outline:none; }
88
+ .row.sel { background:var(--paper-2); }
89
+ .row.cut { opacity:.55; }
90
+ .row.dragging { opacity:.4; }
91
+ .row.drop, #tree.drop { background:var(--accent-bg); border-left-color:var(--accent); }
92
+ .row.flash { background:var(--accent-bg); transition:background .7s; }
93
+ .row input.rn { flex:1; min-width:0; font:inherit; color:var(--ink); background:var(--paper-2); border:1px solid var(--accent); border-radius:var(--r); padding:0 4px; outline:none; }
94
+ .row.new { color:var(--ink); }
95
+ .ctx { position:fixed; z-index:70; min-width:220px; background:var(--paper); border:1px solid var(--rule-2); border-radius:var(--r); box-shadow:0 10px 30px rgba(0,0,0,.35); padding:4px 0; font:400 12.5px/1.5 var(--mono); user-select:none; }
96
+ .ctx .it { display:flex; align-items:center; gap:14px; padding:4px 12px; cursor:pointer; color:var(--ink-2); white-space:nowrap; }
97
+ .ctx .it:hover { background:var(--paper-2); color:var(--ink); }
98
+ .ctx .it .k { margin-left:auto; color:var(--ink-3); font-size:10.5px; }
99
+ .ctx .it.dis { opacity:.4; pointer-events:none; }
100
+ .ctx .it.danger:hover { color:var(--rubric); }
101
+ .ctx .it.ask { color:var(--ink-2); gap:6px; }
102
+ .ctx .it.ask .yes { color:var(--rubric); font-weight:600; cursor:pointer; }
103
+ .ctx .it.ask .no { cursor:pointer; }
104
+ .ctx .it.ask .yes:hover, .ctx .it.ask .no:hover { text-decoration:underline; text-underline-offset:.18em; }
105
+ .ctx .sep { height:1px; background:var(--rule); margin:4px 0; }
package/public/js/core.js CHANGED
@@ -22,7 +22,7 @@ function md(src) { // Markdown mínimo: code fences, inline code, bold, headers,
22
22
  }
23
23
  // un bloque en el chat: 'user' | 'assistant' | 'step' | 'meta' | 'denied' | 'approval' | 'working'
24
24
  function add(cls, html) { showPane('log'); log.classList.remove('hero'); const d = document.createElement('div'); d.className = 'msg ' + cls; d.innerHTML = html; log.appendChild(d); $('#stage').scrollTop = $('#stage').scrollHeight; return d; }
25
- function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
25
+ function describe(c) { const a = c.args || {}; if (c.name === 'bash') return '$ ' + (a.command || ''); if (c.name === 'fetch') return 'fetch ' + (a.url || '') + (a.format && a.format !== 'markdown' ? ' (' + a.format + ')' : ''); if (c.name === 'delegate') return 'delegate → ' + a.agent + ': ' + String(a.brief || '').slice(0, 80); if (a.path) return c.name + ' ' + a.path; if (a.pattern) return c.name + ' ' + a.pattern; if (a.name) return c.name + ' ' + a.name; return c.name + ' ' + JSON.stringify(a); }
26
26
  function cmdHtml(text) { const t = String(text); const long = t.length > 220 || t.split('\n').length > 4; return `<span class="cmd">${esc(t)}</span>` + (long ? ' <span class="more">ver todo</span>' : ''); }
27
27
  function wireMore(el) { const m = el.querySelector('.more'); if (m) m.onclick = () => { el.querySelector('.cmd').classList.toggle('open'); m.textContent = el.querySelector('.cmd').classList.contains('open') ? 'menos' : 'ver todo'; }; }
28
28
  // POST JSON → {ok, status, data}
package/public/js/tree.js CHANGED
@@ -1,5 +1,17 @@
1
- // tree.js — árbol de archivos (panel derecho) con estado git, y el visor de archivos
1
+ // tree.js — árbol de archivos (panel derecho) con estado git, el visor de archivos y el explorador:
2
+ // clic derecho = menú contextual (nuevo archivo/carpeta, abrir, renombrar, duplicar, copiar/cortar/pegar,
3
+ // copiar ruta relativa/absoluta, insertar la ruta en el chat, eliminar), arrastrar y soltar para mover
4
+ // (Ctrl al soltar = copiar; sobre un archivo = a su carpeta), F2 / Supr / Ctrl+C·X·V sobre la fila
5
+ // seleccionada. Las operaciones van a POST /api/fs (lib/fs.syn) y nunca pisan un destino existente.
6
+ // Las carpetas abiertas se recuerdan (localStorage lampson.tree.open) para que un cambio no las cierre.
2
7
  let gitStatus = { changes: {} };
8
+ let treeRoot = ''; // ruta absoluta del workspace (para «copiar ruta absoluta»)
9
+ let treeSel = null; // {path, is_dir, row}: última fila clickeada (izquierdo o derecho)
10
+ let treeClip = null; // {path, is_dir, op: 'copy'|'cut'}
11
+ let treeDrag = null; // {path, is_dir} mientras se arrastra
12
+ const openDirs = new Set((() => { try { return JSON.parse(localStorage.getItem('lampson.tree.open') || '[]'); } catch (e) { return []; } })());
13
+ function saveOpen() { try { localStorage.setItem('lampson.tree.open', JSON.stringify([...openDirs])); } catch (e) {} }
14
+
3
15
  async function loadGit() {
4
16
  try { gitStatus = await (await fetch(BASE + '/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
5
17
  const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
@@ -17,28 +29,218 @@ function gitClass(path, isDir) {
17
29
  if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
18
30
  const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
19
31
  }
32
+ function parentOf(p) { const i = p.lastIndexOf('/'); return i < 0 ? '.' : p.slice(0, i); }
33
+ function baseOf(p) { return p.slice(p.lastIndexOf('/') + 1); }
34
+ function cssEsc(s) { return (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).replace(/["\\]/g, '\\$&'); }
35
+ function rowOf(path) { return $('#tree').querySelector(`.row[data-path="${cssEsc(path)}"]`); }
36
+
20
37
  async function loadTree() {
21
38
  await loadGit();
22
- const box = $('#tree'); box.innerHTML = '';
39
+ const box = $('#tree'); box.innerHTML = ''; box.tabIndex = 0;
23
40
  let t; try { t = await (await fetch(BASE + '/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
24
- const render = (entries, parent, depth) => {
41
+ treeRoot = t.root || '';
42
+ const render = (entries, parent) => {
25
43
  for (const e of entries) {
26
44
  const n = document.createElement('div'); n.className = 'node';
27
45
  const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
28
- const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : ''); r.title = e.path + (code ? ' · git ' + code : '');
29
- r.innerHTML = `<span class="tw">${e.is_dir ? '' : ''}</span><span>${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
46
+ const cut = treeClip && treeClip.op === 'cut' && treeClip.path === e.path;
47
+ const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : '') + (cut ? ' cut' : '');
48
+ r.title = e.path + (code ? ' · git ' + code : ''); r.dataset.path = e.path; r.dataset.dir = e.is_dir ? '1' : '';
49
+ r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span class="nm">${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
30
50
  n.appendChild(r);
51
+ wireRow(r, e);
31
52
  if (e.is_dir) {
32
- const k = document.createElement('div'); k.className = 'kids hidden'; render(e.children || [], k, depth + 1); n.appendChild(k);
33
- r.querySelector('.tw').textContent = '';
34
- r.onclick = () => { k.classList.toggle('hidden'); r.querySelector('.tw').textContent = k.classList.contains('hidden') ? '' : ''; };
35
- } else { r.onclick = () => openFile(e.path, r); }
53
+ const open = openDirs.has(e.path);
54
+ const k = document.createElement('div'); k.className = 'kids' + (open ? '' : ' hidden'); render(e.children || [], k); n.appendChild(k);
55
+ r.querySelector('.tw').textContent = open ? '' : '';
56
+ r.onclick = () => { select(r, e); toggleDir(r, k, e.path); };
57
+ } else { r.onclick = () => { select(r, e); openFile(e.path, r); }; }
36
58
  parent.appendChild(n);
37
59
  }
38
60
  };
39
- render(t.entries || [], box, 0);
61
+ render(t.entries || [], box);
40
62
  if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
63
+ if (treeSel) { const r = rowOf(treeSel.path); if (r) { treeSel.row = r; r.classList.add('sel'); } else treeSel = null; }
64
+ const vp = $('#vpath').textContent; const ar = vp && rowOf(vp); if (ar && $('#viewer').style.display !== 'none') ar.classList.add('active');
41
65
  }
66
+ function toggleDir(r, k, path) {
67
+ k.classList.toggle('hidden'); const open = !k.classList.contains('hidden');
68
+ r.querySelector('.tw').textContent = open ? '▾' : '▸';
69
+ if (open) openDirs.add(path); else openDirs.delete(path); saveOpen();
70
+ }
71
+ function expandDir(path) { if (!path || path === '.') return; openDirs.add(path); saveOpen(); }
72
+ function select(r, e) {
73
+ document.querySelectorAll('#tree .row.sel').forEach(x => x.classList.remove('sel'));
74
+ r.classList.add('sel'); treeSel = { path: e.path, is_dir: e.is_dir, row: r };
75
+ }
76
+
77
+ // ---- arrastrar y soltar: mover (Ctrl = copiar); sobre un archivo = a la carpeta del archivo ----
78
+ function canDrop(d, dir) {
79
+ if (!d) return false;
80
+ if (parentOf(d.path) === dir) return false; // ya está ahí
81
+ if (d.is_dir && (dir === d.path || dir.startsWith(d.path + '/'))) return false; // carpeta dentro de sí misma
82
+ return true;
83
+ }
84
+ function wireRow(r, e) {
85
+ r.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); select(r, e); showMenu(ev.clientX, ev.clientY, { path: e.path, is_dir: e.is_dir, row: r }); };
86
+ r.draggable = true;
87
+ r.ondragstart = ev => { treeDrag = { path: e.path, is_dir: e.is_dir }; ev.dataTransfer.effectAllowed = 'copyMove'; ev.dataTransfer.setData('text/plain', e.path); r.classList.add('dragging'); };
88
+ r.ondragend = () => { treeDrag = null; r.classList.remove('dragging'); document.querySelectorAll('#tree .drop').forEach(x => x.classList.remove('drop')); };
89
+ // soltar sobre una carpeta = dentro de ella; sobre un archivo = en la carpeta del archivo (se ilumina esa)
90
+ const dir = e.is_dir ? e.path : parentOf(e.path);
91
+ const lit = () => (e.is_dir ? r : (rowOf(dir) || $('#tree')));
92
+ r.ondragover = ev => { if (!canDrop(treeDrag, dir)) return; ev.preventDefault(); ev.stopPropagation(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; lit().classList.add('drop'); };
93
+ r.ondragleave = () => lit().classList.remove('drop');
94
+ r.ondrop = ev => { ev.preventDefault(); ev.stopPropagation(); document.querySelectorAll('#tree .drop, #tree.drop').forEach(x => x.classList.remove('drop')); dropInto(dir, ev.ctrlKey); };
95
+ }
96
+ async function dropInto(dir, copy) {
97
+ const d = treeDrag; treeDrag = null;
98
+ if (!canDrop(d, dir)) return;
99
+ await fsOp(copy ? { op: 'copy', path: d.path, to: dir } : { op: 'move', path: d.path, to: dir }, res => { expandDir(dir); if (!copy) afterMove(d.path, res.path); });
100
+ }
101
+ {
102
+ const box = $('#tree');
103
+ box.ondragover = ev => { if (!canDrop(treeDrag, '.')) return; ev.preventDefault(); ev.dataTransfer.dropEffect = ev.ctrlKey ? 'copy' : 'move'; box.classList.add('drop'); };
104
+ box.ondragleave = () => box.classList.remove('drop');
105
+ box.ondrop = ev => { ev.preventDefault(); box.classList.remove('drop'); dropInto('.', ev.ctrlKey); };
106
+ box.oncontextmenu = ev => { if (ev.target.closest('.row')) return; ev.preventDefault(); showMenu(ev.clientX, ev.clientY, null); };
107
+ box.onkeydown = ev => {
108
+ if (ev.target.tagName === 'INPUT' || !treeSel) return;
109
+ const s = treeSel; const key = ev.key.toLowerCase();
110
+ if (ev.key === 'F2') { ev.preventDefault(); renameInline(s); }
111
+ else if (ev.key === 'Delete') { ev.preventDefault(); const b = s.row.getBoundingClientRect(); showMenu(b.left + 60, b.bottom, s, 'delete'); }
112
+ else if (ev.ctrlKey && key === 'c') { ev.preventDefault(); setClip(s, 'copy'); }
113
+ else if (ev.ctrlKey && key === 'x') { ev.preventDefault(); setClip(s, 'cut'); }
114
+ else if (ev.ctrlKey && key === 'v') { ev.preventDefault(); pasteInto(s.is_dir ? s.path : parentOf(s.path)); }
115
+ else if (ev.key === 'Escape') { closeMenu(); }
116
+ };
117
+ }
118
+
119
+ // ---- operaciones (POST /api/fs) ----
120
+ async function fsOp(body, onOk) {
121
+ const r = await api(BASE + '/api/fs', body);
122
+ if (!r.ok) { add('denied', esc((r.data && r.data.error) || 'error')); loadTree(); return null; }
123
+ const res = r.data.result || {};
124
+ if (onOk) onOk(res);
125
+ await loadTree();
126
+ return res;
127
+ }
128
+ function afterMove(oldPath, newPath) {
129
+ if (treeClip && treeClip.path === oldPath) treeClip = null;
130
+ if (treeSel && treeSel.path === oldPath) treeSel.path = newPath;
131
+ const vp = $('#vpath'); const v = vp.textContent;
132
+ if (v === oldPath || v.startsWith(oldPath + '/')) vp.textContent = newPath + v.slice(oldPath.length);
133
+ }
134
+ function afterDelete(path) {
135
+ if (treeClip && treeClip.path === path) treeClip = null;
136
+ if (treeSel && (treeSel.path === path || treeSel.path.startsWith(path + '/'))) treeSel = null;
137
+ const v = $('#vpath').textContent; if (v === path || v.startsWith(path + '/')) { $('#vpath').textContent = ''; showPane('log'); }
138
+ }
139
+ function setClip(s, op) { treeClip = { path: s.path, is_dir: s.is_dir, op }; loadTree(); }
140
+ function canPaste(dir) {
141
+ if (!treeClip) return false;
142
+ if (treeClip.op === 'cut') return canDrop(treeClip, dir);
143
+ return !(treeClip.is_dir && (dir === treeClip.path || dir.startsWith(treeClip.path + '/')));
144
+ }
145
+ async function pasteInto(dir) {
146
+ if (!canPaste(dir)) return;
147
+ const c = treeClip;
148
+ await fsOp(c.op === 'cut' ? { op: 'move', path: c.path, to: dir } : { op: 'copy', path: c.path, to: dir }, res => { expandDir(dir); if (c.op === 'cut') { afterMove(c.path, res.path); treeClip = null; } });
149
+ }
150
+ function absPath(path) {
151
+ if (!treeRoot) return path;
152
+ const sep = treeRoot.includes('\\') ? '\\' : '/';
153
+ const root = treeRoot.replace(/[\\/]+$/, '');
154
+ return path === '.' ? root : root + sep + path.split('/').join(sep);
155
+ }
156
+ function copyText(t, row) {
157
+ const done = () => { if (row) { row.classList.add('flash'); setTimeout(() => row.classList.remove('flash'), 700); } };
158
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(done, () => copyFallback(t, done)); else copyFallback(t, done);
159
+ }
160
+ function copyFallback(t, done) { const ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); done(); }
161
+ function insertInChat(path) {
162
+ const ta = $('#in'); const v = ta.value;
163
+ ta.value = v + (v && !/\s$/.test(v) ? ' ' : '') + path + ' ';
164
+ ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true }));
165
+ }
166
+
167
+ // ---- nuevo archivo / carpeta y renombrar: un input en la propia fila ----
168
+ function nameInput(r, value, onDone) {
169
+ const nm = r.querySelector('.nm'); const inp = document.createElement('input'); inp.className = 'rn'; inp.value = value; inp.spellcheck = false;
170
+ nm.replaceWith(inp); inp.focus();
171
+ const dot = value.startsWith('.') ? -1 : value.lastIndexOf('.'); inp.setSelectionRange(0, dot > 0 ? dot : value.length);
172
+ let finished = false;
173
+ const finish = ok => { if (finished) return; finished = true; onDone(ok ? inp.value.trim() : null); };
174
+ inp.onkeydown = ev => { ev.stopPropagation(); if (ev.key === 'Enter') { ev.preventDefault(); finish(true); } else if (ev.key === 'Escape') { ev.preventDefault(); finish(false); } };
175
+ inp.onblur = () => finish(false);
176
+ inp.onclick = ev => ev.stopPropagation(); inp.oncontextmenu = ev => ev.stopPropagation();
177
+ }
178
+ function newEntry(dir, isDir) {
179
+ closeMenu();
180
+ const box = $('#tree');
181
+ let holder = box;
182
+ if (dir !== '.') {
183
+ const r = rowOf(dir); if (!r) return;
184
+ const k = r.parentNode.querySelector('.kids'); if (k.classList.contains('hidden')) toggleDir(r, k, dir);
185
+ holder = k;
186
+ }
187
+ const n = document.createElement('div'); n.className = 'node';
188
+ const r = document.createElement('div'); r.className = 'row new' + (isDir ? ' dir' : ''); r.innerHTML = `<span class="tw">${isDir ? '▸' : ''}</span><span class="nm"></span>`;
189
+ n.appendChild(r); holder.insertBefore(n, holder.firstChild);
190
+ nameInput(r, '', name => {
191
+ n.remove();
192
+ if (!name) return;
193
+ fsOp({ op: isDir ? 'mkdir' : 'create', path: dir, name }, res => { expandDir(dir); treeSel = { path: res.path, is_dir: !!res.is_dir }; if (!isDir) openFile(res.path); });
194
+ });
195
+ }
196
+ function renameInline(s) {
197
+ closeMenu();
198
+ const r = s.row || rowOf(s.path); if (!r) return;
199
+ const old = baseOf(s.path);
200
+ nameInput(r, old, name => {
201
+ if (!name || name === old) { loadTree(); return; }
202
+ fsOp({ op: 'rename', path: s.path, name }, res => afterMove(s.path, res.path));
203
+ });
204
+ }
205
+
206
+ // ---- menú contextual ----
207
+ let ctxEl = null;
208
+ function closeMenu() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
209
+ document.addEventListener('mousedown', ev => { if (ctxEl && !ctxEl.contains(ev.target)) closeMenu(); }, true);
210
+ document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closeMenu(); });
211
+ window.addEventListener('blur', closeMenu); window.addEventListener('resize', closeMenu);
212
+ $('#tree').addEventListener('scroll', closeMenu);
213
+ // e = {path, is_dir, row} o null (raíz); only = 'delete' abre solo la confirmación de borrado (tecla Supr)
214
+ function showMenu(x, y, e, only) {
215
+ closeMenu();
216
+ const isRoot = !e; const path = isRoot ? '.' : e.path; const isDir = isRoot || e.is_dir; const row = e && e.row;
217
+ const dir = isDir ? path : parentOf(path); // carpeta donde se crea o se pega
218
+ const items = [];
219
+ const del = { l: 'Eliminar', k: 'Supr', danger: true, confirm: '¿eliminar ' + baseOf(path) + (isDir ? '/' : '') + '?', f: () => fsOp({ op: 'delete', path }, () => afterDelete(path)) };
220
+ if (only === 'delete') items.push(del);
221
+ else {
222
+ if (!isDir) items.push({ l: 'Abrir', f: () => openFile(path, row) });
223
+ items.push({ l: 'Nuevo archivo…', f: () => newEntry(dir, false) }, { l: 'Nueva carpeta…', f: () => newEntry(dir, true) }, 'sep');
224
+ if (!isRoot) items.push({ l: 'Renombrar', k: 'F2', f: () => renameInline(e) }, { l: 'Duplicar', f: () => fsOp({ op: 'copy', path }) }, 'sep', { l: 'Copiar', k: 'Ctrl+C', f: () => setClip(e, 'copy') }, { l: 'Cortar', k: 'Ctrl+X', f: () => setClip(e, 'cut') });
225
+ items.push({ l: 'Pegar' + (treeClip ? ' «' + baseOf(treeClip.path) + '»' : ''), k: 'Ctrl+V', dis: !canPaste(dir), f: () => pasteInto(dir) }, 'sep');
226
+ items.push({ l: 'Copiar ruta', f: () => copyText(path, row) }, { l: 'Copiar ruta absoluta', dis: !treeRoot, f: () => copyText(absPath(path), row) }, { l: 'Insertar en el chat', f: () => insertInChat(path) });
227
+ if (!isRoot) items.push('sep', del);
228
+ items.push('sep', { l: 'Recargar', f: loadTree });
229
+ }
230
+ const m = document.createElement('div'); m.className = 'ctx';
231
+ for (const it of items) {
232
+ if (it === 'sep') { const s = document.createElement('div'); s.className = 'sep'; m.appendChild(s); continue; }
233
+ const d = document.createElement('div'); d.className = 'it' + (it.dis ? ' dis' : '') + (it.danger ? ' danger' : '');
234
+ d.innerHTML = `<span>${esc(it.l)}</span>` + (it.k ? `<span class="k">${esc(it.k)}</span>` : '');
235
+ d.onclick = ev => { ev.stopPropagation(); if (it.confirm) inlineConfirm(d, it.confirm, () => { closeMenu(); it.f(); }); else { closeMenu(); it.f(); } };
236
+ m.appendChild(d);
237
+ }
238
+ document.body.appendChild(m); ctxEl = m;
239
+ const W = window.innerWidth, H = window.innerHeight, mw = m.offsetWidth, mh = m.offsetHeight;
240
+ m.style.left = Math.max(4, Math.min(x, W - mw - 4)) + 'px'; m.style.top = Math.max(4, Math.min(y, H - mh - 4)) + 'px';
241
+ if (only === 'delete') { const first = m.querySelector('.it'); if (first) first.click(); }
242
+ }
243
+
42
244
  async function openFile(path, row) {
43
245
  document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
44
246
  const r = await fetch(BASE + '/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
@@ -25,6 +25,14 @@ description: How this harness works — tools, workspace mount, permissions, age
25
25
  more context. `replace_all=true` for intentional multi-replace.
26
26
  - `read` returns up to 2000 lines; use `offset`/`limit` for big files. Outputs > 30k chars are truncated.
27
27
  - `find` is a simple glob (`*.ts`, `test_*`, `*config*`), `grep` is regex by default (`regex=false` for literals).
28
+ - `fetch(url)` returns a web page as Markdown (about a tenth of the characters of its HTML: scripts, styles,
29
+ nav and footers stripped, links absolute, code blocks and tables kept; a site that serves Markdown is
30
+ taken as is). Follows up to 5 redirects. Long pages come back head+tail and the full text is saved to
31
+ `.lampson/spill/fetch-<host>-<id>.md` — page it with `read offset/limit` or `grep`, never re-fetch; the
32
+ same URL within 20 minutes is served from that file. `format=text` for prose without links, `format=html`
33
+ to see the raw markup. localhost / private hosts ask the user (good for checking your own dev server);
34
+ cloud metadata endpoints and URLs carrying tokens are refused. Binary content (images, PDF) is reported,
35
+ not returned — use `bash` with `curl -o` for downloads.
28
36
  - `lsp(op=symbols, path)` is the cheapest way to understand a big file: every function/class/variable
29
37
  with its line range — then `read` only the range you need. `definition` / `references` / `hover` /
30
38
  `implementation` take 1-based `line` + `character` ON the identifier. If no server is configured for
package/web.syn CHANGED
@@ -50,6 +50,7 @@ use "./lib/loop.syn" as loop
50
50
  use "./lib/session.syn" as session
51
51
  use "./lib/agents.syn" as agents
52
52
  use "./lib/tree.syn" as tree
53
+ use "./lib/fs.syn" as fs
53
54
  use "./lib/git.syn" as git
54
55
  use "./lib/tools/proc.syn" as proc
55
56
  use "./lib/tools/memo.syn" as memo
@@ -193,7 +194,10 @@ serve on 8080
193
194
  give {"result": session.delete(text(b["id"]))}
194
195
 
195
196
  route "GET /w/:slug/api/tree" requires auth
196
- give tree.tree(6)
197
+ let t be tree.tree(6)
198
+ -- ruta real del proyecto (lampson.ps1/.sh exportan LAMPSON_WORKSPACE): «copiar ruta absoluta» en el explorador
199
+ set t["root"] to env("LAMPSON_WORKSPACE", "")
200
+ give t
197
201
 
198
202
  -- Terminales del navegador. El pty NO vive en este handler: vive en un agente supervisor
199
203
  -- (lib/term.syn), y este socket es un puente por el bus. Así la shell sobrevive a un F5 —
@@ -440,6 +444,16 @@ serve on 8080
440
444
  give fail(400, "path required")
441
445
  give tree.file_content(query.path)
442
446
 
447
+ -- explorador: nuevo archivo/carpeta, renombrar, mover, copiar/duplicar, borrar (lib/fs.syn; solo dentro
448
+ -- del workspace, nunca pisa un destino). {op, path, name?, to?, content?} → {ok, result: {path, is_dir}}
449
+ route "POST /w/:slug/api/fs" requires auth
450
+ expect body {op: text, path: text}
451
+ let b be json of request
452
+ try
453
+ give {"ok": true, "result": fs.apply(b)}
454
+ recover err
455
+ give fail(400, text(err))
456
+
443
457
  -- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
444
458
  route "GET /w/:slug/api/settings/values" requires auth
445
459
  give {"values": settings.values(), "tz_detected": schedule.tz_offset()}