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/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/probe.mjs +28 -0
- package/tui/editor.mjs +18 -2
- 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.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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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('/') &&
|
|
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
|
package/tui/slash_commands.mjs
CHANGED
|
@@ -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]]' },
|