lazyclaw 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
package/README.ko.md CHANGED
@@ -85,12 +85,16 @@ chat에서 `/orchestrator` (빈 입력 = on/off picker) 또는 `/orchestrator on
85
85
  |---|---|
86
86
  | `/config` | chat 나가고 setup 위저드 재실행 |
87
87
  | `/provider` · `/model` | 검색 picker로 provider/model 선택 |
88
+ | `/trainer [set\|fallback]` · `/agent edit <name>` | trainer / agent의 provider+model을 같은 picker로 선택 (`auto`·custom-id 행 포함) |
88
89
  | `/channels [<name> on\|off]` | 채널 보기 / 토글 |
89
90
  | `/orchestrator [on\|off\|…]` | 멀티에이전트 보기 / 토글 (빈 입력=picker) |
90
91
  | `/context [turns N\|tokens N]` | 히스토리 윈도우 조절 |
92
+ | `/agentic [on\|off]` · `/plan [on\|off]` | chat에서 tool 실행(approval 게이트); plan은 읽기전용 "먼저 제안" |
91
93
  | `/skill` · `/personality` · `/memory` · `/loop` · `/goal` | 스킬·페르소나·메모리·루프·목표 |
92
94
 
93
- `/help`로 전체 목록. 고스트 자동완성, 한글 IME는 박스 안에서 조합.
95
+ `/help`로 전체 목록. 타이핑하면 자동완성: 명령어(`/...`)와 그 **인자**까지 — `/login`→`codex-cli`/`gemini-cli`, `/hud`→`on`/`off`, `/channels`→채널명, `/task`·`/team`·`/agent`·`/personality`·`/trainer`·`/orchestrator` 서브커맨드 등 — 팝업에 떠서 ↑/↓ 선택, Enter로 채움. provider→model 2단계 선택(`/model`·`/trainer set`·`/orchestrator planner`)은 `↹ pick` 힌트가 뜨고 **Tab**으로 드릴인 모달 열림. 한글 IME는 박스 안에서 조합.
96
+
97
+ 스킬은 markdown 지침 번들로 시스템 프롬프트에 합성된다. `lazyclaw skills starter`로 번들 스타터 팩 8종(`concise` · `korean` · `commit-message` · `code-review` · `channel-style` · `summarize` · `explain` · `debug-coach`) 설치, `lazyclaw skills install <user>/<repo>`로 GitHub에서 추가 설치, chat에서는 `/skills`로 선택.
94
98
 
95
99
  ## 대시보드
96
100
 
package/README.md CHANGED
@@ -36,7 +36,7 @@ The first run is a **phased wizard** (Hermes-style — get one clean chat workin
36
36
  1. **Provider + model** — arrow-key picker (`claude-cli` is keyless; gemini/openai/anthropic take an API key).
37
37
  2. **Verify** — a one-token ping confirms the provider answers.
38
38
  3. **Context window** — how much history to keep per turn (optional).
39
- 4. **Channel** — Slack / Telegram / Matrix / HTTP built in; Discord / Email / Signal / Voice / WhatsApp via plugins (optional).
39
+ 4. **Channel** — Slack / Telegram / Matrix / HTTP built in; Discord / Email / Voice / WhatsApp ship in-tree (need a runtime dep via `lazyclaw channels install <name>`), Signal needs `signal-cli` (optional).
40
40
  5. **Workspace / skills / webhook** (optional).
41
41
  6. **Orchestration** — turn on the planner + workers pipeline (optional).
42
42
 
@@ -67,7 +67,7 @@ After every turn, a fire-and-forget loop records the trajectory and distils reus
67
67
 
68
68
  ## Talk to it anywhere
69
69
 
70
- Connect a channel and **the same agent** answers there — every listener forwards into the always-on daemon's shared session store, so chat, the dashboard, and all channels are one agent with one memory (and context follows across channels). Slack, Telegram, Matrix, and HTTP are built in; Discord, Email, Signal, Voice, and WhatsApp install as `@lazyclaw/channel-*` plugins.
70
+ Connect a channel and **the same agent** answers there — every listener forwards into the always-on daemon's shared session store, so chat, the dashboard, and all channels are one agent with one memory (and context follows across channels). Slack, Telegram, Matrix, and HTTP are built in; Discord, Email, Voice, and WhatsApp ship in-tree and run once their runtime dependency is installed into the config dir (`lazyclaw channels install <name>`), and Signal needs the external `signal-cli` binary on your PATH.
71
71
 
72
72
  ```bash
73
73
  lazyclaw service install # 1) the always-on daemon (the shared brain)
@@ -79,7 +79,7 @@ lazyclaw slack listen --daemon-url http://127.0.0.1:19600 # non-default daemon
79
79
 
80
80
  lazyclaw channels # view configured channels
81
81
  lazyclaw channels enable|disable slack
82
- lazyclaw channels install @lazyclaw/channel-discord
82
+ lazyclaw channels install discord # installs discord.js into ~/.lazyclaw and enables the channel
83
83
  ```
84
84
 
85
85
  A listener is a thin forwarder: it owns the channel socket (Slack Socket Mode needs no public URL, just an app-level `xapp-` token) and POSTs each message to the daemon's `/inbound`, which binds the conversation to a persistent session and runs the provider. It needs a reachable daemon (`lazyclaw service install`, or `lazyclaw daemon`); override the target with `--daemon-url` / `LAZYCLAW_DAEMON_URL`. Set it all up from the wizard's channel step, or `/channels` in chat.
@@ -129,14 +129,16 @@ The REPL has slash commands for everything you'd otherwise edit config for — p
129
129
 
130
130
  | Slash | Does |
131
131
  |---|---|
132
- | `/config` | leave chat and re-run the setup wizard |
132
+ | `/config` | change one setting in-chat (provider/model/context/channel creds/webhook/…); `/setup` re-runs the whole wizard |
133
133
  | `/provider` · `/model` | pick provider / model from a searchable list |
134
- | `/channels [<name> on\|off]` | view / toggle channels |
134
+ | `/trainer [set\|fallback]` · `/agent edit <name>` | pick the trainer / an agent's provider+model from the same list (with an `auto` and a custom-id row) |
135
+ | `/channels [<name> on\|off\|setup]` | view / toggle channels; `setup` sets the bot token & credentials in-chat |
135
136
  | `/orchestrator [on\|off\|…]` | view / toggle multi-agent (picker on bare call) |
136
137
  | `/context [turns N\|tokens N]` | resize the chat history window |
138
+ | `/agentic [on\|off]` · `/plan [on\|off]` | let chat run tools (approval-gated); plan mode is read-only "propose first" |
137
139
  | `/skill` · `/personality` · `/memory` · `/loop` · `/goal` | skills, personas, memory, loops, goals |
138
140
 
139
- `/help` lists them all. Ghost-text autocomplete completes commands as you type; CJK/Hangul input composes inside the box.
141
+ `/help` lists them all. Autocomplete works as you type: command names (`/...`) and, after a command, its **arguments** — `/login` → `codex-cli`/`gemini-cli`, `/hud` `on`/`off`, `/channels` channel names, subcommands for `/task` `/team` `/agent` `/personality` `/trainer` `/orchestrator`, and more — appear in a popup (↑/↓ select, Enter fill). The 2-step provider→model picks (`/model`, `/trainer set`, `/orchestrator planner`) show a `↹ pick` hint; press **Tab** to open the drill-in modal. CJK/Hangul input composes inside the box.
140
142
 
141
143
  ## The dashboard
142
144
 
@@ -144,7 +146,7 @@ The REPL has slash commands for everything you'd otherwise edit config for — p
144
146
  lazyclaw dashboard # local web UI on http://127.0.0.1:19600
145
147
  ```
146
148
 
147
- A framework-free SPA over the daemon's JSON API: Chat, Sessions, Workflows, Skills, Providers, Rates, Metrics, Doctor, Config, Status, Agents, Teams, Tasks, Trainer, Recall, Sandbox, Channels — 17 tabs, dark amber theme.
149
+ A framework-free SPA over the daemon's JSON API: Chat, Sessions, Workflows, Skills, Providers, Rates, Metrics, Doctor, Config, Status, Agents, Teams, Tasks, **Team Live**, Trainer, Recall, Sandbox, Channels — 18 tabs, dark amber theme. The **Team Live** tab is a real-time org view: avatar tiles with status rings + harness badges (`provider · model`), live agent-A→B delegation, and click-to-drill-down (harness, current task, recent activity), streamed over Server-Sent Events (`GET /events`).
148
150
 
149
151
  ## Providers
150
152
 
@@ -162,17 +164,19 @@ A framework-free SPA over the daemon's JSON API: Chat, Sessions, Workflows, Skil
162
164
  ## What else it ships
163
165
 
164
166
  - **Tool registry** — 12 categories (`agents`, `browser`, `coding`, `exec`, `fs`, `git`, `iot`, `learning`, `media`, `net`, `os`, `scheduling`) plus stdio MCP. Sensitive tools (shell, write, network) are **fail-closed** behind an approval hook by default.
165
- - **Durable recall** — one SQLite + FTS5 index over sessions, skills, trajectories, and memory; rebuildable from the corpus.
167
+ - **Skills** — markdown instruction bundles composed into the system prompt. `lazyclaw skills starter` installs the bundled pack (`concise` · `korean` · `commit-message` · `code-review` · `channel-style` · `summarize` · `explain` · `debug-coach`); `lazyclaw skills install <user>/<repo>` pulls more from GitHub; `/skills` picks one in chat.
168
+ - **Durable recall** — one SQLite + FTS5 index over sessions, skills, trajectories, and memory; rebuildable from the corpus (`lazyclaw index rebuild`). Optionally blend in embedding similarity (`cfg.recall.embeddings`, off by default; OpenAI/Gemini key or a local Ollama model — the $0 path stays pure FTS5); `lazyclaw index embed` backfills the vectors.
166
169
  - **Loops & goals** — durable foreground/`--detach` loops and cron-scheduled goals that survive restart.
167
170
  - **Personas** — layered SOUL / workspace / personality / role / user-model / skills compose into the system prompt.
168
- - **Sandboxes** — `local` / `docker` / `ssh` / `singularity` / `modal` / `daytona` behind one API.
171
+ - **Agent teams, live** — build a hierarchy (a planner agent with sub-agents on different harnesses, e.g. a data-engineer on `gemini-cli` and a backend on `claude-cli`) via each agent's `manager`; a Slack message to a team's channel auto-routes into the multi-agent loop, and the **Team Live** dashboard tab shows who is doing what, on which harness, and the real-time A→B delegation as it happens.
172
+ - **Default-on confinement** — sensitive tools (`bash`, `python_exec`/`node_exec`, `git_*`, the `os` tools) run confined by default: writes are limited to the workspace + temp, secret dirs (`~/.ssh`, `~/.aws`, the config dir, …) are unreadable, and the secret-scrubbed env still applies. macOS uses `seatbelt`, Linux uses `bubblewrap`/`firejail` (auto-detected). Network is allowed; opt out with `cfg.sandbox.confine=false`. Run `lazyclaw sandbox status` to see the effective posture.
173
+ - **Sandboxes** — `local` / `docker` / `ssh` / `singularity` / `modal` / `daytona` behind one API; pluggable backends for heavier isolation (containers, remote hosts) layered on top of the default local confinement.
174
+ - **MCP** — stdio MCP servers in `cfg.mcp.servers` boot with the daemon; their tools register as `mcp:<server>:<tool>` (always approval-gated). Manage them from the CLI: `lazyclaw mcp list` / `mcp add <name> --command <cmd> [--args "…"] [--allow-glob <glob>]` / `mcp remove <name>` / `mcp call <server> <tool> [--args-json '{…}']` (spawns the server, runs one tool, tears it down).
175
+ - **Daemon lifecycle** — `lazyclaw daemon status | stop | logs` over a pidfile; `maxTokens` in config raises the output cap; `LAZYCLAW_REQUEST_TIMEOUT_MS` sets the per-provider idle timeout (default 120s).
169
176
 
170
177
  ## Configuration & security
171
178
 
172
- Config is plain JSON at `~/.lazyclaw/config.json`; channel + provider secrets live in `~/.lazyclaw/.env` (written 0600, never logged). Move the dir with `LAZYCLAW_CONFIG_DIR=/path`.
173
-
174
- > [!WARNING]
175
- > Treat `~/.lazyclaw/config.json` like a shell rc — values resolved with `$(...)` execute at load. Don't paste an untrusted snippet without reading it.
179
+ Config is plain JSON at `~/.lazyclaw/config.json` — parsed with `JSON.parse`, no shell or code execution; channel + provider secrets live in `~/.lazyclaw/.env` (written 0600, never logged). Move the dir with `LAZYCLAW_CONFIG_DIR=/path`.
176
180
 
177
181
  Sensitive tools deny by default unless an approval hook grants them; `config.json` and workflow state are written owner-only; secrets are scrubbed from the `bash` tool's child env and redacted from trajectories and synthesised skills.
178
182
 
package/agents.mjs CHANGED
@@ -16,11 +16,23 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import os from 'node:os';
18
18
  import { ensureValidName as cronEnsureValidName } from './cron.mjs';
19
+ import * as toolRegistry from './mas/tools/registry.mjs';
19
20
 
20
21
  const AGENTS_DIRNAME = 'agents';
21
22
 
22
23
  export const DEFAULT_TOOLS = ['bash', 'read', 'write', 'grep', 'skill_view'];
23
- export const ALL_TOOLS = ['bash', 'read', 'write', 'grep', 'skill_view', 'web_search', 'web_fetch', 'slack_post'];
24
+
25
+ // The valid tool set is derived from the LIVE tool registry (51+ tools), not a
26
+ // hardcoded 8-name list with a stale, unregistered 'slack_post' — that list
27
+ // rejected recall/delegate/git_*/edit/etc. and silently capped team agents.
28
+ export function knownTools() {
29
+ return new Set([...DEFAULT_TOOLS, ...toolRegistry.listNames()]);
30
+ }
31
+
32
+ // Back-compat export: a snapshot of the registered tool names (the registry
33
+ // self-populates at import). mcp:* tools register dynamically; validateTools
34
+ // accepts them even when absent from this snapshot.
35
+ export const ALL_TOOLS = [...knownTools()];
24
36
 
25
37
  export class AgentError extends Error {
26
38
  constructor(message, code) {
@@ -52,9 +64,12 @@ function validateTools(tools) {
52
64
  if (!Array.isArray(tools)) {
53
65
  throw new AgentError('tools must be an array', 'AGENT_BAD_TOOLS');
54
66
  }
55
- const bad = tools.filter(t => !ALL_TOOLS.includes(t));
67
+ // Validate against the LIVE registry; accept mcp:* names (they register when
68
+ // their server starts) so a config referencing one isn't rejected at edit time.
69
+ const known = knownTools();
70
+ const bad = tools.filter(t => !known.has(t) && !/^mcp:/.test(String(t)));
56
71
  if (bad.length) {
57
- throw new AgentError(`unknown tool(s): ${bad.join(', ')} — known: ${ALL_TOOLS.join(', ')}`, 'AGENT_BAD_TOOLS');
72
+ throw new AgentError(`unknown tool(s): ${bad.join(', ')}`, 'AGENT_BAD_TOOLS');
58
73
  }
59
74
  // Dedupe while preserving order.
60
75
  return [...new Set(tools)];
@@ -75,6 +90,10 @@ function defaultShape(name) {
75
90
  tools: [...DEFAULT_TOOLS],
76
91
  tags: [],
77
92
  iconEmoji: '',
93
+ // Optional parent agent (hierarchy). '' = top-level. A team's org tree is
94
+ // derived from members' manager links (see teams.teamTree). Validated to
95
+ // reference a registered agent and to never form a cycle.
96
+ manager: '',
78
97
  // Phase 18 — agent memory write trigger. 'auto' means the router
79
98
  // fires a reflection LLM call on terminal `done`; 'manual' waits
80
99
  // for `lazyclaw agent reflect`; 'off' disables writes entirely.
@@ -105,7 +124,34 @@ function writeAtomic(filePath, obj) {
105
124
  const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
106
125
  const VALID_SKILL_WRITE = ['auto', 'manual', 'off'];
107
126
 
108
- export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite } = {}, configDir = defaultConfigDir()) {
127
+ // Validate a proposed `manager` (parent agent) for `name`: it must reference a
128
+ // registered agent, may not be the agent itself, and may not close a cycle
129
+ // (i.e. `name` must not already be an ancestor of the proposed manager).
130
+ // Returns the normalised manager string ('' when none).
131
+ function validateManager(name, manager, configDir) {
132
+ if (!manager) return '';
133
+ const mgr = String(manager);
134
+ if (mgr === name) {
135
+ throw new AgentError(`agent "${name}" cannot manage itself`, 'AGENT_BAD_MANAGER');
136
+ }
137
+ if (!getAgent(mgr, configDir)) {
138
+ throw new AgentError(`unknown manager "${mgr}" (not a registered agent)`, 'AGENT_BAD_MANAGER');
139
+ }
140
+ let cur = mgr;
141
+ const visited = new Set();
142
+ while (cur) {
143
+ if (cur === name) {
144
+ throw new AgentError(`manager "${manager}" would create a cycle`, 'AGENT_MANAGER_CYCLE');
145
+ }
146
+ if (visited.has(cur)) break; // pre-existing cycle elsewhere — stop walking
147
+ visited.add(cur);
148
+ const rec = getAgent(cur, configDir);
149
+ cur = rec && rec.manager ? String(rec.manager) : null;
150
+ }
151
+ return mgr;
152
+ }
153
+
154
+ export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
109
155
  ensureValidName(name);
110
156
  const p = agentPath(name, configDir);
111
157
  if (fs.existsSync(p)) {
@@ -132,6 +178,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
132
178
  memoryWrite: mw,
133
179
  memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
134
180
  skillWrite: sw,
181
+ manager: validateManager(name, manager, configDir),
135
182
  };
136
183
  writeAtomic(p, data);
137
184
  return data;
@@ -173,6 +220,9 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
173
220
  if (patch.skillWrite !== undefined && !VALID_SKILL_WRITE.includes(patch.skillWrite)) {
174
221
  throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
175
222
  }
223
+ if (patch.manager !== undefined) {
224
+ next.manager = validateManager(name, patch.manager, configDir);
225
+ }
176
226
  writeAtomic(agentPath(name, configDir), next);
177
227
  return next;
178
228
  }
@@ -15,6 +15,10 @@ export class DiscordChannel extends Channel {
15
15
  constructor(opts = {}) {
16
16
  super('discord');
17
17
  this._token = opts.token || process.env.DISCORD_BOT_TOKEN || null;
18
+ // loadDep resolves the runtime dep from the config dir when the gateway
19
+ // injects it (lazyclaw channels install discord); falls back to a bare
20
+ // import so a dep installed alongside lazyclaw still works.
21
+ this._loadDep = typeof opts.loadDep === 'function' ? opts.loadDep : ((s) => import(s));
18
22
  this._client = null;
19
23
  this._lib = null;
20
24
  }
@@ -22,7 +26,7 @@ export class DiscordChannel extends Channel {
22
26
  async start(handler, opts = {}) {
23
27
  await super.start(handler, opts);
24
28
  if (!this._token) throw new Error('DiscordChannel: DISCORD_BOT_TOKEN missing');
25
- this._lib = await import('discord.js');
29
+ this._lib = await this._loadDep('discord.js');
26
30
  const { Client, GatewayIntentBits, Partials } = this._lib;
27
31
  this._client = new Client({
28
32
  intents: [
@@ -15,15 +15,16 @@ export class EmailChannel extends Channel {
15
15
  this._imapOpts = opts.imap;
16
16
  this._smtpOpts = opts.smtp || {};
17
17
  this._from = opts.from || opts.imap.user;
18
+ this._loadDep = typeof opts.loadDep === 'function' ? opts.loadDep : ((s) => import(s));
18
19
  this._imap = null;
19
20
  this._transporter = null;
20
21
  }
21
22
 
22
23
  async start(handler, opts = {}) {
23
24
  await super.start(handler, opts);
24
- const Imap = (await import('node-imap')).default;
25
- const { simpleParser } = await import('mailparser');
26
- const nodemailer = (await import('nodemailer')).default;
25
+ const Imap = (await this._loadDep('node-imap')).default;
26
+ const { simpleParser } = await this._loadDep('mailparser');
27
+ const nodemailer = (await this._loadDep('nodemailer')).default;
27
28
 
28
29
  this._transporter = nodemailer.createTransport({
29
30
  host: this._smtpOpts.host,
@@ -10,6 +10,7 @@ export class WhatsappChannel extends Channel {
10
10
  constructor(opts = {}) {
11
11
  super('whatsapp');
12
12
  this._opts = opts || {};
13
+ this._loadDep = typeof opts.loadDep === 'function' ? opts.loadDep : ((s) => import(s));
13
14
  this._client = null;
14
15
  this._qrState = 'pending'; // pending | shown | authenticated | failed
15
16
  this._lastQr = null;
@@ -20,8 +21,8 @@ export class WhatsappChannel extends Channel {
20
21
 
21
22
  async start(handler, opts = {}) {
22
23
  await super.start(handler, opts);
23
- const wweb = await import('whatsapp-web.js');
24
- const qrt = await import('qrcode-terminal');
24
+ const wweb = await this._loadDep('whatsapp-web.js');
25
+ const qrt = await this._loadDep('qrcode-terminal');
25
26
  const { Client, LocalAuth } = wweb;
26
27
  this._client = new Client({
27
28
  authStrategy: new LocalAuth({ dataPath: this._opts.dataPath || './whatsapp' }),
package/chat_window.mjs CHANGED
@@ -16,6 +16,16 @@
16
16
  export const CHAT_WINDOW_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
17
17
  export const CHAT_WINDOW_TOKEN_BUDGET = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
18
18
 
19
+ // Approximate token count of a messages[] array (4 chars/token, same heuristic
20
+ // the window cap uses). Drives the status-bar context gauge so it reflects the
21
+ // conversation history lazyclaw actually holds — NOT a provider's self-reported
22
+ // usage, which for CLI providers (codex/claude/gemini) includes their own
23
+ // system prompt + tool defs per call and has nothing to do with this budget.
24
+ export function estimateMessagesTokens(messages) {
25
+ const arr = Array.isArray(messages) ? messages : [];
26
+ return Math.ceil(arr.reduce((n, m) => n + String(m?.content || '').length, 0) / 4);
27
+ }
28
+
19
29
  // Trim a hydrated messages[] array to fit the sliding window. Returns
20
30
  // { messages, dropped } so the caller can log a one-shot "dropped N
21
31
  // older turns" line at session start. The first message is preserved
@@ -31,8 +41,7 @@ export function applyChatWindow(messages, { turns = CHAT_WINDOW_TURNS, tokens =
31
41
  while (out.length > turns) out.shift();
32
42
  // Token budget cap: estimate 4 chars / token, then trim from the
33
43
  // front (oldest) until estimated tokens ≤ budget.
34
- const estTokens = (arr) => Math.ceil(arr.reduce((n, m) => n + String(m.content || '').length, 0) / 4);
35
- while (out.length > 1 && estTokens(out) > tokens) out.shift();
44
+ while (out.length > 1 && estimateMessagesTokens(out) > tokens) out.shift();
36
45
  const dropped = before - out.length;
37
46
  if (sys) out.unshift(sys);
38
47
  return { messages: out, dropped };
package/cli.mjs CHANGED
@@ -10,7 +10,8 @@ import {
10
10
  // Phase D2 — provider-registry bootstrap (lazy load + per-call re-register).
11
11
  import { ensureRegistry, requireRegistry, getRegistry } from './lib/registry_boot.mjs';
12
12
  // Phase D2 — argument parsing + subcommand inventory + agent-registry classifier.
13
- import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from './lib/args.mjs';
13
+ // nearest/usageHint power the unknown-subcommand "did you mean" + `help <name>`.
14
+ import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS, nearest, usageHint } from './lib/args.mjs';
14
15
  // Phase D4 — interactive TUI pickers/banner helpers. Still imported here for the
15
16
  // onboard / setup / launcher / chat paths that remain inline in this entrypoint.
16
17
  import {
@@ -32,6 +33,11 @@ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } fr
32
33
  // loop reads this same list the Ink path (_help) and the popup use.
33
34
  import { SLASH_COMMANDS } from './tui/slash_commands.mjs';
34
35
 
36
+ // Sentinel thrown when cmdHelp would process.exit(2) for a known subcommand
37
+ // that lacks a rich HELP_DETAILS entry — caught in the `help` branch so we can
38
+ // fall back to a one-line usage hint instead of exiting non-zero.
39
+ class _HelpFallback extends Error {}
40
+
35
41
  async function main() {
36
42
  const argv = process.argv.slice(2);
37
43
  const cmd = argv[0];
@@ -189,10 +195,67 @@ async function main() {
189
195
  }
190
196
  break;
191
197
  }
198
+ case 'index': {
199
+ // FTS index recovery surface documented by mas/index_db.mjs and the
200
+ // doctor failure-log. reindexAll (NOT the destructive rebuild) drops the
201
+ // db and repopulates from the on-disk corpus, so recall is never left empty.
202
+ const indexSub = rest.positional[0];
203
+ const cfgDir = path.dirname(configPath());
204
+ if (indexSub === 'embed') {
205
+ // Opt-in hybrid recall: embed FTS rows that lack a vector. No-op when
206
+ // cfg.recall.embeddings is off (then it just reports 0 embedded).
207
+ try {
208
+ const indexDb = await import('./mas/index_db.mjs');
209
+ const embedded = await indexDb.backfillEmbeddings(cfgDir, readConfig());
210
+ console.log(JSON.stringify({ ok: true, embedded }));
211
+ process.exit(0);
212
+ } catch (e) {
213
+ console.error(`index embed failed: ${e.message}`);
214
+ process.exit(1);
215
+ }
216
+ }
217
+ if (indexSub !== 'rebuild') {
218
+ console.error('Usage: lazyclaw index <rebuild|embed>');
219
+ process.exit(2);
220
+ }
221
+ try {
222
+ const indexDb = await import('./mas/index_db.mjs');
223
+ indexDb.reindexAll(cfgDir);
224
+ // reindexAll returns void; derive what landed by counting the FTS rows.
225
+ const db = indexDb.openIndex(cfgDir);
226
+ const counts = {};
227
+ for (const [scope, table] of [
228
+ ['sessions', 'fts_sessions'], ['skills', 'fts_skills'],
229
+ ['trajectories', 'fts_trajectories'], ['memories', 'fts_memories'],
230
+ ]) {
231
+ counts[scope] = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
232
+ }
233
+ console.log(JSON.stringify({ ok: true, counts }));
234
+ process.exit(0);
235
+ } catch (e) {
236
+ console.error(`index rebuild failed: ${e.message}`);
237
+ process.exit(1);
238
+ }
239
+ break;
240
+ }
241
+ case 'mcp': {
242
+ // list (configured + connected) / add / remove / call. Config mutation
243
+ // (add/remove) edits cfg.mcp.servers; servers (re)spawn at daemon boot.
244
+ const sub = rest.positional[0];
245
+ await (await import('./commands/mcp.mjs')).cmdMcp(sub, rest.positional.slice(1), rest.flags);
246
+ break;
247
+ }
192
248
  case 'chat': {
193
249
  await (await import('./commands/chat.mjs')).cmdChat(rest.flags);
194
250
  break;
195
251
  }
252
+ case 'workflow': {
253
+ // Named declarative workflows (cfg.workflows) — add/list/show/remove/run.
254
+ // Distinct from the top-level `run <session-id> <file.mjs>` engine.
255
+ const sub = rest.positional[0];
256
+ await (await import('./commands/workflow_named.mjs')).cmdWorkflow(sub, rest.positional.slice(1), rest.flags);
257
+ break;
258
+ }
196
259
  case 'sessions': {
197
260
  const sub = rest.positional[0];
198
261
  await (await import('./commands/sessions.mjs')).cmdSessions(sub, rest.positional.slice(1), rest.flags);
@@ -369,7 +432,38 @@ async function main() {
369
432
  case 'help':
370
433
  case '--help':
371
434
  case '-h': {
372
- await (await import('./commands/setup.mjs')).cmdHelp(rest.positional[0]);
435
+ const name = rest.positional[0];
436
+ const cmdHelp = (await import('./commands/setup.mjs')).cmdHelp;
437
+ // Bare `help` / unknown-name handling stays here so we can offer a
438
+ // did-you-mean and a bounded usage-hint fallback without editing the
439
+ // rich HELP_DETAILS map in commands/setup.mjs.
440
+ if (!name) { cmdHelp(undefined); break; }
441
+ const hint = usageHint(name);
442
+ if (!hint) {
443
+ // Unknown subcommand name passed to help — suggest the nearest match.
444
+ process.stderr.write(`unknown command "${name}"`);
445
+ const guess = nearest(name, SUBCOMMANDS);
446
+ process.stderr.write(guess ? ` — did you mean "${guess}"?\n` : `\n`);
447
+ process.stderr.write('run `lazyclaw help` to see the list\n');
448
+ process.exit(2);
449
+ }
450
+ // Known subcommand: prefer the rich HELP_DETAILS entry. cmdHelp exits 2
451
+ // for subcommands that lack a detailed entry (writing its own "unknown
452
+ // subcommand" lines to stderr first), so guard that fatal exit AND buffer
453
+ // its stderr — on fallback we drop that noise and print the one-line
454
+ // usage hint instead. `help <name>` must never error on a real command.
455
+ const realExit = process.exit;
456
+ const realErr = process.stderr.write.bind(process.stderr);
457
+ const buffered = [];
458
+ let detailMissing = false;
459
+ process.exit = (code) => { if (code === 2) { detailMissing = true; throw new _HelpFallback(); } realExit(code); };
460
+ process.stderr.write = (chunk, ...a) => { buffered.push([chunk, a]); return true; };
461
+ try { cmdHelp(name); }
462
+ catch (e) { if (!(e instanceof _HelpFallback)) { process.exit = realExit; process.stderr.write = realErr; throw e; } }
463
+ finally { process.exit = realExit; process.stderr.write = realErr; }
464
+ // Non-fallback path: replay whatever cmdHelp wrote to stderr verbatim.
465
+ if (detailMissing) process.stdout.write(hint + '\n');
466
+ else for (const [chunk, a] of buffered) process.stderr.write(chunk, ...a);
373
467
  break;
374
468
  }
375
469
  case 'menu': {
@@ -377,10 +471,16 @@ async function main() {
377
471
  await (await import('./commands/setup.mjs')).cmdLauncher();
378
472
  break;
379
473
  }
380
- default:
474
+ default: {
475
+ // Unknown top-level subcommand: lead with a closest-match suggestion so
476
+ // a typo (`sesions` -> `sessions`) is fixable without scanning the dump.
477
+ const guess = nearest(cmd, SUBCOMMANDS);
478
+ if (guess) console.error(`unknown command "${cmd}" — did you mean "${guess}"?`);
479
+ else console.error(`unknown command "${cmd}"`);
381
480
  console.error('Usage: lazyclaw <' + SUBCOMMANDS.join('|') + '> ...');
382
481
  console.error('Run `lazyclaw help` for a one-line summary of each subcommand.');
383
482
  process.exit(2);
483
+ }
384
484
  }
385
485
  }
386
486