lampson 0.1.2 → 0.1.4

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 (49) hide show
  1. package/.env.example +9 -0
  2. package/README.md +60 -2
  3. package/chat.syn +264 -9
  4. package/lampson.ps1 +17 -3
  5. package/lampson.sh +17 -3
  6. package/lib/agents.syn +1 -1
  7. package/lib/approvals.syn +176 -0
  8. package/lib/diff.syn +37 -11
  9. package/lib/line.syn +13 -8
  10. package/lib/loop.syn +25 -11
  11. package/lib/md.syn +76 -18
  12. package/lib/permission.syn +18 -0
  13. package/lib/prompt.syn +1 -1
  14. package/lib/sched_run.syn +161 -0
  15. package/lib/schedule.syn +660 -0
  16. package/lib/session.syn +38 -1
  17. package/lib/settings.syn +55 -0
  18. package/lib/tools.syn +94 -2
  19. package/lib/ui.syn +161 -0
  20. package/package.json +1 -1
  21. package/public/css/chat.css +56 -0
  22. package/public/css/layout.css +97 -0
  23. package/public/css/panel.css +125 -0
  24. package/public/css/sidebar.css +80 -0
  25. package/public/css/tokens.css +57 -0
  26. package/public/index.html +49 -1187
  27. package/public/js/agents.js +31 -0
  28. package/public/js/app.js +21 -0
  29. package/public/js/approvals.js +26 -0
  30. package/public/js/chat.js +92 -0
  31. package/public/js/config.js +98 -0
  32. package/public/js/core.js +88 -0
  33. package/public/js/events.js +31 -0
  34. package/public/js/lamps.js +112 -0
  35. package/public/js/lsp.js +81 -0
  36. package/public/js/mcp.js +74 -0
  37. package/public/js/memory.js +17 -0
  38. package/public/js/panel.js +101 -0
  39. package/public/js/procs.js +45 -0
  40. package/public/js/schedules.js +118 -0
  41. package/public/js/sessions.js +69 -0
  42. package/public/js/sidebar.js +33 -0
  43. package/public/js/terminal.js +49 -0
  44. package/public/js/theme.js +6 -0
  45. package/public/js/todo.js +10 -0
  46. package/public/js/tree.js +53 -0
  47. package/public/js/update.js +14 -0
  48. package/skills/lampson/SKILL.md +16 -0
  49. package/web.syn +106 -15
package/lib/session.syn CHANGED
@@ -72,7 +72,8 @@ export task list()
72
72
  try
73
73
  let d be load(id)
74
74
  let title be when contains(d["meta"], "title") then d["meta"]["title"] otherwise ""
75
- let project be when contains(d["meta"], "project") then d["meta"]["project"] otherwise mine
75
+ -- sin marca de proyecto (anteriores al 2026-08-27) no se listan en ningún workspace (aparecían en todos)
76
+ let project be when contains(d["meta"], "project") then d["meta"]["project"] otherwise ""
76
77
  when project == mine
77
78
  set out to append(out, {"id": id, "updated": d["updated"], "title": title})
78
79
  recover err
@@ -81,6 +82,42 @@ export task list()
81
82
  give []
82
83
  give sort_by(out, (s) => 0 - number(replace_text(replace_text(s["id"], "-", ""), "test", "")))
83
84
 
85
+ -- Búsqueda de texto en las sesiones de ESTE proyecto (título y contenido de los mensajes user/assistant):
86
+ -- [{id, updated, title, snippet}] — snippet = el primer trozo donde aparece `q`. Vacío = todas (sin snippet).
87
+ -- Lineal sobre los archivos (local, decenas o cientos de sesiones): suficiente; migrar a db si crece.
88
+ export task search(q, limit)
89
+ require file.read(".lampson")
90
+ require file.read(".lampson/*")
91
+ require env("LAMPSON_*")
92
+ let needle be lower(trim(text(q)))
93
+ let out be []
94
+ each s in list()
95
+ when length(out) >= limit
96
+ give out
97
+ when needle == ""
98
+ set out to append(out, {"id": s["id"], "updated": s["updated"], "title": s["title"], "snippet": ""})
99
+ otherwise
100
+ let hit be nothing
101
+ when contains(lower(s["title"]), needle)
102
+ set hit to ""
103
+ otherwise
104
+ try
105
+ let d be load(s["id"])
106
+ each m in d["messages"]
107
+ when hit == nothing and (m["role"] == "user" or m["role"] == "assistant") and text(m["content"]) == m["content"]
108
+ let c be text(m["content"])
109
+ let lc be lower(c)
110
+ when contains(lc, needle)
111
+ let pos be length(split(lc, needle)[0])
112
+ let a be when pos > 60 then pos - 60 otherwise 0
113
+ let b be when pos + length(needle) + 90 < length(c) then pos + length(needle) + 90 otherwise length(c)
114
+ set hit to (when a > 0 then "…" otherwise "") + replace_text(replace_text(slice(c, a, b), "\r", ""), "\n", " ") + (when b < length(c) then "…" otherwise "")
115
+ recover err
116
+ set hit to nothing
117
+ when hit != nothing
118
+ set out to append(out, {"id": s["id"], "updated": s["updated"], "title": s["title"], "snippet": hit})
119
+ give out
120
+
84
121
  -- Borrar una sesión (no hay delete_file en el runtime: se usa el shell del sistema sobre la ruta fija).
85
122
  export task delete(id)
86
123
  require exec
package/lib/settings.syn CHANGED
@@ -68,3 +68,58 @@ export task set_default(name, model)
68
68
  set doc["model"] to model
69
69
  save(doc)
70
70
  give true
71
+
72
+ -- valores de configuración generales (rueda de la UI): tz, public_url, webhook_url, webhook_secret…
73
+ -- Vacío = borrar la clave. Nunca se devuelven secretos al navegador: `values()` marca webhook_secret como has_*.
74
+ export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret"]
75
+
76
+ -- el archivo REAL, ignorando LAMPSON_NO_CONFIG: escribir con el doc en blanco de los tests pisaría provider/keys
77
+ -- (pasó el 2026-08-29 probando la rueda con un web de test)
78
+ task load_raw()
79
+ try
80
+ let doc be json_decode(read_file(PATH))
81
+ when not contains(doc, "keys")
82
+ set doc["keys"] to {}
83
+ when not contains(doc, "provider")
84
+ set doc["provider"] to ""
85
+ when not contains(doc, "model")
86
+ set doc["model"] to ""
87
+ give doc
88
+ recover err
89
+ give {"provider": "", "model": "", "keys": {}}
90
+
91
+ export task set_value(key, value)
92
+ require file(".lampson")
93
+ require file(".lampson/*")
94
+ require env("LAMPSON_*")
95
+ when not contains(KEYS, key)
96
+ raise("unknown setting '" + text(key) + "'")
97
+ let doc be load_raw()
98
+ when trim(text(value)) == ""
99
+ let clean be {}
100
+ each k in keys(doc)
101
+ when k != key
102
+ set clean[k] to doc[k]
103
+ set doc to clean
104
+ otherwise
105
+ set doc[key] to trim(text(value))
106
+ save(doc)
107
+ give true
108
+
109
+ export task values()
110
+ require file.read(".lampson")
111
+ require file.read(".lampson/*")
112
+ require env("LAMPSON_*")
113
+ let doc be load_raw()
114
+ let out be {}
115
+ each k in KEYS
116
+ when k == "webhook_secret"
117
+ set out["has_webhook_secret"] to (contains(doc, k) and text(doc[k]) != "") or env("LAMPSON_WEBHOOK_SECRET", "") != ""
118
+ otherwise
119
+ set out[k] to when contains(doc, k) then text(doc[k]) otherwise ""
120
+ -- lo fijado en .env gana y se muestra como tal
121
+ let e be env("LAMPSON_" + upper(k), "")
122
+ when e != ""
123
+ set out[k] to e
124
+ set out[k + "_from_env"] to true
125
+ give out
package/lib/tools.syn CHANGED
@@ -27,6 +27,7 @@ use "./skills.syn" as skills
27
27
  use "./mcp.syn" as mcp
28
28
  use "./lamps.syn" as lamps
29
29
  use "./lsp.syn" as lsp
30
+ use "./schedule.syn" as schedule
30
31
 
31
32
  -- la task de la tool skill (su SPEC está en tools/skill.syn)
32
33
  task skill_tool(name, action, source, scope)
@@ -155,6 +156,96 @@ let LSP_SPEC be {
155
156
  }, "required": ["op"]}
156
157
  }
157
158
 
159
+ -- la tool schedule (lib/schedule.syn): tareas programadas. add/remove/enable/run piden humano SIEMPRE (permission.syn):
160
+ -- crear una tarea = autorizar de una vez todo lo que va a hacer sin nadie mirando. Las corridas las hace el daemon
161
+ -- (web.syn); desde acá solo se corren en el acto las lamp/bash (una prompt anidaría un loop dentro del turno).
162
+ task schedule_tool(action, id, name, at, kind, lamp, tool, args, command, prompt, agent, permission, approval_timeout, notify)
163
+ require exec
164
+ require time
165
+ require net
166
+ require env("LAMPSON_*")
167
+ require env("OS")
168
+ require file(".lampson")
169
+ require file(".lampson/*")
170
+ require file.read("lamps")
171
+ require file.read("lamps/*")
172
+ require file("workspace")
173
+ require file("workspace/*")
174
+ let act be when action == nothing then "list" otherwise action
175
+ when act == "add"
176
+ let a be {"type": kind}
177
+ when kind == "lamp"
178
+ set a to {"type": "lamp", "lamp": lamp, "tool": tool, "args": when args == nothing then {} otherwise args}
179
+ otherwise when kind == "bash"
180
+ set a to {"type": "bash", "command": command}
181
+ otherwise when kind == "prompt"
182
+ set a to {"type": "prompt", "prompt": prompt, "agent": when agent == nothing then "build" otherwise agent}
183
+ let spec be {"name": name, "when": at, "action": a, "permission": when permission == nothing then "ask" otherwise permission}
184
+ when approval_timeout != nothing
185
+ set spec["approval_timeout"] to approval_timeout
186
+ when notify != nothing
187
+ set spec["notify"] to notify
188
+ let t be schedule.add(spec)
189
+ give "scheduled '" + t["name"] + "' (id " + t["id"] + "): " + schedule.describe_plan(t["plan"]) + " (local time) · " + schedule.describe_action(t["action"]) + " · permission " + t["permission"] + " · next run " + schedule.fmt_local(t["next_run"]) + " — tell the user this next-run time as is. The run's report lands in a NEW session named ⏰ <name> (sidebar «Sesiones» / /sessions) and, if notify is set, in that webhook — NOT in this chat: say so. " + daemon_note()
190
+ when act == "remove"
191
+ give schedule.remove(id)
192
+ when act == "enable"
193
+ give schedule.set_enabled(id, true)
194
+ when act == "disable"
195
+ give schedule.set_enabled(id, false)
196
+ when act == "run"
197
+ let t be schedule.get(id)
198
+ when t == nothing
199
+ raise("no scheduled task '" + text(id) + "'")
200
+ when t["action"]["type"] == "prompt"
201
+ schedule.request_run(id)
202
+ give "run requested: it runs within " + text(schedule.TICK_SECONDS) + " s in the background and its report lands in a NEW session (⏰ " + t["name"] + "), not in this chat. " + daemon_note()
203
+ schedule.mark_started(t)
204
+ let started be now()
205
+ let out be schedule.run_simple(t)
206
+ schedule.record(t, started, when starts_with(out, "ERROR") or starts_with(out, "DENIED") then "error" otherwise "ok", out, {})
207
+ give out
208
+ when act == "log"
209
+ let tail be schedule.log_tail(id, 80)
210
+ give when tail == "" then "no runs yet for " + text(id) otherwise tail
211
+ let sm be schedule.summary()
212
+ when length(sm) == 0
213
+ give "no scheduled tasks. " + daemon_note()
214
+ let lines be []
215
+ each s in sm
216
+ set lines to append(lines, s["id"] + (when s["enabled"] then " ON " otherwise " off") + " · " + s["plan"] + " · " + s["action_text"] + " · permission " + s["permission"] + " · next " + s["next_local"] + (when s["last_run"] != nothing then " · last " + s["last_status"] + ": " + s["last_summary"] otherwise ""))
217
+ give join(lines, "\n") + "\n" + daemon_note()
218
+
219
+ task daemon_note()
220
+ require time
221
+ require file(".lampson")
222
+ require file(".lampson/*")
223
+ let age be schedule.daemon_age()
224
+ when age == nothing or age > schedule.TICK_SECONDS * 3
225
+ give "NOTE: no scheduler is running right now (scheduled tasks run inside the web/daemon process): the user starts it with `lampson --daemon start` (or keeps `lampson --web` open)."
226
+ give "Scheduler alive (last tick " + text(floor(age)) + " s ago)."
227
+
228
+ let SCHEDULE_SPEC be {
229
+ "name": "schedule",
230
+ "description": "Scheduled tasks: run something on a schedule with nobody watching — a lamp tool, a fixed shell command, or a full agent run from a prompt (kind=prompt: the agent works unattended with the chosen profile and writes a report; its session appears as ⏰ <name>). `at` formats — recurring: 'every 6h' | 'every 30m' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'; ONE-TIME (runs once, then turns itself off): 'today 15:14' | 'tomorrow 09:00' | 'once 2026-08-29 15:14' | 'in 2h'. Times are the USER'S LOCAL time (the machine's timezone): write the hour exactly as the user says it — NEVER convert to UTC. Tasks belong to the current workspace. permission = what a prompt run may do without asking: strict (dangerous actions denied), ask (default: a dangerous action sends the user an approval link/notification and waits up to approval_timeout seconds, denied if unanswered), yolo (allowed). notify = optional webhook URL that receives the result as JSON (for 'search and send me' tasks). action=add ALWAYS asks the user (it authorizes future unattended runs); remove/enable/disable/run also ask; list and log do not. The tasks are executed by Lampson's resident process (`lampson --daemon start` or the open web UI) — say so if the list shows no scheduler running. Use it when the user says 'every day at', 'each N hours', 'on Mondays', 'periodically', 'remind me', 'send me'.",
231
+ "parameters": {"type": "object", "properties": {
232
+ "action": {"type": "string", "enum": ["list", "add", "remove", "enable", "disable", "run", "log"], "description": "Default: list"},
233
+ "id": {"type": "string", "description": "remove/enable/disable/run/log: the task id (from list)"},
234
+ "name": {"type": "string", "description": "add: short human name"},
235
+ "at": {"type": "string", "description": "add: the schedule — 'every 6h' | 'daily 09:00' | 'mon,wed 08:30' | 'weekdays 09:00'"},
236
+ "kind": {"type": "string", "enum": ["lamp", "bash", "prompt"], "description": "add: what runs"},
237
+ "lamp": {"type": "string", "description": "add kind=lamp: lamp name (must be ON)"},
238
+ "tool": {"type": "string", "description": "add kind=lamp: tool of that lamp"},
239
+ "args": {"type": "object", "description": "add kind=lamp: arguments for the tool"},
240
+ "command": {"type": "string", "description": "add kind=bash: the shell command (must finish on its own; no servers)"},
241
+ "prompt": {"type": "string", "description": "add kind=prompt: self-contained instructions for the unattended agent run (what to do, how to verify, what to report)"},
242
+ "agent": {"type": "string", "enum": ["build", "plan", "review", "explore"], "description": "add kind=prompt: profile (default build)"},
243
+ "permission": {"type": "string", "enum": ["strict", "ask", "yolo"], "description": "add: permission envelope for unattended runs (default ask)"},
244
+ "approval_timeout": {"type": "integer", "description": "add: seconds to wait for a remote approval (default 7200)"},
245
+ "notify": {"type": "string", "description": "add: webhook URL that receives each result as JSON"}
246
+ }, "required": ["action"]}
247
+ }
248
+
158
249
  -- Allow-list nombre → task. El modelo solo devuelve NOMBRES; el loop decide si se ejecuta.
159
250
  export task registry()
160
251
  give {
@@ -171,10 +262,11 @@ export task registry()
171
262
  "skill": skill_tool,
172
263
  "mcp": mcp_tool,
173
264
  "lamp": lamp_tool,
174
- "lsp": lsp_tool
265
+ "lsp": lsp_tool,
266
+ "schedule": schedule_tool
175
267
  }
176
268
 
177
- export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, LAMP_SPEC]
269
+ export let CATALOG be [t_read.SPEC, t_write.SPEC, t_edit.SPEC, t_ls.SPEC, t_find.SPEC, t_grep.SPEC, LSP_SPEC, t_bash.SPEC, t_process.SPEC, t_memo.SPEC, t_todo.SPEC, t_skill.SPEC, MCP_SPEC, LAMP_SPEC, SCHEDULE_SPEC]
178
270
 
179
271
  -- Subconjuntos (para perfiles de agente): registry/catálogo filtrados por nombre.
180
272
  export task registry_subset(names)
package/lib/ui.syn ADDED
@@ -0,0 +1,161 @@
1
+ -- lib/ui.syn — una sola paleta y medidas de texto para la terminal (md.syn, diff.syn, line.syn, chat.syn)
2
+ -- Colores: dim/blue/green/red/yellow/cyan/bold/inv (+ fondos bg_add/bg_del para diffs). color=false → texto plano.
3
+ -- width(s) ancho visible (sin escapes; emoji/CJK cuentan 2)
4
+ -- cut(s, max) corta a `max` columnas respetando escapes (y cierra el color)
5
+ -- wrap(s, max, indent) envuelve texto PLANO por palabras; las líneas siguientes llevan `indent`
6
+ -- cols() columnas de la terminal (el editor las publica en el blackboard "lampson:ui:cols"; 100 si no hay)
7
+ -- fmt_duration(secs) → "12s" · "2m 27s" · "1h 03m"
8
+
9
+ let ESC be decode(bytes("1b", "hex"))
10
+
11
+ export task sgr(color, code, s)
12
+ when not color or s == ""
13
+ give s
14
+ give ESC + "[" + code + "m" + s + ESC + "[0m"
15
+
16
+ export task dim(color, s)
17
+ give sgr(color, "2", s)
18
+ export task bold(color, s)
19
+ give sgr(color, "1", s)
20
+ export task inv(color, s)
21
+ give sgr(color, "7", s)
22
+ export task blue(color, s)
23
+ give sgr(color, "34", s)
24
+ export task cyan(color, s)
25
+ give sgr(color, "36", s)
26
+ export task green(color, s)
27
+ give sgr(color, "32", s)
28
+ export task red(color, s)
29
+ give sgr(color, "31", s)
30
+ export task yellow(color, s)
31
+ give sgr(color, "33", s)
32
+ -- fondos para diffs (256 colores: verde y rojo oscuros, legibles en tema claro y oscuro)
33
+ export task bg_add(color, s)
34
+ give sgr(color, "48;5;22", s)
35
+ export task bg_del(color, s)
36
+ give sgr(color, "48;5;52", s)
37
+
38
+ -- ---------- medidas ----------
39
+
40
+ -- punto de código de UN carácter a partir de sus bytes UTF-8
41
+ task codepoint(ch)
42
+ let b be bytes(ch)
43
+ let n be length(b)
44
+ when n == 1
45
+ give b[0]
46
+ when n == 2
47
+ give (b[0] - 192) * 64 + (b[1] - 128)
48
+ when n == 3
49
+ give (b[0] - 224) * 4096 + (b[1] - 128) * 64 + (b[2] - 128)
50
+ give (b[0] - 240) * 262144 + (b[1] - 128) * 4096 + (b[2] - 128) * 64 + (b[3] - 128)
51
+
52
+ -- ancho de un carácter en celdas: emoji, CJK y símbolos "wide" ocupan 2
53
+ task char_width(ch)
54
+ let cp be codepoint(ch)
55
+ when cp < 4352
56
+ give 1
57
+ when cp >= 127744 and cp <= 129791
58
+ give 2
59
+ when cp >= 4352 and cp <= 4447
60
+ give 2
61
+ when cp >= 11904 and cp <= 42191
62
+ give 2
63
+ when cp >= 44032 and cp <= 55203
64
+ give 2
65
+ when cp >= 63744 and cp <= 64255
66
+ give 2
67
+ when cp >= 65040 and cp <= 65135
68
+ give 2
69
+ when cp >= 65280 and cp <= 65376
70
+ give 2
71
+ when cp >= 9800 and cp <= 9811
72
+ give 2
73
+ when cp == 9888 or cp == 9889 or cp == 9989 or cp == 10060 or cp == 11093
74
+ give 2
75
+ give 1
76
+
77
+ export task width(s)
78
+ let t be strip_ansi(s)
79
+ let w be 0
80
+ let i be 0
81
+ let n be length(t)
82
+ while i < n
83
+ set w to w + char_width(slice(t, i, i + 1))
84
+ set i to i + 1
85
+ give w
86
+
87
+ -- corta a `max` celdas visibles, saltando secuencias ANSI enteras; añade "…" si cortó y cierra el color
88
+ export task cut(s, max)
89
+ when width(s) <= max
90
+ give s
91
+ let out be ""
92
+ let w be 0
93
+ let i be 0
94
+ let n be length(s)
95
+ let limit be max - 1
96
+ while i < n and w < limit
97
+ let ch be slice(s, i, i + 1)
98
+ when ch == ESC
99
+ -- copiar la secuencia CSI completa: ESC [ … letra
100
+ let j be i + 1
101
+ while j < n and not matches(slice(s, j, j + 1), "[A-Za-z]")
102
+ set j to j + 1
103
+ set out to out + slice(s, i, j + 1)
104
+ set i to j + 1
105
+ otherwise
106
+ let cw be char_width(ch)
107
+ when w + cw <= limit
108
+ set out to out + ch
109
+ set w to w + cw
110
+ set i to i + 1
111
+ otherwise
112
+ set i to n
113
+ give out + ESC + "[0m" + "…"
114
+
115
+ -- envuelve texto plano (sin escapes) por palabras; give lista de líneas (sin indent en la primera)
116
+ export task wrap(s, max, indent)
117
+ when max <= 10 or length(s) <= max
118
+ give [s]
119
+ let words be split(s, " ")
120
+ let lines be []
121
+ let cur be ""
122
+ let curw be 0
123
+ each w in words
124
+ let ww be width(w)
125
+ let room be when length(lines) == 0 then max otherwise max - length(indent)
126
+ when curw == 0
127
+ set cur to w
128
+ set curw to ww
129
+ otherwise when curw + 1 + ww <= room
130
+ set cur to cur + " " + w
131
+ set curw to curw + 1 + ww
132
+ otherwise
133
+ set lines to append(lines, (when length(lines) == 0 then "" otherwise indent) + cur)
134
+ set cur to w
135
+ set curw to ww
136
+ set lines to append(lines, (when length(lines) == 0 then "" otherwise indent) + cur)
137
+ give lines
138
+
139
+ -- ---------- terminal ----------
140
+
141
+ export task cols()
142
+ observe "lampson:ui:cols" as c
143
+ when c == nothing
144
+ give 100
145
+ give c
146
+
147
+ export task set_cols(n)
148
+ share n as "lampson:ui:cols"
149
+ give n
150
+
151
+ export task fmt_duration(secs)
152
+ let s be floor(secs)
153
+ when s < 60
154
+ give text(s) + "s"
155
+ when s < 3600
156
+ let m be floor(s / 60)
157
+ let r be s - m * 60
158
+ give text(m) + "m " + (when r < 10 then "0" otherwise "") + text(r) + "s"
159
+ let h be floor(s / 3600)
160
+ let m2 be floor((s - h * 3600) / 60)
161
+ give text(h) + "h " + (when m2 < 10 then "0" otherwise "") + text(m2) + "m"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lampson",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A coding agent written in Synsema: terminal + web, tools confined to the mounted project, lamps (your own tool plugins), LSP, MCP, sub-agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,56 @@
1
+ /* chat: mensajes, pasos del agente, tarjeta de permiso, portada (hero) */
2
+ /* mensajes: el del usuario es una nota al margen (como el blockquote de la doc), el del agente es prosa */
3
+ .msg { margin:0 0 16px; }
4
+ .user { margin:26px 0 16px; padding:.85em 1.1em; background:var(--paper-2); border-left:2px solid var(--accent); white-space:pre-wrap; font-size:15px; }
5
+ .user::before { content:"vos"; display:block; font:400 10px/1 var(--mono); letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-bottom:6px; }
6
+ .assistant { font-size:15px; line-height:1.62; }
7
+ .assistant p { margin:0 0 .9em; }
8
+ .assistant code { font:400 .86em/1.5 var(--mono); background:var(--paper-2); border-radius:2px; padding:.08em .3em; color:var(--ink); }
9
+ .assistant pre { background:var(--paper-2); border:1px solid var(--rule); border-radius:var(--r); padding:14px 16px; overflow-x:auto; margin:1em 0; }
10
+ .assistant pre code { background:none; padding:0; font-size:13px; line-height:1.62; }
11
+ .assistant ul, .assistant ol { margin:0 0 .9em; padding-left:1.25em; }
12
+ .assistant li { margin:.3em 0; }
13
+ .assistant li::marker { color:var(--ink-3); }
14
+ .assistant h1, .assistant h2, .assistant h3 { font:600 1.06rem/1.4 var(--serif); margin:1.4em 0 .5em; }
15
+ .assistant b, .assistant strong { font-weight:600; }
16
+
17
+ /* pasos del agente: maquinaria, en mono */
18
+ .step { display:grid; grid-template-columns:16px 1fr; column-gap:8px; margin:0 0 7px 2px; font:400 12.5px/1.5 var(--mono); color:var(--ink-2); }
19
+ .step .ic { text-align:center; color:var(--ink-3); }
20
+ .step .ok { color:var(--str); }
21
+ .step .bad { color:var(--rubric); }
22
+ .step .name { color:var(--ink); }
23
+ .step .cmd { white-space:pre-wrap; word-break:break-word; max-height:4.6em; overflow:hidden; }
24
+ .step .cmd.open { max-height:none; }
25
+ .step .more { color:var(--accent); cursor:pointer; font-size:11px; }
26
+ .step details { grid-column:2; min-width:0; }
27
+ .step summary { cursor:pointer; list-style:none; color:var(--ink-3); white-space:pre-wrap; word-break:break-word; max-height:3.2em; overflow:hidden; }
28
+ .step summary::before { content:"→ "; }
29
+ .step summary.bad { color:var(--rubric); }
30
+ .step pre { margin:6px 0 4px; padding:10px 12px; background:var(--paper-2); border:1px solid var(--rule); border-radius:var(--r); white-space:pre-wrap; max-height:340px; overflow:auto; font-size:12px; color:var(--ink-2); }
31
+ .denied { color:var(--rubric); font:400 12.5px/1.5 var(--mono); }
32
+ .meta { color:var(--ink-3); font:400 11px/1 var(--mono); letter-spacing:.03em; margin:4px 0 20px 2px; }
33
+ .working { display:flex; align-items:center; gap:8px; color:var(--ink-3); font:400 11.5px/1 var(--mono); margin:4px 0 14px 2px; }
34
+ .working .dot { width:7px; height:7px; border-radius:50%; background:var(--accent); animation:pulse 1s infinite; }
35
+ @keyframes pulse { 0%,100% { opacity:.25 } 50% { opacity:1 } }
36
+
37
+ /* permisos: lo único en rubric */
38
+ .approval .card { border:1px solid var(--rubric); border-radius:var(--r); padding:12px 14px; max-width:720px; }
39
+ .approval .why { color:var(--rubric); font:600 12px/1.5 var(--mono); margin-bottom:6px; }
40
+ .approval code { font:400 12.5px/1.5 var(--mono); display:block; background:var(--paper-2); padding:8px 10px; border-radius:2px; margin-bottom:10px; white-space:pre-wrap; }
41
+ .approval .btns { display:flex; gap:8px; }
42
+
43
+ /* ---- hero: el primer golpe de vista ---- */
44
+ .empty { width:100%; max-width:1180px; margin:0 auto; color:var(--ink-2); }
45
+ .empty .eyebrow { font:400 10.5px/1 var(--mono); text-transform:uppercase; letter-spacing:.12em; color:var(--rubric); margin:0 0 12px; }
46
+ .empty h1 { font:600 2.1rem/1.15 var(--serif); letter-spacing:-.022em; color:var(--ink); margin:0 0 .4em; }
47
+ .empty .lead { font-size:1.06rem; margin:0 0 26px; max-width:720px; }
48
+ .empty .grid { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:14px; }
49
+ .empty .card { border:1px solid var(--rule); border-radius:var(--r); padding:14px 16px 12px; min-width:0; }
50
+ .empty .card h3 { font:400 10.5px/1 var(--mono); text-transform:uppercase; letter-spacing:.1em; color:var(--ink-3); margin:0 0 12px; }
51
+ .empty .card p { margin:0 0 8px; font-size:13.5px; line-height:1.5; }
52
+ .empty code { font:400 .88em/1.5 var(--mono); background:var(--paper-2); padding:.08em .3em; border-radius:2px; color:var(--ink); white-space:nowrap; }
53
+ .empty .k { font:600 12px/1 var(--mono); color:var(--ink); margin-right:.3em; }
54
+ .empty .try { cursor:pointer; color:var(--accent); }
55
+ .empty .try:hover { text-decoration:underline; text-underline-offset:.18em; }
56
+ @media (max-width:1400px) { .empty .grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
@@ -0,0 +1,97 @@
1
+ /* layout: grid de tres columnas, cabecera, paneles laterales plegables, zona central (chat / visor / terminal), composer */
2
+ /* ---- grid: cabecera arriba, panel izquierdo (todo lo operativo), chat, panel derecho (árbol) ---- */
3
+ body { display:grid; grid-template-rows:auto 1fr; grid-template-columns:var(--side-l) 1fr var(--side-r); grid-template-areas:"top top top" "side main tree"; --side-l:var(--side); --side-r:var(--side); }
4
+ body.l-closed { --side-l:40px; }
5
+ body.r-closed { --side-r:40px; }
6
+
7
+ /* ---- header: fila 1 quién/dónde, fila 2 contexto y cómo corre el agente ---- */
8
+ header { grid-area:top; border-bottom:1px solid var(--rule); background:var(--paper); }
9
+ .hrow { display:flex; align-items:center; gap:14px; padding:0 18px; height:44px; min-width:0; }
10
+ .hrow.sub { height:32px; border-top:1px solid var(--rule); gap:12px; }
11
+ .brand { font:600 14px/1 var(--mono); color:var(--ink); letter-spacing:-.01em; white-space:nowrap; }
12
+ .brand .proj { color:var(--ink-3); font-weight:400; margin-left:.6em; }
13
+ .hbtn { display:flex; align-items:center; gap:6px; font:600 10.5px/1 var(--mono); text-transform:uppercase; letter-spacing:.09em; color:var(--ink-2); background:none; border:1px solid var(--rule-2); border-radius:2px; padding:7px 12px; cursor:pointer; }
14
+ .hbtn:hover { color:var(--ink); border-color:var(--ink-3); }
15
+ .hbtn.upd { color:var(--amber); border-color:var(--amber); }
16
+ .hbtn.upd:hover { color:var(--ink); border-color:var(--ink); }
17
+ .theme-toggle { display:flex; align-items:center; justify-content:center; background:none; border:0; color:var(--ink-3); cursor:pointer; padding:6px; border-radius:var(--r); line-height:0; }
18
+ .theme-toggle:hover { color:var(--ink); background:var(--paper-2); }
19
+ header select { font:400 11.5px/1 var(--mono); color:var(--ink-2); background:var(--paper); border:1px solid var(--rule); border-radius:var(--r); padding:5px 6px; cursor:pointer; }
20
+ header select:hover { border-color:var(--rule-2); color:var(--ink); }
21
+ .pill { font:400 11.5px/1 var(--mono); color:var(--ink-3); letter-spacing:.02em; white-space:nowrap; max-width:200px; overflow:hidden; text-overflow:ellipsis; flex:none; cursor:pointer; border-bottom:1px dashed transparent; }
22
+ .pill:hover { color:var(--ink); border-bottom-color:var(--rule-2); }
23
+ .pill.nokey { color:var(--rubric); }
24
+ .pill.on { color:var(--accent); }
25
+
26
+ .gitchip { display:none; align-items:center; gap:8px; font:400 11.5px/1 var(--mono); color:var(--ink-2); max-width:45%; flex:none; }
27
+ .gitchip .br { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
28
+ .gitchip b { font-weight:600; }
29
+ .gitchip .w { color:var(--amber); } .gitchip .g { color:var(--str); } .gitchip .r { color:var(--rubric); } .gitchip .c { color:var(--ink-3); font-weight:400; }
30
+ .hpath { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--ink-3); font:400 11.5px/1 var(--mono); }
31
+
32
+ /* ---- paneles laterales: iguales, plegables (cerrado = barra angosta con su icono) ---- */
33
+ aside { grid-area:side; position:relative; border-right:1px solid var(--rule); background:var(--paper); display:flex; flex-direction:column; min-height:0; overflow:hidden; }
34
+ aside.right { grid-area:tree; border-right:0; border-left:1px solid var(--rule); }
35
+ aside .rail { display:none; flex-direction:column; align-items:center; padding-top:10px; gap:8px; }
36
+ aside .rail button, aside .fold { background:none; border:0; color:var(--ink-3); cursor:pointer; padding:5px; border-radius:var(--r); line-height:0; }
37
+ aside .rail button:hover, aside .fold:hover { color:var(--ink); background:var(--paper-2); }
38
+ aside .fold { position:absolute; top:7px; right:6px; z-index:2; }
39
+ aside.right h2 { padding-right:56px; }
40
+ aside:not(.right) > .sec:first-of-type h2 { padding-right:60px; }
41
+ aside.closed .sec, aside.closed .fold { display:none; }
42
+ aside.closed .rail { display:flex; }
43
+
44
+ /* ---- main ---- */
45
+ main { grid-area:main; display:grid; grid-template-rows:1fr auto; min-height:0; min-width:0; background:var(--paper); }
46
+ #stage { overflow:auto; min-height:0; }
47
+ #log { padding:26px 40px 30px; max-width:920px; }
48
+ #log.hero { max-width:none; min-height:100%; display:flex; align-items:center; padding:20px 40px; }
49
+ #viewer { display:none; padding:0; }
50
+ .vh { position:sticky; top:0; display:flex; gap:12px; align-items:center; padding:9px 16px; background:var(--paper-3); border-bottom:1px solid var(--rule); font:400 11.5px/1 var(--mono); color:var(--ink-3); letter-spacing:.03em; }
51
+ .vh b { color:var(--ink); font-weight:400; }
52
+ .vh .x { cursor:pointer; color:var(--ink-3); }
53
+ .vh .x:hover { color:var(--ink); }
54
+ #viewer pre { margin:0; padding:14px 18px; font:400 12.5px/1.62 var(--mono); tab-size:4; }
55
+ #viewer pre .ln { display:inline-block; width:4ch; margin-right:16px; color:var(--ink-3); text-align:right; user-select:none; }
56
+ #viewer pre.log { color:var(--ink-2); white-space:pre-wrap; }
57
+ /* terminal real (xterm.js sobre un pty del servidor) — ocupa la zona central como un archivo abierto */
58
+ #termpane { display:none; height:100%; flex-direction:column; }
59
+ #termpane .vh { position:static; }
60
+ #termpane .vh .st { width:7px; height:7px; border-radius:50%; background:var(--ink-3); }
61
+ #termpane.live .vh .st { background:var(--str); }
62
+ #xterm { flex:1; min-height:0; padding:10px 14px; background:var(--paper); }
63
+ #xterm .xterm { height:100%; }
64
+ #xterm .xterm-viewport { background:transparent !important; }
65
+
66
+ /* ---- composer ---- */
67
+ form { padding:12px 40px 12px; border-top:1px solid var(--rule); background:var(--paper); box-sizing:border-box; width:100%; max-width:100%; min-width:0; }
68
+ main > div { min-width:0; }
69
+ .composer { background:var(--paper-2); border:1px solid var(--rule); border-radius:var(--r); padding:10px 10px 8px 14px; transition:border-color .12s, box-shadow .12s; }
70
+ .composer:focus-within { border-color:var(--rule-2); box-shadow:0 0 0 3px var(--accent-bg); }
71
+ textarea { display:block; width:100%; resize:none; min-height:40px; max-height:220px; background:transparent; color:var(--ink); border:0; padding:2px 0; font:400 15px/1.55 var(--serif); }
72
+ textarea::placeholder { color:var(--ink-3); }
73
+ textarea:focus { outline:none; }
74
+ .crow { display:flex; align-items:center; gap:10px; margin-top:6px; min-width:0; }
75
+ .crow .tools { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
76
+ .crow .keys, .crow button { flex:none; }
77
+ /* imágenes pegadas: miniaturas en el composer y en el mensaje */
78
+ .attach { display:flex; gap:8px; flex-wrap:wrap; }
79
+ .attach:not(:empty) { margin:2px 0 8px; }
80
+ .attach .th, .user .th { position:relative; width:72px; height:72px; border:1px solid var(--rule); border-radius:var(--r); overflow:hidden; background:var(--paper-3); }
81
+ .attach .th img, .user .th img { width:100%; height:100%; object-fit:cover; display:block; }
82
+ .attach .th .rm { position:absolute; top:2px; right:2px; width:18px; height:18px; border-radius:50%; background:var(--paper); color:var(--ink-2); font:600 11px/18px var(--mono); text-align:center; cursor:pointer; border:1px solid var(--rule-2); }
83
+ .attach .th .rm:hover { color:var(--rubric); }
84
+ .attach .th .sz { position:absolute; left:0; right:0; bottom:0; background:rgba(0,0,0,.55); color:#fff; font:400 9.5px/1.4 var(--mono); text-align:center; }
85
+ .user .ims { display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }
86
+ .user .th { width:120px; height:90px; cursor:zoom-in; }
87
+ .composer.drop { border-color:var(--accent); }
88
+ .attach .novision { flex-basis:100%; color:var(--amber); font:400 11.5px/1.5 var(--mono); }
89
+ .crow .tools { color:var(--ink-3); font:400 11px/1 var(--mono); letter-spacing:.03em; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
90
+ .crow .keys { color:var(--ink-3); font:400 10.5px/1 var(--mono); letter-spacing:.03em; }
91
+ #send { width:34px; height:30px; padding:0; font-size:15px; display:inline-flex; align-items:center; justify-content:center; }
92
+ #stop { color:var(--rubric); border-color:var(--rubric); display:none; height:30px; }
93
+ form.busy #send { display:none; } form.busy #stop { display:inline-flex; align-items:center; gap:6px; }
94
+ .hint { display:none; }
95
+
96
+ @media (max-width:900px) { body { grid-template-columns:1fr; grid-template-areas:"top" "main"; } aside { display:none; } #log, form { padding-left:20px; padding-right:20px; } }
97
+ @media (prefers-reduced-motion:reduce) { *, *::before, *::after { transition:none !important; animation:none !important; } }