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.
package/dotenv_min.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { writeTextSecure } from './secure_write.mjs';
8
9
 
9
10
  export function loadDotenvIfAny(cfgDir) {
10
11
  const p = path.join(cfgDir, '.env');
@@ -21,3 +22,30 @@ export function loadDotenvIfAny(cfgDir) {
21
22
  }
22
23
  return { path: p, loaded };
23
24
  }
25
+
26
+ // Read-merge-write <cfgDir>/.env at 0600. Existing keys are preserved;
27
+ // keys present in `vars` are overwritten. Values are written verbatim
28
+ // (no quoting) — callers pass already-trimmed strings. Returns the path.
29
+ // Mirror of loadDotenvIfAny so .env read + write live together.
30
+ export function writeDotenvMerge(cfgDir, vars) {
31
+ const p = path.join(cfgDir, '.env');
32
+ const lines = [];
33
+ const seen = new Set();
34
+ const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
35
+ for (const line of existing.split(/\r?\n/)) {
36
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=/);
37
+ if (m && Object.prototype.hasOwnProperty.call(vars, m[1])) {
38
+ seen.add(m[1]);
39
+ lines.push(`${m[1]}=${vars[m[1]]}`);
40
+ } else {
41
+ lines.push(line);
42
+ }
43
+ }
44
+ for (const [k, v] of Object.entries(vars)) {
45
+ if (!seen.has(k)) lines.push(`${k}=${v}`);
46
+ }
47
+ let text = lines.join('\n').replace(/\n+$/, '');
48
+ if (text) text += '\n';
49
+ writeTextSecure(p, text);
50
+ return p;
51
+ }
package/first_run.mjs CHANGED
@@ -13,3 +13,12 @@ export function firstRunMode({ hasProvider, flagPick, isTTY }) {
13
13
  if (!hasProvider) return 'setup';
14
14
  return 'none';
15
15
  }
16
+
17
+ // A blank or 'mock' provider is a placeholder, not a real configured choice:
18
+ // the mock provider returns canned replies and v5.3.2 stopped treating it as a
19
+ // usable default. Treat both as "not configured" so the very first run always
20
+ // lands in setup, and only a genuine saved provider skips it afterwards.
21
+ const PLACEHOLDER_PROVIDERS = new Set(['', 'mock']);
22
+ export function hasConfiguredProvider(provider) {
23
+ return !PLACEHOLDER_PROVIDERS.has(String(provider ?? '').trim().toLowerCase());
24
+ }
@@ -0,0 +1,100 @@
1
+ // lib/gateway_guard.mjs — boot-time safety guards + crash handlers for the
2
+ // always-on gateway (the daemon and the channel listeners).
3
+ //
4
+ // These are deliberately small, pure, and dependency-free so every entry
5
+ // point that opens a remote inbound surface can call them the same way and
6
+ // fail CLOSED before a single message is accepted.
7
+
8
+ export class GatewayGuardError extends Error {
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.name = 'GatewayGuardError';
12
+ this.code = code || 'GATEWAY_GUARD';
13
+ }
14
+ }
15
+
16
+ // `security.allowUnattendedSensitive` is a GLOBAL opt-in read by
17
+ // mas/tool_runner.mjs for EVERY sensitive tool call (bash/write/net). It
18
+ // bypasses the fail-closed approval gate process-wide. Combined with an
19
+ // always-on channel or daemon surface, an inbound chat message could drive
20
+ // bash/write with no human in the loop — remote-prompt-injection-to-RCE.
21
+ // Refuse to start such a surface while the flag is set.
22
+ export function assertUnattendedSafe(cfg, { surface = 'channel' } = {}) {
23
+ if (cfg && cfg.security && cfg.security.allowUnattendedSensitive === true) {
24
+ throw new GatewayGuardError(
25
+ `refusing to start a ${surface} surface while security.allowUnattendedSensitive=true: ` +
26
+ `that flag bypasses the fail-closed tool-approval gate for EVERY inbound message, so an ` +
27
+ `always-on ${surface} could run bash/write on remote command (RCE). Remove ` +
28
+ `security.allowUnattendedSensitive from your config (the ${surface} path does not need it), ` +
29
+ `or run sensitive tools only in an interactive session.`,
30
+ 'UNATTENDED_SENSITIVE_WITH_CHANNEL',
31
+ );
32
+ }
33
+ }
34
+
35
+ // An unattended service (started under launchd/systemd, no operator at a
36
+ // terminal) with an empty pairing allowlist answers anyone who can reach
37
+ // it. Require at least one paired sender before booting in service mode.
38
+ export function assertServicePairing(cfg, { service = false, surface = 'channel' } = {}) {
39
+ if (!service) return;
40
+ const allow = Array.isArray(cfg && cfg.pairing)
41
+ ? cfg.pairing.filter((p) => p && p.id != null)
42
+ : [];
43
+ if (allow.length === 0) {
44
+ throw new GatewayGuardError(
45
+ `refusing to start ${surface} in unattended --service mode with an empty pairing allowlist: ` +
46
+ `the agent would answer anyone who can reach it, 24/7. Pair at least one sender first: ` +
47
+ `lazyclaw pairing add <id>.`,
48
+ 'SERVICE_REQUIRES_PAIRING',
49
+ );
50
+ }
51
+ }
52
+
53
+ // Process-level crash handlers for long-running processes. There are none in
54
+ // the codebase, so a single unhandledRejection / uncaughtException kills the
55
+ // always-on process with no log and no clean socket shutdown. This makes the
56
+ // crash OBSERVABLE (structured log) and CLEAN (best-effort stop), then exits
57
+ // non-zero so a service manager (launchd KeepAlive / systemd Restart) restarts
58
+ // it. Deliberately NOT installed on the interactive TUI path — Ink manages the
59
+ // terminal and needs its own handling (see tui/repl.mjs).
60
+ //
61
+ // Idempotent: a second call is a no-op and returns the same cleanup fn.
62
+ const INSTALLED = Symbol.for('lazyclaw.gateway.crashHandlers');
63
+
64
+ export function installCrashHandlers({ label = 'lazyclaw', logger = null, stop = null, exit = null } = {}) {
65
+ if (process[INSTALLED]) return process[INSTALLED];
66
+ const doExit = typeof exit === 'function' ? exit : (code) => process.exit(code);
67
+
68
+ const report = (kind, err) => {
69
+ const detail = {
70
+ level: 'fatal',
71
+ msg: 'crash',
72
+ label,
73
+ kind,
74
+ error: (err && (err.stack || err.message)) || String(err),
75
+ };
76
+ if (logger && typeof logger.error === 'function') logger.error('crash', detail);
77
+ else process.stderr.write(JSON.stringify(detail) + '\n');
78
+ };
79
+
80
+ const handle = (kind) => async (err) => {
81
+ report(kind, err);
82
+ if (typeof stop === 'function') {
83
+ try { await stop(); } catch { /* best-effort: we're already crashing */ }
84
+ }
85
+ doExit(1);
86
+ };
87
+
88
+ const onRej = handle('unhandledRejection');
89
+ const onExc = handle('uncaughtException');
90
+ process.on('unhandledRejection', onRej);
91
+ process.on('uncaughtException', onExc);
92
+
93
+ const cleanup = () => {
94
+ process.removeListener('unhandledRejection', onRej);
95
+ process.removeListener('uncaughtException', onExc);
96
+ delete process[INSTALLED];
97
+ };
98
+ process[INSTALLED] = cleanup;
99
+ return cleanup;
100
+ }
@@ -0,0 +1,108 @@
1
+ // lib/inbound_client.mjs — the channel-listener -> daemon bridge.
2
+ //
3
+ // Phase 2 of the unified gateway: a channel listener no longer calls the
4
+ // provider inline (its own per-process history Map). Instead it POSTs each
5
+ // inbound message to the always-on daemon's session-bearing POST /inbound,
6
+ // which binds {channel, externalId} -> a persistent session, hydrates prior
7
+ // turns, runs the provider, and persists the turn. The result is one shared
8
+ // agent (session + memory + skills) across chat, the dashboard, and every
9
+ // channel — plus cross-channel /handoff lights up for free.
10
+
11
+ export class InboundClientError extends Error {
12
+ constructor(message, code, status) {
13
+ super(message);
14
+ this.name = 'InboundClientError';
15
+ this.code = code || 'INBOUND_ERR';
16
+ this.status = status || 0;
17
+ }
18
+ }
19
+
20
+ // A connection refusal means the daemon isn't up yet (e.g. still starting, or
21
+ // momentarily restarting under a service manager) — worth a short backoff.
22
+ // Definitive HTTP answers (403/4xx/5xx) are NOT retried.
23
+ function isConnRefused(err) {
24
+ if (!err) return false;
25
+ if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET') return true;
26
+ if (err.cause && (err.cause.code === 'ECONNREFUSED' || err.cause.code === 'ECONNRESET')) return true;
27
+ return /ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(err.message || '');
28
+ }
29
+
30
+ export async function postInbound(opts, deps = {}) {
31
+ const { url, authToken, channel, externalId, senderId, messageId, text, provider, model } = opts;
32
+ const f = deps.fetchImpl || globalThis.fetch;
33
+ if (typeof f !== 'function') throw new InboundClientError('no fetch implementation available', 'NO_FETCH');
34
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
35
+ const retries = Number.isFinite(deps.retries) ? deps.retries : 5;
36
+ const backoffMs = Number.isFinite(deps.backoffMs) ? deps.backoffMs : 250;
37
+ const endpoint = String(url).replace(/\/+$/, '') + '/inbound';
38
+
39
+ const payload = { text };
40
+ if (channel) payload.channel = channel;
41
+ if (externalId != null && externalId !== '') payload.externalId = String(externalId);
42
+ if (senderId != null && senderId !== '') payload.senderId = String(senderId);
43
+ // Native per-message id (Slack channel:ts, Telegram update_id, Matrix
44
+ // event_id) — lets /inbound dedup retries/redeliveries idempotently.
45
+ if (messageId != null && messageId !== '') payload.messageId = String(messageId);
46
+ if (provider) payload.provider = provider;
47
+ if (model) payload.model = model;
48
+ const headers = { 'content-type': 'application/json' };
49
+ if (authToken) headers['authorization'] = `Bearer ${authToken}`;
50
+
51
+ let lastErr = null;
52
+ for (let attempt = 0; attempt <= retries; attempt++) {
53
+ let resp;
54
+ try {
55
+ resp = await f(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) });
56
+ } catch (err) {
57
+ lastErr = err;
58
+ if (isConnRefused(err) && attempt < retries) { await sleep(backoffMs * (attempt + 1)); continue; }
59
+ throw new InboundClientError(`daemon unreachable at ${endpoint}: ${err?.message || err}`, 'DAEMON_UNREACHABLE');
60
+ }
61
+ if (resp.status === 403) throw new InboundClientError('sender not paired', 'NOT_PAIRED', 403);
62
+ if (!resp.ok) {
63
+ let detail = '';
64
+ try { detail = await resp.text(); } catch { /* body is best-effort */ }
65
+ throw new InboundClientError(`daemon /inbound returned ${resp.status}${detail ? ': ' + detail.slice(0, 200) : ''}`, 'HTTP_ERROR', resp.status);
66
+ }
67
+ try { return await resp.json(); }
68
+ catch (err) { throw new InboundClientError(`bad JSON from /inbound: ${err?.message || err}`, 'BAD_JSON', resp.status); }
69
+ }
70
+ throw new InboundClientError(`daemon unreachable at ${endpoint} after ${retries + 1} attempts: ${lastErr?.message || lastErr}`, 'DAEMON_UNREACHABLE');
71
+ }
72
+
73
+ // Build the per-message handler a Channel calls. Shared by slack/telegram/
74
+ // matrix listeners (they differ only in channel name + slack's @mention
75
+ // strip). The handler returns the reply string to post back, or null to stay
76
+ // silent (empty input, unpaired sender, empty daemon reply).
77
+ export function makeInboundHandler(opts, deps = {}) {
78
+ const { channel, daemonUrl, daemonToken, provider, model } = opts;
79
+ const post = deps.postInbound || postInbound;
80
+ const log = deps.log || ((s) => process.stderr.write(s));
81
+ const stripMention = channel === 'slack';
82
+
83
+ return async ({ threadId, text, senderId, messageId } = {}) => {
84
+ let cleaned = String(text || '');
85
+ if (stripMention) cleaned = cleaned.replace(/<@[A-Z0-9]+>/g, '');
86
+ cleaned = cleaned.trim();
87
+ if (!cleaned) { log(`[${channel}] dropping empty inbound (after mention strip)\n`); return null; }
88
+ try {
89
+ const out = await post({ url: daemonUrl, authToken: daemonToken, channel, externalId: threadId, senderId, messageId, text: cleaned, provider, model });
90
+ // A duplicate means THIS message was already answered (redelivery, a
91
+ // restart replaying the backlog, the Slack double-fire landing twice):
92
+ // the channel already has the reply, so posting the replayed body
93
+ // would double-post. Stay silent. (Raw /inbound API callers still get
94
+ // the replayed body — this gate is listener-side only.)
95
+ if (out && out.duplicate) { log(`[${channel}] duplicate message — already answered, staying silent\n`); return null; }
96
+ const reply = out && typeof out.reply === 'string' ? out.reply : '';
97
+ if (!reply.trim()) { log(`[${channel}] daemon returned empty reply — not posting\n`); return null; }
98
+ return reply;
99
+ } catch (err) {
100
+ // Unpaired senders are dropped silently — that is what pairing is for.
101
+ if (err && err.code === 'NOT_PAIRED') { log(`[${channel}] sender not paired — ignoring\n`); return null; }
102
+ // Anything else: log the detail to stderr (operator), but post only a
103
+ // generic line to the channel so we never leak internals to users.
104
+ log(`[${channel}] inbound bridge error: ${err?.message || err}\n`);
105
+ return '(agent unavailable — backend not reachable)';
106
+ }
107
+ };
108
+ }
@@ -0,0 +1,207 @@
1
+ // lib/service_install.mjs — install a lazyclaw long-running command as an
2
+ // always-on OS service across three backends:
3
+ // - launchd (macOS): plist with RunAtLoad + KeepAlive
4
+ // - systemd (Linux user unit): Restart=always
5
+ // - fallback (no service manager): detached child + pidfile
6
+ //
7
+ // The unit-file builders + backend detection are PURE. The apply layer
8
+ // (install/uninstall/status) takes injectable deps (fs/spawn/spawnSync/isAlive)
9
+ // so it is fully testable without touching the real OS.
10
+ //
11
+ // NOTE: this is distinct from cron.mjs's plist machinery, which emits a
12
+ // StartCalendarInterval (scheduled) plist with RunAtLoad=false — the opposite
13
+ // of an always-on service.
14
+
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import fsReal from 'node:fs';
18
+ import { spawn as spawnReal, spawnSync as spawnSyncReal } from 'node:child_process';
19
+ import { isProcessAlive } from '../loops.mjs';
20
+
21
+ export class ServiceError extends Error {
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.name = 'ServiceError';
25
+ this.code = code || 'SERVICE_ERR';
26
+ }
27
+ }
28
+
29
+ function xmlEscape(s) {
30
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
31
+ }
32
+
33
+ // ---- pure helpers ----
34
+
35
+ export function servicePaths(name, { home = os.homedir(), configDir } = {}) {
36
+ const cfg = configDir || path.join(home, '.lazyclaw');
37
+ return {
38
+ launchd: path.join(home, 'Library', 'LaunchAgents', `com.lazyclaw.${name}.plist`),
39
+ systemd: path.join(home, '.config', 'systemd', 'user', `lazyclaw-${name}.service`),
40
+ pidfile: path.join(cfg, `${name}.pid`),
41
+ logfile: path.join(cfg, `${name}.log`),
42
+ };
43
+ }
44
+
45
+ export function detectBackend({ platform = process.platform, hasSystemctl = false, override = null } = {}) {
46
+ if (override) return override;
47
+ if (platform === 'darwin') return 'launchd';
48
+ if (platform === 'linux' && hasSystemctl) return 'systemd';
49
+ return 'fallback';
50
+ }
51
+
52
+ export function buildLaunchdPlist({ name, execPath, args, workingDir, logfile, env = {} }) {
53
+ const progArgs = [execPath, ...args]
54
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
55
+ .join('\n');
56
+ const envBlock = Object.keys(env).length
57
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n` +
58
+ Object.entries(env).map(([k, v]) => ` <key>${xmlEscape(k)}</key><string>${xmlEscape(v)}</string>`).join('\n') +
59
+ `\n </dict>\n`
60
+ : '';
61
+ return `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>Label</key><string>com.lazyclaw.${xmlEscape(name)}</string>
66
+ <key>ProgramArguments</key>
67
+ <array>
68
+ ${progArgs}
69
+ </array>
70
+ <key>RunAtLoad</key>
71
+ <true/>
72
+ <key>KeepAlive</key>
73
+ <true/>
74
+ <key>WorkingDirectory</key><string>${xmlEscape(workingDir)}</string>
75
+ <key>StandardOutPath</key><string>${xmlEscape(logfile)}</string>
76
+ <key>StandardErrorPath</key><string>${xmlEscape(logfile)}</string>
77
+ ${envBlock}</dict>
78
+ </plist>
79
+ `;
80
+ }
81
+
82
+ export function buildSystemdUnit({ description, execPath, args, workingDir, env = {} }) {
83
+ // systemd ExecStart wants an absolute path + a single command line. We keep
84
+ // the argv simple (paths/flags lazyclaw produces have no shell metachars),
85
+ // so a plain space-join is correct and readable.
86
+ const exec = [execPath, ...args].join(' ');
87
+ const envLines = Object.entries(env).map(([k, v]) => `Environment=${k}=${v}`).join('\n');
88
+ return `[Unit]
89
+ Description=${description}
90
+ After=network-online.target
91
+ Wants=network-online.target
92
+
93
+ [Service]
94
+ Type=simple
95
+ ExecStart=${exec}
96
+ WorkingDirectory=${workingDir}
97
+ Restart=always
98
+ RestartSec=3
99
+ ${envLines ? envLines + '\n' : ''}
100
+ [Install]
101
+ WantedBy=default.target
102
+ `;
103
+ }
104
+
105
+ // ---- apply layer (side-effectful, injectable deps) ----
106
+
107
+ function resolveDeps(deps = {}) {
108
+ return {
109
+ fs: deps.fs || fsReal,
110
+ spawnSync: deps.spawnSync || spawnSyncReal,
111
+ spawn: deps.spawn || spawnReal,
112
+ isAlive: deps.isAlive || isProcessAlive,
113
+ };
114
+ }
115
+
116
+ export function installService(spec, depsIn = {}) {
117
+ const { name, execPath, args, workingDir, env = {}, backend, description, home, configDir, logfile } = spec;
118
+ const deps = resolveDeps(depsIn);
119
+ const p = servicePaths(name, { home, configDir });
120
+ const log = logfile || p.logfile;
121
+
122
+ if (backend === 'launchd') {
123
+ deps.fs.mkdirSync(path.dirname(p.launchd), { recursive: true });
124
+ deps.fs.writeFileSync(p.launchd, buildLaunchdPlist({ name, execPath, args, workingDir, logfile: log, env }));
125
+ deps.spawnSync('launchctl', ['unload', p.launchd], { stdio: 'ignore' });
126
+ const r = deps.spawnSync('launchctl', ['load', p.launchd], { encoding: 'utf8' });
127
+ if (r && r.status !== 0) throw new ServiceError(`launchctl load failed: ${r.stderr || r.status}`, 'SERVICE_LAUNCHD_FAIL');
128
+ return { backend, target: p.launchd };
129
+ }
130
+
131
+ if (backend === 'systemd') {
132
+ deps.fs.mkdirSync(path.dirname(p.systemd), { recursive: true });
133
+ deps.fs.writeFileSync(p.systemd, buildSystemdUnit({ description, execPath, args, workingDir, env }));
134
+ deps.spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
135
+ const r = deps.spawnSync('systemctl', ['--user', 'enable', '--now', `lazyclaw-${name}.service`], { encoding: 'utf8' });
136
+ if (r && r.status !== 0) throw new ServiceError(`systemctl enable failed: ${r.stderr || r.status}`, 'SERVICE_SYSTEMD_FAIL');
137
+ return { backend, target: p.systemd };
138
+ }
139
+
140
+ if (backend === 'fallback') {
141
+ // No service manager: detach a child and record its pid. This survives
142
+ // the launching terminal but NOT a reboot — the caller is told so.
143
+ deps.fs.mkdirSync(path.dirname(p.pidfile), { recursive: true });
144
+ const child = deps.spawn(execPath, args, {
145
+ detached: true,
146
+ stdio: 'ignore',
147
+ env: { ...process.env, ...env },
148
+ });
149
+ if (child && typeof child.unref === 'function') child.unref();
150
+ deps.fs.writeFileSync(p.pidfile, String(child.pid));
151
+ return { backend, pid: child.pid, pidfile: p.pidfile };
152
+ }
153
+
154
+ throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
155
+ }
156
+
157
+ export function uninstallService(spec, depsIn = {}) {
158
+ const { name, backend, home, configDir } = spec;
159
+ const deps = resolveDeps(depsIn);
160
+ const p = servicePaths(name, { home, configDir });
161
+
162
+ if (backend === 'launchd') {
163
+ if (deps.fs.existsSync(p.launchd)) {
164
+ deps.spawnSync('launchctl', ['unload', p.launchd], { stdio: 'ignore' });
165
+ deps.fs.rmSync(p.launchd);
166
+ }
167
+ return { backend, removed: p.launchd };
168
+ }
169
+ if (backend === 'systemd') {
170
+ deps.spawnSync('systemctl', ['--user', 'disable', '--now', `lazyclaw-${name}.service`], { stdio: 'ignore' });
171
+ if (deps.fs.existsSync(p.systemd)) deps.fs.rmSync(p.systemd);
172
+ deps.spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
173
+ return { backend, removed: p.systemd };
174
+ }
175
+ if (backend === 'fallback') {
176
+ let pid = null;
177
+ if (deps.fs.existsSync(p.pidfile)) {
178
+ pid = parseInt(deps.fs.readFileSync(p.pidfile, 'utf8'), 10);
179
+ if (Number.isFinite(pid)) { try { process.kill(pid); } catch { /* already gone */ } }
180
+ deps.fs.rmSync(p.pidfile);
181
+ }
182
+ return { backend, killed: pid };
183
+ }
184
+ throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
185
+ }
186
+
187
+ export function serviceStatus(spec, depsIn = {}) {
188
+ const { name, backend, home, configDir } = spec;
189
+ const deps = resolveDeps(depsIn);
190
+ const p = servicePaths(name, { home, configDir });
191
+
192
+ if (backend === 'launchd') {
193
+ return { backend, installed: deps.fs.existsSync(p.launchd), target: p.launchd };
194
+ }
195
+ if (backend === 'systemd') {
196
+ const installed = deps.fs.existsSync(p.systemd);
197
+ const r = deps.spawnSync('systemctl', ['--user', 'is-active', `lazyclaw-${name}.service`], { encoding: 'utf8' });
198
+ const running = !!r && typeof r.stdout === 'string' && r.stdout.trim() === 'active';
199
+ return { backend, installed, running, target: p.systemd };
200
+ }
201
+ if (backend === 'fallback') {
202
+ if (!deps.fs.existsSync(p.pidfile)) return { backend, installed: false, running: false };
203
+ const pid = parseInt(deps.fs.readFileSync(p.pidfile, 'utf8'), 10);
204
+ return { backend, installed: true, running: Number.isFinite(pid) && deps.isAlive(pid), pid: Number.isFinite(pid) ? pid : null };
205
+ }
206
+ throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.0.1",
3
+ "version": "6.1.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -37,8 +37,7 @@
37
37
  "test:bench": "node scripts/bench-providers.mjs",
38
38
  "test:bench:index": "node --test tests/index_store.bench.mjs",
39
39
  "test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
40
- "migrate:v5": "node scripts/migrate-v5.mjs",
41
- "build:splash": "node scripts/build-splash.mjs"
40
+ "migrate:v5": "node scripts/migrate-v5.mjs"
42
41
  },
43
42
  "files": [
44
43
  "cli.mjs",
@@ -0,0 +1,28 @@
1
+ // providers/probe.mjs — smoke-test a single provider with a tiny prompt.
2
+ //
3
+ // Returns the result object; it never logs and never calls process.exit, so
4
+ // callers decide how to render and whether to exit. The CLI `providers test`
5
+ // prints JSON and exits; the setup wizard's verify step prints one concise
6
+ // line and KEEPS GOING (a process.exit here would kill the rest of the
7
+ // wizard — the bug this split fixes).
8
+ import { getRegistry } from '../lib/registry_boot.mjs';
9
+
10
+ export async function probeProvider({ name, model, prompt = 'ping', apiKey = '' }) {
11
+ const provider = getRegistry().PROVIDERS[name];
12
+ if (!provider) {
13
+ return { ok: false, provider: name, model, durationMs: 0, error: `unknown provider: ${name}`, code: 'UNKNOWN_PROVIDER' };
14
+ }
15
+ const t0 = Date.now();
16
+ try {
17
+ let reply = '';
18
+ const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
19
+ for await (const chunk of stream) {
20
+ if (typeof chunk === 'string') reply += chunk;
21
+ }
22
+ const durationMs = Date.now() - t0;
23
+ const ok = reply.length > 0;
24
+ return { ok, provider: name, model, durationMs, replyLength: reply.length, reply: reply.slice(0, 200) + (reply.length > 200 ? '…' : '') };
25
+ } catch (err) {
26
+ return { ok: false, provider: name, model, durationMs: Date.now() - t0, error: err?.message || String(err), code: err?.code || null };
27
+ }
28
+ }
package/tui/editor.mjs CHANGED
@@ -359,7 +359,13 @@ export function Editor({
359
359
  // sees at most a one-tick flicker. The IME-correctness win is worth
360
360
  // the cosmetic cost. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
361
361
  useEffect(() => {
362
- if (!altEnabled) return;
362
+ // Runs in BOTH the alt-buffer and the default (non-alt Static) layouts: the
363
+ // editor is the last child either way, so the cursor parks below it and the
364
+ // rowsUp math (editor geometry only) is identical. Anchoring in non-alt is
365
+ // what makes the terminal cursor visible AT the caret (so you can see where
366
+ // you're typing) and what keeps a CJK/Hangul IME pre-edit inside the box
367
+ // instead of leaking onto the row below. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
368
+ void altEnabled;
363
369
  if (process.env.LAZYCLAW_NO_CURSOR_ANCHOR === '1') return;
364
370
  if (!(process.stdout && process.stdout.isTTY)) return;
365
371
  const cols = Math.max(20, process.stdout.columns || 80);
@@ -402,9 +408,19 @@ export function Editor({
402
408
  const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
403
409
  const colTarget = 3 + prefixWidth + colInLine;
404
410
  _installAnchorShim();
411
+ // If a previous anchor moved the cursor up and NO render's eraseLines has
412
+ // consumed that offset yet (two state updates between redraws — e.g. fast
413
+ // typing or backspace), the cursor is still parked up inside the editor.
414
+ // Undo that move first (\x1b[<pending>B) so we re-anchor from the true
415
+ // "below the frame" baseline. Without this the moves stacked, the shim
416
+ // only compensated for the LAST one, and eraseLines walked up into — and
417
+ // erased — the scrollback above the editor (corruption was invisible in
418
+ // the fixed alt-buffer canvas, visible in the default Static layout).
419
+ const pending = _anchorState.offset;
420
+ const undo = pending > 0 ? `\x1b[${pending}B\r` : '';
405
421
  _anchorState.offset = rowsUp;
406
422
  try {
407
- process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
423
+ process.stdout.write(`${undo}\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
408
424
  } catch { /* stdout closed — swallow */ }
409
425
  }, [state.buffer, state.cursor, altEnabled]);
410
426
 
package/tui/repl.mjs CHANGED
@@ -154,9 +154,25 @@ export function onEscape(state) {
154
154
  };
155
155
  }
156
156
 
157
- // New reducer: stream chunk arrives, accumulate in liveAssistant.
157
+ // Stream chunk arrives. Completed lines are committed to the <Static>
158
+ // scrollback immediately (so they scroll up ABOVE the sticky editor), and only
159
+ // the in-progress trailing partial stays in the live region. Without this, a
160
+ // reply taller than the terminal grew the live frame past the viewport and
161
+ // spilled BELOW the input box (long orchestrator replies). Chunks without a
162
+ // newline still just accumulate (the prior behaviour), so short replies and the
163
+ // existing reducer tests are unchanged.
158
164
  export function onStreamChunk(state, { chunk }) {
159
- return { ...state, liveAssistant: state.liveAssistant + chunk };
165
+ const buf = state.liveAssistant + chunk;
166
+ const nl = buf.lastIndexOf('\n');
167
+ if (nl < 0) return { ...state, liveAssistant: buf };
168
+ const complete = buf.slice(0, nl); // one or more whole lines
169
+ const remainder = buf.slice(nl + 1); // trailing partial (may be '')
170
+ const id = `as-${state.turnCounter}-${state.scrollback.length}`;
171
+ return {
172
+ ...state,
173
+ scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
174
+ liveAssistant: remainder,
175
+ };
160
176
  }
161
177
 
162
178
  export function onTurnComplete(state, { reason, error } = {}) {
@@ -463,10 +479,15 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
463
479
  const modalOpen = !!modal;
464
480
 
465
481
  // Slash popup is suppressed whenever a modal picker is active so the
466
- // overlays don't stack.
482
+ // overlays don't stack, AND once the buffer has a space (the user is typing
483
+ // ARGS, e.g. `/orchestrator off`). Without the space guard the popup stayed
484
+ // open as a one-row hint and the editor treated Enter as "fill the matched
485
+ // command", which dropped the args and reverted the buffer to the bare
486
+ // command. With args, Enter must submit the full line instead.
467
487
  const showSlashPopup =
468
488
  !modalOpen &&
469
- bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
489
+ bufferPeek.startsWith('/') && bufferPeek.indexOf(' ') < 0 &&
490
+ filtered.length > 0 && !_exactOnly;
470
491
 
471
492
  // Outer column height: pinned to rows-1 in alt-buffer mode so the
472
493
  // Editor truly sticks to the bottom. Non-alt keeps content-sized layout
@@ -26,6 +26,10 @@ export const SLASH_COMMANDS = [
26
26
  { cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
27
27
  { cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
28
28
  { cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
29
+ { cmd: '/config', help: 'leave chat and re-run the setup wizard (provider, model, channels)' },
30
+ { cmd: '/channels', help: 'view configured channels; /channels <name> on|off to toggle' },
31
+ { cmd: '/orchestrator', help: 'multi-agent: status | on | off | planner <spec> | worker add|remove <spec>' },
32
+ { cmd: '/context', help: 'chat history window: status | turns <N> | tokens <N>' },
29
33
  { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
30
34
  { cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
31
35
  { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },