lazyclaw 6.0.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ // commands/gateway.mjs — `lazyclaw gateway`: the single-process always-on
2
+ // agent (Phase 5, "approach B").
3
+ //
4
+ // One process owns everything: it starts the HTTP daemon in-process (the
5
+ // session-bearing core) AND the configured channel transports (Slack Socket
6
+ // Mode / Telegram long-poll / Matrix /sync), feeding every inbound through
7
+ // the same POST /inbound the standalone listeners use — so the bridge
8
+ // contract, pairing gate, dedup, cost cap, and learning hook are identical.
9
+ // Because the channels live in-process, each registers a live sender into
10
+ // ctx.channelSenders, which finally gives POST /handoff a notifier: the
11
+ // target channel receives a resume marker and a failed notify rolls the
12
+ // binding back (channels/handoff.mjs). `slack|telegram|matrix listen` remain
13
+ // supported as standalone single-channel forwarders.
14
+ import path from 'node:path';
15
+ import fs from 'node:fs';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
18
+ import { ensureRegistry } from '../lib/registry_boot.mjs';
19
+ import { loadDotenvIfAny } from '../dotenv_min.mjs';
20
+ import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
21
+ import { makeInboundHandler } from '../lib/inbound_client.mjs';
22
+
23
+ export const GATEWAY_CHANNELS = ['slack', 'telegram', 'matrix'];
24
+
25
+ // Default per-channel constructors. Each receives { handler, logger,
26
+ // allowlist }, must return a started channel exposing send()/stop().
27
+ // Injectable so tests can run the full gateway with stub transports.
28
+ const DEFAULT_FACTORIES = {
29
+ async slack({ handler, logger }) {
30
+ const { SlackChannel } = await import('../channels/slack.mjs');
31
+ const ch = new SlackChannel();
32
+ await ch.start(handler);
33
+ await ch._connectSocketMode({ logger });
34
+ return ch;
35
+ },
36
+ async telegram({ handler, logger, allowlist }) {
37
+ const { TelegramChannel } = await import('../channels/telegram.mjs');
38
+ const ch = new TelegramChannel({ allowlist });
39
+ await ch.start(handler, { poll: true, logger });
40
+ return ch;
41
+ },
42
+ async matrix({ handler, logger, allowlist }) {
43
+ const { MatrixChannel } = await import('../channels/matrix.mjs');
44
+ const ch = new MatrixChannel({ allowlist });
45
+ await ch.start(handler, { poll: true, logger });
46
+ return ch;
47
+ },
48
+ };
49
+
50
+ // Which channels should the gateway run? --channels a,b wins; otherwise the
51
+ // enabled cfg.channels sections that we have a built-in transport for.
52
+ export function _selectChannels(cfg, flags = {}) {
53
+ if (flags.channels) {
54
+ return String(flags.channels).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
55
+ .filter((n) => GATEWAY_CHANNELS.includes(n));
56
+ }
57
+ const configured = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
58
+ return GATEWAY_CHANNELS.filter((n) => configured[n] && configured[n].enabled !== false);
59
+ }
60
+
61
+ // The whole gateway, with injectable deps so tests can drive it end-to-end
62
+ // (stub transports, ephemeral port) without Slack/Telegram/Matrix creds.
63
+ // Throws GatewayGuardError on an unsafe config. Returns { port, channels,
64
+ // channelSenders, skipped, stop }.
65
+ export async function runGateway(flags = {}, deps = {}) {
66
+ await ensureRegistry();
67
+ const log = deps.log || ((s) => process.stderr.write(s));
68
+ const cfg = deps.readConfig ? deps.readConfig() : readConfig();
69
+ assertUnattendedSafe(cfg, { surface: 'gateway' });
70
+ const cfgDir = path.dirname(configPath());
71
+ loadDotenvIfAny(cfgDir);
72
+
73
+ // The gateway is session-bearing AND sits on a well-known always-on port,
74
+ // so unlike the ad-hoc daemon it does not run unauthenticated by default:
75
+ // resolve --auth-token > env > the persisted gateway.token, else mint one
76
+ // and persist it 0600 (so `service install gateway` restarts keep the same
77
+ // token and the operator can read it from the file — it is never logged).
78
+ // --no-auth opts back into the historical open-loopback posture.
79
+ let authToken = flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null;
80
+ const tokenFile = path.join(cfgDir, 'gateway.token');
81
+ if (!authToken && !flags['no-auth']) {
82
+ try { authToken = fs.readFileSync(tokenFile, 'utf8').trim() || null; } catch { /* not minted yet */ }
83
+ if (!authToken) {
84
+ authToken = randomBytes(24).toString('hex');
85
+ fs.writeFileSync(tokenFile, authToken + '\n', { mode: 0o600 });
86
+ }
87
+ log(`[gateway] auth token active (read it from ${tokenFile}; external callers need Authorization: Bearer)\n`);
88
+ }
89
+ if (!authToken) log('[gateway] warning: --no-auth — any local process can drive the agent on this port.\n');
90
+ const allowlistArr = (cfg.pairing || []).map((p) => String(p.id));
91
+ if (allowlistArr.length === 0) {
92
+ log('[gateway] warning: pairing allowlist is empty — the agent will answer ANYONE who can reach a connected channel. Pair senders with `lazyclaw pairing add <id>`.\n');
93
+ }
94
+
95
+ // Live sender map: channels register here as they come up; the daemon's
96
+ // handoff route reads it per-request (live Map, registrations visible).
97
+ const channelSenders = new Map();
98
+
99
+ const sessionsMod = await import('../sessions.mjs');
100
+ const { startDaemon } = await import('../daemon.mjs');
101
+ const startDaemonImpl = deps.startDaemonImpl || startDaemon;
102
+ const d = await startDaemonImpl({
103
+ port: flags.port !== undefined ? parseInt(flags.port, 10) : 19600,
104
+ once: false,
105
+ readConfig,
106
+ writeConfig: authToken ? writeConfig : undefined,
107
+ sessionsDirGetter: () => cfgDir,
108
+ sessionsMod,
109
+ version: () => readVersionFromRepo(),
110
+ authToken: authToken || undefined,
111
+ allowedOrigins: [],
112
+ logger: deps.logger || null,
113
+ channelSenders,
114
+ });
115
+ log(`[gateway] daemon core on http://127.0.0.1:${d.port}\n`);
116
+
117
+ const daemonUrl = `http://127.0.0.1:${d.port}`;
118
+ const factories = { ...DEFAULT_FACTORIES, ...(deps.channelFactories || {}) };
119
+ const wanted = _selectChannels(cfg, flags);
120
+ if (wanted.length === 0) {
121
+ log('[gateway] no channels configured/enabled — running the daemon core only. Add one with `lazyclaw setup` or pass --channels slack,telegram,matrix.\n');
122
+ }
123
+
124
+ const channels = [];
125
+ const skipped = [];
126
+ for (const name of wanted) {
127
+ const handler = makeInboundHandler({
128
+ channel: name, daemonUrl, daemonToken: authToken,
129
+ provider: flags.provider, model: flags.model,
130
+ });
131
+ try {
132
+ const ch = await factories[name]({
133
+ handler,
134
+ logger: (line) => log(line),
135
+ allowlist: allowlistArr.length ? allowlistArr : null,
136
+ });
137
+ channels.push({ name, ch });
138
+ channelSenders.set(name, (externalId, text) => ch.send(externalId, text));
139
+ log(`[gateway] ${name}: connected\n`);
140
+ } catch (err) {
141
+ // One channel missing creds must not take down the others.
142
+ skipped.push({ name, error: err?.message || String(err) });
143
+ log(`[gateway] ${name}: skipped (${err?.message || err})\n`);
144
+ }
145
+ }
146
+
147
+ const stop = async () => {
148
+ for (const { name, ch } of channels) {
149
+ try { await ch.stop(); } catch { /* best-effort drain */ }
150
+ channelSenders.delete(name);
151
+ }
152
+ // A wedged in-flight request must not hang shutdown forever — the crash
153
+ // handler awaits stop() before exiting, and a hang there would leave a
154
+ // crashed gateway alive-but-dead with no service-manager restart.
155
+ await Promise.race([
156
+ d.close(),
157
+ new Promise((r) => setTimeout(r, 8_000)),
158
+ ]);
159
+ };
160
+
161
+ return { port: d.port, channels, channelSenders, skipped, stop, authToken };
162
+ }
163
+
164
+ export async function cmdGateway(flags = {}) {
165
+ let gw;
166
+ try {
167
+ gw = await runGateway(flags);
168
+ } catch (err) {
169
+ console.error(`gateway: ${err?.message || err}`);
170
+ process.exit(2);
171
+ }
172
+ process.stdout.write(JSON.stringify({
173
+ ok: true,
174
+ url: `http://127.0.0.1:${gw.port}`,
175
+ port: gw.port,
176
+ channels: gw.channels.map((c) => c.name),
177
+ skipped: gw.skipped,
178
+ }) + '\n');
179
+ process.stderr.write('[gateway] running. Ctrl-C to stop.\n');
180
+
181
+ installCrashHandlers({ label: 'gateway', stop: () => gw.stop() });
182
+ await new Promise((resolve) => {
183
+ let shuttingDown = false;
184
+ const onSig = async () => {
185
+ if (shuttingDown) return process.exit(1);
186
+ shuttingDown = true;
187
+ process.stderr.write('\n[gateway] shutting down…\n');
188
+ try { await gw.stop(); } catch { /* best-effort */ }
189
+ resolve();
190
+ };
191
+ process.once('SIGINT', onSig);
192
+ process.once('SIGTERM', onSig);
193
+ });
194
+ }
@@ -3,6 +3,7 @@
3
3
  import { readConfig, writeConfig } from '../lib/config.mjs';
4
4
  import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
5
5
  import { _fetchModelsForProvider } from '../tui/pickers.mjs';
6
+ import { probeProvider } from '../providers/probe.mjs';
6
7
 
7
8
  export async function cmdRates(sub, positional, flags = {}) {
8
9
  // Manage cfg.rates without hand-editing JSON. Same shape as
@@ -234,47 +235,15 @@ export async function cmdProviders(sub, positional, flags = {}) {
234
235
  }
235
236
  // cfg already declared above for the all-mode branch; reuse it.
236
237
  const meta = getRegistry().PROVIDER_INFO[name] || {};
237
- // --model / --prompt come in via the parsed flags map (parseArgs
238
- // lifted them out of positional). --model wins over config.model
239
- // wins over PROVIDER_INFO.defaultModel.
238
+ // --model wins over config.model wins over PROVIDER_INFO.defaultModel.
240
239
  const model = flags.model || cfg.model || meta.defaultModel || 'unknown';
241
240
  const prompt = flags.prompt || 'ping';
242
241
  const apiKey = cfg['api-key'] || '';
243
- const t0 = Date.now();
244
- try {
245
- // Drain the streaming response (every provider yields chunks of
246
- // string). For mock this is instant; for real providers it's
247
- // bounded by the prompt length and provider latency. We don't
248
- // support a timeout flag here — the user can SIGINT if a
249
- // provider hangs.
250
- let reply = '';
251
- const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
252
- for await (const chunk of stream) {
253
- if (typeof chunk === 'string') reply += chunk;
254
- }
255
- const durationMs = Date.now() - t0;
256
- const ok = reply.length > 0;
257
- console.log(JSON.stringify({
258
- ok,
259
- provider: name,
260
- model,
261
- durationMs,
262
- replyLength: reply.length,
263
- reply: reply.slice(0, 200) + (reply.length > 200 ? '…' : ''),
264
- }, null, 2));
265
- process.exit(ok ? 0 : 1);
266
- } catch (err) {
267
- const durationMs = Date.now() - t0;
268
- console.log(JSON.stringify({
269
- ok: false,
270
- provider: name,
271
- model,
272
- durationMs,
273
- error: err?.message || String(err),
274
- code: err?.code || null,
275
- }, null, 2));
276
- process.exit(1);
277
- }
242
+ // Shared, no-exit probe (providers/probe.mjs). The CLI prints JSON and
243
+ // exits; the setup wizard renders one line and keeps going.
244
+ const result = await probeProvider({ name, model, prompt, apiKey });
245
+ console.log(JSON.stringify(result, null, 2));
246
+ process.exit(result.ok ? 0 : 1);
278
247
  }
279
248
  case 'add': {
280
249
  // Register an OpenAI-compatible custom endpoint non-interactively.
@@ -437,6 +406,16 @@ export async function cmdOrchestrator(sub, positional, _flags = {}) {
437
406
  }, null, 2));
438
407
  return;
439
408
  }
409
+ case 'on':
410
+ case 'off': {
411
+ // Route cfg.provider to/from 'orchestrator' (shared with /orchestrator).
412
+ const cf = await import('../config_features.mjs');
413
+ cf.orchestratorEnable(cfg, sub === 'on');
414
+ writeConfig(cfg);
415
+ const w = Array.isArray(orch.workers) ? orch.workers.length : 0;
416
+ console.log(JSON.stringify({ ok: true, enabled: sub === 'on', provider: cfg.provider, ...(sub === 'on' && w === 0 ? { warning: 'no workers configured — add one: lazyclaw orchestrator workers add <provider[:model]>' } : {}) }, null, 2));
417
+ return;
418
+ }
440
419
  case 'set-planner': {
441
420
  try {
442
421
  const spec = validateSpec(positional[0]);
@@ -0,0 +1,113 @@
1
+ // commands/service.mjs — `lazyclaw service <install|uninstall|status|restart>`.
2
+ // Wraps a lazyclaw long-running command as an always-on OS service via
3
+ // lib/service_install.mjs (launchd / systemd / detached-fallback).
4
+ //
5
+ // v1 manages the DAEMON — the single always-on agent core. Once the channel
6
+ // listeners forward into the daemon's /inbound (next phase), the daemon is
7
+ // the one process that has to stay up; channel surfaces graduate to their own
8
+ // service targets in a later phase.
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { configPath, readConfig } from '../lib/config.mjs';
13
+ import { assertUnattendedSafe, assertServicePairing } from '../lib/gateway_guard.mjs';
14
+ import { detectBackend, installService, uninstallService, serviceStatus } from '../lib/service_install.mjs';
15
+
16
+ const SUPPORTED_SURFACES = new Set(['daemon', 'gateway']);
17
+
18
+ function hasSystemctl() {
19
+ try {
20
+ const r = spawnSync('systemctl', ['--version'], { stdio: 'ignore' });
21
+ return !r.error && r.status === 0;
22
+ } catch { return false; }
23
+ }
24
+
25
+ function cliPath() {
26
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.mjs');
27
+ }
28
+
29
+ // Exported for tests: builds the service spec (wrapped argv + backend) without
30
+ // any side effects.
31
+ export function _buildSpec(surface, flags = {}, cfgDir = '', deps = {}) {
32
+ const hasSystemd = typeof deps.hasSystemctl === 'function' ? deps.hasSystemctl() : hasSystemctl();
33
+ const args = [deps.cliPath ? deps.cliPath() : cliPath(), surface];
34
+ // Default the service to the well-known port the channel listeners dial
35
+ // (the LAZYCLAW_DAEMON_URL default), so `service install` + `* listen`
36
+ // work together out of the box. Bare `lazyclaw daemon` still defaults to a
37
+ // random port for ad-hoc / scripted use.
38
+ args.push('--port', flags.port !== undefined ? String(flags.port) : '19600');
39
+ if (flags['auth-token']) args.push('--auth-token', String(flags['auth-token']));
40
+ if (flags.log) args.push('--log', String(flags.log));
41
+ if (flags.channels) args.push('--channels', String(flags.channels));
42
+ return {
43
+ name: surface,
44
+ surface,
45
+ execPath: process.execPath,
46
+ args,
47
+ workingDir: deps.cwd || process.cwd(),
48
+ configDir: cfgDir,
49
+ description: `lazyclaw always-on ${surface}`,
50
+ env: { LAZYCLAW_CONFIG_DIR: cfgDir },
51
+ backend: detectBackend({ override: flags.backend || null, hasSystemctl: hasSystemd }),
52
+ };
53
+ }
54
+
55
+ export async function cmdService(sub, positional = [], flags = {}) {
56
+ const surface = (positional[0] || 'daemon').toLowerCase();
57
+ if (!SUPPORTED_SURFACES.has(surface)) {
58
+ console.error(`service: only '${[...SUPPORTED_SURFACES].join("', '")}' is supported in this version (channel gateway lands in a later phase). Got: ${surface}`);
59
+ process.exit(2);
60
+ }
61
+ const cfg = readConfig();
62
+ const cfgDir = path.dirname(configPath());
63
+
64
+ // Fail closed before installing: never wrap a remote surface as a service
65
+ // while the global unattended-sensitive tool override is on (RCE). Channel
66
+ // surfaces additionally require a non-empty pairing allowlist.
67
+ try {
68
+ assertUnattendedSafe(cfg, { surface });
69
+ if (surface !== 'daemon') assertServicePairing(cfg, { service: true, surface });
70
+ } catch (e) { console.error(e.message); process.exit(2); }
71
+
72
+ const spec = _buildSpec(surface, flags, cfgDir);
73
+ const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
74
+
75
+ try {
76
+ if (sub === 'install') {
77
+ const r = installService(spec);
78
+ emit({ ok: true, action: 'install', ...r });
79
+ if (r.backend === 'fallback') {
80
+ process.stderr.write('note: no service manager detected — running detached with a pidfile. This survives the terminal but NOT a reboot. Re-run `lazyclaw service install` after reboot, or install launchd/systemd.\n');
81
+ // The detached child's fate is invisible (stdio ignored), so verify it
82
+ // actually stayed up — a daemon that hit EADDRINUSE (port already in
83
+ // use) exits within ~200ms. Surface that instead of a false "ok".
84
+ await new Promise((s) => setTimeout(s, 800));
85
+ const st = serviceStatus(spec);
86
+ if (!st.running) {
87
+ const port = flags.port !== undefined ? String(flags.port) : '19600';
88
+ process.stderr.write(`warning: the daemon did not stay up — port ${port} may already be in use (a daemon is already running?). Check: lsof -ti tcp:${port}. Free it or pass --port, then re-run.\n`);
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ if (sub === 'uninstall' || sub === 'remove') {
94
+ emit({ ok: true, action: 'uninstall', ...uninstallService(spec) });
95
+ return;
96
+ }
97
+ if (sub === 'status') {
98
+ emit({ ok: true, action: 'status', ...serviceStatus(spec) });
99
+ return;
100
+ }
101
+ if (sub === 'restart') {
102
+ try { uninstallService(spec); } catch { /* not installed yet */ }
103
+ emit({ ok: true, action: 'restart', ...installService(spec) });
104
+ return;
105
+ }
106
+ } catch (e) {
107
+ console.error(`service: ${e.message}`);
108
+ process.exit(2);
109
+ }
110
+
111
+ console.error('Usage: lazyclaw service <install|uninstall|status|restart> [daemon|gateway] [--port N] [--auth-token T] [--log LEVEL] [--channels a,b] [--backend launchd|systemd|fallback]');
112
+ process.exit(2);
113
+ }
@@ -22,6 +22,8 @@ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOK
22
22
  import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
23
23
  import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
24
24
  import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
25
+ import { runChannelStep, runWebhookStep, runOrchestratorStep, runContextStep } from './setup_channels.mjs';
26
+ import { splashPropsForSetup, renderSplashToString } from '../tui/splash_props.mjs';
25
27
 
26
28
  function applyOnboardConfig(currentCfg, flags) {
27
29
  // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
@@ -60,7 +62,7 @@ export async function cmdOnboard(flags) {
60
62
  const ask = q => new Promise(resolve => rl.question(q, resolve));
61
63
  if (!flags.provider) {
62
64
  const provs = Object.keys(getRegistry().PROVIDERS).join('|');
63
- const noKeyHint = '\x1b[38;5;208mclaude-cli\x1b[0m (subscription, no key) is the default';
65
+ const noKeyHint = '\x1b[38;2;217;179;90mclaude-cli\x1b[0m (subscription, no key) is the default';
64
66
  process.stdout.write(`hint: ${noKeyHint}\n`);
65
67
  flags.provider = (await ask(`provider [${provs}]: `)).trim() || 'claude-cli';
66
68
  }
@@ -117,7 +119,7 @@ const HELP_SUMMARIES = {
117
119
  workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
118
120
  browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
119
121
  cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
120
- setup: 'OpenClaw-style multi-step first-run wizard (provider + workspace + skill + webhook + ping)',
122
+ setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
121
123
  dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
122
124
  inspect: 'Print persisted workflow state without executing',
123
125
  clear: 'Delete a persisted workflow state file (idempotent)',
@@ -158,7 +160,7 @@ const HELP_DETAILS = {
158
160
  workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
159
161
  browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
160
162
  cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
161
- setup: 'Usage: lazyclaw setup [--skip-test]\n OpenClaw-style multi-step first-run wizard. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick)\n 2. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 3. Optional skill bundle install from GitHub\n 4. Optional outbound webhook (Slack / Discord)\n 5. Reachability test against the picked provider\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
163
+ setup: 'Usage: lazyclaw setup [--skip-test]\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
162
164
  dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
163
165
  orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
164
166
  };
@@ -187,7 +189,7 @@ export function cmdHelp(name) {
187
189
 
188
190
  export async function cmdSetup(_sub, _positional, flags = {}) {
189
191
  await ensureRegistry();
190
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
192
+ const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
191
193
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
192
194
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
193
195
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -195,17 +197,18 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
195
197
 
196
198
  // Header.
197
199
  if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
198
- _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
200
+ process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
199
201
  process.stdout.write('\n');
200
202
  process.stdout.write(` ${bold('🔧 Setup wizard')}\n`);
201
- process.stdout.write(` ${dim('Five short steps. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
203
+ process.stdout.write(` ${dim('Get one clean chat working first, then optionally add a channel, workspace, or skills. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
202
204
 
203
205
  const cfg = readConfig();
204
206
  const cfgDir = path.dirname(configPath());
207
+ const colors = { accent, bold, dim, ok, warn };
205
208
 
206
- // ── Step 1: Provider + model (mandatory) ────────────────────
207
- process.stdout.write(` ${accent('Step 1/5 ·')} ${bold('Pick a provider + model')}\n`);
208
- process.stdout.write(` ${dim('Opens the arrow-key picker. The list leads with gemini / openai / claude-cli pick the one you have an account or login for.')}\n\n`);
209
+ // ── Step 1/7: Provider + model (mandatory) ──────────────────
210
+ process.stdout.write(` ${accent('Step 1/7 ·')} ${bold('Pick a provider + model')}\n`);
211
+ process.stdout.write(` ${dim('Opens the arrow-key picker. Tip: pick a model with ≥64k contextsmall windows can\'t hold multi-step tool-calling state.')}\n\n`);
209
212
  await _quickPrompt(' ▶ press Enter to open the picker ');
210
213
  try {
211
214
  await cmdOnboard({ pick: true });
@@ -230,8 +233,38 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
230
233
  }
231
234
  process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
232
235
 
233
- // ── Step 2: Optional workspace ──────────────────────────────
234
- process.stdout.write(` ${accent('Step 2/5 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
236
+ // Context window asked right after the model pick (optional; Enter keeps
237
+ // defaults). Not a numbered step: it's part of the core model setup.
238
+ await runContextStep({ prompt: _quickPrompt, colors });
239
+
240
+ // ── Step 2/7: Verify one clean chat works ───────────────────
241
+ // Hermes rule: get a clean reply before layering on channels/skills.
242
+ process.stdout.write(` ${accent('Step 2/7 ·')} ${bold('Verify the provider responds')}\n`);
243
+ process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirm a clean reply before layering on channels/skills.')}\n\n`);
244
+ const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
245
+ if (wantPing) {
246
+ try {
247
+ // No-exit probe (providers/probe.mjs) — the CLI `providers test` calls
248
+ // process.exit, which would kill the rest of this wizard. Render one
249
+ // concise line instead of the full JSON dump and keep going.
250
+ const { probeProvider } = await import('../providers/probe.mjs');
251
+ const r = await probeProvider({ name: cfgAfterOnboard.provider, model: cfgAfterOnboard.model || undefined });
252
+ if (r.ok) process.stdout.write(` ${ok('✓ ' + (r.reply || 'ok'))} ${dim(`· ${r.model || cfgAfterOnboard.provider} · ${r.durationMs}ms`)}\n`);
253
+ else process.stdout.write(` ${warn('✗ ' + (r.error || 'no reply'))} ${dim(`· retry: lazyclaw providers test ${cfgAfterOnboard.provider}`)}\n`);
254
+ } catch (e) {
255
+ process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
256
+ }
257
+ process.stdout.write('\n');
258
+ } else {
259
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
260
+ }
261
+
262
+ // ── Step 3/7: Channel / gateway (optional) ──────────────────
263
+ process.stdout.write(` ${accent('Step 3/7 ·')} ${bold('Where will you run it?')} ${dim('(optional)')}\n`);
264
+ await runChannelStep({ cfgDir, prompt: _quickPrompt, colors });
265
+
266
+ // ── Step 4/7: Optional workspace ────────────────────────────
267
+ process.stdout.write(` ${accent('Step 4/7 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
235
268
  process.stdout.write(` ${dim('A workspace is a folder of AGENTS.md / SOUL.md / TOOLS.md prompt files that auto-inject into chat / agent. Skip if you don\'t need project-specific personas yet.')}\n\n`);
236
269
  const wsName = (await _quickPrompt(' workspace name (Enter to skip): ')).trim();
237
270
  if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
@@ -249,8 +282,8 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
249
282
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
250
283
  }
251
284
 
252
- // ── Step 3: Optional skill bundle install ───────────────────
253
- process.stdout.write(` ${accent('Step 3/5 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
285
+ // ── Step 5/7: Optional skill bundle install ─────────────────
286
+ process.stdout.write(` ${accent('Step 5/7 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
254
287
  process.stdout.write(` ${dim('Format: <user>/<repo>[@<ref>]. Skills are .md prompt fragments that compose into the system prompt via --skill.')}\n\n`);
255
288
  const skillSpec = (await _quickPrompt(' github spec (Enter to skip): ')).trim();
256
289
  if (skillSpec) {
@@ -270,45 +303,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
270
303
  process.stdout.write(` ${dim('— skipped —')}\n\n`);
271
304
  }
272
305
 
273
- // ── Step 4: Optional outbound webhook ───────────────────────
274
- process.stdout.write(` ${accent('Step 4/5 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
275
- process.stdout.write(` ${dim('Use with: lazyclaw message send <name> <text>. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
276
- const hookName = (await _quickPrompt(' webhook name (Enter to skip): ')).trim();
277
- if (hookName) {
278
- const hookUrl = (await _quickPrompt(' webhook URL: ')).trim();
279
- if (!hookUrl) {
280
- process.stdout.write(` ${warn('skipped:')} URL required\n\n`);
281
- } else {
282
- try {
283
- const cf = await import('../config_features.mjs');
284
- const fresh = readConfig();
285
- cf.messageAdd(fresh, hookName, hookUrl);
286
- writeConfig(fresh);
287
- process.stdout.write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
288
- } catch (e) {
289
- process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
290
- }
291
- }
292
- } else {
293
- process.stdout.write(` ${dim('— skipped —')}\n\n`);
294
- }
306
+ // ── Step 6/7: Optional outbound webhook ─────────────────────
307
+ process.stdout.write(` ${accent('Step 6/7 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
308
+ await runWebhookStep({ prompt: _quickPrompt, colors });
295
309
 
296
- // ── Step 5: Reachability check ──────────────────────────────
297
- process.stdout.write(` ${accent('Step 5/5 ·')} ${bold('Verify the picked provider responds')}\n`);
298
- process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirms your key / subscription / local daemon is wired up.')}\n\n`);
299
- const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
300
- if (wantPing) {
301
- try {
302
- // Reuse the existing providers-test path so behaviour matches
303
- // a manual `lazyclaw providers test`.
304
- await (await import('../commands/providers.mjs')).cmdProviders('test', [cfgAfterOnboard.provider], {});
305
- } catch (e) {
306
- process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
307
- process.stdout.write(` ${dim('Setup still completed; you can retry with:')} lazyclaw providers test ${cfgAfterOnboard.provider}\n`);
308
- }
309
- } else {
310
- process.stdout.write(` ${dim('— skipped —')}\n`);
311
- }
310
+ // ── Step 7/7: Optional multi-agent orchestration ────────────
311
+ process.stdout.write(` ${accent('Step 7/7 ·')} ${bold('Enable multi-agent orchestration?')} ${dim('(optional)')}\n`);
312
+ await runOrchestratorStep({ prompt: _quickPrompt, colors });
312
313
 
313
314
  // ── Wrap up ─────────────────────────────────────────────────
314
315
  process.stdout.write('\n');
@@ -327,11 +328,11 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
327
328
  // exits politely instead of dropping into a menu where every option
328
329
  // would error.
329
330
  async function _runFirstTimeOnboard() {
330
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
331
+ const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
331
332
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
332
333
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
333
334
  process.stdout.write('\x1b[2J\x1b[H');
334
- _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
335
+ process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
335
336
  process.stdout.write('\n');
336
337
  process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
337
338
  process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
@@ -513,7 +514,7 @@ export async function cmdLauncher() {
513
514
  { id: 'quit', label: 'Quit', desc: 'exit lazyclaw', argv: null },
514
515
  ];
515
516
 
516
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
517
+ const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
517
518
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
518
519
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
519
520
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;