lampson 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.env.example +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +382 -0
  4. package/bin/lampson.js +81 -0
  5. package/chat.syn +799 -0
  6. package/lamps/example-hello/lamp.json +16 -0
  7. package/lamps/example-hello/lamp.syn +19 -0
  8. package/lampson.cmd +4 -0
  9. package/lampson.ps1 +88 -0
  10. package/lampson.sh +42 -0
  11. package/lib/agents.syn +471 -0
  12. package/lib/git.syn +58 -0
  13. package/lib/lamps.syn +386 -0
  14. package/lib/loop.syn +455 -0
  15. package/lib/lsp.syn +503 -0
  16. package/lib/mcp.syn +403 -0
  17. package/lib/permission.syn +154 -0
  18. package/lib/prompt.syn +75 -0
  19. package/lib/provider.syn +522 -0
  20. package/lib/session.syn +111 -0
  21. package/lib/settings.syn +70 -0
  22. package/lib/skills.syn +179 -0
  23. package/lib/tools/bash.syn +105 -0
  24. package/lib/tools/common.sh +49 -0
  25. package/lib/tools/common.syn +91 -0
  26. package/lib/tools/edit.syn +32 -0
  27. package/lib/tools/find.syn +35 -0
  28. package/lib/tools/grep.syn +31 -0
  29. package/lib/tools/img.ps1 +36 -0
  30. package/lib/tools/img.sh +22 -0
  31. package/lib/tools/ls.syn +18 -0
  32. package/lib/tools/memo.syn +148 -0
  33. package/lib/tools/proc.sh +42 -0
  34. package/lib/tools/proc.syn +314 -0
  35. package/lib/tools/process.syn +46 -0
  36. package/lib/tools/read.syn +25 -0
  37. package/lib/tools/skill.syn +14 -0
  38. package/lib/tools/todo.syn +97 -0
  39. package/lib/tools/write.syn +22 -0
  40. package/lib/tools.syn +198 -0
  41. package/lib/trace.syn +116 -0
  42. package/lib/tree.syn +59 -0
  43. package/lib/update.syn +57 -0
  44. package/package.json +40 -0
  45. package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
  46. package/public/fonts/plex-mono-400-latin.woff2 +0 -0
  47. package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
  48. package/public/fonts/plex-mono-600-latin.woff2 +0 -0
  49. package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
  50. package/public/fonts/plex-serif-400-latin.woff2 +0 -0
  51. package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
  52. package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
  53. package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
  54. package/public/fonts/plex-serif-600-latin.woff2 +0 -0
  55. package/public/index.html +1268 -0
  56. package/public/vendor/xterm-addon-fit.js +2 -0
  57. package/public/vendor/xterm.css +218 -0
  58. package/public/vendor/xterm.js +2 -0
  59. package/skills/debugging/SKILL.md +33 -0
  60. package/skills/lampson/SKILL.md +117 -0
  61. package/skills/synsema/SKILL.md +75 -0
  62. package/web.syn +468 -0
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # img.sh — imágenes para el REPL (Linux/macOS). Imprime UNA línea: media_type|ancho|alto|base64
3
+ # bash lib/tools/img.sh clip # portapapeles: wl-paste, xclip o pbpaste (macOS)
4
+ # bash lib/tools/img.sh file <ruta>
5
+ # Sin redimensionar (no asumimos ImageMagick); ancho/alto en 0 si no hay `identify`.
6
+ set -euo pipefail
7
+ mode="$1"; tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT
8
+ if [ "$mode" = "clip" ]; then
9
+ if command -v wl-paste >/dev/null 2>&1; then wl-paste -t image/png > "$tmp" 2>/dev/null || true
10
+ elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -t image/png -o > "$tmp" 2>/dev/null || true
11
+ elif command -v osascript >/dev/null 2>&1; then osascript -e 'set f to POSIX file "'"$tmp"'"' -e 'try' -e 'set d to the clipboard as «class PNGf»' -e 'set o to open for access f with write permission' -e 'write d to o' -e 'close access o' -e 'end try' >/dev/null 2>&1 || true
12
+ fi
13
+ [ -s "$tmp" ] || { echo "ERROR|no hay una imagen en el portapapeles"; exit 0; }
14
+ type="image/png"
15
+ else
16
+ [ -f "$2" ] || { echo "ERROR|no existe $2"; exit 0; }
17
+ cp "$2" "$tmp"
18
+ case "${2,,}" in *.jpg|*.jpeg) type="image/jpeg";; *.gif) type="image/gif";; *.webp) type="image/webp";; *) type="image/png";; esac
19
+ fi
20
+ w=0; h=0
21
+ if command -v identify >/dev/null 2>&1; then read -r w h < <(identify -format "%w %h" "$tmp[0]" 2>/dev/null || echo "0 0"); fi
22
+ echo "$type|$w|$h|$(base64 < "$tmp" | tr -d '\n')"
@@ -0,0 +1,18 @@
1
+ -- lib/tools/ls.syn — listar un directorio (no recursivo)
2
+ use "./common.syn" as c
3
+
4
+ export task tool(path)
5
+ require file.read("workspace")
6
+ require file.read("workspace/*")
7
+ let lines be []
8
+ each e in list_dir(c.ws(path))
9
+ set lines to append(lines, e["name"] + (when e["is_dir"] then "/" otherwise ""))
10
+ when length(lines) == 0
11
+ give "(empty directory)"
12
+ give join(lines, "\n")
13
+
14
+ export let SPEC be {
15
+ "name": "ls",
16
+ "description": "List the entries of a directory (non-recursive). Directories end with a slash. Defaults to the workspace root.",
17
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": []}
18
+ }
@@ -0,0 +1,148 @@
1
+ -- lib/tools/memo.syn — memoria por proyecto: notas Markdown que el agente escribe y relee
2
+ --
3
+ -- Dónde: lampson/memory/<slug>/<nombre>.md — <slug> sale de la ruta REAL del workspace
4
+ -- (LAMPSON_WORKSPACE, que fija el launcher): "<carpeta>-<hash8>". Así cada proyecto tiene su carpeta
5
+ -- aunque dos se llamen igual, y las notas viven en lampson (no ensucian el repo del usuario).
6
+ --
7
+ -- El system prompt lleva SOLO el índice (nombre + primera línea de cada nota); el contenido entra al
8
+ -- contexto cuando el agente llama `memory(read)`. Son archivos: el humano los lee/edita a mano y la
9
+ -- UI web los muestra.
10
+ use "./common.syn" as c
11
+
12
+ export let ROOT be "memory"
13
+
14
+ task valid_name(name)
15
+ when name == nothing or name == ""
16
+ give false
17
+ give matches(name, "[a-zA-Z0-9_-]{1,60}")
18
+
19
+ -- slug del proyecto montado
20
+ export task slug()
21
+ require env("LAMPSON_*")
22
+ let ws be env("LAMPSON_WORKSPACE", "workspace")
23
+ let norm be replace_text(lower(ws), "\\", "/")
24
+ let parts be where(split(norm, "/"), (x) => x != "")
25
+ let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
26
+ let runs be find_all(base, "[a-z0-9]+")
27
+ let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
28
+ let h be slice(decode(sha256(norm), "hex"), 0, 8)
29
+ give clean + "-" + h
30
+
31
+ export task dir()
32
+ require env("LAMPSON_*")
33
+ give ROOT + "/" + slug()
34
+
35
+ task path(name)
36
+ give dir() + "/" + name + ".md"
37
+
38
+ -- [{name, title}] — title = primera línea no vacía (sin '#')
39
+ export task list()
40
+ require env("LAMPSON_*")
41
+ require file.read("memory")
42
+ require file.read("memory/*")
43
+ let out be []
44
+ try
45
+ each e in list_dir(dir())
46
+ when not e["is_dir"] and c.ends_with(e["name"], ".md")
47
+ let name be slice(e["name"], 0, length(e["name"]) - 3)
48
+ let title be ""
49
+ each line in split(read_file(path(name)), "\n")
50
+ when title == "" and trim(line) != ""
51
+ set title to trim(replace_text(line, "#", ""))
52
+ set out to append(out, {"name": name, "title": when length(title) > 120 then slice(title, 0, 120) otherwise title})
53
+ recover err
54
+ -- el runtime lanza "Not a directory: …" (mayúscula) cuando memory/<slug> aún no existe
55
+ let e be lower(text(err))
56
+ when not contains(e, "not a directory") and not contains(e, "not found") and not contains(e, "no such")
57
+ raise(err)
58
+ give []
59
+ give sort_by(out, (x) => x["name"])
60
+
61
+ export task note_read(name)
62
+ require env("LAMPSON_*")
63
+ require file.read("memory")
64
+ require file.read("memory/*")
65
+ when not valid_name(name)
66
+ raise("invalid note name")
67
+ try
68
+ give read_file(path(name))
69
+ recover err
70
+ raise("no such note '" + name + "'. Existing: " + join(apply((x) => x["name"], list()), ", "))
71
+
72
+ export task note_write(name, content)
73
+ require env("LAMPSON_*")
74
+ require file("memory")
75
+ require file("memory/*")
76
+ when not valid_name(name)
77
+ raise("invalid note name '" + text(name) + "' (letters, digits, - or _)")
78
+ write_file(path(name), content)
79
+ give "saved memory/" + slug() + "/" + name + ".md (" + text(length(content)) + " chars)"
80
+
81
+ export task note_append(name, content)
82
+ require env("LAMPSON_*")
83
+ require file("memory")
84
+ require file("memory/*")
85
+ when not valid_name(name)
86
+ raise("invalid note name")
87
+ let prev be ""
88
+ try
89
+ set prev to read_file(path(name))
90
+ recover err
91
+ set prev to ""
92
+ let joined be when prev == "" then content otherwise prev + "\n\n" + content
93
+ write_file(path(name), joined)
94
+ give "appended to " + name + ".md (" + text(length(joined)) + " chars total)"
95
+
96
+ export task note_clear(name)
97
+ require env("LAMPSON_*")
98
+ require file("memory")
99
+ require file("memory/*")
100
+ when not valid_name(name)
101
+ raise("invalid note name")
102
+ write_file(path(name), "")
103
+ give "cleared " + name + ".md (the file stays empty; the user can delete it)"
104
+
105
+ -- sección del system prompt
106
+ export task prompt_section()
107
+ require env("LAMPSON_*")
108
+ require file.read("memory")
109
+ require file.read("memory/*")
110
+ let items be list()
111
+ let head be "\n\n# Project memory (your own notes about THIS project, in memory/" + slug() + "/ — read with memory(read), keep them current with memory(write|append))"
112
+ when length(items) == 0
113
+ give head + "\n(empty — when you discover something non-obvious about this project: how to run/test it, gotchas, decisions, where things live — save it with memory(write). Keep notes short and factual.)"
114
+ let lines be [head]
115
+ each it in items
116
+ set lines to append(lines, "- " + it["name"] + ": " + it["title"])
117
+ give join(lines, "\n")
118
+
119
+ -- ---------- tool ----------
120
+
121
+ export task tool(action, name, content)
122
+ require env("LAMPSON_*")
123
+ require file("memory")
124
+ require file("memory/*")
125
+ when action == "list"
126
+ let items be list()
127
+ when length(items) == 0
128
+ give "no notes yet for this project"
129
+ give join(apply((x) => x["name"] + ": " + x["title"], items), "\n")
130
+ when action == "read"
131
+ give note_read(name)
132
+ when action == "write"
133
+ give note_write(name, content)
134
+ when action == "append"
135
+ give note_append(name, content)
136
+ when action == "delete"
137
+ give note_clear(name)
138
+ raise("unknown action '" + text(action) + "' (list | read | write | append | delete)")
139
+
140
+ export let SPEC be {
141
+ "name": "memory",
142
+ "description": "Your persistent notes about THIS project, kept across sessions (Markdown files in Lampson's memory folder, never inside the repo). Use `write` to save non-obvious facts you discovered and will need again: how to run/build/test, environment quirks, architecture decisions, where things live, bugs and their causes, what the user prefers. `append` adds to an existing note, `read` loads one, `list` shows them, `delete` clears one. The system prompt shows the index of notes — read the relevant ones before repeating an investigation. Keep notes short, factual and current: update a note instead of writing a contradictory one.",
143
+ "parameters": {"type": "object", "properties": {
144
+ "action": {"type": "string", "enum": ["list", "read", "write", "append", "delete"]},
145
+ "name": {"type": "string", "description": "Note id: letters, digits, - or _ (e.g. how-to-run, db-schema, gotchas)"},
146
+ "content": {"type": "string", "description": "write/append: Markdown content (first line = title)"}
147
+ }, "required": ["action"]}
148
+ }
@@ -0,0 +1,42 @@
1
+ # proc.sh — consultas al sistema que Synsema no tiene nativas (puertos, línea de comando, matar un pid ajeno)
2
+ #
3
+ # bash lib/tools/proc.sh ports → "port pid name" por línea (>=1024, sin sistema)
4
+ # bash lib/tools/proc.sh info <pid> → línea de comando del proceso
5
+ # bash lib/tools/proc.sh kill <pid> → mata el árbol de un pid AJENO (huérfano de otra herramienta)
6
+ #
7
+ # Los procesos gestionados (start/stop/alive) ya NO pasan por acá: viven en un agente supervisor con
8
+ # proc_spawn / proc_close (lib/tools/proc.syn, v0.6.9: tree-kill nativo). 2026-08-27.
9
+ . "$(dirname "$0")/common.sh"
10
+ ACTION="$1"
11
+ case "$ACTION" in
12
+ ports)
13
+ if is_win; then
14
+ # netstat + tasklist → "port pid name"
15
+ tasklist //FO CSV //NH 2>/dev/null | awk -F'","' '{gsub(/"/,"",$1); gsub(/"/,"",$2); print $2" "$1}' > /tmp/lampson_tl.$$
16
+ netstat -ano 2>/dev/null | awk '/LISTENING/ {n=split($2,a,":"); print a[n]" "$NF}' | sort -u -k1,1n | while read -r port pid; do
17
+ name="$(awk -v p="$pid" '$1==p {print $2; exit}' /tmp/lampson_tl.$$)"
18
+ echo "$port $pid ${name:-?}"
19
+ done
20
+ rm -f /tmp/lampson_tl.$$
21
+ elif command -v ss >/dev/null 2>&1; then
22
+ ss -ltnpH 2>/dev/null | awk '{n=split($4,a,":"); port=a[n]; name="?"; pid="?"; if (match($0,/users:\(\("[^"]+",pid=[0-9]+/)) { s=substr($0,RSTART,RLENGTH); gsub(/users:\(\("/,"",s); split(s,b,"\",pid="); name=b[1]; pid=b[2] } print port" "pid" "name}' | sort -u -k1,1n
23
+ elif command -v lsof >/dev/null 2>&1; then
24
+ lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {n=split($9,a,":"); print a[n]" "$2" "$1}' | sort -u -k1,1n
25
+ fi
26
+ ;;
27
+ info)
28
+ # línea de comando de un pid (para saber QUÉ escucha en un puerto)
29
+ PID="$2"
30
+ if is_win; then
31
+ powershell.exe -NoProfile -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=$PID').CommandLine" 2>/dev/null | tr -d '\r'
32
+ else
33
+ ps -o args= -p "$PID" 2>/dev/null
34
+ fi
35
+ ;;
36
+ kill)
37
+ PID="$2"
38
+ if is_win; then taskkill //T //F //PID "$PID" >/dev/null 2>&1; else kill_tree "$PID"; fi
39
+ echo "killed $PID"
40
+ ;;
41
+ esac
42
+ exit 0
@@ -0,0 +1,314 @@
1
+ -- lib/tools/proc.syn — procesos gestionados (servidores, watchers): start / logs / stop / list / report
2
+ --
3
+ -- DISEÑO (2026-08-27, nativo sobre proc_spawn — antes era bash + pidfiles, ver README "runtime notes"):
4
+ -- * Cada proceso vive dentro de un AGENTE supervisor (`Sup`), que es lo único con ciclo de vida
5
+ -- propio bajo `serve`: un proc_spawn hecho en un handler muere al terminar el request; uno hecho
6
+ -- en un agente vive hasta que el agente termina (verificado en v0.6.9, run y serve).
7
+ -- * El supervisor drena los eventos del proceso (select) al ARCHIVO .lampson/proc/<name>.log, así:
8
+ -- - `report()` devuelve las líneas NUEVAS desde la última vez (offset en .pos) — la tool bash lo
9
+ -- agrega a cada resultado: el agente "ve la consola" de sus servidores sin pedirla;
10
+ -- - la UI web y `/logs` leen el mismo archivo;
11
+ -- - el archivo es el spill: nada se pierde por tamaño de cola.
12
+ -- * Estado vivo en el blackboard: "proc:<name>" = {status, pid, exit_code, started}. Se lee con
13
+ -- observe desde cualquier handler/agente (misma máquina de estado que ve el modelo).
14
+ -- * `stop` = bus_publish("proc.stop.<name>") → el supervisor hace proc_close = TERM → KILL a 2 s
15
+ -- sobre el ÁRBOL (Job Object en Windows, process group en unix; v0.6.9+). Sin taskkill, sin ps.
16
+ -- * Al cerrar Lampson (chat: /exit; web: Ctrl-C → agent_stop) los procesos mueren con él —
17
+ -- el runtime no deja huérfanos. Es la decisión de opencode/deepseek; hermes es el único que persiste.
18
+ -- * El comando corre en `bash -c` (Git Bash en Windows) para que el modelo tenga pipes y &&.
19
+ use "./common.syn" as c
20
+
21
+ export let DIR be ".lampson/proc"
22
+ let MAX_REPORT be 4000
23
+ -- procesos del sistema que nunca son "tu servidor" (ruido en la lista de puertos)
24
+ let SYSTEM_PROCS be ["svchost.exe", "lsass.exe", "wininit.exe", "services.exe", "spoolsv.exe", "system", "mmsshost.exe", "sqlservr.exe", "dns.exe", "onedrive.exe", "msedge.exe", "chrome.exe", "code.exe", "explorer.exe", "searchhost.exe", "nvcontainer.exe"]
25
+
26
+ task shell()
27
+ require env("LAMPSON_*")
28
+ require env("OS")
29
+ let is_win be env("OS", "") == "Windows_NT"
30
+ give env("LAMPSON_SHELL", when is_win then "C:\\Program Files\\Git\\bin\\bash.exe" otherwise "bash")
31
+
32
+ task valid_name(name)
33
+ when name == nothing or name == ""
34
+ give false
35
+ give matches(name, "[a-zA-Z0-9_-]{1,40}")
36
+
37
+ task path(name, ext)
38
+ give DIR + "/" + name + "." + ext
39
+
40
+ task tail_lines(content, n)
41
+ let lines be split(replace_text(content, "\r", ""), "\n")
42
+ when length(lines) > n
43
+ set lines to slice(lines, length(lines) - n, length(lines))
44
+ give join(lines, "\n")
45
+
46
+ -- ---------- supervisor: un agente por proceso ----------
47
+ -- Parámetros del spawn: name, command, sh (ejecutable del shell), cwd, log (path). OJO: un agente NO ve
48
+ -- las constantes del módulo (DIR, c.*) — solo sus parámetros y las builtins. Estado → blackboard "proc:<name>";
49
+ -- salida → .lampson/proc/<name>.log (una línea por evento, más "[process exited with code N]").
50
+ agent Sup
51
+ require exec
52
+ require time
53
+ require env("LAMPSON_*")
54
+ require env("OS")
55
+ require file(".lampson")
56
+ require file(".lampson/*")
57
+ require file("workspace")
58
+ require file("workspace/*")
59
+ let key be "proc:" + name
60
+ let p be nothing
61
+ try
62
+ set p to proc_spawn(sh, ["-c", command], {"cwd": cwd, "stderr": "merge", "on_full": "drop_oldest"})
63
+ recover err
64
+ append_file(log, "[cannot start: " + text(err) + "]\n[process exited with code -1]\n")
65
+ share {"status": "exited", "exit_code": -1, "pid": 0, "started": now()} as key
66
+ when p != nothing
67
+ share {"status": "running", "exit_code": nothing, "pid": proc_stats(p)["pid"], "started": now()} as key
68
+ bus_publish("proc." + name, {"name": name, "status": "running"})
69
+ let sub be bus_subscribe(["proc.stop." + name, "proc.stop_all"])
70
+ let open be true
71
+ while open
72
+ let ev be select({"p": p, "stop": sub}, 30)
73
+ when ev == nothing
74
+ set open to proc_status(p) == "running"
75
+ otherwise when ev["name"] == "stop"
76
+ proc_close(p)
77
+ append_file(log, "[process stopped by lampson]\n")
78
+ share {"status": "stopped", "exit_code": -1, "pid": 0, "started": nothing} as key
79
+ bus_publish("proc." + name, {"name": name, "status": "stopped"})
80
+ set open to false
81
+ otherwise when ev["type"] == "exit"
82
+ let code be ev["data"]["exit_code"]
83
+ append_file(log, "[process exited with code " + text(code) + "]\n")
84
+ share {"status": "exited", "exit_code": code, "pid": 0, "started": nothing} as key
85
+ bus_publish("proc." + name, {"name": name, "status": "exited", "exit_code": code})
86
+ set open to false
87
+ otherwise
88
+ append_file(log, ev["data"] + "\n")
89
+ -- la UI web lo recibe por /api/events (SSE) y refresca el log sin timers
90
+ bus_publish("proc." + name, {"name": name, "status": "running", "line": ev["data"]})
91
+ proc_close(p)
92
+ bus_unsubscribe(sub)
93
+
94
+ -- estado vivo de un proceso: {status, exit_code, pid, started} o nothing si nunca arrancó en ESTA sesión
95
+ export task state(name)
96
+ observe "proc:" + name as st
97
+ give st
98
+
99
+ export task alive(name)
100
+ let st be state(name)
101
+ when st == nothing
102
+ give false
103
+ give st["status"] == "running"
104
+
105
+ export task start(name, command)
106
+ require exec
107
+ require time
108
+ require env("LAMPSON_*")
109
+ require env("OS")
110
+ require file(".lampson")
111
+ require file(".lampson/*")
112
+ require file("workspace")
113
+ require file("workspace/*")
114
+ when not valid_name(name)
115
+ raise("invalid process name '" + text(name) + "' (use letters, digits, - or _)")
116
+ when alive(name)
117
+ raise("process '" + name + "' is already running — stop it first or pick another name")
118
+ write_file(path(name, "cmd"), command)
119
+ write_file(path(name, "log"), "")
120
+ write_file(path(name, "pos"), "0")
121
+ -- dueño: la corrida y la sesión que lo arrancaron (blackboard). list() solo muestra lo que corre o lo
122
+ -- de ESTA sesión en ESTA corrida: un proceso terminado de otra sesión/corrida ya no aparece
123
+ write_file(path(name, "owner"), json_encode(owner()))
124
+ -- la clave del blackboard puede tener el estado VIEJO (stopped/exited) de un arranque anterior:
125
+ -- marcar "starting" antes del spawn, el supervisor la pisa con running/exited
126
+ share {"status": "starting", "exit_code": nothing, "pid": 0, "started": now()} as "proc:" + name
127
+ spawn Sup with name = name, command = command, sh = shell(), cwd = c.ROOT, log = path(name, "log")
128
+ -- esperar a que el supervisor publique (running/exited) y hasta 3s a que salga algo (npm en Windows
129
+ -- tarda >2s en escribir una línea en frío); "EXITED" solo lo dice el supervisor, nunca un log vacío
130
+ let waited be 0
131
+ let log be ""
132
+ while waited < 20 and state(name)["status"] == "starting"
133
+ sleep(0.1)
134
+ set waited to waited + 1
135
+ set waited to 0
136
+ while waited < 6 and trim(log) == "" and alive(name)
137
+ sleep(0.5)
138
+ set waited to waited + 1
139
+ set log to read_file(path(name, "log"))
140
+ set log to read_file(path(name, "log"))
141
+ let running be alive(name)
142
+ let status be when running then "status: running (pid " + text(state(name)["pid"]) + "; NEW log lines arrive with every bash result, or call logs)" otherwise "status: EXITED (the log ends with its exit code) — read it, fix the cause and start again with this tool; do NOT run the same command with bash"
143
+ when running and trim(log) == ""
144
+ set status to status + "\nlog: empty so far (cold start) — call logs in a few seconds"
145
+ give "started '" + name + "': $ " + command + "\n" + status + "\nlog (first lines):\n" + tail_lines(log, 30)
146
+
147
+ export task logs(name, tail)
148
+ require file(".lampson")
149
+ require file(".lampson/*")
150
+ let n be when tail == nothing then 60 otherwise floor(tail)
151
+ try
152
+ give tail_lines(read_file(path(name, "log")), n)
153
+ recover err
154
+ give "no such process '" + text(name) + "'"
155
+
156
+ -- stop cooperativo: aviso por bus → el supervisor mata el árbol; esperamos hasta 6s la confirmación
157
+ export task halt(name)
158
+ require time
159
+ when not alive(name)
160
+ give "process '" + text(name) + "' is not running"
161
+ bus_publish("proc.stop." + name, {})
162
+ let waited be 0
163
+ while waited < 12 and alive(name)
164
+ sleep(0.5)
165
+ set waited to waited + 1
166
+ when alive(name)
167
+ give "stop requested for '" + name + "' but it is still running — check `list` again in a moment"
168
+ give "stopped '" + name + "'"
169
+
170
+ -- mata todos los gestionados (salida de lampson)
171
+ export task halt_all()
172
+ require time
173
+ bus_publish("proc.stop_all", {})
174
+ let waited be 0
175
+ while waited < 10 and length(where(list(), (p) => p["running"])) > 0
176
+ sleep(0.3)
177
+ set waited to waited + 1
178
+
179
+ -- Nota para el modelo cuando se reanuda una sesión guardada por una corrida ANTERIOR de lampson:
180
+ -- los procesos gestionados mueren con lampson, pero el historial de la sesión dice que estaban corriendo.
181
+ -- Sin esto el modelo "recuerda" servers vivos que ya no existen (visto 2026-08-28). "" si no hay nada que decir.
182
+ -- procesos que ESTA sesión arrancó en cualquier corrida (para la nota de resume: qué se murió con el restart)
183
+ export task list_for_session(sid)
184
+ require file(".lampson")
185
+ require file(".lampson/*")
186
+ let out be []
187
+ try
188
+ each e in list_dir(DIR)
189
+ when c.ends_with(e["name"], ".cmd")
190
+ let name be slice(e["name"], 0, length(e["name"]) - 4)
191
+ let mine be false
192
+ try
193
+ set mine to json_decode(read_file(path(name, "owner")))["session"] == text(sid)
194
+ recover err
195
+ set mine to false
196
+ when mine
197
+ set out to append(out, {"name": name, "running": alive(name), "command": read_file(path(name, "cmd"))})
198
+ recover err
199
+ give []
200
+ give out
201
+
202
+ export task resume_note(sid)
203
+ require file(".lampson")
204
+ require file(".lampson/*")
205
+ let items be list_for_session(sid)
206
+ when length(items) == 0
207
+ give ""
208
+ let lines be []
209
+ each p in items
210
+ set lines to append(lines, "- " + p["name"] + ": " + (when p["running"] then "running" otherwise "NOT running") + " ($ " + first_line_of(p["command"]) + ")")
211
+ give "[harness] This session was resumed after a lampson restart. Managed processes do NOT survive a restart — anything this conversation started earlier is gone unless listed as running below. Current real state:\n" + join(lines, "\n") + "\nOld logs remain in " + DIR + "/<name>.log. Start a process again with the `process` tool only if it is needed now; do not assume any server is up."
212
+
213
+ task first_line_of(s)
214
+ let t be replace_text(text(s), "\r", "")
215
+ give split(t, "\n")[0]
216
+
217
+ -- {run, session} actuales (blackboard "lampson:run" / "lampson:session"; vacíos fuera de un entry)
218
+ task owner()
219
+ observe "lampson:run" as r
220
+ observe "lampson:session" as s
221
+ give {"run": when r == nothing then "" otherwise text(r["id"]), "session": when s == nothing then "" otherwise text(s["id"])}
222
+
223
+ task owned_here(name)
224
+ try
225
+ let o be json_decode(read_file(path(name, "owner")))
226
+ let cur be owner()
227
+ give o["run"] == cur["run"] and o["session"] == cur["session"]
228
+ recover err
229
+ give false
230
+
231
+ -- [{name, running, command}] — lo que corre ahora + lo terminado de esta sesión en esta corrida.
232
+ -- Como la lista de tareas (todo): un proceso muerto de otra sesión o de una corrida anterior no es
233
+ -- información, es ruido (2026-08-28: la UI mostraba servers de hace días como "apagados").
234
+ export task list()
235
+ require file(".lampson")
236
+ require file(".lampson/*")
237
+ let out be []
238
+ try
239
+ each e in list_dir(DIR)
240
+ when c.ends_with(e["name"], ".cmd")
241
+ let name be slice(e["name"], 0, length(e["name"]) - 4)
242
+ let running be alive(name)
243
+ when running or owned_here(name)
244
+ set out to append(out, {"name": name, "running": running, "command": read_file(path(name, "cmd"))})
245
+ recover err
246
+ give []
247
+ give out
248
+
249
+ -- Puertos TCP en escucha (cualquier proceso, gestionado o no): [{port, pid, name, command}] — via proc.sh ports
250
+ -- (Windows: netstat+tasklist · Linux: ss · macOS: lsof). Un servidor huérfano no aparece en list(); acá sí.
251
+ export task listeners()
252
+ require exec
253
+ require env("LAMPSON_*")
254
+ require env("OS")
255
+ require file(".lampson")
256
+ require file(".lampson/*")
257
+ let out be []
258
+ try
259
+ let r be run(shell(), ["lib/tools/proc.sh", "ports"], 20, {"cwd": "."})
260
+ let seen be {}
261
+ each line in split(replace_text(r["stdout"], "\r", ""), "\n")
262
+ let parts be where(split(trim(line), " "), (x) => x != "")
263
+ when length(parts) >= 2
264
+ let port be floor(number(parts[0]))
265
+ let nm be when length(parts) >= 3 then parts[2] otherwise "?"
266
+ when port >= 1024 and port < 49152 and not contains(SYSTEM_PROCS, lower(nm)) and not contains(seen, text(port))
267
+ set seen[text(port)] to true
268
+ set out to append(out, {"port": port, "pid": floor(number(parts[1])), "name": nm, "command": ""})
269
+ recover err
270
+ give out
271
+ -- la línea de comando dice QUÉ es (next dev, un MCP server de otra herramienta, synsema serve…); un run por pid
272
+ let cmds be {}
273
+ let enriched be []
274
+ each l in out
275
+ let key be text(l["pid"])
276
+ when not contains(cmds, key)
277
+ try
278
+ let r be run(shell(), ["lib/tools/proc.sh", "info", key], 15, {"cwd": "."})
279
+ set cmds[key] to trim(replace_text(r["stdout"], "\n", " "))
280
+ recover err
281
+ set cmds[key] to ""
282
+ set l["command"] to when length(cmds[key]) > 300 then slice(cmds[key], 0, 300) + "…" otherwise cmds[key]
283
+ set enriched to append(enriched, l)
284
+ give sort_by(enriched, (x) => x["port"])
285
+
286
+ -- matar un pid ajeno (huérfano de otra herramienta): taskkill /T o kill_tree, via proc.sh
287
+ export task kill_pid(pid)
288
+ require exec
289
+ require env("LAMPSON_*")
290
+ require env("OS")
291
+ let r be run(shell(), ["lib/tools/proc.sh", "kill", text(floor(pid))], 20, {"cwd": "."})
292
+ give trim(r["stdout"])
293
+
294
+ -- líneas nuevas de cada proceso desde la última llamada. "" si no hay nada.
295
+ -- La salida del proceso ("[process exited with code N]") llega como línea nueva: es el aviso.
296
+ export task report()
297
+ require file(".lampson")
298
+ require file(".lampson/*")
299
+ let parts be []
300
+ each p in list()
301
+ let log be read_file(path(p["name"], "log"))
302
+ let pos be 0
303
+ try
304
+ set pos to floor(number(trim(read_file(path(p["name"], "pos")))))
305
+ recover err
306
+ set pos to 0
307
+ when pos > length(log)
308
+ set pos to 0
309
+ let fresh be slice(log, pos, length(log))
310
+ write_file(path(p["name"], "pos"), text(length(log)))
311
+ when trim(fresh) != ""
312
+ let status be when p["running"] then "running" otherwise "EXITED"
313
+ set parts to append(parts, "[process '" + p["name"] + "' (" + status + ") new output:\n" + c.truncate(fresh, MAX_REPORT) + "]")
314
+ give join(parts, "\n")
@@ -0,0 +1,46 @@
1
+ -- lib/tools/process.syn — tool `process`: servidores y watchers gestionados (start / logs / stop / list)
2
+ use "./proc.syn" as proc
3
+
4
+ export task tool(action, name, command, tail)
5
+ require exec
6
+ require time
7
+ require env("LAMPSON_*")
8
+ require env("OS")
9
+ require file(".lampson")
10
+ require file(".lampson/*")
11
+ when action == "start"
12
+ when command == nothing or command == ""
13
+ raise("start needs a command")
14
+ give proc.start(name, command)
15
+ when action == "logs"
16
+ give proc.logs(name, tail)
17
+ when action == "stop"
18
+ give proc.halt(name)
19
+ when action == "ports"
20
+ let ls be proc.listeners()
21
+ when length(ls) == 0
22
+ give "no TCP ports listening (>=1024)"
23
+ let lines be []
24
+ each l in ls
25
+ set lines to append(lines, "port " + text(l["port"]) + " pid " + text(l["pid"]) + " " + l["name"])
26
+ give join(lines, "\n")
27
+ when action == "list"
28
+ let items be proc.list()
29
+ when length(items) == 0
30
+ give "no managed processes"
31
+ let lines be []
32
+ each p in items
33
+ set lines to append(lines, p["name"] + " " + (when p["running"] then "running" otherwise "exited") + " $ " + p["command"])
34
+ give join(lines, "\n")
35
+ raise("unknown action '" + text(action) + "' (start | logs | stop | list | ports)")
36
+
37
+ export let SPEC be {
38
+ "name": "process",
39
+ "description": "Manage long-running processes (dev servers, watchers, APIs) so you can SEE their output. start: launches `command` detached with a `name`; its stdout/stderr go to a log you can read any time. After every bash call you automatically receive the NEW log lines of every managed process, so errors in a server you started reach you without asking. logs: last `tail` lines (default 60). A process is EXITED only when its pid is really gone; then its log ends with `[process exited with code N]` — read the reason there instead of retrying the command in bash. stop: kills the process and its whole tree (npm → node included). list: what is running. ports: every TCP port listening on the machine with its pid and program — use it when a port is busy (EADDRINUSE) or to check whether something is already up. Always use this instead of `cmd &` in bash, and stop what you started when the task is done.",
40
+ "parameters": {"type": "object", "properties": {
41
+ "action": {"type": "string", "enum": ["start", "logs", "stop", "list", "ports"]},
42
+ "name": {"type": "string", "description": "Short id: letters, digits, - or _ (e.g. web, api, dev)"},
43
+ "command": {"type": "string", "description": "start only: the shell command (runs with cwd = workspace root)"},
44
+ "tail": {"type": "integer", "description": "logs only: number of lines (default 60)"}
45
+ }, "required": ["action"]}
46
+ }
@@ -0,0 +1,25 @@
1
+ -- lib/tools/read.syn — leer un archivo del workspace (con rango de líneas)
2
+ use "./common.syn" as c
3
+
4
+ export task tool(path, offset, limit)
5
+ require file.read("workspace")
6
+ require file.read("workspace/*")
7
+ require file.read(".lampson")
8
+ require file.read(".lampson/*")
9
+ let off be when offset == nothing then 1 otherwise floor(offset)
10
+ let lim be when limit == nothing then 2000 otherwise floor(limit)
11
+ let real be c.ws(path)
12
+ let out be c.truncate(read_file(real, off, lim), c.MAX_OUTPUT)
13
+ -- registrar la observación (hash del archivo completo): edit/write la exigen
14
+ c.mark_observed(real)
15
+ give out
16
+
17
+ export let SPEC be {
18
+ "name": "read",
19
+ "description": "Read a file from the workspace. Returns the text with line-range support. Use offset (1-based line) and limit (max lines) for big files; default reads the first 2000 lines. Always read a file before editing it.",
20
+ "parameters": {"type": "object", "properties": {
21
+ "path": {"type": "string", "description": "Path relative to the workspace root"},
22
+ "offset": {"type": "integer", "description": "1-based first line to read (optional)"},
23
+ "limit": {"type": "integer", "description": "Max lines to read (optional)"}
24
+ }, "required": ["path"]}
25
+ }
@@ -0,0 +1,14 @@
1
+ -- lib/tools/skill.syn — cargar una skill (SKILL.md) al contexto
2
+ -- Nota: skills.syn está un nivel arriba y un módulo no puede importar "../", así que este archivo
3
+ -- solo define el SPEC; la task real vive en lib/tools.syn (que sí puede importar ./skills.syn).
4
+
5
+ export let SPEC be {
6
+ "name": "skill",
7
+ "description": "Skills are folders with a SKILL.md (the Agent Skills format used on skills.sh). action=load (default): load the instructions of a skill listed in the system prompt — do it BEFORE starting that kind of task and follow them. action=list: every skill available with its source. action=install: fetch a skill from a GitHub repo with `npx skills add` (source=owner/repo, name=the skill folder; e.g. source=anthropics/skills name=frontend-design). scope=global (default) installs into ~/.agents/skills so it is available in EVERY project; scope=project into ./.agents/skills. Installing always asks the user for approval — propose it when the task needs know-how you lack (a framework, a language, design guidelines) and a skill for it exists.",
8
+ "parameters": {"type": "object", "properties": {
9
+ "name": {"type": "string", "description": "Skill name (load: exactly as listed; install: the skill folder in the repo)"},
10
+ "action": {"type": "string", "enum": ["load", "list", "install"], "description": "Default: load"},
11
+ "source": {"type": "string", "description": "install only: owner/repo on GitHub, or a github URL"},
12
+ "scope": {"type": "string", "enum": ["global", "project"], "description": "install only: global (default, every project) or project"}
13
+ }, "required": ["name"]}
14
+ }