lampson 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -132,6 +132,22 @@ In the REPL, `/` lists every command (`/agent`, `/ask` `/yolo` `/strict`, `/mode
132
132
  `/files`, `/procs`, `/logs <name>`, `/stop <name>`, `/kill <pid>`, `/skills`, `/sessions`,
133
133
  `/resume <id>`, `/tokens`, `/flags`). `!cmd` runs a command yourself.
134
134
 
135
+ The prompt is a real line editor (Synsema ≥ 0.6.11, falls back to plain `read_line` without a TTY):
136
+ typing `/` opens the command menu (recent ones first, filtered as you type), `Tab` completes the
137
+ command and then its arguments (files for `/image`, sessions, providers, processes, lamps, MCP/LSP
138
+ servers, flags), `↑↓` browse history or the menu, `Alt+Enter` inserts a newline, `Ctrl+O` shows the
139
+ last tool result in full, `Ctrl+U`/`Ctrl+W` clear the line/word, `Esc` closes the menu. Approvals are
140
+ an arrow-key menu (`permitir`/`denegar`, or `p`/`d`).
141
+
142
+ While the model thinks or a slow tool runs (a long `bash`, a sub-agent), a status line shows what is
143
+ running, the elapsed time and a bar that fills as you wait (`⠋ pensando ▰▰▱▱▱▱▱▱▱▱ 12s`); it appears
144
+ after 0.4 s so instant tools do not flicker, and anything you type meanwhile lands in the prompt.
145
+
146
+ The terminal renders the model's markdown (headings, lists, tables, code fences) and shows every tool
147
+ result: `edit`/`write` print a line diff (`- red / + green`, line numbers, 2 lines of context); other
148
+ tools are collapsed to 15 lines. `/out [n]` prints the n-th last result of the turn in full and
149
+ `/verbose` toggles full output for every tool (saved in `.lampson/config.json`).
150
+
135
151
  The project is mounted as `lampson/workspace` (an NTFS junction on Windows, a symlink elsewhere)
136
152
  and every tool declares `file("workspace/*")` — that literal, named scope is what makes the
137
153
  confinement real. Config, sessions and process logs live in the `lampson` folder, never in your project.
@@ -287,7 +303,7 @@ Borrowed from the harness that does each part best (see `notes/*.md`):
287
303
  | Budget runs out silently | 80 %: a notice appended to the latest tool result (no new user message, cache stays warm); 95 %: last step without tools, summary required | hermes / opencode |
288
304
  | Edits a file it never read, or one that changed | **Observation gate in code**: `read` records the file hash; `edit`/`write` on an existing file are rejected without it, or if the file changed since | deepseek |
289
305
  | Loses the plan | `todo` tool (whole-list replacement, one `in_progress` at a time, scoped to the session like the three references); re-injected only after context compaction, active items only | hermes, opencode |
290
- | Reads the whole project before touching anything | **Exploration cap** (ours): after 8 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 16 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
306
+ | Reads the whole project before touching anything | **Exploration cap** (ours): after 12 read-only calls in a row (read/ls/find/grep) without an edit/write/command the result carries a warning; after 24 they are refused until it acts (`LAMPSON_EXPLORE_CAP`) | — |
291
307
 
292
308
  ### Sub-agents
293
309
 
package/chat.syn CHANGED
@@ -43,6 +43,10 @@ use "./lib/tools/memo.syn" as memo
43
43
  use "./lib/update.syn" as update
44
44
  use "./lib/settings.syn" as settings
45
45
  use "./lib/trace.syn" as trace
46
+ use "./lib/md.syn" as md
47
+ use "./lib/diff.syn" as diff
48
+ use "./lib/line.syn" as ed
49
+ use "./lib/ui.syn" as ui
46
50
  use "./lib/mcp.syn" as mcp
47
51
  use "./lib/lamps.syn" as lamps
48
52
  use "./lib/lsp.syn" as lsp
@@ -53,6 +57,12 @@ use "./lib/tools/todo.syn" as todo
53
57
 
54
58
  let ESC be decode(bytes("1b", "hex"))
55
59
  let COLOR be env("LAMPSON_NO_COLOR", "") == ""
60
+ -- /verbose: mostrar el output completo de cada tool (persistido en .lampson/config.json); si no, se
61
+ -- colapsa a OUT_LINES líneas y /out [n] muestra el resultado n-ésimo (desde el último) del turno completo
62
+ let saved_cfg be settings.load()
63
+ let VERBOSE be contains(saved_cfg, "verbose") and saved_cfg["verbose"] == true
64
+ let OUT_LINES be 15
65
+ let turn_outputs be []
56
66
 
57
67
  task c(code, s)
58
68
  when not COLOR
@@ -86,37 +96,201 @@ task summarize(name, out)
86
96
  let lines be split(out, "\n")
87
97
  when starts_with(out, "ERROR") or starts_with(out, "DENIED")
88
98
  give first_line(out, 140)
89
- when name == "read"
99
+ when name == "read" and not VERBOSE
90
100
  give text(length(lines)) + " líneas"
91
- when name == "ls" or name == "find" or name == "grep"
92
- give text(length(lines)) + " resultados · " + first_line(out, 90)
93
- when name == "bash"
94
- -- hasta 5 líneas del output, el resto colapsado
101
+ when (name == "ls" or name == "find") and not VERBOSE
102
+ -- las primeras entradas en una línea (hasta ~100 chars), y cuántas más hay
103
+ let shown be []
104
+ let used be 0
105
+ each l in lines
106
+ when used < 100 and trim(l) != ""
107
+ set shown to append(shown, trim(l))
108
+ set used to used + length(l) + 2
109
+ let rest be length(lines) - length(shown)
110
+ give text(length(lines)) + (when name == "ls" then " entradas: " otherwise " archivos: ") + join(shown, " ") + (when rest > 0 then dim(" +" + text(rest)) otherwise "")
111
+ when name == "delegate" and not VERBOSE
112
+ -- una línea por subagente: el encabezado [id · agente · estado · pasos · tokens · log]
113
+ let heads be []
114
+ each l in lines
115
+ when starts_with(trim(l), "[") and contains(l, " steps") or (starts_with(trim(l), "[") and contains(l, " · log:"))
116
+ set heads to append(heads, trim(l))
117
+ when length(heads) == 0
118
+ give first_line(out, 120)
119
+ give join(heads, "\n ")
120
+ when name == "grep" and not VERBOSE
121
+ let files be {}
122
+ each l in lines
123
+ let f be split(l, ":")[0]
124
+ when trim(l) != ""
125
+ set files[f] to true
126
+ give text(length(lines)) + " coincidencias en " + text(length(keys(files))) + " archivo" + (when length(keys(files)) == 1 then "" otherwise "s") + " · " + first_line(out, 90)
127
+ when name == "bash" or name == "process" or VERBOSE
128
+ -- hasta OUT_LINES líneas del output (todas con /verbose), el resto colapsado → /out
95
129
  let shown be []
96
130
  each e in enumerate(lines)
97
- when e["index"] < 5
98
- when trim(e["item"]) != ""
99
- set shown to append(shown, slice(e["item"], 0, 140))
100
- let extra be when length(lines) > 5 then dim(" (+" + text(length(lines) - 5) + " líneas)") otherwise ""
131
+ when VERBOSE or e["index"] < OUT_LINES
132
+ set shown to append(shown, when VERBOSE then e["item"] otherwise slice(e["item"], 0, 160))
133
+ let extra be when not VERBOSE and length(lines) > OUT_LINES then "\n " + dim("(+" + text(length(lines) - OUT_LINES) + " líneas · /out para ver todo)") otherwise ""
101
134
  give join(shown, "\n ") + extra
102
135
  give first_line(out, 120)
103
136
 
137
+ -- diff de un edit/write (antes/después publicado por la tool en el blackboard) — nothing si no hay
138
+ task diff_of(name, out)
139
+ when name != "edit" and name != "write"
140
+ give nothing
141
+ when starts_with(out, "ERROR") or starts_with(out, "DENIED")
142
+ give nothing
143
+ observe "lampson:ui:diff" as d
144
+ when d == nothing
145
+ give nothing
146
+ give diff.diff(d["old"], d["new"], 2, COLOR)
147
+
148
+ task print_diff(path, d)
149
+ print_diff_with(" ", path, d)
150
+
151
+ -- lead = lo que va antes del ✓ (la llamada, si se fusionó en la misma línea)
152
+ task print_diff_with(lead, path, d)
153
+ let head be green("✓") + " " + c("34", path) + " " + green("+" + text(d["added"])) + " " + red("−" + text(d["removed"]))
154
+ print(lead + head)
155
+ each l in d["lines"]
156
+ print(" " + l)
157
+
158
+ -- tools "instantáneas": la llamada se imprime junto con su resultado, en una sola línea
159
+ task is_instant(name)
160
+ give name == "read" or name == "ls" or name == "find" or name == "grep" or name == "memory" or name == "edit" or name == "write" or name == "todo" or name == "lsp"
161
+
162
+ let pending_call be ""
163
+
164
+ -- /out: resultado completo de una tool del turno
165
+ task print_full(o)
166
+ when o["diff"] != nothing
167
+ print_diff(o["path"], o["diff"])
168
+ otherwise
169
+ each l in split(o["output"], "\n")
170
+ print(" " + l)
171
+
172
+ -- ---------- indicador de espera ----------
173
+ -- El intérprete principal se bloquea en la llamada al modelo y en cada tool; el que dibuja es un agente
174
+ -- (hilo real) que mira el blackboard: `lampson:busy` = {label, kind, since} mientras hay algo en curso.
175
+ -- Arranca a dibujar recién a los 0,4 s (las tools instantáneas no parpadean) y suelta la terminal
176
+ -- (`lampson:spinner:holding` = false) antes de que el principal vuelva a imprimir.
177
+ agent Spinner
178
+ require time
179
+ let frames be ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
180
+ let i be 0
181
+ let drawn be false
182
+ let run be true
183
+ -- MIGA: un agente NO puede abrir la terminal (term_open → nothing) pero sí print+flush. Cada print de un
184
+ -- agente sale con el prefijo "[Spinner_0] " en la línea de abajo: cada frame limpia esa línea (\r ESC[K),
185
+ -- sube una (ESC[1A) y redibuja la suya. Al terminar deja el cursor en la línea del spinner, vacía.
186
+ let clear_here be "\r" + esc + "[K"
187
+ let up be esc + "[1A"
188
+ while run
189
+ observe "lampson:spinner:stop" as st
190
+ observe "lampson:busy" as b
191
+ when st == true
192
+ set run to false
193
+ otherwise when b == nothing
194
+ when drawn
195
+ print(clear_here + up + clear_here + up)
196
+ flush()
197
+ set drawn to false
198
+ share false as "lampson:spinner:holding"
199
+ sleep(0.05)
200
+ otherwise
201
+ let el be now() - b["since"]
202
+ when el >= 0.4
203
+ -- barra de "costo hundido": se llena rápido al principio y cada vez más despacio
204
+ let fill be floor(10 * (1 - exp(0 - el / 40)))
205
+ let bar be ""
206
+ let k be 0
207
+ while k < 10
208
+ set bar to bar + (when k < fill then "▰" otherwise "▱")
209
+ set k to k + 1
210
+ let hint be when el > 90 then " esto está tardando (Ctrl+C corta)" otherwise (when el > 30 then " ya casi" otherwise (when el > 8 then " sigue trabajando" otherwise ""))
211
+ let secs be text(floor(el)) + "s"
212
+ let label be b["label"]
213
+ when length(label) > 60
214
+ set label to slice(label, 0, 59) + "…"
215
+ let frame be frames[i % 10]
216
+ let body be when color then esc + "[36m" + frame + esc + "[0m " + label + " " + esc + "[2m" + bar + " " + secs + hint + esc + "[0m" otherwise frame + " " + label + " " + bar + " " + secs + hint
217
+ when drawn
218
+ print(clear_here + up + clear_here + " " + body)
219
+ otherwise
220
+ share true as "lampson:spinner:holding"
221
+ print(clear_here + " " + body)
222
+ set drawn to true
223
+ flush()
224
+ set i to i + 1
225
+ sleep(0.08)
226
+ when drawn
227
+ print(clear_here + up + clear_here + up)
228
+ flush()
229
+ share false as "lampson:spinner:holding"
230
+
231
+ task busy_on(label, kind)
232
+ share {"label": label, "kind": kind, "since": now()} as "lampson:busy"
233
+
234
+ -- apagar y esperar a que el agente suelte la terminal (máx. 1 s) antes de volver a imprimir
235
+ task busy_off()
236
+ share nothing as "lampson:busy"
237
+ let waited be 0
238
+ let holding be true
239
+ while holding and waited < 100
240
+ observe "lampson:spinner:holding" as hd
241
+ set holding to hd == true
242
+ when holding
243
+ sleep(0.01)
244
+ set waited to waited + 1
245
+
104
246
  -- tag = "" para el agente principal; los subagentes en background NO pasan por acá (escriben su log)
105
247
  task on_event(kind, data, tag)
106
248
  trace.event(sid, kind, data, tag)
107
- when kind == "assistant"
249
+ when kind == "busy"
250
+ busy_on(data["label"], data["kind"])
251
+ otherwise when kind == "idle"
252
+ busy_off()
253
+ otherwise when kind == "assistant"
254
+ -- ● marca el turno del asistente (primera línea con contenido)
255
+ let lines be split(md.render(data, COLOR), "\n")
256
+ let marked be false
257
+ let out_lines be []
258
+ each l in lines
259
+ when not marked and trim(l) != "" and starts_with(l, " ")
260
+ set out_lines to append(out_lines, c("36;1", "● ") + slice(l, 2, length(l)))
261
+ set marked to true
262
+ otherwise
263
+ set out_lines to append(out_lines, l)
108
264
  print("")
109
- print(data)
265
+ print(join(out_lines, "\n"))
110
266
  print("")
111
267
  otherwise when kind == "inbox"
112
268
  print(" " + cyan("✉ " + first_line(data, 140)))
113
269
  otherwise when kind == "tool_call"
114
- print(" " + yellow("⚙ " + permission.describe_call(data["name"], data["args"])))
270
+ let desc be permission.describe_call(data["name"], data["args"])
271
+ when is_instant(data["name"])
272
+ set pending_call to desc
273
+ otherwise
274
+ set pending_call to ""
275
+ print(" " + yellow("▸ " + desc))
115
276
  otherwise when kind == "tool_result"
116
277
  let out be data["output"]
278
+ let name be data["call"]["name"]
117
279
  let bad be starts_with(out, "ERROR") or starts_with(out, "DENIED")
118
- let mark be when bad then red("✗") otherwise green("✓")
119
- print(" " + mark + " " + (when bad then red(summarize(data["call"]["name"], out)) otherwise dim(summarize(data["call"]["name"], out))))
280
+ let d be diff_of(name, out)
281
+ let path be when d != nothing then text(data["call"]["args"]["path"]) otherwise ""
282
+ set turn_outputs to append(turn_outputs, {"name": name, "args": data["call"]["args"], "output": out, "diff": d, "path": path})
283
+ let lead be when pending_call != "" then " " + yellow("▸ " + pending_call) + " " otherwise " "
284
+ set pending_call to ""
285
+ when d != nothing
286
+ print_diff_with(lead, path, d)
287
+ otherwise
288
+ let mark be when bad then red("✗") otherwise green("✓")
289
+ let s be summarize(name, out)
290
+ -- una sola línea → cortar al ancho; multilínea (bash) se deja
291
+ when not contains(s, "\n")
292
+ set s to ui.cut(s, ui.cols() - ui.width(lead) - 4)
293
+ print(lead + mark + " " + (when bad then red(s) otherwise dim(s)))
120
294
  otherwise when kind == "tool_denied"
121
295
  set data to data
122
296
  otherwise when kind == "error"
@@ -125,17 +299,23 @@ task on_event(kind, data, tag)
125
299
  print(" " + yellow("el proveedor rechazó el nombre del modelo (" + cfg["model"] + "): /model sin argumentos lista los válidos, /model <nombre> lo cambia"))
126
300
  otherwise when kind == "compact"
127
301
  print(" " + dim("⧗ compactando contexto (~" + text(data["before"]) + " tokens)"))
302
+ busy_on("compactando contexto", "llm")
128
303
  flush()
129
304
 
130
305
  -- Human in the loop con el `approve` nativo de Synsema: prompt [approve] … (y/n) en TTY; sin TTY deniega
131
306
  -- (fail-closed, un agente no puede auto-aprobarse); `within` acota la espera.
132
307
  task ask_user(name, args, why)
308
+ busy_off()
133
309
  print("")
134
310
  print(" " + yellow("⚠ requiere tu aprobación · " + why))
135
311
  print(" " + permission.describe_call(name, args))
136
312
  flush()
137
- let ok be approve " ¿permitir?" within 10m
138
- give ok
313
+ -- menú ↑↓/Enter (o p/d, 1/2, Esc = no) cuando hay TTY; sin TTY, el approve nativo (fail-closed)
314
+ let pick be ed.choose(" ¿permitir?", ["permitir", "denegar"], COLOR)
315
+ when pick == -1
316
+ let ok be approve " ¿permitir?" within 10m
317
+ give ok
318
+ give pick == 0
139
319
 
140
320
  let LINE be " ────────────────────────────────────────────────────────────────────────"
141
321
 
@@ -164,6 +344,8 @@ let COMMANDS be [
164
344
  ["/mcp", "[add <nombre> <comando…> [--project] | remove <nombre>]", "servers MCP: listar, conectar o quitar (global: lampson/.lampson/mcp.json · proyecto: .lampson/mcp.json)"],
165
345
  ["/lamps", "[on <nombre> | off <nombre> | run <lámpara> <tool> [json] | remove <nombre>]", "lámparas (plugins de tools): listar, encender o apagar (global: lampson/lamps/ · proyecto: .lampson/lamps/)"],
166
346
  ["/lsp", "[add <typescript|python|rust|go|css|html> [--project] | add <nombre> <comando…> --ext .x=lang | remove <nombre>]", "language servers (navegación semántica: symbols/definition/references/hover); arrancan en la primera consulta"],
347
+ ["/out", "[n]", "resultado completo de la última tool del turno (n = contar hacia atrás: /out 2 es la anteúltima)"],
348
+ ["/verbose", "", "alternar: mostrar SIEMPRE el output completo de cada tool (queda guardado en config.json)"],
167
349
  ["/trace", "[n]", "traza legible de esta sesión (pasos, tools, tiempos, tokens, errores): .lampson/trace/<sesión>.log"],
168
350
  ["/tokens", "", "tamaño del contexto y tokens gastados en esta sesión"],
169
351
  ["/new", "", "empezar una sesión nueva (historial vacío)"],
@@ -182,10 +364,138 @@ task pad(s, n)
182
364
  set out to out + " "
183
365
  give out
184
366
 
367
+ -- entrada multilínea sin modo raw (read_key llegará a Synsema): una línea que termina en "\" continúa
368
+ -- en la siguiente; una línea que EMPIEZA con """ abre un bloque que cierra con una línea """ sola
369
+ -- (ideal para pegar código o logs). EOF corta el bloque con lo acumulado.
370
+ -- candidatos para completar el argumento de un comando (Tab / menú del editor de línea).
371
+ -- head = argumentos ya escritos antes del último token; last = lo que se está escribiendo
372
+ task complete_args(cmd, head, last)
373
+ let first be trim(head) == ""
374
+ when cmd == "/image"
375
+ give workspace_paths(last)
376
+ when cmd == "/resume" or cmd == "/delete"
377
+ give apply((s) => s["id"], session.list())
378
+ when cmd == "/provider"
379
+ when first
380
+ give apply((p) => p["name"], provider.providers())
381
+ give []
382
+ when cmd == "/logs" or cmd == "/stop"
383
+ when first
384
+ give apply((p) => p["name"], proc.list())
385
+ give []
386
+ when cmd == "/memory"
387
+ give apply((m) => m["name"], memo.list())
388
+ when cmd == "/lamps"
389
+ when first
390
+ give ["on", "off", "run", "remove"]
391
+ give apply((l) => l["name"], lamps.summary())
392
+ when cmd == "/mcp"
393
+ when first
394
+ give ["add", "remove"]
395
+ when starts_with(trim(head), "remove")
396
+ give apply((m) => m["name"], mcp.summary())
397
+ give ["--project"]
398
+ when cmd == "/lsp"
399
+ when first
400
+ give ["add", "remove"]
401
+ when starts_with(trim(head), "remove")
402
+ give apply((s) => s["name"], lsp.summary())
403
+ when trim(head) == "add"
404
+ give sort_by(keys(lsp.PRESETS), (x) => x)
405
+ give ["--project", "--ext"]
406
+ when cmd == "/agent"
407
+ give ["build", "plan", "review", "explore", "worker"]
408
+ when cmd == "/model"
409
+ give []
410
+ give []
411
+
412
+ -- rutas del workspace que empiezan con el prefijo escrito (directorios con "/" final para seguir completando)
413
+ task workspace_paths(prefix)
414
+ let dir be ""
415
+ let base be prefix
416
+ let cut be -1
417
+ let i be length(prefix) - 1
418
+ while i >= 0 and cut == -1
419
+ when slice(prefix, i, i + 1) == "/" or slice(prefix, i, i + 1) == "\\"
420
+ set cut to i
421
+ set i to i - 1
422
+ when cut >= 0
423
+ set dir to slice(prefix, 0, cut + 1)
424
+ set base to slice(prefix, cut + 1, length(prefix))
425
+ let out be []
426
+ try
427
+ each e in list_dir(when dir == "" then "workspace" otherwise "workspace/" + dir)
428
+ when starts_with(e["name"], base) and e["name"] != ".git"
429
+ set out to append(out, dir + e["name"] + (when e["is_dir"] then "/" otherwise ""))
430
+ recover err
431
+ set out to []
432
+ give sort_by(out, (x) => x)
433
+
434
+ -- mientras el humano no escribe: ¿hay avisos de subagentes en background? → cortar la lectura y
435
+ -- dejar que el bucle principal los inyecte (turno automático)
436
+ task idle()
437
+ let pending be false
438
+ try
439
+ each c in agents.list_children()
440
+ when c["status"] != "running" and not c["delivered"]
441
+ set pending to true
442
+ recover err
443
+ set pending to false
444
+ give pending
445
+
446
+ let input_history be []
447
+
448
+ task read_input(prompt)
449
+ let first be read_line(prompt)
450
+ when first == nothing
451
+ give nothing
452
+ let more be []
453
+ when trim(first) == "\"\"\"" or starts_with(first, "\"\"\"")
454
+ let body be slice(first, 3, length(first))
455
+ when trim(body) != ""
456
+ set more to append(more, body)
457
+ let open be true
458
+ while open
459
+ let l be read_line(dim(" … "))
460
+ when l == nothing or trim(l) == "\"\"\""
461
+ set open to false
462
+ otherwise
463
+ set more to append(more, l)
464
+ give join(more, "\n")
465
+ let cur be first
466
+ while ends_with(cur, "\\")
467
+ set more to append(more, slice(cur, 0, length(cur) - 1))
468
+ let l be read_line(dim(" … "))
469
+ set cur to when l == nothing then "" otherwise l
470
+ set more to append(more, cur)
471
+ give join(more, "\n")
472
+
473
+ -- últimos comandos usados (config.json "recent"): "/" solo los muestra primero
474
+ task remember_command(input)
475
+ let name be split(trim(input), " ")[0]
476
+ when name == "/" or name == "/help"
477
+ give nothing
478
+ let doc be settings.load()
479
+ let old be when contains(doc, "recent") then doc["recent"] otherwise []
480
+ let recent be [name]
481
+ each r in old
482
+ when r != name and length(recent) < 3
483
+ set recent to append(recent, r)
484
+ set doc["recent"] to recent
485
+ settings.save(doc)
486
+ give nothing
487
+
185
488
  task help()
489
+ let doc be settings.load()
490
+ let recent be when contains(doc, "recent") then doc["recent"] otherwise []
186
491
  print("")
492
+ when length(recent) > 0
493
+ print(" recientes " + join(apply((r) => cyan(r), recent), " "))
494
+ print("")
187
495
  print(" Escribí lo que querés hacer en el proyecto. El agente lee, busca, edita y corre comandos con tools")
188
496
  print(" acotadas al workspace; cada paso queda a la vista. Los comandos empiezan con /:")
497
+ print(" Teclas: Tab completa · ↑↓ historial/menú · Alt+Enter salto de línea · Ctrl+O último resultado completo · Esc cierra el menú")
498
+ print(" Sin TTY (pipe): terminá la línea con \\ para continuar, o un bloque entre líneas \"\"\".")
189
499
  print("")
190
500
  each c in COMMANDS
191
501
  print(" " + pad(c[0] + " " + c[1], 38) + c[2])
@@ -238,6 +548,21 @@ task banner(ws, cfg, profile, mode, sid)
238
548
  print(" workspace " + ws)
239
549
  print(" agente " + profile + " permisos " + mode + " modelo " + cfg["model"])
240
550
  print(" sesión " + sid + " " + git.summary())
551
+ -- extensiones en una línea (detalle con /lamps, /mcp, /lsp): encendidas/total
552
+ let ls be lamps.summary()
553
+ let lamps_on be length(where(ls, (l) => l["enabled"]))
554
+ let ms be mcp.summary()
555
+ let mcp_on be length(where(ms, (m) => m["status"] == "ready"))
556
+ let ss be lsp.summary()
557
+ let ext be []
558
+ when length(ls) > 0
559
+ set ext to append(ext, (when lamps_on > 0 then green("lamps " + text(lamps_on) + "/" + text(length(ls))) otherwise dim("lamps 0/" + text(length(ls)))))
560
+ when length(ms) > 0
561
+ set ext to append(ext, (when mcp_on == length(ms) then green("mcp " + text(mcp_on) + "/" + text(length(ms))) otherwise red("mcp " + text(mcp_on) + "/" + text(length(ms)))))
562
+ when length(ss) > 0
563
+ set ext to append(ext, dim("lsp " + text(length(ss))))
564
+ when length(ext) > 0
565
+ print(" extras " + join(ext, dim(" · ")) + dim(" (/lamps /mcp /lsp)"))
241
566
  let upd be update.line()
242
567
  when upd != ""
243
568
  print(" " + yellow("⬆ " + upd) + dim(" (o /update acá)"))
@@ -402,14 +727,13 @@ let system_msg be system_for(profile)
402
727
  -- servers MCP (globales en .lampson/mcp.json, del proyecto en workspace/.lampson/mcp.json): arrancan antes
403
728
  -- de armar el catálogo de tools; hasta 8 s de espera a que estén listos
404
729
  let mcp_servers be mcp.start_all(8)
730
+ -- mcp / lsp / lámparas van resumidos en el banner ("extras"); acá solo se avisa lo que está ROTO
405
731
  each ms in mcp.summary()
406
- print(" " + (when ms["status"] == "ready" then green("● mcp " + ms["name"]) otherwise red("○ mcp " + ms["name"] + " " + ms["status"])) + dim(" (" + ms["scope"] + ") " + text(length(ms["tools"])) + " tools" + (when ms["error"] != nothing then " · " + text(ms["error"]) otherwise "")))
407
- -- language servers (lib/lsp.syn): solo se anuncian; arrancan lazy en la primera consulta
408
- each s in lsp.summary()
409
- print(" " + dim("○ lsp " + s["name"] + " (" + s["scope"] + ") " + join(s["extensions"], " ") + " · arranca en la primera consulta"))
410
- -- lámparas (plugins de tools, lib/lamps.syn): solo se anuncian; apagadas no entran al catálogo
732
+ when ms["status"] != "ready"
733
+ print(" " + red("○ mcp " + ms["name"] + " " + ms["status"]) + dim(when ms["error"] != nothing then " · " + text(ms["error"]) otherwise ""))
411
734
  each l in lamps.summary()
412
- print(" " + (when l["enabled"] then green("● lamp " + l["name"]) otherwise dim("○ lamp " + l["name"] + " off")) + dim(" (" + l["scope"] + ", " + l["kind"] + ") " + text(length(l["tools"])) + " tools" + (when l["error"] != nothing then " · " + text(l["error"]) otherwise "")))
735
+ when l["error"] != nothing
736
+ print(" " + red("○ lamp " + l["name"] + " rota") + dim(" · " + text(l["error"])))
413
737
  let opts be opts_for(profile, lower(env("LAMPSON_PERMISSION", "ask")))
414
738
 
415
739
  -- marca de corrida (blackboard): session.save la estampa en meta.run; reanudar una sesión guardada por
@@ -438,6 +762,9 @@ when sid == ""
438
762
  share {"id": sid} as "lampson:session"
439
763
 
440
764
  banner(env_info["cwd"], cfg, profile, opts["permission_mode"], sid)
765
+ share nothing as "lampson:busy"
766
+ share false as "lampson:spinner:stop"
767
+ spawn Spinner with esc = ESC, color = COLOR
441
768
  flush()
442
769
 
443
770
  let total_usage be {"input": 0, "output": 0}
@@ -465,14 +792,24 @@ while running
465
792
  session.save(sid, messages, {"title": session.title_of(messages)})
466
793
  flush()
467
794
  otherwise
468
- set line to read_line("\n" + (when length(pending_images) > 0 then dim("📎" + text(length(pending_images)) + " ") otherwise "") + cyan("❯ "))
795
+ let saved be settings.load()
796
+ let ctx be {"commands": COMMANDS, "recent": (when contains(saved, "recent") then saved["recent"] otherwise []), "history": input_history, "complete": complete_args, "idle": idle, "fallback": read_input}
797
+ set line to ed.read((when length(pending_images) > 0 then dim("📎" + text(length(pending_images)) + " ") otherwise "") + cyan("❯ "), COLOR, ctx)
798
+ when line != nothing and line != ed.INTERRUPT and trim(line) != ""
799
+ set input_history to append(input_history, line)
469
800
  when inbox != nothing
470
801
  set running to running
802
+ otherwise when line == ed.INTERRUPT
803
+ set running to running
471
804
  otherwise when line == nothing
472
805
  set running to false
473
806
  otherwise
474
807
  set followups to 0
808
+ when not starts_with(trim(line), "/")
809
+ set turn_outputs to []
475
810
  let input be trim(line)
811
+ when starts_with(input, "/") and length(input) > 1
812
+ remember_command(input)
476
813
  when input == ""
477
814
  set running to running
478
815
  otherwise when input == "/exit" or input == "/quit"
@@ -491,6 +828,23 @@ while running
491
828
  print(" " + update.apply())
492
829
  otherwise when input == "/config"
493
830
  show_config(cfg, env_info["cwd"], profile, mode, opts)
831
+ otherwise when input == "/verbose"
832
+ set VERBOSE to not VERBOSE
833
+ let doc be settings.load()
834
+ set doc["verbose"] to VERBOSE
835
+ settings.save(doc)
836
+ print(" " + (when VERBOSE then green("verbose ON") + dim(" · cada tool muestra su output completo") otherwise dim("verbose OFF · outputs colapsados a " + text(OUT_LINES) + " líneas; /out para ver uno completo")))
837
+ otherwise when starts_with(input, "/out")
838
+ let n be trim(slice(input, 4, length(input)))
839
+ let back be when n == "" then 1 otherwise floor(number(n))
840
+ when length(turn_outputs) == 0
841
+ print(dim(" ninguna tool corrió en este turno"))
842
+ otherwise when back < 1 or back > length(turn_outputs)
843
+ print(dim(" hay " + text(length(turn_outputs)) + " resultados en este turno: /out 1 … /out " + text(length(turn_outputs))))
844
+ otherwise
845
+ let o be turn_outputs[length(turn_outputs) - back]
846
+ print(" " + yellow("▸ " + permission.describe_call(o["name"], o["args"])))
847
+ print_full(o)
494
848
  otherwise when starts_with(input, "/trace")
495
849
  let n be trim(slice(input, 6, length(input)))
496
850
  let lines be when n == "" then 40 otherwise floor(number(n))
@@ -775,23 +1129,31 @@ while running
775
1129
  -- catálogo fresco por turno: si el turno anterior conectó/quitó un server MCP, sus tools
776
1130
  -- entran/salen acá (con los mismos servers el catálogo es idéntico → el prompt cache no se corta)
777
1131
  set opts to opts_for(profile, mode)
1132
+ let t0 be now()
778
1133
  let result be loop.run_turn(cfg, messages, opts, on_event)
779
1134
  trace.turn_end(sid, result)
780
1135
  set messages to result["messages"]
781
1136
  set total_usage to {"input": total_usage["input"] + result["usage"]["input"], "output": total_usage["output"] + result["usage"]["output"]}
782
- let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens"
1137
+ let summary be text(result["steps"]) + (when result["steps"] == 1 then " paso" otherwise " pasos") + " · " + fmt_tokens(result["usage"]["input"] + result["usage"]["output"]) + " tokens · " + ui.fmt_duration(now() - t0)
783
1138
  when result["stopped"] == "max_steps"
784
1139
  set summary to summary + " · " + red("límite de " + text(opts["max_steps"]) + " pasos por turno")
785
1140
  print(dim(" ─── " + summary))
786
1141
  print(" " + yellow("El agente paró por el límite de pasos, no porque terminó. Escribí «seguí» para que continúe donde quedó, o subí LAMPSON_MAX_STEPS."))
787
1142
  otherwise when result["stopped"] != "done"
788
- set summary to summary + " · " + red("detenido: " + result["stopped"])
1143
+ -- el detalle del error ya salió en la línea ‼; acá solo la causa corta
1144
+ let why be result["stopped"]
1145
+ when starts_with(why, "error: network")
1146
+ set why to "error de red (ver ‼ arriba)"
1147
+ otherwise when starts_with(why, "error:")
1148
+ set why to first_line(why, 60)
1149
+ set summary to summary + " · " + red("detenido: " + why)
789
1150
  print(dim(" ─── " + summary))
790
1151
  otherwise
791
1152
  print(dim(" ─── " + summary))
792
1153
  session.save(sid, messages, {"title": session.title_of(messages)})
793
1154
  flush()
794
1155
  -- los procesos gestionados (servidores) y los subagentes en background mueren con lampson
1156
+ share true as "lampson:spinner:stop"
795
1157
  agents.stop_all()
796
1158
  mcp.stop_all()
797
1159
  lsp.stop_all()