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.
- package/.env.example +29 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/lampson.js +81 -0
- package/chat.syn +799 -0
- package/lamps/example-hello/lamp.json +16 -0
- package/lamps/example-hello/lamp.syn +19 -0
- package/lampson.cmd +4 -0
- package/lampson.ps1 +88 -0
- package/lampson.sh +42 -0
- package/lib/agents.syn +471 -0
- package/lib/git.syn +58 -0
- package/lib/lamps.syn +386 -0
- package/lib/loop.syn +455 -0
- package/lib/lsp.syn +503 -0
- package/lib/mcp.syn +403 -0
- package/lib/permission.syn +154 -0
- package/lib/prompt.syn +75 -0
- package/lib/provider.syn +522 -0
- package/lib/session.syn +111 -0
- package/lib/settings.syn +70 -0
- package/lib/skills.syn +179 -0
- package/lib/tools/bash.syn +105 -0
- package/lib/tools/common.sh +49 -0
- package/lib/tools/common.syn +91 -0
- package/lib/tools/edit.syn +32 -0
- package/lib/tools/find.syn +35 -0
- package/lib/tools/grep.syn +31 -0
- package/lib/tools/img.ps1 +36 -0
- package/lib/tools/img.sh +22 -0
- package/lib/tools/ls.syn +18 -0
- package/lib/tools/memo.syn +148 -0
- package/lib/tools/proc.sh +42 -0
- package/lib/tools/proc.syn +314 -0
- package/lib/tools/process.syn +46 -0
- package/lib/tools/read.syn +25 -0
- package/lib/tools/skill.syn +14 -0
- package/lib/tools/todo.syn +97 -0
- package/lib/tools/write.syn +22 -0
- package/lib/tools.syn +198 -0
- package/lib/trace.syn +116 -0
- package/lib/tree.syn +59 -0
- package/lib/update.syn +57 -0
- package/package.json +40 -0
- package/public/fonts/plex-mono-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-400-latin.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-mono-600-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400-latin.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-400i-latin.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin-ext.woff2 +0 -0
- package/public/fonts/plex-serif-600-latin.woff2 +0 -0
- package/public/index.html +1268 -0
- package/public/vendor/xterm-addon-fit.js +2 -0
- package/public/vendor/xterm.css +218 -0
- package/public/vendor/xterm.js +2 -0
- package/skills/debugging/SKILL.md +33 -0
- package/skills/lampson/SKILL.md +117 -0
- package/skills/synsema/SKILL.md +75 -0
- package/web.syn +468 -0
package/lib/tools/img.sh
ADDED
|
@@ -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')"
|
package/lib/tools/ls.syn
ADDED
|
@@ -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
|
+
}
|