lazyclaw 6.3.1 → 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 +90 -33
- 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 +196 -80
- 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
|
@@ -9,12 +9,20 @@
|
|
|
9
9
|
// and the dashboard read. Plugin channels are recorded the same way with an
|
|
10
10
|
// honest "needs a plugin package" notice — we never pretend a channel works
|
|
11
11
|
// when it requires an uninstalled package or an external binary.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
12
14
|
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
13
15
|
import { writeDotenvMerge } from '../dotenv_min.mjs';
|
|
14
16
|
import { messageAdd } from '../config_features.mjs';
|
|
15
17
|
|
|
16
18
|
// fields[].key is the answer key; .env is the env var it maps to; .secret
|
|
17
19
|
// masks it on echo; .optional lets the user skip it.
|
|
20
|
+
//
|
|
21
|
+
// The five non-builtin channels are NOT separate npm packages — they ship
|
|
22
|
+
// IN-TREE under channels-<name>/ and the gateway runs them directly. What they
|
|
23
|
+
// need is a RUNTIME dependency: an npm package (`deps`) installed into the
|
|
24
|
+
// config dir, or an external `binary` on PATH. (The old catalog pointed at
|
|
25
|
+
// unpublished `@lazyclaw/channel-*` packages, so `channels install` 404'd.)
|
|
18
26
|
export const CHANNEL_CATALOG = [
|
|
19
27
|
{ name: 'slack', builtin: true, label: 'Slack',
|
|
20
28
|
fields: [
|
|
@@ -30,29 +38,62 @@ export const CHANNEL_CATALOG = [
|
|
|
30
38
|
{ key: 'userId', env: 'MATRIX_USER_ID', prompt: 'User id (@you:matrix.org)' },
|
|
31
39
|
] },
|
|
32
40
|
{ name: 'http', builtin: true, label: 'HTTP (generic inbound endpoint)', fields: [] },
|
|
33
|
-
{ name: 'discord', builtin: false,
|
|
41
|
+
{ name: 'discord', builtin: false, deps: ['discord.js'], label: 'Discord',
|
|
34
42
|
fields: [{ key: 'token', env: 'DISCORD_BOT_TOKEN', prompt: 'Bot token', secret: true }] },
|
|
35
|
-
{ name: 'email', builtin: false,
|
|
43
|
+
{ name: 'email', builtin: false, deps: ['node-imap', 'nodemailer', 'mailparser'], label: 'Email (IMAP/SMTP)',
|
|
36
44
|
fields: [
|
|
37
45
|
{ key: 'host', env: 'EMAIL_IMAP_HOST', prompt: 'IMAP host' },
|
|
38
46
|
{ key: 'user', env: 'EMAIL_IMAP_USER', prompt: 'IMAP user' },
|
|
39
47
|
{ key: 'pass', env: 'EMAIL_IMAP_PASS', prompt: 'IMAP password', secret: true },
|
|
40
48
|
] },
|
|
41
|
-
{ name: 'signal', builtin: false,
|
|
49
|
+
{ name: 'signal', builtin: false, binary: 'signal-cli', label: 'Signal (needs signal-cli)',
|
|
42
50
|
fields: [{ key: 'account', env: 'SIGNAL_ACCOUNT', prompt: 'Signal account (+15551234567)' }] },
|
|
43
|
-
{ name: 'voice', builtin: false,
|
|
51
|
+
{ name: 'voice', builtin: false, deps: [], label: 'Voice (Whisper transcription)',
|
|
44
52
|
fields: [{ key: 'apiKey', env: 'OPENAI_API_KEY', prompt: 'OpenAI API key (whisper)', secret: true }] },
|
|
45
|
-
{ name: 'whatsapp', builtin: false,
|
|
53
|
+
{ name: 'whatsapp', builtin: false, deps: ['whatsapp-web.js', 'qrcode-terminal'], label: 'WhatsApp (web session)',
|
|
46
54
|
fields: [] },
|
|
47
55
|
];
|
|
48
56
|
|
|
57
|
+
// Is a runtime npm dep installed into <cfgDir>/node_modules? (the in-tree
|
|
58
|
+
// adapter resolves it from there; see commands/gateway.mjs _loadPluginChannel).
|
|
59
|
+
export function depInstalled(dep, cfgDir) {
|
|
60
|
+
if (!cfgDir) return false;
|
|
61
|
+
return fs.existsSync(path.join(cfgDir, 'node_modules', dep, 'package.json'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Is an external binary on PATH? (signal needs signal-cli.)
|
|
65
|
+
export function binaryOnPath(bin) {
|
|
66
|
+
const PATH = process.env.PATH || '';
|
|
67
|
+
const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
68
|
+
for (const dir of PATH.split(path.delimiter).filter(Boolean)) {
|
|
69
|
+
for (const ext of exts) {
|
|
70
|
+
try { if (fs.existsSync(path.join(dir, bin + ext))) return true; } catch { /* skip */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Can this channel actually run right now? Builtins always; in-tree channels
|
|
77
|
+
// only once their runtime dep (npm package) / external binary is present.
|
|
78
|
+
// Returns { ready, missingDeps:[], missingBinary:null }.
|
|
79
|
+
export function channelReadiness(name, cfgDir) {
|
|
80
|
+
const spec = channelByName(name);
|
|
81
|
+
if (!spec) return { ready: false, missingDeps: [], missingBinary: null };
|
|
82
|
+
if (spec.builtin) return { ready: true, missingDeps: [], missingBinary: null };
|
|
83
|
+
const missingDeps = (spec.deps || []).filter((d) => !depInstalled(d, cfgDir));
|
|
84
|
+
const missingBinary = spec.binary && !binaryOnPath(spec.binary) ? spec.binary : null;
|
|
85
|
+
return { ready: missingDeps.length === 0 && !missingBinary, missingDeps, missingBinary };
|
|
86
|
+
}
|
|
87
|
+
|
|
49
88
|
export function channelByName(name) {
|
|
50
89
|
return CHANNEL_CATALOG.find((c) => c.name === name) || null;
|
|
51
90
|
}
|
|
52
91
|
|
|
53
|
-
// Pure: turn collected answers into { envVars,
|
|
54
|
-
//
|
|
55
|
-
//
|
|
92
|
+
// Pure: turn collected answers into { envVars, deps, binary, builtin }. Empty/
|
|
93
|
+
// whitespace answers are dropped so a skipped optional field doesn't write an
|
|
94
|
+
// empty env var. `deps`/`binary` describe the runtime requirement the in-tree
|
|
95
|
+
// channel needs to actually run (replaces the old, unpublished `@lazyclaw/
|
|
96
|
+
// channel-*` plugin pointer).
|
|
56
97
|
export function buildChannelEntry(name, answers = {}) {
|
|
57
98
|
const spec = channelByName(name);
|
|
58
99
|
if (!spec) throw new Error(`unknown channel: ${name}`);
|
|
@@ -63,8 +104,9 @@ export function buildChannelEntry(name, answers = {}) {
|
|
|
63
104
|
}
|
|
64
105
|
return {
|
|
65
106
|
envVars,
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
builtin: !!spec.builtin,
|
|
108
|
+
deps: spec.deps || [],
|
|
109
|
+
binary: spec.binary || null,
|
|
68
110
|
};
|
|
69
111
|
}
|
|
70
112
|
|
|
@@ -78,9 +120,17 @@ export function persistChannel(cfgDir, name, answers) {
|
|
|
78
120
|
if (Object.keys(entry.envVars).length) writeDotenvMerge(cfgDir, entry.envVars);
|
|
79
121
|
const cfg = readConfig();
|
|
80
122
|
cfg.channels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
|
|
81
|
-
|
|
123
|
+
// Don't mark a channel enabled until it can actually run — a plugin channel
|
|
124
|
+
// whose runtime dep / binary is still missing is recorded but left disabled
|
|
125
|
+
// (with the missing requirement) so the gateway never tries (and fails) to
|
|
126
|
+
// start it. `channels enable <name>` flips it once the dep is installed.
|
|
127
|
+
const r = channelReadiness(name, cfgDir);
|
|
128
|
+
const section = { ...(cfg.channels[name] || {}), enabled: r.ready };
|
|
129
|
+
if (!r.ready) section.pending = { deps: r.missingDeps, binary: r.missingBinary };
|
|
130
|
+
else delete section.pending;
|
|
131
|
+
cfg.channels[name] = section;
|
|
82
132
|
writeConfig(cfg);
|
|
83
|
-
return entry;
|
|
133
|
+
return { ...entry, ready: r.ready, missingDeps: r.missingDeps, missingBinary: r.missingBinary };
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
const mask = (v) => {
|
|
@@ -88,48 +138,153 @@ const mask = (v) => {
|
|
|
88
138
|
return s.length <= 4 ? '••••' : `${s.slice(0, 3)}…${s.slice(-2)}`;
|
|
89
139
|
};
|
|
90
140
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
101
|
-
const
|
|
102
|
-
|
|
141
|
+
// Verify a channel's stored credentials with a cheap live call (Slack
|
|
142
|
+
// auth.test, Telegram getMe, Matrix whoami). Reads creds from `env` (defaults
|
|
143
|
+
// to process.env — load the .env first). Returns { ok, detail, hint }:
|
|
144
|
+
// ok=true verified, ok=false rejected/unreachable (hint = how to fix),
|
|
145
|
+
// ok=null when the channel has no verifiable endpoint (http/plugins). Pure
|
|
146
|
+
// w.r.t. config; fetch is injectable for tests.
|
|
147
|
+
export async function verifyChannel(name, { env = process.env, fetchImpl } = {}) {
|
|
148
|
+
const f = fetchImpl || globalThis.fetch;
|
|
149
|
+
const spec = channelByName(name);
|
|
150
|
+
if (!spec) return { ok: false, detail: `unknown channel "${name}"`, hint: '' };
|
|
151
|
+
const need = (envVar) => env[envVar] || '';
|
|
152
|
+
try {
|
|
153
|
+
if (name === 'slack') {
|
|
154
|
+
const token = need('SLACK_BOT_TOKEN');
|
|
155
|
+
if (!token) return { ok: false, detail: 'no SLACK_BOT_TOKEN set', hint: 'set it with /channels slack setup' };
|
|
156
|
+
const base = env.SLACK_API_BASE || 'https://slack.com/api';
|
|
157
|
+
const res = await f(`${base}/auth.test`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
|
|
158
|
+
const j = await res.json().catch(() => ({}));
|
|
159
|
+
return j && j.ok
|
|
160
|
+
? { ok: true, detail: `team=${j.team || '?'} user=${j.user || '?'}`, hint: '' }
|
|
161
|
+
: { ok: false, detail: `Slack rejected the token (${j && j.error ? j.error : 'auth.test failed'})`, hint: 'check SLACK_BOT_TOKEN via /channels slack setup' };
|
|
162
|
+
}
|
|
163
|
+
if (name === 'telegram') {
|
|
164
|
+
const token = need('TELEGRAM_BOT_TOKEN');
|
|
165
|
+
if (!token) return { ok: false, detail: 'no TELEGRAM_BOT_TOKEN set', hint: 'set it with /channels telegram setup' };
|
|
166
|
+
const res = await f(`https://api.telegram.org/bot${token}/getMe`);
|
|
167
|
+
const j = await res.json().catch(() => ({}));
|
|
168
|
+
return j && j.ok
|
|
169
|
+
? { ok: true, detail: `@${(j.result && j.result.username) || '?'}`, hint: '' }
|
|
170
|
+
: { ok: false, detail: `Telegram rejected the token (${j && j.description ? j.description : 'getMe failed'})`, hint: 'check TELEGRAM_BOT_TOKEN via /channels telegram setup' };
|
|
171
|
+
}
|
|
172
|
+
if (name === 'matrix') {
|
|
173
|
+
const hs = need('MATRIX_HOMESERVER');
|
|
174
|
+
const token = need('MATRIX_ACCESS_TOKEN');
|
|
175
|
+
if (!hs || !token) return { ok: false, detail: 'no MATRIX_HOMESERVER / MATRIX_ACCESS_TOKEN set', hint: 'set them with /channels matrix setup' };
|
|
176
|
+
const res = await f(`${hs.replace(/\/$/, '')}/_matrix/client/v3/account/whoami`, { headers: { authorization: `Bearer ${token}` } });
|
|
177
|
+
const j = await res.json().catch(() => ({}));
|
|
178
|
+
return res.ok && j && j.user_id
|
|
179
|
+
? { ok: true, detail: j.user_id, hint: '' }
|
|
180
|
+
: { ok: false, detail: 'Matrix rejected the token', hint: 'check MATRIX_ACCESS_TOKEN via /channels matrix setup' };
|
|
181
|
+
}
|
|
182
|
+
return { ok: null, detail: `no live verification for "${name}"`, hint: '' };
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { ok: false, detail: `could not reach ${name}: ${e && e.message ? e.message : e}`, hint: 'check your network / credentials' };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
103
187
|
|
|
188
|
+
// Configure ONE channel: prompt each credential field (secrets masked),
|
|
189
|
+
// persist to .env + cfg.channels, echo a summary. Returns the persist entry.
|
|
190
|
+
async function configureChannel({ cfgDir, spec, prompt, colors, write, fetchImpl }) {
|
|
191
|
+
const { dim, ok, warn } = colors;
|
|
104
192
|
const answers = {};
|
|
105
193
|
for (const f of spec.fields) {
|
|
106
|
-
|
|
194
|
+
// Mask secret fields so tokens aren't echoed in plaintext to the terminal.
|
|
195
|
+
const v = (await prompt(` ${spec.label} — ${f.prompt}${f.optional ? ' (optional)' : ''}: `, { secret: !!f.secret })).trim();
|
|
107
196
|
if (v) answers[f.key] = v;
|
|
108
197
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
198
|
+
const entry = persistChannel(cfgDir, spec.name, answers);
|
|
199
|
+
const credKeys = Object.keys(entry.envVars);
|
|
200
|
+
const status = entry.ready ? ok('✓ channel enabled:') : warn('• channel saved (not yet enabled):');
|
|
201
|
+
write(` ${status} ${spec.name} ${credKeys.length ? dim(`creds: ${credKeys.join(', ')}`) : ''}\n`);
|
|
202
|
+
for (const f of spec.fields) {
|
|
203
|
+
if (f.secret && answers[f.key]) write(` ${dim(`${f.env} = ${mask(answers[f.key])} (stored in ${cfgDir}/.env, 0600)`)}\n`);
|
|
204
|
+
}
|
|
205
|
+
// Verify the credentials with a cheap live call (same probe as `channels
|
|
206
|
+
// test`) so a typo'd token is caught here, not later in chat. The creds were
|
|
207
|
+
// just written to .env; verifyChannel reads from env, so merge them in.
|
|
208
|
+
// ok=null (http / plugins) prints nothing.
|
|
209
|
+
if (credKeys.length) {
|
|
210
|
+
try {
|
|
211
|
+
const ver = await verifyChannel(spec.name, { env: { ...process.env, ...entry.envVars }, fetchImpl });
|
|
212
|
+
if (ver.ok === true) write(` ${ok('✓ verified:')} ${dim(ver.detail || '')}\n`);
|
|
213
|
+
else if (ver.ok === false) write(` ${warn('✗ not verified:')} ${ver.detail || ''}${ver.hint ? dim(` (${ver.hint})`) : ''}\n`);
|
|
214
|
+
} catch { /* verification is best-effort — never block setup on it */ }
|
|
215
|
+
}
|
|
216
|
+
// Honest requirement notice: these channels ship in-tree but need a runtime
|
|
217
|
+
// dependency before the gateway can start them. We never point at a package
|
|
218
|
+
// that doesn't exist.
|
|
219
|
+
if (!entry.ready) {
|
|
220
|
+
if (entry.missingDeps && entry.missingDeps.length) {
|
|
221
|
+
write(` ${warn('needs a runtime package:')} ${entry.missingDeps.join(', ')}\n`);
|
|
222
|
+
write(` ${dim(`install with: lazyclaw channels install ${spec.name} (runs: npm install --prefix ${cfgDir} ${entry.missingDeps.join(' ')})`)}\n`);
|
|
115
223
|
}
|
|
116
|
-
if (entry.
|
|
117
|
-
write(` ${warn('
|
|
118
|
-
write(` ${dim(`install with: lazyclaw channels install ${entry.needsPlugin} (or npm install --prefix ${cfgDir} ${entry.needsPlugin})`)}\n`);
|
|
224
|
+
if (entry.missingBinary) {
|
|
225
|
+
write(` ${warn('needs an external binary:')} ${entry.missingBinary} (must be on your PATH)\n`);
|
|
119
226
|
}
|
|
120
|
-
write(
|
|
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 };
|
|
227
|
+
write(` ${dim(`then enable it with: lazyclaw channels enable ${spec.name}`)}\n`);
|
|
125
228
|
}
|
|
229
|
+
write('\n');
|
|
230
|
+
return entry;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Interactive channel step. Picks the channel from an arrow-key list (no
|
|
234
|
+
// typing the name), then prompts that channel's credential fields, and loops
|
|
235
|
+
// so several channels can be set up in one pass. `prompt(label, {secret})`
|
|
236
|
+
// resolves to a trimmed string; `write(text)` sinks UI output. Returns
|
|
237
|
+
// { skipped, channels: [names], needsPlugin? }. Never echoes a secret value.
|
|
238
|
+
export async function runChannelStep({ cfgDir, prompt, colors, write = (s) => process.stdout.write(s), pick, fetchImpl }) {
|
|
239
|
+
const { dim, ok, warn } = colors;
|
|
240
|
+
// `pick` is the arrow-key list selector — injectable so tests can script it
|
|
241
|
+
// (the real _arrowMenu reads raw stdin, which a unit test can't drive).
|
|
242
|
+
const picker = pick || (await import('../tui/pickers.mjs'))._arrowMenu;
|
|
243
|
+
write(` ${dim('Where will you talk to the agent? Pick one (arrow keys); set its credentials inline. (* = ships in-tree but needs a runtime package/binary.) You can add more than one.')}\n\n`);
|
|
244
|
+
|
|
245
|
+
const configured = [];
|
|
246
|
+
let needsPlugin = null;
|
|
247
|
+
while (true) {
|
|
248
|
+
const done = configured.length
|
|
249
|
+
? { id: '__done__', label: `✓ Done (${configured.length} channel${configured.length === 1 ? '' : 's'} set)`, desc: 'finish the channel step' }
|
|
250
|
+
: { id: '__done__', label: 'Skip — no channel', desc: 'set one later with /channels <name> setup' };
|
|
251
|
+
const items = [done, ...CHANNEL_CATALOG.map((c) => ({
|
|
252
|
+
id: c.name,
|
|
253
|
+
label: `${c.label}${c.builtin ? '' : ' *'}`,
|
|
254
|
+
desc: configured.includes(c.name) ? '(already set — reconfigure)' : (c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`),
|
|
255
|
+
}))];
|
|
256
|
+
const picked = await picker({
|
|
257
|
+
title: 'Step 3 · pick a channel to set up',
|
|
258
|
+
subtitle: 'Enter to configure · Esc / Done to finish',
|
|
259
|
+
items,
|
|
260
|
+
searchable: true,
|
|
261
|
+
});
|
|
262
|
+
// Esc / q / Ctrl-C / Done → leave the step.
|
|
263
|
+
const id = picked && typeof picked === 'object' ? picked.id : picked;
|
|
264
|
+
if (!id || id === '__done__' || id === 'BACK' || id === 'CANCEL') break;
|
|
265
|
+
const spec = channelByName(id);
|
|
266
|
+
if (!spec) { write(` ${warn('skipped:')} unknown channel "${id}"\n\n`); continue; }
|
|
267
|
+
try {
|
|
268
|
+
const entry = await configureChannel({ cfgDir, spec, prompt, colors, write, fetchImpl });
|
|
269
|
+
if (!configured.includes(id)) configured.push(id);
|
|
270
|
+
if (!entry.ready && entry.missingDeps && entry.missingDeps.length) needsPlugin = entry.missingDeps.join(', ');
|
|
271
|
+
} catch (e) {
|
|
272
|
+
write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!configured.length) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
277
|
+
write(` ${ok('channels:')} ${configured.join(', ')}\n\n`);
|
|
278
|
+
return { skipped: false, channels: configured, needsPlugin };
|
|
126
279
|
}
|
|
127
280
|
|
|
128
281
|
// Outbound webhook step (moved verbatim-in-spirit from setup.mjs).
|
|
129
282
|
export async function runWebhookStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
130
283
|
const { dim, ok, warn } = colors;
|
|
284
|
+
const { _pickYesNo } = await import('../tui/pickers.mjs');
|
|
131
285
|
write(` ${dim('Outbound webhook for `lazyclaw message send <name> <text>`. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
|
|
132
|
-
const
|
|
286
|
+
const wantHook = await _pickYesNo('Add an outbound webhook?', { yesLabel: 'Add one', noLabel: 'Skip', defaultYes: false });
|
|
287
|
+
const hookName = wantHook ? (await prompt(' webhook name: ')).trim() : '';
|
|
133
288
|
if (!hookName) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
134
289
|
const hookUrl = (await prompt(' webhook URL: ')).trim();
|
|
135
290
|
if (!hookUrl) { write(` ${warn('skipped:')} URL required\n\n`); return { skipped: true }; }
|
|
@@ -151,13 +306,29 @@ export async function runWebhookStep({ prompt, colors, write = (s) => process.st
|
|
|
151
306
|
export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
|
|
152
307
|
const { dim, ok } = colors;
|
|
153
308
|
const cf = await import('../config_features.mjs');
|
|
309
|
+
const { _pickChoice } = await import('../tui/pickers.mjs');
|
|
154
310
|
const cur = cf.chatWindowGet(readConfig());
|
|
155
311
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
312
|
+
// Pick a preset (or "custom" → type the two numbers). Presets cover the
|
|
313
|
+
// common small/default/large tradeoffs so the user rarely has to type.
|
|
314
|
+
const PRESETS = [
|
|
315
|
+
{ id: 'keep', label: `Keep current (${cur.turns} turns · ${cur.tokens} tokens)`, turns: cur.turns, tokens: cur.tokens },
|
|
316
|
+
{ id: 'small', label: 'Small — 20 turns · 8k tokens', desc: 'cheaper, shorter memory', turns: 20, tokens: 8000 },
|
|
317
|
+
{ id: 'default', label: 'Default — 40 turns · 16k tokens', turns: 40, tokens: 16000 },
|
|
318
|
+
{ id: 'large', label: 'Large — 80 turns · 32k tokens', desc: 'more context, costs more', turns: 80, tokens: 32000 },
|
|
319
|
+
{ id: 'custom', label: 'Custom…', desc: 'type your own turns + token budget' },
|
|
320
|
+
];
|
|
321
|
+
const choice = await _pickChoice('Context window', PRESETS, { subtitle: 'how much past conversation to send each turn', fallback: 'keep' });
|
|
322
|
+
if (choice === 'keep') { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
|
|
323
|
+
let turns, tokens;
|
|
324
|
+
if (choice === 'custom') {
|
|
325
|
+
turns = parseInt((await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim(), 10);
|
|
326
|
+
tokens = parseInt((await prompt(` token budget (Enter = ${cur.tokens}): `)).trim(), 10);
|
|
327
|
+
} else {
|
|
328
|
+
const p = PRESETS.find((x) => x.id === choice);
|
|
329
|
+
turns = p ? p.turns : NaN;
|
|
330
|
+
tokens = p ? p.tokens : NaN;
|
|
331
|
+
}
|
|
161
332
|
const cfg = readConfig();
|
|
162
333
|
cf.chatWindowSet(cfg, {
|
|
163
334
|
...(Number.isFinite(turns) && turns > 0 ? { turns } : {}),
|
|
@@ -180,9 +351,10 @@ export async function runOrchestratorStep({ prompt, colors, write = (s) => proce
|
|
|
180
351
|
// planner + workers are chosen from a list (arrow keys / filter), not typed.
|
|
181
352
|
const { _pickProviderInteractive } = await import('../tui/pickers.mjs');
|
|
182
353
|
const toSpec = (p) => (p && p.provider) ? (p.model ? `${p.provider}:${p.model}` : p.provider) : null;
|
|
354
|
+
const { _pickYesNo } = await import('../tui/pickers.mjs');
|
|
183
355
|
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
|
|
185
|
-
if (
|
|
356
|
+
const enable = await _pickYesNo('Enable multi-agent orchestration?', { yesLabel: 'Enable', noLabel: 'Skip', defaultYes: false });
|
|
357
|
+
if (!enable) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
|
|
186
358
|
const cfg = readConfig();
|
|
187
359
|
const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
|
|
188
360
|
// Planner — pick from the provider/model list (Esc keeps the default).
|
package/commands/skills.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// extracted from cli.mjs in Phase D3. Self-contained over skills*.mjs modules.
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { configPath } from '../lib/config.mjs';
|
|
6
7
|
|
|
7
8
|
export async function cmdSkills(sub, positional, flags = {}) {
|
|
@@ -118,6 +119,23 @@ export async function cmdSkills(sub, positional, flags = {}) {
|
|
|
118
119
|
console.log(JSON.stringify({ ok: true, name, path: written, bytes: content.length }));
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
122
|
+
case 'starter': {
|
|
123
|
+
// Bundled starter pack — the .md skills shipped under the package's
|
|
124
|
+
// skills/ directory. pickSkillFiles() already prefers a skills/ dir
|
|
125
|
+
// at a repo root, so the same heuristic that resolves a GitHub
|
|
126
|
+
// bundle resolves the local package. Existing names are skipped
|
|
127
|
+
// unless --force, so user edits survive re-runs.
|
|
128
|
+
const inst = await import('../skills_install.mjs');
|
|
129
|
+
const pkgRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
130
|
+
const picked = inst.pickSkillFiles(pkgRoot);
|
|
131
|
+
if (!picked.length) {
|
|
132
|
+
console.error('no bundled starter skills found (package incomplete?)');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const r = inst.installPickedSkills(picked, cfgDir, { force: !!flags.force });
|
|
136
|
+
console.log(JSON.stringify({ ok: true, installed: r.installed, skipped: r.skipped }, null, 2));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
121
139
|
case 'remove': {
|
|
122
140
|
const name = positional[0];
|
|
123
141
|
if (!name) { console.error('Usage: lazyclaw skills remove <name>'); process.exit(2); }
|
|
@@ -212,7 +230,7 @@ export async function cmdSkills(sub, positional, flags = {}) {
|
|
|
212
230
|
return;
|
|
213
231
|
}
|
|
214
232
|
default:
|
|
215
|
-
console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]|curate|classify <name>>');
|
|
233
|
+
console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|starter [--force]|remove <name>|search <query> [--regex]|curate|classify <name>>');
|
|
216
234
|
process.exit(2);
|
|
217
235
|
}
|
|
218
236
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// commands/workflow_named.mjs — `lazyclaw workflow <add|list|show|remove|run>`.
|
|
2
|
+
//
|
|
3
|
+
// Stored, named declarative workflows (cfg.workflows[<name>]) — the Hermes-style
|
|
4
|
+
// automation surface. `add` persists a def from a JSON file (+ optional Slack
|
|
5
|
+
// channel / cron schedule); `run` executes it by name and posts the reply to
|
|
6
|
+
// the bound channel. Distinct from the top-level `run <session-id> <file.mjs>`
|
|
7
|
+
// (the hand-written .mjs engine) — these are DATA workflows.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { readConfig, writeConfig, configPath } from '../lib/config.mjs';
|
|
11
|
+
import { PROVIDERS } from '../providers/registry.mjs';
|
|
12
|
+
import { parseWorkflow } from '../workflow/declarative.mjs';
|
|
13
|
+
import { runNamedWorkflow, getNamedWorkflow, listNamedWorkflows, namedReplyText, validWorkflowName } from '../workflow/named.mjs';
|
|
14
|
+
|
|
15
|
+
const emitJson = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
|
|
16
|
+
const providerLookup = (name) => PROVIDERS[name] || null;
|
|
17
|
+
|
|
18
|
+
// Post a workflow's reply text to a channel target ("slack:#chan" or a bare
|
|
19
|
+
// channel id). makeSender is injected in tests; in production it builds a
|
|
20
|
+
// short-lived SlackChannel (requireInbound:false → only SLACK_BOT_TOKEN needed),
|
|
21
|
+
// mirroring the goal-tick fan-out. Best-effort: a Slack failure is reported, not
|
|
22
|
+
// fatal to the run.
|
|
23
|
+
async function postReply(channelTarget, text, { makeSender } = {}) {
|
|
24
|
+
if (!channelTarget || !text) return { posted: false };
|
|
25
|
+
const channel = String(channelTarget).startsWith('slack:') ? String(channelTarget).slice('slack:'.length) : String(channelTarget);
|
|
26
|
+
let sender;
|
|
27
|
+
try {
|
|
28
|
+
if (makeSender) {
|
|
29
|
+
sender = await makeSender();
|
|
30
|
+
} else {
|
|
31
|
+
const slackMod = await import('../channels/slack.mjs');
|
|
32
|
+
sender = new slackMod.SlackChannel({ requireInbound: false });
|
|
33
|
+
await sender.start(async () => '', { gate: null });
|
|
34
|
+
}
|
|
35
|
+
const res = await sender.send(channel, text);
|
|
36
|
+
return { posted: true, ts: res?.ts || null, channel };
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return { posted: false, error: e?.message || String(e), channel };
|
|
39
|
+
} finally {
|
|
40
|
+
try { if (sender && typeof sender.stop === 'function') await sender.stop(); } catch { /* best-effort */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Run a named workflow and (when it has a bound channel) post its reply. Pure
|
|
45
|
+
// enough to test in-process: pass makeSender + fetchImpl. Returns
|
|
46
|
+
// { result, reply, post }.
|
|
47
|
+
export async function runNamedAndReport(name, cfg, opts = {}) {
|
|
48
|
+
const entry = getNamedWorkflow(cfg, name);
|
|
49
|
+
if (!entry) throw new Error(`no workflow named "${name}"`);
|
|
50
|
+
const result = await runNamedWorkflow(name, cfg, { providerLookup, fetchImpl: opts.fetchImpl, input: opts.input });
|
|
51
|
+
const reply = namedReplyText(result, entry);
|
|
52
|
+
let post = { posted: false };
|
|
53
|
+
if (entry.channel && reply) post = await postReply(entry.channel, reply, { makeSender: opts.makeSender });
|
|
54
|
+
return { result, reply, post };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function cmdWorkflow(sub, positional = [], flags = {}) {
|
|
58
|
+
switch (sub) {
|
|
59
|
+
case undefined:
|
|
60
|
+
case 'list':
|
|
61
|
+
emitJson({ ok: true, workflows: listNamedWorkflows(readConfig()) });
|
|
62
|
+
return;
|
|
63
|
+
|
|
64
|
+
case 'show': {
|
|
65
|
+
const name = positional[0];
|
|
66
|
+
const entry = name && getNamedWorkflow(readConfig(), name);
|
|
67
|
+
if (!entry) { console.error(`workflow show: no workflow "${name}"`); process.exit(2); }
|
|
68
|
+
emitJson({ ok: true, name, ...entry });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case 'add': {
|
|
73
|
+
const name = positional[0];
|
|
74
|
+
const file = positional[1];
|
|
75
|
+
if (!name || !file) {
|
|
76
|
+
console.error('Usage: lazyclaw workflow add <name> <def.json> [--channel slack:#x] [--cron "<spec>"] [--reply-node <id>]');
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
if (!validWorkflowName(name)) { console.error(`workflow add: invalid name "${name}" (letters/digits/.-_ only)`); process.exit(2); }
|
|
80
|
+
let def;
|
|
81
|
+
try {
|
|
82
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
83
|
+
def = /\.ya?ml$/i.test(file)
|
|
84
|
+
? await (await import('../workflow/declarative.mjs')).parseWorkflowYaml(text)
|
|
85
|
+
: parseWorkflow(text);
|
|
86
|
+
} catch (e) { console.error(`workflow add: ${e.message}`); process.exit(2); }
|
|
87
|
+
const entry = { def };
|
|
88
|
+
if (flags.channel && flags.channel !== true) entry.channel = String(flags.channel);
|
|
89
|
+
if (flags.cron && flags.cron !== true) entry.schedule = String(flags.cron);
|
|
90
|
+
if (flags['reply-node'] && flags['reply-node'] !== true) entry.replyNode = String(flags['reply-node']);
|
|
91
|
+
const cfg = readConfig();
|
|
92
|
+
cfg.workflows = cfg.workflows || {};
|
|
93
|
+
cfg.workflows[name] = entry;
|
|
94
|
+
writeConfig(cfg);
|
|
95
|
+
// Install a cron job when a schedule was given (C-3).
|
|
96
|
+
if (entry.schedule) {
|
|
97
|
+
try {
|
|
98
|
+
const { attachWorkflowCron } = await import('../workflow/named_cron.mjs');
|
|
99
|
+
attachWorkflowCron(name, entry.schedule);
|
|
100
|
+
} catch (e) { console.error(`workflow add: stored, but cron install failed: ${e.message}`); }
|
|
101
|
+
}
|
|
102
|
+
emitJson({ ok: true, added: name, channel: entry.channel || null, schedule: entry.schedule || null });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'remove':
|
|
107
|
+
case 'rm':
|
|
108
|
+
case 'delete': {
|
|
109
|
+
const name = positional[0];
|
|
110
|
+
if (!name) { console.error('Usage: lazyclaw workflow remove <name>'); process.exit(2); }
|
|
111
|
+
const cfg = readConfig();
|
|
112
|
+
if (!getNamedWorkflow(cfg, name)) { console.error(`workflow remove: no workflow "${name}"`); process.exit(2); }
|
|
113
|
+
delete cfg.workflows[name];
|
|
114
|
+
writeConfig(cfg);
|
|
115
|
+
emitJson({ ok: true, removed: name });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'run': {
|
|
120
|
+
const name = positional[0];
|
|
121
|
+
if (!name) { console.error('Usage: lazyclaw workflow run <name>'); process.exit(2); }
|
|
122
|
+
const cfg = readConfig();
|
|
123
|
+
try {
|
|
124
|
+
const { result, reply, post } = await runNamedAndReport(name, cfg, {});
|
|
125
|
+
emitJson({ ok: result.success, success: result.success, reply, post, ...(result.error ? { error: result.error, failedAt: result.failedAt } : {}) });
|
|
126
|
+
if (!result.success) process.exit(1);
|
|
127
|
+
} catch (e) { console.error(`workflow run: ${e.message}`); process.exit(1); }
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
default:
|
|
132
|
+
console.error('Usage: lazyclaw workflow <list|show|add|remove|run> ...');
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
void configPath; // configPath kept available for callers that derive the dir.
|
package/config-validate.mjs
CHANGED
|
@@ -6,7 +6,16 @@
|
|
|
6
6
|
// `GET /config/validate` (daemon) so both produce bit-for-bit
|
|
7
7
|
// identical output.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Top-level cfg.json keys the codebase reads. Anything else warns (a likely
|
|
10
|
+
// typo). Kept in sync with the actual readers: sandbox (tui/run_turn,
|
|
11
|
+
// sandbox/index), channels/authProfiles/authActiveProfile/nodes/messaging/
|
|
12
|
+
// pairing (config_features), cron (cron.mjs), mcp + orchestra (daemon.mjs),
|
|
13
|
+
// security (gateway_guard), skills + workspace (commands/chat).
|
|
14
|
+
const KNOWN_KEYS = new Set([
|
|
15
|
+
'provider', 'model', 'api-key', 'rates', 'trainer', 'orchestrator', 'persona', 'customProviders', 'chat',
|
|
16
|
+
'sandbox', 'channels', 'authProfiles', 'authActiveProfile', 'nodes', 'messaging', 'pairing',
|
|
17
|
+
'cron', 'mcp', 'orchestra', 'security', 'skills', 'workspace', 'recall', 'workflows',
|
|
18
|
+
]);
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* @param {Record<string, unknown>} cfg
|
package/config_features.mjs
CHANGED
|
@@ -303,6 +303,51 @@ export function chatWindowSet(cfg, { turns, tokens } = {}) {
|
|
|
303
303
|
return cfg;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// ── Chat agentic REPL + plan mode (cfg.chat.{agentic,tools,planMode}) ────
|
|
307
|
+
// Group 1 — when agentic mode is ON the chat turn routes through the MAS
|
|
308
|
+
// tool loop (runAgentTurn) instead of plain streaming. Opt-in, OFF by
|
|
309
|
+
// default, so existing users keep the exact streaming behavior. Plan mode
|
|
310
|
+
// rides on the same loop but intersects the tool whitelist down to a
|
|
311
|
+
// read-only set ("propose, don't mutate"). Accessors are the registration
|
|
312
|
+
// surface for these keys, mirroring chatWindowGet/Set above.
|
|
313
|
+
//
|
|
314
|
+
// Default whitelist is read-only + safe: a chat turn that can silently
|
|
315
|
+
// propose bash is higher-risk than /task, so bash/write are opt-in per tool
|
|
316
|
+
// via cfg.chat.tools. Sensitive tools still pass the fail-closed approval
|
|
317
|
+
// gate in mas/tool_runner.mjs regardless.
|
|
318
|
+
export const DEFAULT_CHAT_TOOLS = ['read', 'grep', 'skill_view'];
|
|
319
|
+
// Read-only safe set used to intersect the whitelist in plan mode (drops
|
|
320
|
+
// bash/write/delegate and any other sensitive verb). Kept narrow on purpose.
|
|
321
|
+
export const READONLY_CHAT_TOOLS = ['read', 'grep', 'skill_view'];
|
|
322
|
+
|
|
323
|
+
export function chatAgenticGet(cfg) {
|
|
324
|
+
return !!(cfg && cfg.chat && cfg.chat.agentic === true);
|
|
325
|
+
}
|
|
326
|
+
export function chatPlanModeGet(cfg) {
|
|
327
|
+
return !!(cfg && cfg.chat && cfg.chat.planMode === true);
|
|
328
|
+
}
|
|
329
|
+
export function chatToolsGet(cfg) {
|
|
330
|
+
const c = (cfg && cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
|
|
331
|
+
return Array.isArray(c.tools) ? c.tools.slice() : DEFAULT_CHAT_TOOLS.slice();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Persist a chat.* boolean/array key, mutating cfg in place. Returns cfg.
|
|
335
|
+
export function chatSet(cfg, key, value) {
|
|
336
|
+
cfg.chat = (cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
|
|
337
|
+
cfg.chat[key] = value;
|
|
338
|
+
return cfg;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Resolve the effective tool whitelist for an agentic chat turn. In plan
|
|
342
|
+
// mode the configured whitelist is intersected with the read-only set so
|
|
343
|
+
// no mutating tool can be proposed; otherwise the configured list is used
|
|
344
|
+
// as-is (default read-only safe set when unset).
|
|
345
|
+
export function effectiveChatTools(cfg, { planMode = false } = {}) {
|
|
346
|
+
const tools = chatToolsGet(cfg);
|
|
347
|
+
if (!planMode) return tools;
|
|
348
|
+
return tools.filter((t) => READONLY_CHAT_TOOLS.includes(t));
|
|
349
|
+
}
|
|
350
|
+
|
|
306
351
|
// ── Orchestrator — multi-agent config (cfg.orchestrator) ────────────────
|
|
307
352
|
// Shared by the setup wizard, the /orchestrator slash, and the CLI so the
|
|
308
353
|
// "planner + workers" config has one shape. Orchestration is ACTIVE only when
|
package/cron.mjs
CHANGED
|
@@ -192,14 +192,25 @@ export function buildPlist(name, schedule, command) {
|
|
|
192
192
|
const dom = expandField(parsed.dom, { min: 1, max: 31 });
|
|
193
193
|
const month = expandField(parsed.month, { min: 1, max: 12 });
|
|
194
194
|
const dow = expandField(parsed.dow, { min: 0, max: 6 });
|
|
195
|
-
// launchd takes a single dict per fire-time. We expand the cron
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
195
|
+
// launchd takes a single dict per fire-time. We expand the cron schedule
|
|
196
|
+
// into the cartesian product of (Minute × Hour × Day × Month × Weekday);
|
|
197
|
+
// each null field means "every" so we encode nothing for it.
|
|
198
|
+
//
|
|
199
|
+
// BUT cron treats day-of-month and day-of-week with OR semantics when BOTH
|
|
200
|
+
// are restricted: `0 9 13 * 5` fires on the 13th OR on any Friday. A plain
|
|
201
|
+
// cartesian product ANDs them (only when the 13th IS a Friday) — diverging
|
|
202
|
+
// from crontab on Linux. So when both are restricted, emit the UNION of two
|
|
203
|
+
// sets: one keyed on Day (Weekday=every) and one on Weekday (Day=every).
|
|
204
|
+
let entries;
|
|
205
|
+
if (dom !== null && dow !== null) {
|
|
206
|
+
const byDom = cartesian([minOrNull(min), minOrNull(hour), dom, minOrNull(month), [null]]);
|
|
207
|
+
const byDow = cartesian([minOrNull(min), minOrNull(hour), [null], minOrNull(month), dow]);
|
|
208
|
+
entries = [...byDom, ...byDow];
|
|
209
|
+
} else {
|
|
210
|
+
entries = cartesian([
|
|
211
|
+
minOrNull(min), minOrNull(hour), minOrNull(dom), minOrNull(month), minOrNull(dow),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
203
214
|
const intervals = entries.map(([Minute, Hour, Day, Month, Weekday]) => {
|
|
204
215
|
const dict = {};
|
|
205
216
|
if (Minute !== null) dict.Minute = Minute;
|