lazyclaw 6.0.0 → 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.
Files changed (73) 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/channels-discord/index.mjs +76 -0
  9. package/channels-discord/package.json +14 -0
  10. package/channels-email/index.mjs +109 -0
  11. package/channels-email/package.json +14 -0
  12. package/channels-signal/index.mjs +69 -0
  13. package/channels-signal/package.json +9 -0
  14. package/channels-voice/index.mjs +81 -0
  15. package/channels-voice/package.json +9 -0
  16. package/channels-whatsapp/index.mjs +70 -0
  17. package/channels-whatsapp/package.json +13 -0
  18. package/cli.mjs +10 -21
  19. package/commands/agents.mjs +669 -0
  20. package/commands/auth_nodes.mjs +323 -0
  21. package/commands/automation.mjs +582 -0
  22. package/commands/channels.mjs +254 -0
  23. package/commands/chat.mjs +1253 -0
  24. package/commands/config.mjs +315 -0
  25. package/commands/daemon.mjs +275 -0
  26. package/commands/gateway.mjs +194 -0
  27. package/commands/misc.mjs +128 -0
  28. package/commands/providers.mjs +490 -0
  29. package/commands/service.mjs +113 -0
  30. package/commands/sessions.mjs +343 -0
  31. package/commands/setup.mjs +742 -0
  32. package/commands/setup_channels.mjs +207 -0
  33. package/commands/skills.mjs +218 -0
  34. package/commands/workflow.mjs +661 -0
  35. package/config_features.mjs +106 -0
  36. package/daemon/lib/auth.mjs +58 -0
  37. package/daemon/lib/cost.mjs +30 -0
  38. package/daemon/lib/inbound_dedup.mjs +108 -0
  39. package/daemon/lib/learn_queue.mjs +46 -0
  40. package/daemon/lib/provider.mjs +69 -0
  41. package/daemon/lib/respond.mjs +83 -0
  42. package/daemon/route_table.mjs +96 -0
  43. package/daemon/routes/_deps.mjs +42 -0
  44. package/daemon/routes/config.mjs +99 -0
  45. package/daemon/routes/conversation.mjs +435 -0
  46. package/daemon/routes/meta.mjs +239 -0
  47. package/daemon/routes/ops.mjs +165 -0
  48. package/daemon/routes/providers.mjs +211 -0
  49. package/daemon/routes/rates.mjs +90 -0
  50. package/daemon/routes/registry.mjs +223 -0
  51. package/daemon/routes/sessions.mjs +213 -0
  52. package/daemon/routes/skills.mjs +260 -0
  53. package/daemon/routes/workflows.mjs +224 -0
  54. package/dotenv_min.mjs +51 -0
  55. package/first_run.mjs +24 -0
  56. package/goals_cron.mjs +37 -0
  57. package/lib/args.mjs +216 -0
  58. package/lib/config.mjs +113 -0
  59. package/lib/gateway_guard.mjs +100 -0
  60. package/lib/inbound_client.mjs +108 -0
  61. package/lib/registry_boot.mjs +55 -0
  62. package/lib/service_install.mjs +207 -0
  63. package/package.json +15 -3
  64. package/providers/probe.mjs +28 -0
  65. package/secure_write.mjs +46 -0
  66. package/tui/editor.mjs +18 -2
  67. package/tui/repl.mjs +25 -4
  68. package/tui/slash_commands.mjs +4 -0
  69. package/tui/slash_dispatcher.mjs +118 -0
  70. package/tui/splash_props.mjs +52 -0
  71. package/web/dashboard.css +6 -12
  72. package/web/dashboard.html +2 -34
  73. package/web/dashboard.js +3 -3
package/lib/args.mjs ADDED
@@ -0,0 +1,216 @@
1
+ // CLI argument parsing + shell-completion generation, extracted from cli.mjs.
2
+ // Pure leaf module: no imports, no side effects — just the subcommand inventory
3
+ // and the parseArgs/completion functions that main() and `lazyclaw completion`
4
+ // share.
5
+
6
+ // Subcommand inventory used by `lazyclaw completion`. Single source of
7
+ // truth so adding a subcommand updates the completion script too. The
8
+ // dispatcher in main() is the runtime authority; this list mirrors it.
9
+ export const SUBCOMMANDS = [
10
+ 'run', 'resume', 'inspect', 'clear', 'validate', 'graph',
11
+ 'config', 'chat', 'agent',
12
+ 'doctor', 'status', 'onboard',
13
+ 'sessions', 'skills', 'providers',
14
+ 'daemon', 'version', 'completion', 'help',
15
+ 'export', 'import',
16
+ 'rates',
17
+ // v5.0 — sandbox 6-backend (Phase D)
18
+ 'sandbox',
19
+ // OpenClaw-parity subsurfaces (v3.93–v3.98)
20
+ 'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
21
+ // v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
22
+ 'setup', 'dashboard',
23
+ // v3.99.22 — multi-agent orchestrator config
24
+ 'orchestrator',
25
+ // v3.99.30 — /loop and /goal slash commands (in-session + detached)
26
+ 'loop', 'loops', 'goal', 'memory',
27
+ // v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
28
+ 'slack',
29
+ // v4.3.0 — Telegram long-poll listener (zero-install mobile control)
30
+ 'telegram',
31
+ // v4.3.0 — Matrix /sync long-poll listener
32
+ 'matrix',
33
+ // v5.0 — channels plugin loader (Phase F)
34
+ 'channels',
35
+ // v4.1.0 — multi-agent slack system (Phase 9+)
36
+ 'agent', 'team', 'task',
37
+ // v5.0 Phase G — persona compose + cross-tool import (spec §9, §10)
38
+ 'personality', 'migrate', 'hermes', 'openclaw',
39
+ // v5.0.6 — arrow-key launcher menu (was the no-arg default in v5.0.5-)
40
+ 'menu',
41
+ // v5.0 Phase H1 — trajectory exporter (spec §2.7)
42
+ 'trajectories',
43
+ ];
44
+
45
+ export const SUBCOMMAND_SUBS = {
46
+ config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
47
+ sessions: ['list', 'show', 'clear', 'export', 'search'],
48
+ skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
49
+ providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
50
+ rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
51
+ sandbox: ['list', 'test', 'add', 'use'],
52
+ completion: ['bash', 'zsh'],
53
+ auth: ['list', 'add', 'remove', 'use', 'rotate'],
54
+ pairing: ['list', 'add', 'remove'],
55
+ nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
56
+ message: ['list', 'add', 'remove', 'send'],
57
+ workspace: ['list', 'init', 'show', 'remove', 'path'],
58
+ cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
59
+ orchestrator: ['status', 'set-planner', 'workers', 'set-max-subtasks', 'clear'],
60
+ loops: ['list', 'show', 'kill', 'tail'],
61
+ goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
62
+ memory: ['show', 'dream', 'edit'],
63
+ slack: ['listen'],
64
+ telegram: ['listen'],
65
+ matrix: ['listen'],
66
+ agent: ['add', 'list', 'show', 'edit', 'remove'],
67
+ team: ['add', 'list', 'show', 'edit', 'remove'],
68
+ task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
69
+ trajectories: ['export'],
70
+ };
71
+
72
+ // Flags whose presence is the signal — they don't consume the next arg
73
+ // even when one is available. Without this allow-list,
74
+ // `lazyclaw run --parallel demo wf.mjs` would set `flags.parallel='demo'`
75
+ // and silently lose the session id; the user would only see a
76
+ // "missing positional" error after the dispatcher rejected it.
77
+ export const BOOLEAN_FLAGS = new Set([
78
+ 'parallel',
79
+ 'parallel-persistent',
80
+ 'once',
81
+ 'non-interactive',
82
+ 'include-secrets',
83
+ 'include-sessions',
84
+ 'overwrite-skills',
85
+ 'no-overwrite-config',
86
+ 'import-sessions',
87
+ 'show-thinking',
88
+ 'usage',
89
+ 'cost',
90
+ 'response-cache',
91
+ 'help', // also handled as a subcommand alias
92
+ 'version',
93
+ 'summary', // inspect: trim per-node detail
94
+ 'regex', // sessions search: treat query as a regex
95
+ 'lr', // graph: emit Mermaid `graph LR` (left-right)
96
+ 'force', // rates copy: overwrite existing destination
97
+ 'aggregate', // inspect (list mode): per-node stats across sessions
98
+ 'all', // providers test: run all providers in parallel
99
+ 'with-turn-count', // sessions list: include turn count per session
100
+ 'no-probe', // providers add: skip the /v1/models reachability probe
101
+ 'pick', // onboard / chat: force the interactive picker even when provider already set
102
+ 'detach', // loop: fork worker and return immediately
103
+ 'use-memory', // loop: prepend core memory to each iteration
104
+ 'force', // goal tick --force: bypass schedule when invoked manually
105
+ ]);
106
+
107
+ export function parseArgs(argv) {
108
+ const out = { positional: [], flags: {} };
109
+ for (let i = 0; i < argv.length; i++) {
110
+ const a = argv[i];
111
+ // POSIX `--`: everything after is positional verbatim. Used by
112
+ // `cron add <name> "<spec>" -- <cmd> [args...]` so a recurring
113
+ // command with --flag of its own doesn't get parsed as our flag.
114
+ if (a === '--') {
115
+ for (let j = i + 1; j < argv.length; j++) out.positional.push(argv[j]);
116
+ break;
117
+ }
118
+ if (a.startsWith('--')) {
119
+ const eq = a.indexOf('=');
120
+ if (eq >= 0) {
121
+ out.flags[a.slice(2, eq)] = a.slice(eq + 1);
122
+ } else {
123
+ const name = a.slice(2);
124
+ if (BOOLEAN_FLAGS.has(name)) {
125
+ // Known boolean — never consumes the next arg.
126
+ out.flags[name] = true;
127
+ continue;
128
+ }
129
+ const next = argv[i + 1];
130
+ if (next === undefined || next.startsWith('--')) {
131
+ // Unknown flag at end-of-args or before another --flag: still boolean.
132
+ out.flags[name] = true;
133
+ } else {
134
+ out.flags[name] = next;
135
+ i += 1;
136
+ }
137
+ }
138
+ } else out.positional.push(a);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export function bashCompletion() {
144
+ // Standard bash COMPREPLY pattern. We split COMP_WORDS into:
145
+ // [0] = lazyclaw, [1] = subcommand, [2+] = subcommand args.
146
+ // Two-level completion: word index 1 → top subcommands; index 2 → the
147
+ // sub-subcommand list (if defined for that subcommand). Beyond index 2
148
+ // we don't try to enumerate dynamic items (session ids etc.) — that
149
+ // would require running the CLI on every <Tab>, which is too slow.
150
+ const subs = SUBCOMMANDS.join(' ');
151
+ const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
152
+ .map(([name, list]) => ` ${name})\n COMPREPLY=( $(compgen -W "${list.join(' ')}" -- "$cur") )\n ;;`)
153
+ .join('\n');
154
+ return `# lazyclaw bash completion. Source from your shell:
155
+ # eval "$(node /path/to/cli.mjs completion bash)"
156
+ _lazyclaw_completion() {
157
+ local cur prev words cword
158
+ _init_completion 2>/dev/null || {
159
+ cur="\${COMP_WORDS[COMP_CWORD]}"
160
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
161
+ cword=$COMP_CWORD
162
+ }
163
+ if [ "$cword" -eq 1 ]; then
164
+ COMPREPLY=( $(compgen -W "${subs}" -- "$cur") )
165
+ return 0
166
+ fi
167
+ if [ "$cword" -eq 2 ]; then
168
+ case "\${COMP_WORDS[1]}" in
169
+ ${subSubsCases}
170
+ esac
171
+ return 0
172
+ fi
173
+ return 0
174
+ }
175
+ complete -F _lazyclaw_completion lazyclaw
176
+ `;
177
+ }
178
+
179
+ export function zshCompletion() {
180
+ // _arguments-style. We list subcommands then dispatch on the first
181
+ // positional via a single `_describe`. Sub-subcommands handled by a
182
+ // case inside the function. Same coverage rationale as bash.
183
+ const subs = SUBCOMMANDS.map(s => ` '${s}'`).join('\n');
184
+ const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
185
+ .map(([name, list]) => ` (${name}) _values 'sub' ${list.map(v => `'${v}'`).join(' ')} ;;`)
186
+ .join('\n');
187
+ return `#compdef lazyclaw
188
+ # lazyclaw zsh completion. Add to fpath, or eval inline:
189
+ # eval "$(node /path/to/cli.mjs completion zsh)"
190
+ _lazyclaw() {
191
+ local subs=(
192
+ ${subs}
193
+ )
194
+ if (( CURRENT == 2 )); then
195
+ _values 'subcommand' \${subs[@]}
196
+ return
197
+ fi
198
+ if (( CURRENT == 3 )); then
199
+ case \${words[2]} in
200
+ ${subSubsCases}
201
+ esac
202
+ return
203
+ fi
204
+ }
205
+ compdef _lazyclaw lazyclaw
206
+ _lazyclaw "$@"
207
+ `;
208
+ }
209
+
210
+ // Subcommand-classifier sets for the multi-agent surfaces. cli.mjs uses
211
+ // AGENT_REG_SUBS to decide whether a bare `agent <sub>` routes to the agent
212
+ // registry vs a one-shot agent run. TEAM_SUBS/TASK_SUBS mirror their command's
213
+ // subcommands for the same disambiguation pattern.
214
+ export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
215
+ export const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
216
+ export const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
package/lib/config.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // Shared config + key/url resolution helpers, extracted from cli.mjs so the
2
+ // per-domain command modules (commands/*.mjs) can import them without pulling
3
+ // in the whole entrypoint. Leaf module: depends only on node builtins and the
4
+ // owner-only secure writer — never on the provider registry (the registry
5
+ // dependency is injected via setRegistryEnvResolver to keep this a leaf and
6
+ // avoid a config<->registry_boot import cycle).
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ // Owner-only (0600/0700) atomic writer for config.json — it holds plaintext
11
+ // API keys / auth profiles and must not be group/other readable.
12
+ import { writeJsonSecure, tightenIfLoose } from '../secure_write.mjs';
13
+
14
+ export function configPath() {
15
+ const override = process.env.LAZYCLAW_CONFIG_DIR;
16
+ const dir = override ? override : path.join(os.homedir(), '.lazyclaw');
17
+ return path.join(dir, 'config.json');
18
+ }
19
+
20
+ export function readConfig() {
21
+ const p = configPath();
22
+ if (!fs.existsSync(p)) return {};
23
+ // Migrate already-deployed world/group-readable config.json (plaintext keys)
24
+ // to 0600 the first time we touch it. Best-effort, idempotent.
25
+ tightenIfLoose(p);
26
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
27
+ catch { return {}; }
28
+ }
29
+
30
+ export function writeConfig(cfg) {
31
+ // 0600 file in a 0700 dir, atomically — config.json stores plaintext API
32
+ // keys / auth profiles, so it must be owner-only on disk.
33
+ writeJsonSecure(configPath(), cfg);
34
+ }
35
+
36
+ // Injected built-in OpenAI-compat env-var resolver. registry_boot wires this
37
+ // to registry.resolveBuiltinEnvKey on ensureRegistry() so _resolveAuthKey can
38
+ // fall back to provider env vars without importing the registry (no cycle).
39
+ let _envResolver = null;
40
+ export function setRegistryEnvResolver(fn) { _envResolver = fn; }
41
+
42
+ // Synchronous, dependency-free resolver for the api-key the
43
+ // chat / agent flow sends. Mirrors config_features.resolveApiKey
44
+ // without forcing the dynamic import on every hot-path call.
45
+ // 1. cfg.authProfiles[provider] active label, if set
46
+ // 2. first profile in the array
47
+ // 3. customProviders[<provider>].apiKey (custom OpenAI-compat entries)
48
+ // 4. PROVIDER_INFO[<provider>].envKey / altEnvKeys env var (built-in
49
+ // OpenAI-compat: nim → NVIDIA_API_KEY, openrouter → OPENROUTER_API_KEY, …)
50
+ // 5. legacy single `cfg["api-key"]` (pre-v3.93 configs)
51
+ export function _resolveAuthKey(cfg, provider) {
52
+ const arr = (cfg.authProfiles || {})[provider] || [];
53
+ const active = (cfg.authActiveProfile || {})[provider];
54
+ const hit = arr.find((p) => p && p.label === active) || arr[0];
55
+ if (hit?.key) return hit.key;
56
+ const custom = Array.isArray(cfg.customProviders)
57
+ ? cfg.customProviders.find((p) => p && p.name === provider)
58
+ : null;
59
+ if (custom?.apiKey) return custom.apiKey;
60
+ // Built-in OpenAI-compat env var fallback. Skipped silently when the
61
+ // registry module isn't loaded yet (every chat / agent path calls
62
+ // ensureRegistry() before _resolveAuthKey, so this is just defence-in-depth).
63
+ if (typeof _envResolver === 'function') {
64
+ const envHit = _envResolver(provider);
65
+ if (envHit) return envHit;
66
+ }
67
+ return cfg['api-key'] || '';
68
+ }
69
+
70
+ // Per-provider base-URL override (used by tests + private gateways).
71
+ // Single source of truth — the reflect / skill-synth / task-tick paths
72
+ // all resolve through here so a new provider's env var lands in one spot.
73
+ export function _resolveBaseUrl(provider) {
74
+ return {
75
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
76
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
77
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
78
+ }[provider] || undefined;
79
+ }
80
+
81
+ export function readVersionFromRepo() {
82
+ // Two source-of-truth lookups, in order:
83
+ // 1. The npm-published package's own package.json (sits next to the
84
+ // entrypoint once installed via `npm i -g lazyclaw`).
85
+ // 2. The monorepo's VERSION file at the repo root (one or two
86
+ // levels up depending on how the file is symlinked / copied).
87
+ // Either one wins on first hit. Falls back to '0.0.0' so the CLI
88
+ // never crashes on a stripped-down install. The candidate list is a
89
+ // superset spanning both the repo-root entrypoint and this lib/ module
90
+ // location so resolution is identical regardless of where it runs from.
91
+ const here = path.dirname(new URL(import.meta.url).pathname);
92
+ const candidates = [
93
+ { kind: 'pkg', path: path.resolve(here, './package.json') },
94
+ { kind: 'pkg', path: path.resolve(here, '../package.json') },
95
+ { kind: 'pkg', path: path.resolve(here, '../../package.json') },
96
+ { kind: 'version', path: path.resolve(here, '../../VERSION') },
97
+ { kind: 'version', path: path.resolve(here, '../../../VERSION') },
98
+ { kind: 'version', path: path.resolve(here, '../../../../VERSION') },
99
+ ];
100
+ for (const c of candidates) {
101
+ try {
102
+ const raw = fs.readFileSync(c.path, 'utf8').trim();
103
+ if (!raw) continue;
104
+ if (c.kind === 'pkg') {
105
+ const v = JSON.parse(raw).version;
106
+ if (v) return v;
107
+ } else {
108
+ return raw;
109
+ }
110
+ } catch { /* keep trying */ }
111
+ }
112
+ return '0.0.0';
113
+ }
@@ -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,55 @@
1
+ // Provider-registry bootstrap, extracted from cli.mjs so every command module
2
+ // (commands/*.mjs) shares one lazily-loaded registry instance instead of each
3
+ // re-importing and re-registering. One-directional dependency: registry_boot
4
+ // -> config (never the reverse), so this stays cycle-free.
5
+ import { readConfig, _resolveAuthKey, setRegistryEnvResolver } from './config.mjs';
6
+
7
+ let _registryMod = null;
8
+
9
+ // Throwing accessor — use when the caller has already awaited ensureRegistry()
10
+ // and a null registry would be a programmer error.
11
+ export function requireRegistry() {
12
+ if (!_registryMod) {
13
+ throw new Error('registry module not pre-loaded — call ensureRegistry() first');
14
+ }
15
+ return _registryMod;
16
+ }
17
+
18
+ // Nullable accessor — returns the loaded registry module or null. Mirrors the
19
+ // old bare `_registryMod` read semantics for sites that tolerate "not loaded".
20
+ export function getRegistry() {
21
+ return _registryMod;
22
+ }
23
+
24
+ export async function ensureRegistry() {
25
+ if (!_registryMod) {
26
+ _registryMod = await import('../providers/registry.mjs');
27
+ // Wire config._resolveAuthKey's built-in env-var fallback to the registry
28
+ // without config importing the registry (keeps config a leaf module).
29
+ if (typeof _registryMod.resolveBuiltinEnvKey === 'function') {
30
+ setRegistryEnvResolver((p) => _registryMod.resolveBuiltinEnvKey(p));
31
+ }
32
+ }
33
+ // Re-run registration on every call so config changes within the same
34
+ // process (e.g. setup wizard adding a custom endpoint mid-session) take
35
+ // effect for the next chat / agent / picker invocation. registerCustom-
36
+ // Providers is idempotent — re-registering the same name is a no-op.
37
+ try {
38
+ if (typeof _registryMod.registerCustomProviders === 'function') {
39
+ _registryMod.registerCustomProviders(readConfig());
40
+ }
41
+ } catch { /* never let a malformed cfg.customProviders block startup */ }
42
+ // Wire the orchestrator's live cfg + auth-key resolver. We do this on
43
+ // every ensureRegistry() call (cheap — just replaces the closure) so a
44
+ // mid-session config edit (custom provider added, env var exported)
45
+ // takes effect on the next orchestrator turn without a restart.
46
+ try {
47
+ if (typeof _registryMod.registerOrchestrator === 'function') {
48
+ _registryMod.registerOrchestrator({
49
+ cfgGetter: readConfig,
50
+ keyResolver: _resolveAuthKey,
51
+ });
52
+ }
53
+ } catch { /* defensive */ }
54
+ return _registryMod;
55
+ }