lazyclaw 6.0.1 → 6.2.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 (44) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/cli.mjs +10 -21
  9. package/commands/channels.mjs +106 -107
  10. package/commands/chat.mjs +81 -45
  11. package/commands/daemon.mjs +15 -0
  12. package/commands/gateway.mjs +194 -0
  13. package/commands/providers.mjs +17 -38
  14. package/commands/service.mjs +113 -0
  15. package/commands/setup.mjs +55 -54
  16. package/commands/setup_channels.mjs +207 -0
  17. package/config_features.mjs +106 -0
  18. package/daemon/lib/inbound_dedup.mjs +108 -0
  19. package/daemon/lib/learn_queue.mjs +46 -0
  20. package/daemon/routes/_deps.mjs +6 -0
  21. package/daemon/routes/conversation.mjs +68 -4
  22. package/daemon/routes/ops.mjs +9 -29
  23. package/dotenv_min.mjs +28 -0
  24. package/first_run.mjs +9 -0
  25. package/lib/gateway_guard.mjs +100 -0
  26. package/lib/inbound_client.mjs +108 -0
  27. package/lib/service_install.mjs +207 -0
  28. package/package.json +2 -3
  29. package/providers/codex_cli.mjs +17 -1
  30. package/providers/compat_vendors.mjs +161 -0
  31. package/providers/gemini_cli.mjs +19 -3
  32. package/providers/model_catalogue.mjs +142 -9
  33. package/providers/probe.mjs +28 -0
  34. package/providers/registry.mjs +31 -162
  35. package/tui/editor.mjs +18 -2
  36. package/tui/pickers.mjs +3 -3
  37. package/tui/provider_families.mjs +8 -6
  38. package/tui/repl.mjs +25 -4
  39. package/tui/slash_commands.mjs +4 -0
  40. package/tui/slash_dispatcher.mjs +118 -0
  41. package/tui/splash_props.mjs +52 -0
  42. package/web/dashboard.css +6 -12
  43. package/web/dashboard.html +2 -34
  44. package/web/dashboard.js +3 -3
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.2.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",
@@ -144,6 +144,16 @@ export const codexCliProvider = {
144
144
  const onAbort = () => { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } };
145
145
  if (opts.signal) opts.signal.addEventListener('abort', onAbort);
146
146
 
147
+ // A missing binary surfaces as an ASYNC ChildProcess 'error' event on
148
+ // some platforms (the sync try/catch above doesn't see it). Without a
149
+ // listener that's an uncaughtException that kills the WHOLE process mid
150
+ // `providers test` — capture it and surface a per-provider CliMissingError
151
+ // instead (same fix claude_cli.mjs received in F8).
152
+ let spawnError = null;
153
+ const spawnErrorPromise = new Promise((resolve) => {
154
+ proc.once('error', (err) => { spawnError = err; resolve(); });
155
+ });
156
+
147
157
  let stderr = '';
148
158
  proc.stderr.setEncoding('utf8');
149
159
  proc.stderr.on('data', (chunk) => { stderr += chunk; });
@@ -184,7 +194,13 @@ export const codexCliProvider = {
184
194
  if (text) yield text;
185
195
  } catch (_) { /* incomplete tail — drop */ }
186
196
  }
187
- await exitPromise;
197
+ // Wait for either a clean exit or an async spawn error. On ENOENT the
198
+ // process never starts, so 'close' never fires — racing against
199
+ // spawnErrorPromise keeps this from hanging forever.
200
+ await Promise.race([exitPromise, spawnErrorPromise]);
201
+ if (spawnError) {
202
+ throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
203
+ }
188
204
  if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
189
205
  throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
190
206
  }