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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// commands/setup_channels.mjs — Hermes-style "where will you run it?"
|
|
2
|
+
// onboarding step plus the outbound-webhook step, split out of
|
|
3
|
+
// commands/setup.mjs (CLAUDE.md §7: one file = one responsibility, and to
|
|
4
|
+
// keep setup.mjs under its size-gate ceiling).
|
|
5
|
+
//
|
|
6
|
+
// Credentials are written to <cfgDir>/.env (0600) — the same file the
|
|
7
|
+
// channel modules read via dotenv_min. Metadata (enabled / boundAgent) is
|
|
8
|
+
// written to cfg.channels.<name>, the exact shape daemon/routes/ops.mjs
|
|
9
|
+
// and the dashboard read. Plugin channels are recorded the same way with an
|
|
10
|
+
// honest "needs a plugin package" notice — we never pretend a channel works
|
|
11
|
+
// when it requires an uninstalled package or an external binary.
|
|
12
|
+
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
13
|
+
import { writeDotenvMerge } from '../dotenv_min.mjs';
|
|
14
|
+
import { messageAdd } from '../config_features.mjs';
|
|
15
|
+
|
|
16
|
+
// fields[].key is the answer key; .env is the env var it maps to; .secret
|
|
17
|
+
// masks it on echo; .optional lets the user skip it.
|
|
18
|
+
export const CHANNEL_CATALOG = [
|
|
19
|
+
{ name: 'slack', builtin: true, label: 'Slack',
|
|
20
|
+
fields: [
|
|
21
|
+
{ key: 'token', env: 'SLACK_BOT_TOKEN', prompt: 'Bot token (xoxb-…)', secret: true },
|
|
22
|
+
{ key: 'appToken', env: 'SLACK_APP_TOKEN', prompt: 'App-level token (xapp-…, for inbound / Socket Mode)', secret: true, optional: true },
|
|
23
|
+
] },
|
|
24
|
+
{ name: 'telegram', builtin: true, label: 'Telegram',
|
|
25
|
+
fields: [{ key: 'token', env: 'TELEGRAM_BOT_TOKEN', prompt: 'Bot token (from @BotFather)', secret: true }] },
|
|
26
|
+
{ name: 'matrix', builtin: true, label: 'Matrix',
|
|
27
|
+
fields: [
|
|
28
|
+
{ key: 'homeserver', env: 'MATRIX_HOMESERVER', prompt: 'Homeserver URL (https://matrix.org)' },
|
|
29
|
+
{ key: 'token', env: 'MATRIX_ACCESS_TOKEN', prompt: 'Access token', secret: true },
|
|
30
|
+
{ key: 'userId', env: 'MATRIX_USER_ID', prompt: 'User id (@you:matrix.org)' },
|
|
31
|
+
] },
|
|
32
|
+
{ name: 'http', builtin: true, label: 'HTTP (generic inbound endpoint)', fields: [] },
|
|
33
|
+
{ name: 'discord', builtin: false, plugin: '@lazyclaw/channel-discord', label: 'Discord',
|
|
34
|
+
fields: [{ key: 'token', env: 'DISCORD_BOT_TOKEN', prompt: 'Bot token', secret: true }] },
|
|
35
|
+
{ name: 'email', builtin: false, plugin: '@lazyclaw/channel-email', label: 'Email (IMAP/SMTP)',
|
|
36
|
+
fields: [
|
|
37
|
+
{ key: 'host', env: 'EMAIL_IMAP_HOST', prompt: 'IMAP host' },
|
|
38
|
+
{ key: 'user', env: 'EMAIL_IMAP_USER', prompt: 'IMAP user' },
|
|
39
|
+
{ key: 'pass', env: 'EMAIL_IMAP_PASS', prompt: 'IMAP password', secret: true },
|
|
40
|
+
] },
|
|
41
|
+
{ name: 'signal', builtin: false, plugin: '@lazyclaw/channel-signal', label: 'Signal (needs signal-cli)',
|
|
42
|
+
fields: [{ key: 'account', env: 'SIGNAL_ACCOUNT', prompt: 'Signal account (+15551234567)' }] },
|
|
43
|
+
{ name: 'voice', builtin: false, plugin: '@lazyclaw/channel-voice', label: 'Voice (Whisper transcription)',
|
|
44
|
+
fields: [{ key: 'apiKey', env: 'OPENAI_API_KEY', prompt: 'OpenAI API key (whisper)', secret: true }] },
|
|
45
|
+
{ name: 'whatsapp', builtin: false, plugin: '@lazyclaw/channel-whatsapp', label: 'WhatsApp (web session)',
|
|
46
|
+
fields: [] },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function channelByName(name) {
|
|
50
|
+
return CHANNEL_CATALOG.find((c) => c.name === name) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pure: turn collected answers into { envVars, channelConfig, needsPlugin }.
|
|
54
|
+
// Empty/whitespace answers are dropped so a skipped optional field doesn't
|
|
55
|
+
// write an empty env var.
|
|
56
|
+
export function buildChannelEntry(name, answers = {}) {
|
|
57
|
+
const spec = channelByName(name);
|
|
58
|
+
if (!spec) throw new Error(`unknown channel: ${name}`);
|
|
59
|
+
const envVars = {};
|
|
60
|
+
for (const f of spec.fields) {
|
|
61
|
+
const v = (answers[f.key] == null ? '' : String(answers[f.key])).trim();
|
|
62
|
+
if (v) envVars[f.env] = v;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
envVars,
|
|
66
|
+
channelConfig: { enabled: true },
|
|
67
|
+
needsPlugin: spec.builtin ? null : spec.plugin,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Side-effecting persist used by the interactive step (and unit-tested with a
|
|
72
|
+
// temp cfgDir). Writes creds to <cfgDir>/.env and metadata to cfg.channels.
|
|
73
|
+
// readConfig()/writeConfig() resolve configPath() fresh on every call (it
|
|
74
|
+
// reads LAZYCLAW_CONFIG_DIR at use-time, nothing import-cached), so they
|
|
75
|
+
// target <cfgDir>/config.json when the caller has pointed the env var there.
|
|
76
|
+
export function persistChannel(cfgDir, name, answers) {
|
|
77
|
+
const entry = buildChannelEntry(name, answers);
|
|
78
|
+
if (Object.keys(entry.envVars).length) writeDotenvMerge(cfgDir, entry.envVars);
|
|
79
|
+
const cfg = readConfig();
|
|
80
|
+
cfg.channels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
|
|
81
|
+
cfg.channels[name] = { ...(cfg.channels[name] || {}), ...entry.channelConfig };
|
|
82
|
+
writeConfig(cfg);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mask = (v) => {
|
|
87
|
+
const s = String(v);
|
|
88
|
+
return s.length <= 4 ? '••••' : `${s.slice(0, 3)}…${s.slice(-2)}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Interactive channel step. `prompt(label)` resolves to a trimmed string;
|
|
92
|
+
// `write(text)` sinks UI output (defaults to process.stdout). Returns
|
|
93
|
+
// { skipped, channel?, needsPlugin? }. Never echoes a secret field value.
|
|
94
|
+
export async function runChannelStep({ cfgDir, prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
95
|
+
const { dim, ok, warn } = colors;
|
|
96
|
+
const list = CHANNEL_CATALOG.map((c) => `${c.name}${c.builtin ? '' : ' *'}`).join(' · ');
|
|
97
|
+
write(` ${dim('Where will you talk to the agent? Built-in: slack/telegram/matrix/http. (* = needs a plugin package.)')}\n`);
|
|
98
|
+
write(` ${dim(list)}\n\n`);
|
|
99
|
+
const pick = (await prompt(' channel (Enter to skip): ')).trim().toLowerCase();
|
|
100
|
+
if (!pick) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
101
|
+
const spec = channelByName(pick);
|
|
102
|
+
if (!spec) { write(` ${warn('skipped:')} unknown channel "${pick}"\n\n`); return { skipped: true }; }
|
|
103
|
+
|
|
104
|
+
const answers = {};
|
|
105
|
+
for (const f of spec.fields) {
|
|
106
|
+
const v = (await prompt(` ${spec.label} — ${f.prompt}${f.optional ? ' (optional)' : ''}: `)).trim();
|
|
107
|
+
if (v) answers[f.key] = v;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const entry = persistChannel(cfgDir, pick, answers);
|
|
111
|
+
const credKeys = Object.keys(entry.envVars);
|
|
112
|
+
write(` ${ok('✓ channel enabled:')} ${pick} ${credKeys.length ? dim(`creds: ${credKeys.join(', ')}`) : ''}\n`);
|
|
113
|
+
for (const f of spec.fields) {
|
|
114
|
+
if (f.secret && answers[f.key]) write(` ${dim(`${f.env} = ${mask(answers[f.key])} (stored in ${cfgDir}/.env, 0600)`)}\n`);
|
|
115
|
+
}
|
|
116
|
+
if (entry.needsPlugin) {
|
|
117
|
+
write(` ${warn('plugin required:')} ${entry.needsPlugin}\n`);
|
|
118
|
+
write(` ${dim(`install with: lazyclaw channels install ${entry.needsPlugin} (or npm install --prefix ${cfgDir} ${entry.needsPlugin})`)}\n`);
|
|
119
|
+
}
|
|
120
|
+
write('\n');
|
|
121
|
+
return { skipped: false, channel: pick, needsPlugin: entry.needsPlugin };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
124
|
+
return { skipped: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Outbound webhook step (moved verbatim-in-spirit from setup.mjs).
|
|
129
|
+
export async function runWebhookStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
130
|
+
const { dim, ok, warn } = colors;
|
|
131
|
+
write(` ${dim('Outbound webhook for `lazyclaw message send <name> <text>`. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
|
|
132
|
+
const hookName = (await prompt(' webhook name (Enter to skip): ')).trim();
|
|
133
|
+
if (!hookName) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
134
|
+
const hookUrl = (await prompt(' webhook URL: ')).trim();
|
|
135
|
+
if (!hookUrl) { write(` ${warn('skipped:')} URL required\n\n`); return { skipped: true }; }
|
|
136
|
+
try {
|
|
137
|
+
const fresh = readConfig();
|
|
138
|
+
messageAdd(fresh, hookName, hookUrl);
|
|
139
|
+
writeConfig(fresh);
|
|
140
|
+
write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
|
|
141
|
+
return { skipped: false, name: hookName };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
144
|
+
return { skipped: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Context-window step (asked right after the model pick): how much past
|
|
149
|
+
// conversation to keep each turn — a sliding history budget, NOT the model's
|
|
150
|
+
// hard limit. Enter on both prompts keeps the defaults.
|
|
151
|
+
export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
152
|
+
const { dim, ok } = colors;
|
|
153
|
+
const cf = await import('../config_features.mjs');
|
|
154
|
+
const cur = cf.chatWindowGet(readConfig());
|
|
155
|
+
write(` ${dim(`Context window — how much past conversation to send each turn (sliding budget, not the model's hard limit). Now: ${cur.turns} turns · ${cur.tokens} tokens.`)}\n\n`);
|
|
156
|
+
const turnsRaw = (await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim();
|
|
157
|
+
const tokensRaw = (await prompt(` token budget (Enter = ${cur.tokens}): `)).trim();
|
|
158
|
+
if (!turnsRaw && !tokensRaw) { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
|
|
159
|
+
const turns = parseInt(turnsRaw, 10);
|
|
160
|
+
const tokens = parseInt(tokensRaw, 10);
|
|
161
|
+
const cfg = readConfig();
|
|
162
|
+
cf.chatWindowSet(cfg, {
|
|
163
|
+
...(Number.isFinite(turns) && turns > 0 ? { turns } : {}),
|
|
164
|
+
...(Number.isFinite(tokens) && tokens >= 256 ? { tokens } : {}),
|
|
165
|
+
});
|
|
166
|
+
writeConfig(cfg);
|
|
167
|
+
const w = cf.chatWindowGet(cfg);
|
|
168
|
+
write(` ${ok('✓ context window:')} ${w.turns} turns · ${w.tokens} tokens\n`);
|
|
169
|
+
write(` ${dim('change later: /context turns <N> | tokens <N>')}\n\n`);
|
|
170
|
+
return { skipped: false, ...w };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Optional orchestration step: enable the multi-agent planner+workers pipeline.
|
|
174
|
+
// Defaults the planner to the configured provider and workers to [planner];
|
|
175
|
+
// the user can layer more workers later via /orchestrator or the CLI.
|
|
176
|
+
export async function runOrchestratorStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
177
|
+
const { dim, ok } = colors;
|
|
178
|
+
const cf = await import('../config_features.mjs');
|
|
179
|
+
// Reuse the same searchable provider/model picker as the model step, so
|
|
180
|
+
// planner + workers are chosen from a list (arrow keys / filter), not typed.
|
|
181
|
+
const { _pickProviderInteractive } = await import('../tui/pickers.mjs');
|
|
182
|
+
const toSpec = (p) => (p && p.provider) ? (p.model ? `${p.provider}:${p.model}` : p.provider) : null;
|
|
183
|
+
write(` ${dim('Multi-agent: a planner splits your task into subtasks, workers run them in parallel, then a synthesis step merges the results. Skip for a single agent.')}\n\n`);
|
|
184
|
+
const yn = (await prompt(' enable orchestration? [y/N] ')).trim().toLowerCase();
|
|
185
|
+
if (yn !== 'y' && yn !== 'yes') { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
186
|
+
const cfg = readConfig();
|
|
187
|
+
const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
|
|
188
|
+
// Planner — pick from the provider/model list (Esc keeps the default).
|
|
189
|
+
write(` ${dim('Pick the PLANNER (decomposes the task):')}\n`);
|
|
190
|
+
const planner = toSpec(await _pickProviderInteractive()) || base;
|
|
191
|
+
// Workers — pick one or more from the same list.
|
|
192
|
+
const workers = [];
|
|
193
|
+
let more = true;
|
|
194
|
+
while (more) {
|
|
195
|
+
write(` ${dim(`Pick a WORKER (runs the subtasks)${workers.length ? ` — ${workers.length} added` : ''}:`)}\n`);
|
|
196
|
+
const spec = toSpec(await _pickProviderInteractive());
|
|
197
|
+
if (spec && !workers.includes(spec)) workers.push(spec);
|
|
198
|
+
more = ((await prompt(' add another worker? [y/N] ')).trim().toLowerCase().startsWith('y'));
|
|
199
|
+
}
|
|
200
|
+
if (!workers.length) workers.push(planner);
|
|
201
|
+
cf.orchestratorSet(cfg, { planner, workers });
|
|
202
|
+
cf.orchestratorEnable(cfg, true);
|
|
203
|
+
writeConfig(cfg);
|
|
204
|
+
write(` ${ok('✓ orchestration enabled:')} planner ${planner} · workers ${workers.join(', ')}\n`);
|
|
205
|
+
write(` ${dim('change later: /orchestrator or lazyclaw orchestrator status')}\n\n`);
|
|
206
|
+
return { skipped: false, planner, workers };
|
|
207
|
+
}
|
package/config_features.mjs
CHANGED
|
@@ -239,3 +239,109 @@ export async function messageSend(cfg, name, text, opts = {}) {
|
|
|
239
239
|
}
|
|
240
240
|
return { ok: true, kind: hook.kind, status: res.status };
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// ── Channels — built-in channel config (cfg.channels.<name>) ────────────
|
|
244
|
+
// KNOWN_CHANNELS mirrors channels/ (built-in) + channels-* (plugins). Single
|
|
245
|
+
// source of truth: the daemon /channels route, the CLI `channels` command, and
|
|
246
|
+
// the in-chat /channels slash all read it so the three views can't drift.
|
|
247
|
+
export const KNOWN_CHANNELS = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
|
|
248
|
+
|
|
249
|
+
// A channel counts as "configured" when it has a cfg.channels.<name> section
|
|
250
|
+
// or a legacy <name>-bot-token / <name>-token key. Returns one row per
|
|
251
|
+
// configured channel: { name, enabled, boundAgent, lastInboundAt }. Matches
|
|
252
|
+
// the daemon /channels route's enabled semantics exactly.
|
|
253
|
+
export function channelStatusList(cfg) {
|
|
254
|
+
const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const name of KNOWN_CHANNELS) {
|
|
257
|
+
const sec = chCfg[name];
|
|
258
|
+
if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
|
|
259
|
+
out.push({
|
|
260
|
+
name,
|
|
261
|
+
enabled: !!(sec && (sec.enabled !== false)),
|
|
262
|
+
boundAgent: sec?.agent || sec?.boundAgent || null,
|
|
263
|
+
lastInboundAt: sec?.lastInboundAt || null,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Surface any additional configured channels not in the known list.
|
|
267
|
+
for (const name of Object.keys(chCfg)) {
|
|
268
|
+
if (KNOWN_CHANNELS.includes(name)) continue;
|
|
269
|
+
const sec = chCfg[name] || {};
|
|
270
|
+
out.push({
|
|
271
|
+
name,
|
|
272
|
+
enabled: sec.enabled !== false,
|
|
273
|
+
boundAgent: sec.agent || sec.boundAgent || null,
|
|
274
|
+
lastInboundAt: sec.lastInboundAt || null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Enable/disable a channel. Mutates cfg.channels.<name>.enabled; the caller
|
|
281
|
+
// persists via writeConfig. Returns cfg for chaining.
|
|
282
|
+
export function channelSetEnabled(cfg, name, enabled) {
|
|
283
|
+
if (!name) throw new Error('channel name is required');
|
|
284
|
+
cfg.channels = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
285
|
+
cfg.channels[name] = { ...(cfg.channels[name] || {}), enabled: !!enabled };
|
|
286
|
+
return cfg;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Chat context window (cfg.chat.window{Turns,Tokens}) ─────────────────
|
|
290
|
+
// The sliding history budget sent to the model each turn (NOT the model's hard
|
|
291
|
+
// context limit). Shared by the /context slash, the setup step, the status bar,
|
|
292
|
+
// and applyChatWindow so all four agree. Defaults mirror chat_window.mjs.
|
|
293
|
+
const _CTX_DEFAULT_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
|
|
294
|
+
const _CTX_DEFAULT_TOKENS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
|
|
295
|
+
export function chatWindowGet(cfg) {
|
|
296
|
+
const c = (cfg && cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
|
|
297
|
+
return { turns: Number(c.windowTurns) || _CTX_DEFAULT_TURNS, tokens: Number(c.windowTokens) || _CTX_DEFAULT_TOKENS };
|
|
298
|
+
}
|
|
299
|
+
export function chatWindowSet(cfg, { turns, tokens } = {}) {
|
|
300
|
+
cfg.chat = (cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
|
|
301
|
+
if (turns !== undefined) cfg.chat.windowTurns = turns;
|
|
302
|
+
if (tokens !== undefined) cfg.chat.windowTokens = tokens;
|
|
303
|
+
return cfg;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Orchestrator — multi-agent config (cfg.orchestrator) ────────────────
|
|
307
|
+
// Shared by the setup wizard, the /orchestrator slash, and the CLI so the
|
|
308
|
+
// "planner + workers" config has one shape. Orchestration is ACTIVE only when
|
|
309
|
+
// cfg.provider === 'orchestrator' AND there is at least one worker.
|
|
310
|
+
export function orchestratorGet(cfg) {
|
|
311
|
+
const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
|
|
312
|
+
const workers = Array.isArray(o.workers) ? o.workers : [];
|
|
313
|
+
return {
|
|
314
|
+
planner: o.planner || null,
|
|
315
|
+
workers,
|
|
316
|
+
maxSubtasks: Number.isFinite(o.maxSubtasks) ? o.maxSubtasks : 5,
|
|
317
|
+
active: cfg.provider === 'orchestrator' && workers.length > 0,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Merge planner / workers / maxSubtasks into cfg.orchestrator (only the keys
|
|
322
|
+
// provided). Returns cfg.
|
|
323
|
+
export function orchestratorSet(cfg, { planner, workers, maxSubtasks } = {}) {
|
|
324
|
+
cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
|
|
325
|
+
if (planner !== undefined) cfg.orchestrator.planner = planner;
|
|
326
|
+
if (workers !== undefined) cfg.orchestrator.workers = workers;
|
|
327
|
+
if (maxSubtasks !== undefined) cfg.orchestrator.maxSubtasks = maxSubtasks;
|
|
328
|
+
return cfg;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Turn orchestration on/off by routing cfg.provider. Enabling stashes the
|
|
332
|
+
// previous real provider so disabling can restore it (else falls back to the
|
|
333
|
+
// planner's base provider, then claude-cli).
|
|
334
|
+
export function orchestratorEnable(cfg, enabled) {
|
|
335
|
+
if (enabled) {
|
|
336
|
+
if (cfg.provider && cfg.provider !== 'orchestrator') {
|
|
337
|
+
cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
|
|
338
|
+
cfg.orchestrator._prevProvider = cfg.provider;
|
|
339
|
+
}
|
|
340
|
+
cfg.provider = 'orchestrator';
|
|
341
|
+
} else {
|
|
342
|
+
const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
|
|
343
|
+
const plannerBase = String(o.planner || '').split(':')[0];
|
|
344
|
+
cfg.provider = o._prevProvider || plannerBase || 'claude-cli';
|
|
345
|
+
}
|
|
346
|
+
return cfg;
|
|
347
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// daemon/lib/inbound_dedup.mjs — idempotency store for POST /inbound.
|
|
2
|
+
//
|
|
3
|
+
// A channel retry (Slack event redelivery, a listener restart replaying its
|
|
4
|
+
// backlog, app_mention+message double-fire arriving from two listener
|
|
5
|
+
// processes) must not run the provider twice or append duplicate turns to the
|
|
6
|
+
// session. Callers claim `${channel}:${messageId}` BEFORE persisting the user
|
|
7
|
+
// turn, record the reply after, and a duplicate replays the recorded reply.
|
|
8
|
+
//
|
|
9
|
+
// Persistence: append-only JSONL at <cfgDir>/inbound_seen.jsonl (0600 — the
|
|
10
|
+
// recorded replies are conversation content), loaded on open, compacted to
|
|
11
|
+
// the newest `cap` entries when the file grows past 4×cap lines. Pending
|
|
12
|
+
// (claimed-but-unrecorded) keys are memory-only with a TTL so a crash between
|
|
13
|
+
// claim and record can never permanently wedge a message id.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CAP = 500;
|
|
19
|
+
const DEFAULT_PENDING_TTL_MS = 120_000;
|
|
20
|
+
// Replays only need enough of the reply to repost it; cap what we persist so
|
|
21
|
+
// a pathological multi-MB reply can't balloon the store.
|
|
22
|
+
const MAX_PERSISTED_REPLY = 16_384;
|
|
23
|
+
|
|
24
|
+
// One instance per config dir — the daemon is long-lived and the store keeps
|
|
25
|
+
// its working set in memory; reopening per request would re-read the file.
|
|
26
|
+
const _instances = new Map();
|
|
27
|
+
export function _resetDedupCache() { _instances.clear(); }
|
|
28
|
+
|
|
29
|
+
export function openDedup(cfgDir, { cap = DEFAULT_CAP, pendingTtlMs = DEFAULT_PENDING_TTL_MS, now = Date.now } = {}) {
|
|
30
|
+
const existing = _instances.get(cfgDir);
|
|
31
|
+
if (existing) return existing;
|
|
32
|
+
|
|
33
|
+
const file = path.join(cfgDir, 'inbound_seen.jsonl');
|
|
34
|
+
/** @type {Map<string, {reply: string, threadId: string|null, sessionId: string|null, at: number}>} */
|
|
35
|
+
const entries = new Map(); // insertion order == age order
|
|
36
|
+
/** @type {Map<string, number>} pending claim -> claimed-at ms */
|
|
37
|
+
const pending = new Map();
|
|
38
|
+
|
|
39
|
+
const load = () => {
|
|
40
|
+
let raw;
|
|
41
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
42
|
+
catch { return; }
|
|
43
|
+
for (const line of raw.split('\n')) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
appendedLines++;
|
|
46
|
+
let rec;
|
|
47
|
+
try { rec = JSON.parse(line); } catch { continue; } // skip corrupt lines
|
|
48
|
+
if (!rec || typeof rec.key !== 'string') continue;
|
|
49
|
+
entries.delete(rec.key); // re-insert to refresh age order
|
|
50
|
+
entries.set(rec.key, { reply: rec.reply ?? '', threadId: rec.threadId ?? null, sessionId: rec.sessionId ?? null, at: rec.at ?? 0 });
|
|
51
|
+
}
|
|
52
|
+
trim();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const trim = () => {
|
|
56
|
+
while (entries.size > cap) {
|
|
57
|
+
const oldest = entries.keys().next().value;
|
|
58
|
+
entries.delete(oldest);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Appended-lines counter (seeded by load) instead of re-reading the file on
|
|
63
|
+
// every record — the store may see one append per inbound message.
|
|
64
|
+
let appendedLines = 0;
|
|
65
|
+
const compactIfNeeded = () => {
|
|
66
|
+
if (appendedLines <= cap * 4) return;
|
|
67
|
+
const out = [...entries.entries()]
|
|
68
|
+
.map(([key, e]) => JSON.stringify({ key, ...e }))
|
|
69
|
+
.join('\n') + '\n';
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(file, out, { mode: 0o600 });
|
|
72
|
+
appendedLines = entries.size;
|
|
73
|
+
} catch { /* compaction is best-effort */ }
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const store = {
|
|
77
|
+
claim(key) {
|
|
78
|
+
const hit = entries.get(key);
|
|
79
|
+
if (hit) return { dup: true, pending: false, entry: hit };
|
|
80
|
+
const claimedAt = pending.get(key);
|
|
81
|
+
if (claimedAt != null && now() - claimedAt < pendingTtlMs) {
|
|
82
|
+
return { dup: true, pending: true, entry: null };
|
|
83
|
+
}
|
|
84
|
+
pending.set(key, now());
|
|
85
|
+
return { dup: false, pending: false, entry: null };
|
|
86
|
+
},
|
|
87
|
+
record(key, { reply = '', threadId = null, sessionId = null } = {}) {
|
|
88
|
+
pending.delete(key);
|
|
89
|
+
const bounded = String(reply).length > MAX_PERSISTED_REPLY
|
|
90
|
+
? String(reply).slice(0, MAX_PERSISTED_REPLY) + '\n…(truncated for replay)'
|
|
91
|
+
: reply;
|
|
92
|
+
const entry = { reply: bounded, threadId, sessionId, at: now() };
|
|
93
|
+
entries.delete(key);
|
|
94
|
+
entries.set(key, entry);
|
|
95
|
+
trim();
|
|
96
|
+
try {
|
|
97
|
+
fs.appendFileSync(file, JSON.stringify({ key, ...entry }) + '\n', { mode: 0o600 });
|
|
98
|
+
appendedLines++;
|
|
99
|
+
} catch { /* persistence is best-effort; memory dedup still holds */ }
|
|
100
|
+
compactIfNeeded();
|
|
101
|
+
},
|
|
102
|
+
release(key) { pending.delete(key); },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
load();
|
|
106
|
+
_instances.set(cfgDir, store);
|
|
107
|
+
return store;
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// daemon/lib/learn_queue.mjs — bounded, serialised runner for the /inbound
|
|
2
|
+
// post-task learning hook.
|
|
3
|
+
//
|
|
4
|
+
// runLearning fires up to two trainer LLM completions (skill synthesis + the
|
|
5
|
+
// user model) and, with trainer 'auto', may spawn a claude-cli subprocess —
|
|
6
|
+
// per call, with no throttle of its own. A channel message burst hitting
|
|
7
|
+
// POST /inbound must not fan that out unbounded, so learning jobs run one at
|
|
8
|
+
// a time and the waiting line is capped: when it is full the newest job is
|
|
9
|
+
// DROPPED (learning is best-effort; the trajectory of a dropped turn is
|
|
10
|
+
// simply not recorded — the next turn learns again).
|
|
11
|
+
|
|
12
|
+
const MAX_DEPTH = 8;
|
|
13
|
+
|
|
14
|
+
let _running = false;
|
|
15
|
+
const _waiting = [];
|
|
16
|
+
let _dropped = 0;
|
|
17
|
+
|
|
18
|
+
function _drain() {
|
|
19
|
+
if (_running) return;
|
|
20
|
+
const job = _waiting.shift();
|
|
21
|
+
if (!job) return;
|
|
22
|
+
_running = true;
|
|
23
|
+
Promise.resolve()
|
|
24
|
+
.then(job)
|
|
25
|
+
.catch(() => { /* learning is best-effort */ })
|
|
26
|
+
.finally(() => { _running = false; _drain(); });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Enqueue a learning job (a function returning a promise). Returns true when
|
|
30
|
+
// accepted, false when the queue was full and the job was dropped.
|
|
31
|
+
export function enqueueLearning(job) {
|
|
32
|
+
if (_waiting.length >= MAX_DEPTH) {
|
|
33
|
+
_dropped++;
|
|
34
|
+
if (_dropped === 1 || _dropped % 50 === 0) {
|
|
35
|
+
process.stderr.write(`[learn] queue full — dropped ${_dropped} learning job(s) so far (burst protection)\n`);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
_waiting.push(job);
|
|
40
|
+
_drain();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Test hooks.
|
|
45
|
+
export function _learnQueueStats() { return { running: _running, waiting: _waiting.length, dropped: _dropped }; }
|
|
46
|
+
export function _resetLearnQueue() { _waiting.length = 0; _running = false; _dropped = 0; }
|
package/daemon/routes/_deps.mjs
CHANGED
|
@@ -34,3 +34,9 @@ export { resolveProvider } from '../lib/provider.mjs';
|
|
|
34
34
|
// persistent thread/session and re-point them across channels.
|
|
35
35
|
export { openThreads } from '../../channels/threads.mjs';
|
|
36
36
|
export { handoffWithRollback } from '../../channels/handoff.mjs';
|
|
37
|
+
// Phase 4 — /inbound idempotency: dedup retried/redelivered channel messages
|
|
38
|
+
// by their native message id so the provider runs once per message.
|
|
39
|
+
export { openDedup } from '../lib/inbound_dedup.mjs';
|
|
40
|
+
// Phase 4 — serialised, depth-capped runner for the /inbound learning hook
|
|
41
|
+
// (a message burst must not fan out unbounded trainer calls).
|
|
42
|
+
export { enqueueLearning } from '../lib/learn_queue.mjs';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Daemon route handlers (conversation), extracted verbatim from makeHandler (D5).
|
|
2
2
|
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
3
|
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
-
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider, openThreads, handoffWithRollback } from './_deps.mjs';
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider, openThreads, handoffWithRollback, openDedup, enqueueLearning } from './_deps.mjs';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
// F5 — mint a fresh session id for a newly-seen channel:externalId binding.
|
|
@@ -165,6 +165,35 @@ export async function inbound(c) {
|
|
|
165
165
|
const cfgDir = ctx.sessionsDirGetter();
|
|
166
166
|
const channel = (typeof body.channel === 'string' && body.channel) ? body.channel : null;
|
|
167
167
|
const externalId = (body.externalId != null && String(body.externalId)) ? String(body.externalId) : null;
|
|
168
|
+
// Phase 4 — idempotency. When the relay supplies the native message
|
|
169
|
+
// id, claim a key BEFORE any turn is persisted: a recorded duplicate
|
|
170
|
+
// replays the prior reply (no provider call, no double appendTurn);
|
|
171
|
+
// an in-flight duplicate answers empty so the listener stays silent
|
|
172
|
+
// while the first request finishes. The key is scoped to the
|
|
173
|
+
// CONVERSATION (`channel:externalId:messageId`) so a colliding or
|
|
174
|
+
// forged messageId from another conversation can never replay this
|
|
175
|
+
// one's reply or sessionId.
|
|
176
|
+
const messageId = (body.messageId != null && String(body.messageId)) ? String(body.messageId) : null;
|
|
177
|
+
let dedup = null;
|
|
178
|
+
let dedupKey = null;
|
|
179
|
+
if (channel && messageId) {
|
|
180
|
+
dedup = openDedup(cfgDir);
|
|
181
|
+
dedupKey = `${channel}:${externalId || ''}:${messageId}`;
|
|
182
|
+
const seen = dedup.claim(dedupKey);
|
|
183
|
+
if (seen.dup) {
|
|
184
|
+
if (seen.pending) return writeJson(res, 200, { reply: '', threadId: body.threadId || null, duplicate: true });
|
|
185
|
+
const e = seen.entry;
|
|
186
|
+
const dupOut = { reply: e.reply, threadId: e.threadId, duplicate: true };
|
|
187
|
+
if (e.sessionId) dupOut.sessionId = e.sessionId;
|
|
188
|
+
return writeJson(res, 200, dupOut);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Everything from here to record() must free the pending claim on
|
|
192
|
+
// ANY failure (not just provider errors) — otherwise a thrown
|
|
193
|
+
// loadTurns/appendTurn would wedge the message id for the whole
|
|
194
|
+
// pending TTL and retries would be silently dropped.
|
|
195
|
+
let dedupRecorded = false;
|
|
196
|
+
try {
|
|
168
197
|
let threads = null;
|
|
169
198
|
let bound = null;
|
|
170
199
|
let sessionId = null;
|
|
@@ -193,6 +222,8 @@ export async function inbound(c) {
|
|
|
193
222
|
{ apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
|
|
194
223
|
)) acc += chunk;
|
|
195
224
|
} catch (err) {
|
|
225
|
+
// A provider failure must stay retryable, not poison the message
|
|
226
|
+
// id — the outer finally releases the claim.
|
|
196
227
|
const m = statusForProviderError(err);
|
|
197
228
|
return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
|
|
198
229
|
}
|
|
@@ -211,16 +242,45 @@ export async function inbound(c) {
|
|
|
211
242
|
}
|
|
212
243
|
const out = { reply: acc, threadId: bound ? bound.threadId : (body.threadId || null) };
|
|
213
244
|
if (sessionId) out.sessionId = sessionId;
|
|
245
|
+
if (dedup) { dedup.record(dedupKey, { reply: acc, threadId: out.threadId, sessionId }); dedupRecorded = true; }
|
|
246
|
+
if (sessionId) {
|
|
247
|
+
// Phase 4 — close the post-task learning loop on channel turns,
|
|
248
|
+
// mirroring the chat REPL's fire-and-forget hook (run_turn.mjs).
|
|
249
|
+
// Only session-bound turns learn (a stateless one-shot relay is
|
|
250
|
+
// not a conversation); trainer resolution inside runLearning
|
|
251
|
+
// handles cfg.trainer 'auto' -> claude-cli ($0) routing. The
|
|
252
|
+
// dedup short-circuit above guarantees at most one learning pass
|
|
253
|
+
// per native message id, and enqueueLearning serialises the runs
|
|
254
|
+
// (concurrency 1, bounded depth) so a message burst can't fan out
|
|
255
|
+
// unbounded trainer LLM calls / claude-cli subprocesses.
|
|
256
|
+
const learnTurns = [
|
|
257
|
+
{ agent: 'user', text, ts: new Date().toISOString() },
|
|
258
|
+
{ agent: 'chat', text: acc, ts: new Date().toISOString() },
|
|
259
|
+
];
|
|
260
|
+
enqueueLearning(() =>
|
|
261
|
+
import('../../mas/learning.mjs')
|
|
262
|
+
.then((mod) => mod.runLearning('post-task', {
|
|
263
|
+
agent: { name: 'chat', provider: provName, model: body.model || cfg.model, role: '' },
|
|
264
|
+
task: { id: sessionId, title: '(channel turn)', turns: learnTurns },
|
|
265
|
+
configDir: cfgDir,
|
|
266
|
+
cfg,
|
|
267
|
+
})));
|
|
268
|
+
}
|
|
214
269
|
return writeJson(res, 200, out);
|
|
270
|
+
} finally {
|
|
271
|
+
if (dedup && !dedupRecorded) dedup.release(dedupKey);
|
|
272
|
+
}
|
|
215
273
|
}
|
|
216
274
|
|
|
217
275
|
export async function handoff(c) {
|
|
218
276
|
const { ctx, res, req } = c;
|
|
219
277
|
// F6 — re-point a thread to a new channel/externalId so a later
|
|
220
278
|
// inbound on the target resumes the SAME session (context follows).
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// a
|
|
279
|
+
// When the in-process gateway registered a live sender for the
|
|
280
|
+
// target channel (ctx.channelSenders), the target gets a resume
|
|
281
|
+
// marker and a FAILED notify rolls the binding back (502). Without
|
|
282
|
+
// a sender (bare daemon) the migration persists silently — the
|
|
283
|
+
// session still follows on the next inbound.
|
|
224
284
|
let body;
|
|
225
285
|
try { body = await readJson(req); }
|
|
226
286
|
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
@@ -232,10 +292,14 @@ export async function handoff(c) {
|
|
|
232
292
|
}
|
|
233
293
|
const cfgDir = ctx.sessionsDirGetter();
|
|
234
294
|
const threads = openThreads(cfgDir);
|
|
295
|
+
const liveSend = (ctx.channelSenders && typeof ctx.channelSenders.get === 'function')
|
|
296
|
+
? ctx.channelSenders.get(target)
|
|
297
|
+
: undefined;
|
|
235
298
|
try {
|
|
236
299
|
const next = await handoffWithRollback({
|
|
237
300
|
threads, threadId, target, externalId,
|
|
238
301
|
note: typeof body.note === 'string' ? body.note : '',
|
|
302
|
+
send: liveSend,
|
|
239
303
|
});
|
|
240
304
|
return writeJson(res, 200, {
|
|
241
305
|
threadId: next.threadId, channel: next.channel,
|
package/daemon/routes/ops.mjs
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
4
|
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
5
|
|
|
6
|
+
// Channel meta + status now live in config_features.mjs (single source for the
|
|
7
|
+
// daemon route, the CLI `channels` command, and the /channels slash). Re-export
|
|
8
|
+
// KNOWN_CHANNELS so existing importers (the drift-guard test) keep working.
|
|
9
|
+
export { KNOWN_CHANNELS } from '../../config_features.mjs';
|
|
10
|
+
import { channelStatusList } from '../../config_features.mjs';
|
|
11
|
+
|
|
6
12
|
export async function trainerStatus(c) {
|
|
7
13
|
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
14
|
// Reads cfg.trainer.{provider, model, schedule, budget, recipe}
|
|
@@ -138,36 +144,10 @@ export async function sandboxUse(c) {
|
|
|
138
144
|
|
|
139
145
|
export async function channels(c) {
|
|
140
146
|
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
141
|
-
// Aggregate cfg.channels.<name>
|
|
142
|
-
//
|
|
143
|
-
// each channel module's shape.
|
|
147
|
+
// Aggregate cfg.channels.<name> via the shared status helper so the
|
|
148
|
+
// dashboard, CLI, and /channels slash all report identically.
|
|
144
149
|
const cfg = ctx.readConfig();
|
|
145
|
-
|
|
146
|
-
// Known built-in channel names (matches channels/ + channels-*).
|
|
147
|
-
const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
|
|
148
|
-
const out = [];
|
|
149
|
-
for (const name of KNOWN) {
|
|
150
|
-
const sec = chCfg[name];
|
|
151
|
-
if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
|
|
152
|
-
out.push({
|
|
153
|
-
name,
|
|
154
|
-
enabled: !!(sec && (sec.enabled !== false)),
|
|
155
|
-
lastInboundAt: sec?.lastInboundAt || null,
|
|
156
|
-
boundAgent: sec?.agent || sec?.boundAgent || null,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
// Surface any additional configured channels we didn't enumerate.
|
|
160
|
-
for (const name of Object.keys(chCfg)) {
|
|
161
|
-
if (KNOWN.includes(name)) continue;
|
|
162
|
-
const sec = chCfg[name] || {};
|
|
163
|
-
out.push({
|
|
164
|
-
name,
|
|
165
|
-
enabled: sec.enabled !== false,
|
|
166
|
-
lastInboundAt: sec.lastInboundAt || null,
|
|
167
|
-
boundAgent: sec.agent || sec.boundAgent || null,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
return writeJson(res, 200, { channels: out });
|
|
150
|
+
return writeJson(res, 200, { channels: channelStatusList(cfg) });
|
|
171
151
|
}
|
|
172
152
|
|
|
173
153
|
export async function indexRebuild(c) {
|