lazyclaw 6.3.0 → 6.4.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 +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +95 -37
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +210 -95
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/commands/chat.mjs
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
configPath, readConfig, writeConfig,
|
|
9
|
+
persistActiveModel, persistActiveProvider,
|
|
9
10
|
_resolveAuthKey, _resolveBaseUrl, readVersionFromRepo,
|
|
10
11
|
} from '../lib/config.mjs';
|
|
12
|
+
import { renderRecord } from '../lib/render.mjs';
|
|
11
13
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
12
14
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
13
15
|
import {
|
|
@@ -16,10 +18,20 @@ import {
|
|
|
16
18
|
_quickPrompt, _renderBanner, _renderV5Banner,
|
|
17
19
|
} from '../tui/pickers.mjs';
|
|
18
20
|
import { firstRunMode as _firstRunMode, hasConfiguredProvider } from '../first_run.mjs';
|
|
19
|
-
import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
21
|
+
import { applyChatWindow as _applyChatWindow, estimateMessagesTokens, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
20
22
|
import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
|
|
21
|
-
import {
|
|
23
|
+
import { hudStatus as _hudStatus } from '../tui/hud.mjs';
|
|
24
|
+
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine, _makeInkApprove } from '../tui/slash_dispatcher.mjs';
|
|
22
25
|
import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
|
|
26
|
+
import { wrapInteractiveProv, makeLegacyApprove } from './chat_hardening.mjs';
|
|
27
|
+
|
|
28
|
+
// /new, /reset and /clear must signal the Ink REPL to wipe the screen +
|
|
29
|
+
// scrollback via the 'NEW' sentinel (handled in tui/repl.mjs). _newReset itself
|
|
30
|
+
// returns a human string for the string-rendering consumers, so the Ink handler
|
|
31
|
+
// translates these commands here. Exported so the contract is unit-testable.
|
|
32
|
+
export function _isInkResetCmd(cmd) {
|
|
33
|
+
return /^\/(new|reset|clear)$/i.test(String(cmd || ''));
|
|
34
|
+
}
|
|
23
35
|
|
|
24
36
|
// Legacy (non-Ink) slash routing for dispatcher-style, ctx-only commands.
|
|
25
37
|
// The Ink REPL routes every slash through _dispatchSlash/SLASH_HANDLERS, but
|
|
@@ -89,9 +101,20 @@ export async function cmdChat(flags = {}) {
|
|
|
89
101
|
}
|
|
90
102
|
activeProvName = 'claude-cli';
|
|
91
103
|
}
|
|
92
|
-
let prov = lookupProv(activeProvName);
|
|
104
|
+
let prov = wrapInteractiveProv(lookupProv(activeProvName)); // transient-retry the chat hot path
|
|
93
105
|
if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
|
|
94
106
|
|
|
107
|
+
// First-turn key preflight: warn up front when the active provider needs an
|
|
108
|
+
// API key but none resolves, instead of letting the first turn fail opaquely.
|
|
109
|
+
// Cheap (no network); TTY-only so pipelines aren't spammed.
|
|
110
|
+
if (process.stdout.isTTY) {
|
|
111
|
+
const _meta = (getRegistry().PROVIDER_INFO || {})[activeProvName] || {};
|
|
112
|
+
if (_meta.requiresApiKey && !_resolveAuthKey(cfg, activeProvName)) {
|
|
113
|
+
process.stdout.write(` ⚠ no API key found for ${activeProvName} — the first message will fail until you set one.\n`);
|
|
114
|
+
process.stdout.write(` fix: /provider (pick + paste a key) · or lazyclaw auth add ${activeProvName}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
// Top-of-session banner so the user can see at a glance what they're
|
|
96
119
|
// talking to. Cheap (no provider call) and TTY-only.
|
|
97
120
|
// v5 ink splash + REPL when stdin is a real TTY and the user has not
|
|
@@ -107,8 +130,7 @@ export async function cmdChat(flags = {}) {
|
|
|
107
130
|
// narrow-terminal fallback: <60 cols falls back to v4
|
|
108
131
|
if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
|
|
109
132
|
|
|
110
|
-
// Tool + skill groups for the splash panel — shared with the setup
|
|
111
|
-
// wizard via tui/splash_props.mjs so both surfaces render the same panel.
|
|
133
|
+
// Tool + skill groups for the splash panel — shared with the setup wizard.
|
|
112
134
|
const { gatherToolAndSkillGroups } = await import('../tui/splash_props.mjs');
|
|
113
135
|
const { tools: toolGroups, skills: skillGroups } =
|
|
114
136
|
await gatherToolAndSkillGroups(path.dirname(configPath()));
|
|
@@ -123,13 +145,9 @@ export async function cmdChat(flags = {}) {
|
|
|
123
145
|
};
|
|
124
146
|
void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
|
|
125
147
|
|
|
126
|
-
// C7 — minimal chat-session state for the ink path so runTurn can
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// remains self-contained and the legacy path stays byte-identical).
|
|
130
|
-
// Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
|
|
131
|
-
// until then, system-prompt composition / --session resume happen
|
|
132
|
-
// identically to the legacy path.
|
|
148
|
+
// C7 — minimal chat-session state for the ink path so runTurn can talk to
|
|
149
|
+
// the provider. The legacy readline path below sets up the same shape
|
|
150
|
+
// (kept duplicated so each branch stays self-contained).
|
|
133
151
|
let _inkSandboxSpec = null;
|
|
134
152
|
if (flags.sandbox) {
|
|
135
153
|
const sb = await import('../sandbox.mjs');
|
|
@@ -203,8 +221,7 @@ export async function cmdChat(flags = {}) {
|
|
|
203
221
|
}).catch(() => {});
|
|
204
222
|
} catch { /* swallow */ }
|
|
205
223
|
};
|
|
206
|
-
// v5.4: chars-sent counter for the Ink
|
|
207
|
-
// path's `charsSent` so /usage in Ink reports the same number.
|
|
224
|
+
// v5.4: chars-sent counter for the Ink path (mirrors legacy `charsSent`).
|
|
208
225
|
let _inkCharsSent = 0;
|
|
209
226
|
const _inkCtx = {
|
|
210
227
|
cfg,
|
|
@@ -219,11 +236,15 @@ export async function cmdChat(flags = {}) {
|
|
|
219
236
|
getMessages: () => _inkMessages,
|
|
220
237
|
setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
|
|
221
238
|
getProv: () => prov,
|
|
222
|
-
setProv: (next) => { prov = next; },
|
|
239
|
+
setProv: (next) => { prov = wrapInteractiveProv(next); },
|
|
223
240
|
getActiveProvName: () => activeProvName,
|
|
224
|
-
|
|
241
|
+
// Persist provider/model picks so they survive a restart (was
|
|
242
|
+
// in-memory only — a model chosen via /model reverted to cfg.model on
|
|
243
|
+
// the next launch). persistActiveProvider leaves orchestrator routing
|
|
244
|
+
// to /orchestrator on|off.
|
|
245
|
+
setActiveProvName: (name) => { activeProvName = name; persistActiveProvider(cfg, name); },
|
|
225
246
|
getActiveModel: () => activeModel,
|
|
226
|
-
setActiveModel: (name) => { activeModel = name; },
|
|
247
|
+
setActiveModel: (name) => { activeModel = name; persistActiveModel(cfg, name); },
|
|
227
248
|
getSessionId: () => _inkSessionId,
|
|
228
249
|
setSessionId: (id) => { _inkSessionId = id; },
|
|
229
250
|
getCharsSent: () => _inkCharsSent,
|
|
@@ -235,8 +256,7 @@ export async function cmdChat(flags = {}) {
|
|
|
235
256
|
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
236
257
|
resolveBaseUrl: (providerName) => _resolveBaseUrl(providerName),
|
|
237
258
|
onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
|
|
238
|
-
// P2 —
|
|
239
|
-
// by read-merge-writing config.json from inside the Ink session.
|
|
259
|
+
// P2 — /provider add read-merge-writes config.json from the Ink session.
|
|
240
260
|
readConfig: () => readConfig(),
|
|
241
261
|
writeConfig: (next) => writeConfig(next),
|
|
242
262
|
};
|
|
@@ -253,6 +273,7 @@ export async function cmdChat(flags = {}) {
|
|
|
253
273
|
? api.openPicker(opts)
|
|
254
274
|
: Promise.resolve(null);
|
|
255
275
|
};
|
|
276
|
+
_inkCtx.approve = _makeInkApprove(_inkCtx); // agentic sensitive tools → Ink approval modal
|
|
256
277
|
// Route streamed chunks through ReplApp's injected writeFn: chunks
|
|
257
278
|
// land in React state (liveAssistant) → live region → committed to
|
|
258
279
|
// the <Static/> scrollback on turn-complete, so Ink owns all output.
|
|
@@ -271,9 +292,38 @@ export async function cmdChat(flags = {}) {
|
|
|
271
292
|
const { cmd, args } = _parseSlashLine(line);
|
|
272
293
|
// Thread the REPL's abort signal so Esc/Ctrl-C can stop a /loop.
|
|
273
294
|
_inkCtx.loopSignal = signal || null;
|
|
274
|
-
|
|
295
|
+
const result = await _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
|
|
275
296
|
try { process.stdout.write(chunk); } catch { /* swallow */ }
|
|
276
297
|
});
|
|
298
|
+
// _newReset (/new, /reset, /clear) returns a human string, but repl.mjs
|
|
299
|
+
// only wipes the screen + scrollback on the 'NEW' sentinel. Translate
|
|
300
|
+
// here (mirrors the 'EXIT' sentinel) so the real /new actually clears.
|
|
301
|
+
if (_isInkResetCmd(cmd)) return 'NEW';
|
|
302
|
+
return result;
|
|
303
|
+
};
|
|
304
|
+
// v6.x slash-argument completion (two surfaces, see tui/slash_args.mjs):
|
|
305
|
+
// onArgList → inline candidates rendered in the popup (login, hud,
|
|
306
|
+
// memory, config, channels, subcommands, names, …).
|
|
307
|
+
// onArgComplete → Tab opens the drill-in modal for 2-step provider→model
|
|
308
|
+
// specs (/model, /trainer set, /orchestrator planner).
|
|
309
|
+
// Both resolve through _inkCtx (its openPicker IS ReplApp's modal; its
|
|
310
|
+
// cfgDir/registry feed the inline lists).
|
|
311
|
+
const { argSpecFor: _argSpecFor, runArgCompleter: _runArgCompleter, listArgCandidates: _listArgCandidates } = await import('../tui/slash_args.mjs');
|
|
312
|
+
const { SLASH_COMMANDS: _ARG_CATALOG } = await import('../tui/slash_commands.mjs');
|
|
313
|
+
const _argRegistry = await import('../providers/registry.mjs');
|
|
314
|
+
const _inkArgComplete = async (buffer) => {
|
|
315
|
+
try {
|
|
316
|
+
const spec = _argSpecFor(buffer, _ARG_CATALOG);
|
|
317
|
+
if (!spec || spec.kind !== 'modal') return null;
|
|
318
|
+
return await _runArgCompleter(spec, _inkCtx, _argRegistry);
|
|
319
|
+
} catch { return null; }
|
|
320
|
+
};
|
|
321
|
+
const _inkArgList = (buffer) => {
|
|
322
|
+
try {
|
|
323
|
+
const spec = _argSpecFor(buffer, _ARG_CATALOG);
|
|
324
|
+
if (!spec || spec.kind !== 'inline') return [];
|
|
325
|
+
return _listArgCandidates(spec, _inkCtx, _argRegistry);
|
|
326
|
+
} catch { return []; }
|
|
277
327
|
};
|
|
278
328
|
// v5.4.1: splash renders INSIDE the alt-buffer (not pre-printed to
|
|
279
329
|
// primary). The v5.4.0 pre-print made the screen go blank during
|
|
@@ -282,19 +332,21 @@ export async function cmdChat(flags = {}) {
|
|
|
282
332
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
283
333
|
splashProps,
|
|
284
334
|
statusInfo: { provider: activeProvName, model: activeModel },
|
|
285
|
-
// P3 — live status:
|
|
286
|
-
//
|
|
287
|
-
// turn, instead of showing the values captured at mount.
|
|
335
|
+
// P3 — live status: provider/model + history-based ctx gauge (not the
|
|
336
|
+
// provider's self-reported per-call usage) + the HUD field bundle.
|
|
288
337
|
getStatus: () => ({
|
|
289
338
|
provider: activeProvName,
|
|
290
339
|
model: activeModel,
|
|
291
|
-
ctxUsed:
|
|
340
|
+
ctxUsed: estimateMessagesTokens(_inkMessages),
|
|
292
341
|
ctxTotal: Number((cfg.chat || {}).windowTokens) || CHAT_WINDOW_TOKEN_BUDGET,
|
|
342
|
+
hud: _hudStatus(cfg, _inkRunningUsage),
|
|
293
343
|
}),
|
|
294
344
|
runTurnFactory: _inkRunTurnFactory,
|
|
295
345
|
onSlashCommand: _inkSlashHandler,
|
|
346
|
+
onArgComplete: _inkArgComplete,
|
|
347
|
+
onArgList: _inkArgList,
|
|
296
348
|
pickerRef: _inkPickerRef,
|
|
297
|
-
}), { exitOnCtrlC:
|
|
349
|
+
}), { exitOnCtrlC: false, patchConsole: true }); // false → editor 2-stage Ctrl+C
|
|
298
350
|
await ink.waitUntilExit();
|
|
299
351
|
// /setup → full wizard (then shell). /config single step → run JUST
|
|
300
352
|
// that step now that Ink released stdin, then re-enter chat.
|
|
@@ -302,16 +354,21 @@ export async function cmdChat(flags = {}) {
|
|
|
302
354
|
else if (_inkCtx.requestConfigStep) {
|
|
303
355
|
await (await import('./config_step.mjs')).runConfigStep(_inkCtx.requestConfigStep);
|
|
304
356
|
return cmdChat(flags);
|
|
357
|
+
} else if (_inkCtx.requestLogin) {
|
|
358
|
+
// Connect a keyless CLI provider in the foreground (Ink freed stdin), re-enter.
|
|
359
|
+
await (await import('../providers/cli_login.mjs')).runCliLoginInteractive(_inkCtx.requestLogin);
|
|
360
|
+
return cmdChat(flags);
|
|
305
361
|
}
|
|
306
362
|
return;
|
|
307
363
|
} catch (e) {
|
|
308
|
-
// Fall through to legacy path on any ink failure
|
|
309
|
-
//
|
|
310
|
-
|
|
364
|
+
// Fall through to the legacy readline path on any ink failure. ALWAYS
|
|
365
|
+
// say why, in one dim line — the silent downgrade made real-terminal
|
|
366
|
+
// failures (node incompat, <60-col windows) undiagnosable from reports.
|
|
367
|
+
process.stderr.write(`\x1b[2m(ink UI unavailable: ${e?.message || e} — using the legacy reader)\x1b[0m\n`);
|
|
311
368
|
}
|
|
312
369
|
}
|
|
313
370
|
// ─── legacy v4 path (unchanged) ─────────────────────────────────
|
|
314
|
-
_printChatBanner(activeProvName, activeModel, readVersionFromRepo());
|
|
371
|
+
await _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
|
|
315
372
|
|
|
316
373
|
const readline = await import('node:readline');
|
|
317
374
|
// Use terminal:true when we're attached to a TTY so the prompt shows
|
|
@@ -481,8 +538,7 @@ export async function cmdChat(flags = {}) {
|
|
|
481
538
|
getSessionId: () => sessionId,
|
|
482
539
|
persistTurn,
|
|
483
540
|
accumulateUsage,
|
|
484
|
-
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
485
|
-
onCharsSent: (n) => { charsSent += n; },
|
|
541
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName), onCharsSent: (n) => { charsSent += n; }, approve: makeLegacyApprove(),
|
|
486
542
|
};
|
|
487
543
|
const runTurn = _chatRunTurnFactory({
|
|
488
544
|
ctx: _legacyCtx,
|
|
@@ -528,7 +584,7 @@ export async function cmdChat(flags = {}) {
|
|
|
528
584
|
return;
|
|
529
585
|
}
|
|
530
586
|
activeProvName = picked.provider;
|
|
531
|
-
prov = next;
|
|
587
|
+
prov = wrapInteractiveProv(next);
|
|
532
588
|
if (picked.model) activeModel = picked.model;
|
|
533
589
|
process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
|
|
534
590
|
}
|
|
@@ -541,7 +597,7 @@ export async function cmdChat(flags = {}) {
|
|
|
541
597
|
return true;
|
|
542
598
|
}
|
|
543
599
|
activeProvName = arg;
|
|
544
|
-
prov = next;
|
|
600
|
+
prov = wrapInteractiveProv(next);
|
|
545
601
|
process.stdout.write(`provider → ${arg}\n`);
|
|
546
602
|
return true;
|
|
547
603
|
}
|
|
@@ -574,7 +630,7 @@ export async function cmdChat(flags = {}) {
|
|
|
574
630
|
return true;
|
|
575
631
|
}
|
|
576
632
|
activeProvName = parsed.provider;
|
|
577
|
-
prov = next;
|
|
633
|
+
prov = wrapInteractiveProv(next);
|
|
578
634
|
}
|
|
579
635
|
activeModel = parsed.model || arg;
|
|
580
636
|
process.stdout.write(`model → ${activeModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
|
|
@@ -958,10 +1014,11 @@ export async function cmdChat(flags = {}) {
|
|
|
958
1014
|
process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
|
|
959
1015
|
}
|
|
960
1016
|
} else if (sub === 'show') {
|
|
961
|
-
if (!aname) { process.stdout.write('usage: /agent show <name
|
|
1017
|
+
if (!aname) { process.stdout.write('usage: /agent show <name> [json]\n'); return true; }
|
|
962
1018
|
const a = agentsMod.getAgent(aname, cfgDir);
|
|
963
1019
|
if (!a) process.stdout.write(`no agent "${aname}"\n`);
|
|
964
|
-
else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
|
|
1020
|
+
else if (rest[1] === 'json') process.stdout.write(JSON.stringify(a, null, 2) + '\n');
|
|
1021
|
+
else process.stdout.write(renderRecord(a, { fields: ['name', 'displayName', 'provider', 'model', 'role', 'tools', 'tags', 'iconEmoji', 'memoryWrite', 'skillWrite', 'createdAt', 'updatedAt'] }) + '\n');
|
|
965
1022
|
} else if (sub === 'add') {
|
|
966
1023
|
if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
|
|
967
1024
|
const roleText = rest.slice(1).join(' ').trim();
|
|
@@ -1209,7 +1266,8 @@ export async function cmdChat(flags = {}) {
|
|
|
1209
1266
|
try { process.stdin.pause(); } catch (_) {}
|
|
1210
1267
|
try { process.stdin.unref(); } catch (_) {}
|
|
1211
1268
|
}
|
|
1212
|
-
// /config
|
|
1269
|
+
// /config legacy path: re-run the wizard after the readline loop closes.
|
|
1270
|
+
// (Inline login is Ink-only — legacy ctx has no openPicker — so no requestLogin.)
|
|
1213
1271
|
if (_legacyCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
|
|
1214
1272
|
}
|
|
1215
1273
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// commands/chat_hardening.mjs — chat-path reliability helpers, extracted from
|
|
2
|
+
// commands/chat.mjs to keep that file under its file-size ceiling.
|
|
3
|
+
//
|
|
4
|
+
// 1. wrapInteractiveProv: wrap the active chat provider with the same
|
|
5
|
+
// transient-retry the daemon composes, so a 429/529/5xx before the first
|
|
6
|
+
// chunk is retried instead of surfacing as a chat "error: ..." line. The
|
|
7
|
+
// wrapper never retries a mid-stream error (would duplicate output).
|
|
8
|
+
// 2. makeLegacyApprove: the readline approval hook for the legacy / non-TTY
|
|
9
|
+
// chat path, so agentic sensitive tools have a fail-closed human gate.
|
|
10
|
+
|
|
11
|
+
import { withRateLimitRetry } from '../providers/retry.mjs';
|
|
12
|
+
import { makeReadlineApprove } from '../tui/terminal_approve.mjs';
|
|
13
|
+
|
|
14
|
+
// Guard-safe: returns the falsy input unchanged so the caller's
|
|
15
|
+
// unknown-provider check still fires (withRateLimitRetry(undefined) would
|
|
16
|
+
// otherwise produce a truthy "undefined+retry" wrapper).
|
|
17
|
+
export function wrapInteractiveProv(prov) {
|
|
18
|
+
return prov ? withRateLimitRetry(prov) : prov;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function makeLegacyApprove() {
|
|
22
|
+
return makeReadlineApprove();
|
|
23
|
+
}
|
package/commands/daemon.mjs
CHANGED
|
@@ -1,9 +1,70 @@
|
|
|
1
1
|
// Daemon + dashboard lifecycle commands (cmdDashboard, cmdDaemon) plus the
|
|
2
2
|
// _killPortOccupant helper, extracted from cli.mjs in Phase D3.
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
import { ensureRegistry } from '../lib/registry_boot.mjs';
|
|
5
6
|
import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
|
|
6
7
|
import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
|
|
8
|
+
import { isProcessAlive } from '../loops.mjs';
|
|
9
|
+
|
|
10
|
+
// The bare `lazyclaw daemon` runs in the foreground, so a started daemon
|
|
11
|
+
// records its pid + the actually-bound port here. status/stop read it back;
|
|
12
|
+
// the start path writes it after bind and removes it on graceful shutdown.
|
|
13
|
+
export function _daemonPidfilePath(configDir) {
|
|
14
|
+
return path.join(configDir, 'daemon.pid');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Parse the pidfile into { pid, port } or null if missing/garbage. Never
|
|
18
|
+
// throws — a corrupt pidfile is treated the same as "no daemon".
|
|
19
|
+
export function readDaemonPidfile(pidfilePath) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(pidfilePath, 'utf8');
|
|
22
|
+
const obj = JSON.parse(raw);
|
|
23
|
+
const pid = parseInt(obj?.pid, 10);
|
|
24
|
+
if (!Number.isFinite(pid)) return null;
|
|
25
|
+
const port = Number.isFinite(parseInt(obj?.port, 10)) ? parseInt(obj.port, 10) : null;
|
|
26
|
+
return { pid, port };
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Inspect the daemon pidfile and report liveness. A pidfile pointing at a
|
|
31
|
+
// dead pid is stale (the daemon crashed without removing it), so we clean it
|
|
32
|
+
// up here to keep `status` self-healing.
|
|
33
|
+
export function daemonStatus({ configDir }, deps = {}) {
|
|
34
|
+
const isAlive = deps.isAlive || isProcessAlive;
|
|
35
|
+
const pf = _daemonPidfilePath(configDir);
|
|
36
|
+
const rec = readDaemonPidfile(pf);
|
|
37
|
+
if (!rec) return { running: false, pid: null, port: null };
|
|
38
|
+
if (isAlive(rec.pid)) return { running: true, pid: rec.pid, port: rec.port };
|
|
39
|
+
// Stale pidfile — remove it so the next start/status starts clean.
|
|
40
|
+
try { fs.rmSync(pf); } catch { /* already gone */ }
|
|
41
|
+
return { running: false, pid: null, port: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// SIGTERM the recorded daemon, falling back to SIGKILL only if it ignores the
|
|
45
|
+
// graceful signal. Returns { running, pid, port, killed, exitCode }; a missing
|
|
46
|
+
// or dead pidfile is "not running" (exit 0), never an error.
|
|
47
|
+
export function daemonStop({ configDir }, deps = {}) {
|
|
48
|
+
const isAlive = deps.isAlive || isProcessAlive;
|
|
49
|
+
const kill = deps.kill || ((pid, sig) => process.kill(pid, sig));
|
|
50
|
+
const pf = _daemonPidfilePath(configDir);
|
|
51
|
+
const rec = readDaemonPidfile(pf);
|
|
52
|
+
if (!rec || !isAlive(rec.pid)) {
|
|
53
|
+
try { fs.rmSync(pf); } catch { /* nothing to clean */ }
|
|
54
|
+
return { running: false, pid: rec ? rec.pid : null, port: rec ? rec.port : null, killed: false, exitCode: 0 };
|
|
55
|
+
}
|
|
56
|
+
try { kill(rec.pid, 'SIGTERM'); } catch { /* raced with exit */ }
|
|
57
|
+
// Short grace window, then SIGKILL the holdout. This is best-effort and
|
|
58
|
+
// synchronous so the CLI exits deterministically; the daemon's own
|
|
59
|
+
// graceful-shutdown hook usually wins well inside the window.
|
|
60
|
+
if (isAlive(rec.pid)) {
|
|
61
|
+
const until = Date.now() + 1500;
|
|
62
|
+
while (isAlive(rec.pid) && Date.now() < until) { /* spin briefly */ }
|
|
63
|
+
if (isAlive(rec.pid)) { try { kill(rec.pid, 'SIGKILL'); } catch { /* gone */ } }
|
|
64
|
+
}
|
|
65
|
+
try { fs.rmSync(pf); } catch { /* removed by the daemon already */ }
|
|
66
|
+
return { running: true, pid: rec.pid, port: rec.port, killed: true, exitCode: 0 };
|
|
67
|
+
}
|
|
7
68
|
|
|
8
69
|
// Fail closed before binding the HTTP surface: the daemon/dashboard serve
|
|
9
70
|
// POST /inbound + /agent, so the global unattended-sensitive override must
|
|
@@ -134,6 +195,35 @@ export async function cmdDashboard(flags = {}) {
|
|
|
134
195
|
}
|
|
135
196
|
|
|
136
197
|
export async function cmdDaemon(flags) {
|
|
198
|
+
// cli.mjs dispatches `daemon` with only the parsed flags, so the lifecycle
|
|
199
|
+
// subcommand (status|stop|logs) is recovered from argv here. The first
|
|
200
|
+
// non-flag positional after `daemon` is the subcommand; absent it, we start
|
|
201
|
+
// the server as before.
|
|
202
|
+
const { parseArgs } = await import('../lib/args.mjs');
|
|
203
|
+
const sub = parseArgs(process.argv.slice(3)).positional[0];
|
|
204
|
+
const cfgDirEarly = path.dirname(configPath());
|
|
205
|
+
if (sub === 'status') {
|
|
206
|
+
const st = daemonStatus({ configDir: cfgDirEarly });
|
|
207
|
+
process.stdout.write(JSON.stringify(st) + '\n');
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
if (sub === 'stop') {
|
|
211
|
+
const r = daemonStop({ configDir: cfgDirEarly });
|
|
212
|
+
if (!r.running) process.stdout.write('lazyclaw daemon: not running\n');
|
|
213
|
+
else process.stdout.write(`lazyclaw daemon: stopped pid ${r.pid}${r.killed ? '' : ''}\n`);
|
|
214
|
+
process.exit(r.exitCode);
|
|
215
|
+
}
|
|
216
|
+
if (sub === 'logs') {
|
|
217
|
+
// The foreground daemon writes its boot line + access logs to stdout; a
|
|
218
|
+
// logfile only exists when it was installed as an OS service (see
|
|
219
|
+
// lib/service_install.mjs servicePaths). Point the user at whichever is
|
|
220
|
+
// applicable. No tail-follower — keep it simple.
|
|
221
|
+
const { servicePaths } = await import('../lib/service_install.mjs');
|
|
222
|
+
const logfile = servicePaths('daemon', { configDir: cfgDirEarly }).logfile;
|
|
223
|
+
if (fs.existsSync(logfile)) process.stdout.write(logfile + '\n');
|
|
224
|
+
else process.stdout.write(`No service logfile at ${logfile}.\nThe foreground daemon logs to stdout (run \`lazyclaw daemon --log info\`).\n`);
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
137
227
|
await ensureRegistry();
|
|
138
228
|
_bootGuard('daemon');
|
|
139
229
|
const sessionsMod = await import('../sessions.mjs');
|
|
@@ -245,6 +335,13 @@ export async function cmdDaemon(flags) {
|
|
|
245
335
|
costCap: costCapOrNull,
|
|
246
336
|
}) + '\n');
|
|
247
337
|
if (!once) {
|
|
338
|
+
// Record pid + the ACTUAL bound port (d.port, not the requested port,
|
|
339
|
+
// which may be 0) so `daemon status`/`daemon stop` can find us without
|
|
340
|
+
// an lsof on the port. Removed on graceful shutdown below.
|
|
341
|
+
const pidfile = _daemonPidfilePath(cfgDir);
|
|
342
|
+
try { fs.writeFileSync(pidfile, JSON.stringify({ pid: process.pid, port: d.port })); }
|
|
343
|
+
catch { /* non-fatal: the daemon still runs, just isn't stoppable by pidfile */ }
|
|
344
|
+
const removePidfile = () => { try { fs.rmSync(pidfile); } catch { /* already gone */ } };
|
|
248
345
|
// Forward SIGINT/SIGTERM to a graceful shutdown with a hard timeout
|
|
249
346
|
// (default 10 s, override with --shutdown-timeout-ms). Second signal
|
|
250
347
|
// bypasses the wait and exits immediately — the orchestrator's "I
|
|
@@ -253,7 +350,7 @@ export async function cmdDaemon(flags) {
|
|
|
253
350
|
const timeoutMs = flags['shutdown-timeout-ms'] ? parseInt(flags['shutdown-timeout-ms'], 10) : 10_000;
|
|
254
351
|
// Always-on: make an unhandled crash observable + drain sockets, then
|
|
255
352
|
// exit non-zero so a service manager restarts us (vs. silent death).
|
|
256
|
-
installCrashHandlers({ label: 'daemon', logger, stop: () => gracefulShutdown(d.server, timeoutMs) });
|
|
353
|
+
installCrashHandlers({ label: 'daemon', logger, stop: () => { removePidfile(); return gracefulShutdown(d.server, timeoutMs); } });
|
|
257
354
|
let shuttingDown = false;
|
|
258
355
|
const shutdown = async () => {
|
|
259
356
|
if (shuttingDown) {
|
|
@@ -263,6 +360,7 @@ export async function cmdDaemon(flags) {
|
|
|
263
360
|
shuttingDown = true;
|
|
264
361
|
if (logger) logger.info('shutdown.begin', { timeoutMs });
|
|
265
362
|
const result = await gracefulShutdown(d.server, timeoutMs);
|
|
363
|
+
removePidfile();
|
|
266
364
|
if (logger) logger.info('shutdown.end', result);
|
|
267
365
|
process.exit(result.forced ? 1 : 0);
|
|
268
366
|
};
|
package/commands/gateway.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
17
19
|
import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
|
|
18
20
|
import { ensureRegistry } from '../lib/registry_boot.mjs';
|
|
19
21
|
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
@@ -22,6 +24,13 @@ import { makeInboundHandler } from '../lib/inbound_client.mjs';
|
|
|
22
24
|
|
|
23
25
|
export const GATEWAY_CHANNELS = ['slack', 'telegram', 'matrix'];
|
|
24
26
|
|
|
27
|
+
// In-tree plugin channels (channels-<name>/index.mjs). These are NOT builtins:
|
|
28
|
+
// each ships as a @lazyclaw/channel-* package that exports
|
|
29
|
+
// register({ addChannel }) and wires a factory returning a channels/base.mjs
|
|
30
|
+
// Channel (start/send/stop). The gateway loads the ENABLED ones at runtime so
|
|
31
|
+
// `channels enable discord` is actually reachable instead of a no-op.
|
|
32
|
+
export const PLUGIN_CHANNELS = ['discord', 'email', 'signal', 'voice', 'whatsapp'];
|
|
33
|
+
|
|
25
34
|
// Default per-channel constructors. Each receives { handler, logger,
|
|
26
35
|
// allowlist }, must return a started channel exposing send()/stop().
|
|
27
36
|
// Injectable so tests can run the full gateway with stub transports.
|
|
@@ -47,15 +56,113 @@ const DEFAULT_FACTORIES = {
|
|
|
47
56
|
},
|
|
48
57
|
};
|
|
49
58
|
|
|
59
|
+
// Resolve an enabled plugin channel into the same gateway transport factory
|
|
60
|
+
// shape the builtins use: ({ handler, logger, allowlist }) -> a STARTED
|
|
61
|
+
// channel exposing send()/stop(). Dynamically imports channels-<name>/index.mjs
|
|
62
|
+
// and runs its register({ addChannel }) hook to capture the channel factory.
|
|
63
|
+
//
|
|
64
|
+
// A plugin that does not conform — import fails, no register export, never
|
|
65
|
+
// registers the requested name — is SKIPPED (returns null) so the gateway can
|
|
66
|
+
// log a warning and keep the other channels running. A factory that hands back
|
|
67
|
+
// the wrong shape is caught lazily, when the returned transport factory is
|
|
68
|
+
// invoked, so runGateway's per-channel try/catch turns it into a skip+warn.
|
|
69
|
+
// Resolve an in-tree adapter's runtime dependency from <cfgDir>/node_modules
|
|
70
|
+
// (where `lazyclaw channels install <name>` puts it), falling back to a bare
|
|
71
|
+
// import (dep installed alongside lazyclaw). The adapters do a bare
|
|
72
|
+
// `import('discord.js')` which Node resolves from the adapter's own location —
|
|
73
|
+
// NOT the config dir — so without this an installed dep is never found.
|
|
74
|
+
export function _makeDepLoader(cfgDir) {
|
|
75
|
+
return async (specifier) => {
|
|
76
|
+
if (cfgDir) {
|
|
77
|
+
try {
|
|
78
|
+
// Anchor resolution inside <cfgDir>/node_modules. The anchor file need
|
|
79
|
+
// not exist; createRequire only uses its directory as the base.
|
|
80
|
+
const req = createRequire(path.join(cfgDir, 'node_modules', '_anchor_.js'));
|
|
81
|
+
const resolved = req.resolve(specifier);
|
|
82
|
+
return await import(pathToFileURL(resolved).href);
|
|
83
|
+
} catch { /* fall through to a bare import */ }
|
|
84
|
+
}
|
|
85
|
+
return import(specifier);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map the .env-derived credentials into each in-tree adapter's constructor opts
|
|
90
|
+
// (the gateway loads .env into process.env at boot). discord/whatsapp also fall
|
|
91
|
+
// back to env internally, but email has NO env fallback and threw
|
|
92
|
+
// IMAP_CONFIG_MISSING when the gateway passed only { allowlist }.
|
|
93
|
+
export function _pluginChannelOpts(name, env = process.env, cfgDir = '') {
|
|
94
|
+
switch (name) {
|
|
95
|
+
case 'discord':
|
|
96
|
+
return { token: env.DISCORD_BOT_TOKEN || null };
|
|
97
|
+
case 'email':
|
|
98
|
+
return {
|
|
99
|
+
imap: {
|
|
100
|
+
host: env.EMAIL_IMAP_HOST, user: env.EMAIL_IMAP_USER, password: env.EMAIL_IMAP_PASS,
|
|
101
|
+
port: env.EMAIL_IMAP_PORT ? Number(env.EMAIL_IMAP_PORT) : undefined,
|
|
102
|
+
tls: env.EMAIL_IMAP_TLS ? env.EMAIL_IMAP_TLS !== 'false' : undefined,
|
|
103
|
+
},
|
|
104
|
+
smtp: {
|
|
105
|
+
host: env.EMAIL_SMTP_HOST || env.EMAIL_IMAP_HOST, port: env.EMAIL_SMTP_PORT ? Number(env.EMAIL_SMTP_PORT) : undefined,
|
|
106
|
+
user: env.EMAIL_SMTP_USER || env.EMAIL_IMAP_USER, pass: env.EMAIL_SMTP_PASS || env.EMAIL_IMAP_PASS,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
case 'whatsapp':
|
|
110
|
+
return { dataPath: path.join(cfgDir || '.', 'whatsapp') };
|
|
111
|
+
case 'signal':
|
|
112
|
+
return { account: env.SIGNAL_ACCOUNT || null };
|
|
113
|
+
case 'voice':
|
|
114
|
+
return { apiKey: env.OPENAI_API_KEY || null };
|
|
115
|
+
default:
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `importer` is injectable for tests. cfgDir + env let the factory resolve the
|
|
121
|
+
// adapter's runtime dep from the config dir and thread credentials in.
|
|
122
|
+
export async function _loadPluginChannel(name, { importer, cfgDir = '', env = process.env } = {}) {
|
|
123
|
+
const load = importer || ((n) => import(`../channels-${n}/index.mjs`));
|
|
124
|
+
let mod;
|
|
125
|
+
try {
|
|
126
|
+
mod = await load(name);
|
|
127
|
+
} catch {
|
|
128
|
+
return null; // missing optional dep / module: skip, do not crash the gateway.
|
|
129
|
+
}
|
|
130
|
+
if (!mod || typeof mod.register !== 'function') return null;
|
|
131
|
+
let channelFactory = null;
|
|
132
|
+
try {
|
|
133
|
+
mod.register({ addChannel: (n, factory) => { if (n === name) channelFactory = factory; } });
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (typeof channelFactory !== 'function') return null;
|
|
138
|
+
const loadDep = _makeDepLoader(cfgDir);
|
|
139
|
+
return async ({ handler, logger, allowlist }) => {
|
|
140
|
+
const ch = channelFactory({ allowlist, loadDep, ..._pluginChannelOpts(name, env, cfgDir) });
|
|
141
|
+
if (!ch || typeof ch.start !== 'function' || typeof ch.send !== 'function' || typeof ch.stop !== 'function') {
|
|
142
|
+
throw new Error(`plugin channel "${name}" does not conform to the channel interface (start/send/stop)`);
|
|
143
|
+
}
|
|
144
|
+
await ch.start(handler, { poll: true, logger });
|
|
145
|
+
return ch;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
50
149
|
// Which channels should the gateway run? --channels a,b wins; otherwise the
|
|
51
|
-
// enabled cfg.channels sections
|
|
150
|
+
// enabled cfg.channels sections we can actually run — a built-in transport OR
|
|
151
|
+
// an in-tree plugin channel.
|
|
52
152
|
export function _selectChannels(cfg, flags = {}) {
|
|
153
|
+
const runnable = (n) => GATEWAY_CHANNELS.includes(n) || PLUGIN_CHANNELS.includes(n);
|
|
53
154
|
if (flags.channels) {
|
|
54
155
|
return String(flags.channels).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
55
|
-
.filter(
|
|
156
|
+
.filter(runnable);
|
|
56
157
|
}
|
|
57
158
|
const configured = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
58
|
-
|
|
159
|
+
const isEnabled = (n) => configured[n] && configured[n].enabled !== false;
|
|
160
|
+
// Builtins first (in their canonical order, preserving existing behavior),
|
|
161
|
+
// then enabled plugin channels.
|
|
162
|
+
return [
|
|
163
|
+
...GATEWAY_CHANNELS.filter(isEnabled),
|
|
164
|
+
...PLUGIN_CHANNELS.filter(isEnabled),
|
|
165
|
+
];
|
|
59
166
|
}
|
|
60
167
|
|
|
61
168
|
// The whole gateway, with injectable deps so tests can drive it end-to-end
|
|
@@ -129,7 +236,19 @@ export async function runGateway(flags = {}, deps = {}) {
|
|
|
129
236
|
provider: flags.provider, model: flags.model,
|
|
130
237
|
});
|
|
131
238
|
try {
|
|
132
|
-
|
|
239
|
+
// Builtin transport, else resolve an enabled in-tree plugin channel and
|
|
240
|
+
// adapt it to the same factory shape. A plugin that won't load is skipped
|
|
241
|
+
// (factory === null) rather than crashing the gateway.
|
|
242
|
+
let factory = factories[name];
|
|
243
|
+
if (!factory) {
|
|
244
|
+
factory = await _loadPluginChannel(name, { importer: deps.pluginImporter, cfgDir, env: process.env });
|
|
245
|
+
if (!factory) {
|
|
246
|
+
skipped.push({ name, error: 'plugin channel did not conform (no usable register/factory)' });
|
|
247
|
+
log(`[gateway] ${name}: skipped (plugin channel did not load or does not conform)\n`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ch = await factory({
|
|
133
252
|
handler,
|
|
134
253
|
logger: (line) => log(line),
|
|
135
254
|
allowlist: allowlistArr.length ? allowlistArr : null,
|