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
@@ -0,0 +1,208 @@
1
+ // tui/slash_channels.mjs — the /channels and /context slash-command handlers,
2
+ // extracted verbatim from slash_dispatcher.mjs. Imports the shared masked-prompt
3
+ // helper from slash_helpers.mjs and the dotenv shim from a leaf lib, never the
4
+ // dispatcher (no cycle).
5
+
6
+ import { loadDotenvIfAny } from '../dotenv_min.mjs';
7
+ import { _promptText } from './slash_helpers.mjs';
8
+
9
+ // /channels — view configured channels and toggle them. `/channels` lists;
10
+ // `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
11
+ // available, else lib/config directly, so it works on both REPL paths.
12
+ export async function _channels(args, ctx = {}) {
13
+ const cf = await import('../config_features.mjs');
14
+ const cfgMod = await import('../lib/config.mjs');
15
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
16
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
17
+ const toks = (args || '').trim().split(/\s+/).filter(Boolean);
18
+ const [name, action] = toks;
19
+
20
+ // `/channels [<name>] setup` — set the channel's credentials (bot token,
21
+ // homeserver, …) from chat instead of redirecting to /config. Reuses the
22
+ // masked modal prompt (_promptText) so secrets are never echoed. No modal →
23
+ // fall back to the readline channel step (same as /config's channel item).
24
+ const wantSetup = toks.some((t) => /^setup$/i.test(t));
25
+ if (wantSetup) {
26
+ const channelMod = await import('../commands/setup_channels.mjs');
27
+ const picked = toks.find((t) => !/^setup$/i.test(t));
28
+ if (typeof ctx.openPicker !== 'function') {
29
+ // Readline path: hand off to the existing channel wizard step.
30
+ ctx.requestConfigStep = 'channel';
31
+ return 'EXIT';
32
+ }
33
+ let chName = picked && picked.toLowerCase();
34
+ if (!chName) {
35
+ const sel = await ctx.openPicker({
36
+ kind: 'menu',
37
+ title: 'channel — set credentials',
38
+ subtitle: 'pick a channel to configure',
39
+ items: channelMod.CHANNEL_CATALOG.map((c) => ({
40
+ id: c.name,
41
+ label: c.label,
42
+ desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
43
+ })),
44
+ });
45
+ chName = sel && typeof sel === 'object' ? sel.id : sel;
46
+ if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
47
+ }
48
+ const spec = channelMod.channelByName(chName);
49
+ if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
50
+ if (!spec.fields.length) {
51
+ // No creds (e.g. http / whatsapp) — just enable it.
52
+ const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
53
+ channelMod.persistChannel(cfgDirX, chName, {});
54
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
55
+ return `channel ${chName} → enabled (no credentials needed)`;
56
+ }
57
+ const answers = {};
58
+ for (const f of spec.fields) {
59
+ const v = await _promptText(ctx, {
60
+ title: `${spec.label} — ${f.prompt}`,
61
+ subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
62
+ secret: !!f.secret,
63
+ allowEmpty: !!f.optional,
64
+ });
65
+ if (v === null) {
66
+ if (f.optional) continue; // Esc on an optional field → skip it
67
+ return 'channel setup: cancelled';
68
+ }
69
+ if (v) answers[f.key] = v;
70
+ }
71
+ const path = await import('node:path');
72
+ const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
73
+ const entry = channelMod.persistChannel(cfgDirX, chName, answers);
74
+ // Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
75
+ // list is fresh — an in-tree channel whose runtime dep is missing stays
76
+ // disabled (persistChannel gates it) rather than being force-enabled.
77
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
78
+ const setKeys = Object.keys(answers);
79
+ let note = '';
80
+ if (!entry.ready) {
81
+ if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
82
+ if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
83
+ }
84
+ return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
85
+ }
86
+
87
+ // `/channels <name> test` — verify the stored credentials with a live call.
88
+ if (name && /^test$/i.test(action || '')) {
89
+ const channelMod = await import('../commands/setup_channels.mjs');
90
+ try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
91
+ const r = await channelMod.verifyChannel(name.toLowerCase());
92
+ if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
93
+ if (r.ok === null) return `· ${name}: ${r.detail}`;
94
+ return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
95
+ }
96
+
97
+ if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
98
+ const en = /^(on|enable)$/i.test(action);
99
+ const cfg = read();
100
+ const key = name.toLowerCase();
101
+ // Reject unknown names so a typo can't silently create a bogus
102
+ // cfg.channels.<name> section (which would then leak into the list).
103
+ // Stay permissive for pre-existing custom sections.
104
+ const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
105
+ if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
106
+ return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
107
+ }
108
+ cf.channelSetEnabled(cfg, key, en); write(cfg);
109
+ // Legacy fallback path: the readline ctx (_legacyCtx) has no
110
+ // readConfig/writeConfig, so we read/wrote disk above against a fresh
111
+ // cfg object. Mirror the toggle onto the in-session ctx.cfg so a
112
+ // follow-up `/channels` (list) or other in-session read stays
113
+ // consistent instead of showing the stale pre-toggle value.
114
+ if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
115
+ cf.channelSetEnabled(ctx.cfg, key, en);
116
+ }
117
+ return en
118
+ ? `channel ${key} → enabled`
119
+ : `channel ${key} → disabled (re-enable with /channels ${key} on)`;
120
+ }
121
+ // No-arg + modal → an action menu: each row toggles in place, plus a
122
+ // "set credentials" row. Falls through to the text list when no modal.
123
+ if (!toks.length && typeof ctx.openPicker === 'function') {
124
+ const statusRows = cf.channelStatusList(read());
125
+ const items = statusRows.map((c) => ({
126
+ id: `toggle:${c.name}`,
127
+ label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
128
+ desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
129
+ }));
130
+ items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
131
+ const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
132
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
133
+ if (!pid || typeof pid !== 'string') return 'cancelled';
134
+ if (pid === 'setup') return _channels('setup', ctx);
135
+ if (pid.startsWith('toggle:')) {
136
+ const nm = pid.slice(7);
137
+ const cur = statusRows.find((r) => r.name === nm);
138
+ return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
139
+ }
140
+ }
141
+ const rows = cf.channelStatusList(read());
142
+ if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
143
+ // Cross-reference each channel's required env creds against the loaded env so
144
+ // the list flags a channel that's "enabled" but missing its token.
145
+ let channelMod = null;
146
+ try {
147
+ loadDotenvIfAny(ctx.cfgDir);
148
+ channelMod = await import('../commands/setup_channels.mjs');
149
+ } catch { /* hint is best-effort */ }
150
+ const missingFor = (name) => {
151
+ const spec = channelMod && channelMod.channelByName(name);
152
+ if (!spec) return [];
153
+ return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
154
+ };
155
+ const lines = ['configured channels:'];
156
+ for (const c of rows) {
157
+ const miss = missingFor(c.name);
158
+ const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
159
+ lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
160
+ }
161
+ lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // /context — view/set the chat history window (turns + token budget). This is
166
+ // the sliding history budget sent each turn, NOT the model's hard context
167
+ // limit. ctx-or-lib/config fallback so it works on both REPL paths.
168
+ export async function _context(args, ctx = {}) {
169
+ const cf = await import('../config_features.mjs');
170
+ const cfgMod = await import('../lib/config.mjs');
171
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
172
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
173
+ const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
174
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
175
+ const sub = (parts[0] || 'status').toLowerCase();
176
+ const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
177
+ // No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
178
+ if (!parts.length && typeof ctx.openPicker === 'function') {
179
+ const w = cf.chatWindowGet(read());
180
+ const action = await ctx.openPicker({
181
+ kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
182
+ items: [
183
+ { id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
184
+ { id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
185
+ { id: 'status', label: 'Status', desc: 'show current' },
186
+ ],
187
+ });
188
+ const aid = action && typeof action === 'object' ? action.id : action;
189
+ if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
190
+ if (aid === 'turns') {
191
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
192
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
193
+ if (!Number.isFinite(v)) return 'context turns: cancelled';
194
+ const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
195
+ }
196
+ if (aid === 'tokens') {
197
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
198
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
199
+ if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
200
+ const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
201
+ }
202
+ }
203
+ if (sub === 'status') return fmt();
204
+ const n = parseInt(parts[1], 10);
205
+ if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
206
+ if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
207
+ return 'usage: /context [status | turns <N> | tokens <N>]';
208
+ }
@@ -21,25 +21,27 @@ export const SLASH_COMMANDS = [
21
21
  { cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
22
22
  { cmd: '/tools', help: 'list available tool registry verbs' },
23
23
  { cmd: '/provider', help: 'pick provider from a list (or pass a name: /provider openai)' },
24
+ { cmd: '/login', help: 'connect a keyless CLI provider (codex-cli / gemini-cli): browser, API key, or install' },
24
25
  { cmd: '/model', help: 'pick a model from a list (or pass a name: /model gpt-4.1)' },
26
+ { cmd: '/hud', help: 'toggle the HUD status row (usage · models · cost): /hud on|off' },
25
27
  { cmd: '/trainer', help: 'view or set trainer provider/model: /trainer show|set <p:m>|clear' },
26
28
  { cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
27
29
  { cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
28
30
  { cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
29
31
  { cmd: '/setup', help: 'first-run / full re-setup: leave chat and run every wizard step' },
30
32
  { cmd: '/config', help: 'change ONE setting: provider, model, context, channel creds, webhook, …' },
31
- { cmd: '/channels', help: 'view configured channels; /channels <name> on|off to toggle' },
33
+ { cmd: '/channels', help: 'channels: list · <name> on|off · <name> setup (set bot token / creds)' },
32
34
  { cmd: '/orchestrator', help: 'multi-agent: status | on | off | planner <spec> | worker add|remove <spec>' },
33
35
  { cmd: '/context', help: 'chat history window: status | turns <N> | tokens <N>' },
34
- { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
36
+ { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"] [--use-memory] [--recall "<q>"]' },
35
37
  { cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
36
38
  { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
37
39
  { cmd: '/recall', help: 'FTS5 recall across sessions and memory' },
38
40
  { cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
39
- { cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
40
- { cmd: '/team', help: 'list, create, or join a team' },
41
+ { cmd: '/agent', help: 'agents: list · show <name> · add <name> [role] · edit <name> · remove <name>' },
42
+ { cmd: '/team', help: 'teams: list · show <name> · add <name> --agents a,b [--lead a] · remove <name>' },
41
43
  { cmd: '/task', help: 'multi-agent tasks: start/tick/list/show/transcript/abandon/done/remove' },
42
- { cmd: '/handoff', help: 'hand current task off to another agent' },
44
+ { cmd: '/handoff', help: 'move the current channel thread to another channel: /handoff <target-channel> <externalId> [--note=…]' },
43
45
  { cmd: '/exit', help: 'leave the chat' },
44
46
  { cmd: '/quit', help: 'alias for /exit' },
45
47
  ];
@@ -0,0 +1,220 @@
1
+ // tui/slash_dashboard.mjs — the /dashboard slash-command cluster, extracted
2
+ // verbatim from slash_dispatcher.mjs. Self-contained leaf with its own
3
+ // encapsulated module state (_dashboardSpawning / _dashboardChildPid). Imports
4
+ // only the shared leaf helpers, never the dispatcher (no cycle).
5
+
6
+ import { splitWhitespace } from './slash_helpers.mjs';
7
+
8
+ // /dashboard — open the lazyclaw web UI.
9
+ //
10
+ // v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
11
+ // session spawned 20+ daemon children).
12
+ //
13
+ // Original implementation:
14
+ // probe /healthz → if !200, spawn detached `lazyclaw dashboard
15
+ // --no-open` and poll for up to 3s.
16
+ //
17
+ // Failure mode that produced the 20+ spawn pile-up:
18
+ // 1. User types /dashboard. probe fails (no daemon). Spawn child A.
19
+ // 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
20
+ // 3. User types /dashboard again BEFORE A is ready. probe still fails.
21
+ // Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
22
+ // (cli.mjs:3611) which SIGTERMs child A. B takes over.
23
+ // 4. Repeat. Each /dashboard kills the previous daemon and starts a
24
+ // new one. With autorepeat / many slash calls this stacks fast.
25
+ //
26
+ // Two-layer guard:
27
+ // - A module-level _dashboardSpawning latch refuses concurrent spawn
28
+ // attempts. While a spawn is in flight, /dashboard says so + returns
29
+ // without firing another child.
30
+ // - A _dashboardChildPid cache remembers the PID we already spawned;
31
+ // subsequent calls check kill(pid, 0) to confirm the child is alive
32
+ // and just open the browser without spawning.
33
+ //
34
+ // We probe both /healthz (HTTP) AND a raw net.connect port check so a
35
+ // slow-starting daemon (binding the listener but not yet answering HTTP)
36
+ // still counts as "running".
37
+ let _dashboardSpawning = false;
38
+ let _dashboardChildPid = null;
39
+
40
+ function _portIsListening(port, timeoutMs = 200) {
41
+ return new Promise((resolve) => {
42
+ import('node:net').then(({ createConnection }) => {
43
+ let settled = false;
44
+ const sock = createConnection({ host: '127.0.0.1', port });
45
+ const done = (ok) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ try { sock.destroy(); } catch {}
49
+ resolve(ok);
50
+ };
51
+ sock.once('connect', () => done(true));
52
+ sock.once('error', () => done(false));
53
+ setTimeout(() => done(false), timeoutMs);
54
+ }).catch(() => resolve(false));
55
+ });
56
+ }
57
+
58
+ async function _dashboardProbe(port) {
59
+ // Fast path — port-level probe. Catches a daemon that has bound the
60
+ // socket but hasn't finished initializing its HTTP routes.
61
+ if (await _portIsListening(port, 200)) return true;
62
+ // Slow path — full /healthz fetch, for defense in depth.
63
+ if (typeof fetch !== 'function') return false;
64
+ try {
65
+ const ac = new AbortController();
66
+ const t = setTimeout(() => ac.abort(), 250);
67
+ const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
68
+ clearTimeout(t);
69
+ return !!(r && r.ok);
70
+ } catch { return false; }
71
+ }
72
+
73
+ function _openBrowser(url) {
74
+ return import('node:child_process').then(({ spawn }) => {
75
+ let cmd, args;
76
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
77
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
78
+ else { cmd = 'xdg-open'; args = [url]; }
79
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
80
+ });
81
+ }
82
+
83
+ async function _dashboardStop(port) {
84
+ // Best-effort kill of every lazyclaw dashboard daemon on the box.
85
+ // Used to clean up after the v5.4.3 spawn pile-up bug.
86
+ if (process.platform === 'win32') {
87
+ return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
88
+ }
89
+ const { spawn } = await import('node:child_process');
90
+ // Step 1: lsof the port and SIGTERM each PID.
91
+ const portPids = await new Promise((resolve) => {
92
+ try {
93
+ const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
94
+ let buf = '';
95
+ lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
96
+ lsof.on('error', () => resolve([]));
97
+ lsof.on('close', () => resolve(
98
+ buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
99
+ ));
100
+ } catch { resolve([]); }
101
+ });
102
+ for (const pid of portPids) {
103
+ try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
104
+ }
105
+ // Step 2: pkill any process whose command line includes "lazyclaw dashboard"
106
+ // — catches detached children that bound a different (random) port via
107
+ // cmdDashboard's EADDRINUSE fallback.
108
+ let pkilled = 0;
109
+ try {
110
+ const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
111
+ pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
112
+ } catch { /* fine */ }
113
+ _dashboardChildPid = null;
114
+ return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
115
+ }
116
+
117
+ // Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
118
+ // actually-bound port (the child may fall back to a random port on EADDRINUSE).
119
+ export function parseDashboardUrl(text) {
120
+ const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
121
+ return m ? m[1] : null;
122
+ }
123
+
124
+ // Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
125
+ function _waitForDashboardUrl(child, timeoutMs) {
126
+ return new Promise((resolve) => {
127
+ if (!child || !child.stdout) { resolve(null); return; }
128
+ let buf = '';
129
+ let done = false;
130
+ const finish = (v) => {
131
+ if (done) return;
132
+ done = true;
133
+ try { child.stdout.off('data', onData); } catch { /* ignore */ }
134
+ resolve(v);
135
+ };
136
+ const onData = (d) => {
137
+ buf += d.toString('utf8');
138
+ const u = parseDashboardUrl(buf);
139
+ if (u) finish(u);
140
+ };
141
+ child.stdout.on('data', onData);
142
+ setTimeout(() => finish(null), timeoutMs);
143
+ });
144
+ }
145
+
146
+ export async function _dashboard(args) {
147
+ const port = 19600;
148
+ const url = `http://127.0.0.1:${port}/dashboard`;
149
+ // Under the node:test runner, never launch a real daemon or open a browser
150
+ // (it leaked a background daemon + opened a tab on every test run).
151
+ if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
152
+ const sub = splitWhitespace(args)[0];
153
+ if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
154
+
155
+ // 1. Already running anywhere on the machine? → reuse.
156
+ if (await _dashboardProbe(port)) {
157
+ await _openBrowser(url);
158
+ return `✓ dashboard already running — opened ${url}`;
159
+ }
160
+
161
+ // 2. We spawned in this chat — is that child still alive?
162
+ if (_dashboardChildPid != null) {
163
+ try {
164
+ process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
165
+ // Child alive but not answering yet. Don't re-spawn; just nudge.
166
+ await _openBrowser(url);
167
+ return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
168
+ } catch {
169
+ _dashboardChildPid = null; // child died; fall through and respawn.
170
+ }
171
+ }
172
+
173
+ // 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
174
+ if (_dashboardSpawning) {
175
+ await _openBrowser(url);
176
+ return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
177
+ }
178
+
179
+ // 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
180
+ // spawn flag in a finally so it always clears.
181
+ _dashboardSpawning = true;
182
+ try {
183
+ const { spawn } = await import('node:child_process');
184
+ let child;
185
+ try {
186
+ // Pass --port so the child tries 19600 first; pipe stdout so we can read
187
+ // the real bound URL (it may fall back to a random port on EADDRINUSE).
188
+ child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
189
+ detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
190
+ });
191
+ _dashboardChildPid = child.pid;
192
+ } catch (e) {
193
+ return `dashboard error: failed to spawn — ${e?.message || e}`;
194
+ }
195
+ // Prefer the daemon's own "listening at <url>" line — it carries the
196
+ // actual port even after a random-port fallback.
197
+ const boundUrl = await _waitForDashboardUrl(child, 3000);
198
+ // Release the captured stdout pipe so the detached daemon doesn't keep
199
+ // OUR event loop alive (unref, not destroy — destroying would EPIPE the
200
+ // daemon on its next stdout write). Then unref the child itself.
201
+ try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
202
+ child.unref();
203
+ if (boundUrl) {
204
+ await _openBrowser(boundUrl);
205
+ return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
206
+ }
207
+ // Fallback: the line never arrived — poll the default port best-effort.
208
+ const start = Date.now();
209
+ while (Date.now() - start < 1500) {
210
+ if (await _dashboardProbe(port)) {
211
+ await _openBrowser(url);
212
+ return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
213
+ }
214
+ await new Promise((r) => setTimeout(r, 150));
215
+ }
216
+ return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
217
+ } finally {
218
+ _dashboardSpawning = false;
219
+ }
220
+ }