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.
Files changed (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +90 -33
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +196 -80
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. 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, plugin: '@lazyclaw/channel-discord', label: 'Discord',
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, plugin: '@lazyclaw/channel-email', label: 'Email (IMAP/SMTP)',
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, plugin: '@lazyclaw/channel-signal', label: 'Signal (needs signal-cli)',
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, plugin: '@lazyclaw/channel-voice', label: 'Voice (Whisper transcription)',
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, plugin: '@lazyclaw/channel-whatsapp', label: 'WhatsApp (web session)',
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, channelConfig, needsPlugin }.
54
- // Empty/whitespace answers are dropped so a skipped optional field doesn't
55
- // write an empty env var.
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
- channelConfig: { enabled: true },
67
- needsPlugin: spec.builtin ? null : spec.plugin,
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
- cfg.channels[name] = { ...(cfg.channels[name] || {}), ...entry.channelConfig };
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
- // 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 }; }
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
- const v = (await prompt(` ${spec.label} ${f.prompt}${f.optional ? ' (optional)' : ''}: `)).trim();
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
- 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`);
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.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`);
224
+ if (entry.missingBinary) {
225
+ write(` ${warn('needs an external binary:')} ${entry.missingBinary} (must be on your PATH)\n`);
119
226
  }
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 };
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 hookName = (await prompt(' webhook name (Enter to skip): ')).trim();
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
- 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);
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 yn = (await prompt(' enable orchestration? [y/N] ')).trim().toLowerCase();
185
- if (yn !== 'y' && yn !== 'yes') { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
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).
@@ -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.
@@ -6,7 +6,16 @@
6
6
  // `GET /config/validate` (daemon) so both produce bit-for-bit
7
7
  // identical output.
8
8
 
9
- const KNOWN_KEYS = new Set(['provider', 'model', 'api-key', 'rates']);
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
@@ -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
- // schedule into the cartesian product of (Minute × Hour × Day ×
197
- // Month × Weekday); each null field means "every" so we encode
198
- // nothing for it. For most schedules this is small (e.g. "every
199
- // weekday 9 AM" = 5 entries).
200
- const entries = cartesian([
201
- minOrNull(min), minOrNull(hour), minOrNull(dom), minOrNull(month), minOrNull(dow),
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;