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.
- package/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/cli.mjs +10 -21
- package/commands/channels.mjs +106 -107
- package/commands/chat.mjs +81 -45
- package/commands/daemon.mjs +15 -0
- package/commands/gateway.mjs +194 -0
- package/commands/providers.mjs +17 -38
- package/commands/service.mjs +113 -0
- package/commands/setup.mjs +55 -54
- package/commands/setup_channels.mjs +207 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/routes/_deps.mjs +6 -0
- package/daemon/routes/conversation.mjs +68 -4
- package/daemon/routes/ops.mjs +9 -29
- package/dotenv_min.mjs +28 -0
- package/first_run.mjs +9 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +2 -3
- package/providers/codex_cli.mjs +17 -1
- package/providers/compat_vendors.mjs +161 -0
- package/providers/gemini_cli.mjs +19 -3
- package/providers/model_catalogue.mjs +142 -9
- package/providers/probe.mjs +28 -0
- package/providers/registry.mjs +31 -162
- package/tui/editor.mjs +18 -2
- package/tui/pickers.mjs +3 -3
- package/tui/provider_families.mjs +8 -6
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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",
|
package/providers/codex_cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|