lazyclaw 3.99.20 → 3.99.22

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.md CHANGED
@@ -81,6 +81,49 @@ lazyclaw onboard --non-interactive --provider nim \
81
81
 
82
82
  Need a vendor that's **not** built-in? `+ Add a custom OpenAI-compatible endpoint…` inside the setup picker (or `lazyclaw providers add <name> --base-url <url>`) still works for vLLM / LM Studio / private gateways / anything else that speaks the OpenAI v1 wire format.
83
83
 
84
+ ### `orchestrator` — multi-agent dispatch as a provider
85
+
86
+ `orchestrator` is a synthetic provider that composes the others. A chat message hitting `PROVIDERS.orchestrator` triggers a three-phase pipeline instead of a single 1:1 call:
87
+
88
+ 1. **PLAN** — the *planner* provider decomposes the request into 2–5 parallel subtasks (JSON-only system prompt; fences / prose tolerated).
89
+ 2. **EXECUTE** — each subtask is dispatched round-robin across the *workers*. Replies stream inline so you watch progress in real time.
90
+ 3. **SYNTHESIS** — the planner re-enters with every worker's output and writes the final user-facing answer.
91
+
92
+ Configure in `~/.lazyclaw/config.json`:
93
+
94
+ ```json
95
+ {
96
+ "provider": "orchestrator",
97
+ "orchestrator": {
98
+ "planner": "claude-cli:claude-opus-4-7",
99
+ "workers": [
100
+ "claude-cli:claude-sonnet-4-6",
101
+ "openai:gpt-4o",
102
+ "gemini:gemini-2.5-pro",
103
+ "nim:meta/llama-3.1-405b-instruct"
104
+ ],
105
+ "maxSubtasks": 5
106
+ }
107
+ }
108
+ ```
109
+
110
+ Then `lazyclaw chat` (or any other entry point that ends up calling a provider — `lazyclaw agent`, the daemon's `POST /agent` / `POST /chat`, the dashboard chat tab) routes through the orchestrator. Each worker's api-key is resolved through the same chain a direct chat would use (`authProfiles` → `customProviders` → built-in env var → legacy `cfg['api-key']`).
111
+
112
+ Defaults fall back gracefully: `planner` defaults to `cfg.provider`/`cfg.model`, `workers` defaults to `[planner]` (single-agent chain, still benefits from plan + synthesis structure). Self-recursion (`planner: "orchestrator"`) is rejected up front.
113
+
114
+ You can skip the JSON entirely and configure via `lazyclaw onboard` / `lazyclaw setup` (the picker lands on the orchestrator and walks you through a planner + workers wizard) **or** via the dedicated CLI:
115
+
116
+ ```bash
117
+ lazyclaw orchestrator status
118
+ lazyclaw orchestrator set-planner claude-cli:claude-opus-4-7
119
+ lazyclaw orchestrator workers add openai:gpt-4o
120
+ lazyclaw orchestrator workers add gemini:gemini-2.5-pro
121
+ lazyclaw orchestrator workers set claude-cli:claude-sonnet-4-6,nim:meta/llama-3.1-405b-instruct # bulk replace
122
+ lazyclaw orchestrator set-max-subtasks 5
123
+ lazyclaw orchestrator clear # wipe cfg.orchestrator
124
+ lazyclaw config set provider orchestrator # route chats through it
125
+ ```
126
+
84
127
  ## Launcher (no-arg `lazyclaw`)
85
128
 
86
129
  Running `lazyclaw` with no subcommand drops into an arrow-key launcher with every subcommand laid out as a menu. Navigation:
package/cli.mjs CHANGED
@@ -707,6 +707,18 @@ async function ensureRegistry() {
707
707
  _registryMod.registerCustomProviders(readConfig());
708
708
  }
709
709
  } catch { /* never let a malformed cfg.customProviders block startup */ }
710
+ // Wire the orchestrator's live cfg + auth-key resolver. We do this on
711
+ // every ensureRegistry() call (cheap — just replaces the closure) so a
712
+ // mid-session config edit (custom provider added, env var exported)
713
+ // takes effect on the next orchestrator turn without a restart.
714
+ try {
715
+ if (typeof _registryMod.registerOrchestrator === 'function') {
716
+ _registryMod.registerOrchestrator({
717
+ cfgGetter: readConfig,
718
+ keyResolver: _resolveAuthKey,
719
+ });
720
+ }
721
+ } catch { /* defensive */ }
710
722
  return _registryMod;
711
723
  }
712
724
 
@@ -902,6 +914,8 @@ const SUBCOMMANDS = [
902
914
  'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
903
915
  // v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
904
916
  'setup', 'dashboard',
917
+ // v3.99.22 — multi-agent orchestrator config
918
+ 'orchestrator',
905
919
  ];
906
920
 
907
921
  const SUBCOMMAND_SUBS = {
@@ -917,6 +931,7 @@ const SUBCOMMAND_SUBS = {
917
931
  message: ['list', 'add', 'remove', 'send'],
918
932
  workspace: ['list', 'init', 'show', 'remove', 'path'],
919
933
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
934
+ orchestrator: ['status', 'set-planner', 'workers', 'set-max-subtasks', 'clear'],
920
935
  };
921
936
 
922
937
  function bashCompletion() {
@@ -1173,6 +1188,7 @@ const HELP_DETAILS = {
1173
1188
  cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
1174
1189
  setup: 'Usage: lazyclaw setup [--skip-test]\n OpenClaw-style multi-step first-run wizard. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick)\n 2. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 3. Optional skill bundle install from GitHub\n 4. Optional outbound webhook (Slack / Discord)\n 5. Reachability test against the picked provider\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
1175
1190
  dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n Tabs: Chat · Sessions · Skills · Workspace · Providers · Status. Each tab calls existing daemon endpoints.\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
1191
+ orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
1176
1192
  };
1177
1193
 
1178
1194
  function cmdHelp(name) {
@@ -1823,7 +1839,17 @@ async function _pickProviderInteractive() {
1823
1839
  provider = picked;
1824
1840
  }
1825
1841
 
1826
- // ── Step 3 — model ────────────────────────────────────────────
1842
+ // ── Step 3 — model (or, for composite providers, a config wizard) ───
1843
+ // The orchestrator (and any future composite provider) has no model
1844
+ // of its own — it dispatches to other providers. Step 3 routes
1845
+ // through a custom wizard instead of the standard model picker.
1846
+ const providerMeta = (_registryMod.PROVIDER_INFO || {})[provider.id] || {};
1847
+ if (providerMeta.composite || provider.id === 'orchestrator') {
1848
+ const result = await _setupOrchestratorInteractive();
1849
+ if (result === 'CANCEL') return null;
1850
+ if (result === 'BACK') return _pickProviderInteractive();
1851
+ return { provider: provider.id, model: 'orchestrator' };
1852
+ }
1827
1853
  const picked = await _pickModelInteractive(provider.id, {
1828
1854
  titlePrefix: 'LazyClaw setup — Step 3 of 3:',
1829
1855
  onBack: 'restart',
@@ -1833,6 +1859,125 @@ async function _pickProviderInteractive() {
1833
1859
  return { provider: provider.id, model: picked };
1834
1860
  }
1835
1861
 
1862
+ // Step-3 alternative for composite providers (currently only the
1863
+ // orchestrator). Builds `cfg.orchestrator = { planner, workers,
1864
+ // maxSubtasks }` interactively and persists it before returning.
1865
+ //
1866
+ // planner: single picker over registered non-composite providers.
1867
+ // workers: multi-select with a running list + add/remove/done loop.
1868
+ // maxSubtasks: typed integer, default 5.
1869
+ async function _setupOrchestratorInteractive() {
1870
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1871
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1872
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1873
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1874
+ const info = _registryMod.PROVIDER_INFO || {};
1875
+ const eligibleNames = Object.keys(_registryMod.PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
1876
+ if (eligibleNames.length === 0) {
1877
+ process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
1878
+ await _quickPrompt(' press Enter to continue ');
1879
+ return 'CANCEL';
1880
+ }
1881
+ const cfg = readConfig();
1882
+ const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
1883
+
1884
+ // ── Pick planner ─────────────────────────────────────────────────
1885
+ const plannerItems = eligibleNames.map((name) => {
1886
+ const m = info[name] || {};
1887
+ const defaultModel = m.defaultModel || '';
1888
+ return {
1889
+ id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
1890
+ label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
1891
+ desc: defaultModel ? `default model: ${defaultModel}` : '',
1892
+ };
1893
+ });
1894
+ const plannerPick = await _arrowMenu({
1895
+ title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
1896
+ subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
1897
+ items: plannerItems,
1898
+ searchable: true,
1899
+ defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
1900
+ });
1901
+ if (plannerPick === 'CANCEL') return 'CANCEL';
1902
+ if (plannerPick === 'BACK') return 'BACK';
1903
+ const planner = plannerPick.id;
1904
+
1905
+ // ── Pick workers (iterative add/remove) ──────────────────────────
1906
+ const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
1907
+ while (true) {
1908
+ process.stdout.write('\x1b[2J\x1b[H');
1909
+ process.stdout.write(accent('Orchestrator workers') + '\n');
1910
+ process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
1911
+ if (workers.length === 0) {
1912
+ process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
1913
+ } else {
1914
+ workers.forEach((w, i) => {
1915
+ process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
1916
+ });
1917
+ process.stdout.write('\n');
1918
+ }
1919
+ const items = [
1920
+ { id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
1921
+ { id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
1922
+ { id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
1923
+ ];
1924
+ const action = await _arrowMenu({
1925
+ title: 'LazyClaw setup — orchestrator workers',
1926
+ subtitle: `Planner: ${planner}`,
1927
+ items,
1928
+ });
1929
+ if (action === 'CANCEL') return 'CANCEL';
1930
+ if (action === 'BACK') return 'BACK';
1931
+ if (action.id === '__add__') {
1932
+ const wPick = await _arrowMenu({
1933
+ title: 'Add worker',
1934
+ subtitle: 'Picked entries are appended to the workers list.',
1935
+ items: plannerItems.filter((p) => !workers.includes(p.id)),
1936
+ searchable: true,
1937
+ });
1938
+ if (wPick === 'CANCEL' || wPick === 'BACK') continue;
1939
+ workers.push(wPick.id);
1940
+ continue;
1941
+ }
1942
+ if (action.id === '__remove__') {
1943
+ if (!workers.length) continue;
1944
+ const rPick = await _arrowMenu({
1945
+ title: 'Remove worker',
1946
+ subtitle: 'Highlighted entry is removed from the list.',
1947
+ items: workers.map((w) => ({ id: w, label: w })),
1948
+ });
1949
+ if (rPick === 'CANCEL' || rPick === 'BACK') continue;
1950
+ const idx = workers.indexOf(rPick.id);
1951
+ if (idx >= 0) workers.splice(idx, 1);
1952
+ continue;
1953
+ }
1954
+ if (action.id === '__done__') {
1955
+ if (workers.length === 0) continue;
1956
+ break;
1957
+ }
1958
+ }
1959
+
1960
+ // ── maxSubtasks ──────────────────────────────────────────────────
1961
+ const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
1962
+ ? Math.min(10, existing.maxSubtasks)
1963
+ : 5;
1964
+ const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
1965
+ let maxSubtasks = defaultMax;
1966
+ if (rawMax) {
1967
+ const n = parseInt(rawMax, 10);
1968
+ if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
1969
+ }
1970
+
1971
+ // ── Persist ──────────────────────────────────────────────────────
1972
+ cfg.orchestrator = { planner, workers, maxSubtasks };
1973
+ writeConfig(cfg);
1974
+ process.stdout.write('\n');
1975
+ process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
1976
+ `planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
1977
+ await _quickPrompt(' press Enter to continue ');
1978
+ return { ok: true };
1979
+ }
1980
+
1836
1981
  // Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
1837
1982
  // (provider / model arrow menu) takes over the terminal. The sub-picker
1838
1983
  // installs its own `keypress` listener and toggles raw mode; the chat's
@@ -3710,6 +3855,126 @@ async function cmdProviders(sub, positional, flags = {}) {
3710
3855
  }
3711
3856
  }
3712
3857
 
3858
+ // `lazyclaw orchestrator` — read/write the cfg.orchestrator section
3859
+ // without editing config.json by hand. Mirrors the shape `lazyclaw
3860
+ // providers` / `lazyclaw rates` already use.
3861
+ //
3862
+ // Subcommands:
3863
+ // status Print current planner / workers / maxSubtasks as JSON.
3864
+ // set-planner <provider[:model]> Replace the planner spec.
3865
+ // workers add <provider[:model]> Append a worker (idempotent — duplicates skipped).
3866
+ // workers remove <provider[:model]> Drop a worker by exact match. Idempotent.
3867
+ // workers clear Empty the workers list.
3868
+ // workers set <provider[:model],...> Replace the whole list (comma-separated).
3869
+ // set-max-subtasks <N> Cap the number of subtasks (clamped 1..10).
3870
+ // clear Delete the entire cfg.orchestrator block.
3871
+ async function cmdOrchestrator(sub, positional, _flags = {}) {
3872
+ await ensureRegistry();
3873
+ const cfg = readConfig();
3874
+ const orch = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
3875
+ const known = Object.keys(_registryMod.PROVIDERS);
3876
+ const validateSpec = (spec) => {
3877
+ if (!spec) throw new Error('provider spec required (e.g. "claude-cli" or "openai:gpt-4o")');
3878
+ const colon = spec.indexOf(':');
3879
+ const provName = colon > 0 ? spec.slice(0, colon) : spec;
3880
+ if (provName === 'orchestrator') throw new Error('"orchestrator" cannot reference itself — pick a real provider');
3881
+ if (!known.includes(provName)) {
3882
+ throw new Error(`unknown provider "${provName}" — registered: ${known.join(', ')}`);
3883
+ }
3884
+ return spec;
3885
+ };
3886
+ const saveAndPrint = (next) => {
3887
+ if (next === null) delete cfg.orchestrator;
3888
+ else cfg.orchestrator = next;
3889
+ writeConfig(cfg);
3890
+ console.log(JSON.stringify(cfg.orchestrator || null, null, 2));
3891
+ };
3892
+ switch (sub) {
3893
+ case undefined:
3894
+ case 'status': {
3895
+ console.log(JSON.stringify({
3896
+ ok: true,
3897
+ configured: !!cfg.orchestrator,
3898
+ planner: orch.planner || null,
3899
+ workers: Array.isArray(orch.workers) ? orch.workers : [],
3900
+ maxSubtasks: Number.isFinite(orch.maxSubtasks) ? orch.maxSubtasks : null,
3901
+ knownProviders: known,
3902
+ }, null, 2));
3903
+ return;
3904
+ }
3905
+ case 'set-planner': {
3906
+ try {
3907
+ const spec = validateSpec(positional[0]);
3908
+ saveAndPrint({ ...orch, planner: spec });
3909
+ } catch (e) { console.error(`orchestrator: ${e.message}`); process.exit(2); }
3910
+ return;
3911
+ }
3912
+ case 'workers': {
3913
+ const wsub = positional[0];
3914
+ const workers = Array.isArray(orch.workers) ? orch.workers.slice() : [];
3915
+ switch (wsub) {
3916
+ case 'add': {
3917
+ try {
3918
+ const spec = validateSpec(positional[1]);
3919
+ if (!workers.includes(spec)) workers.push(spec);
3920
+ saveAndPrint({ ...orch, workers });
3921
+ } catch (e) { console.error(`orchestrator: ${e.message}`); process.exit(2); }
3922
+ return;
3923
+ }
3924
+ case 'remove': {
3925
+ const spec = positional[1];
3926
+ if (!spec) { console.error('orchestrator: workers remove <provider[:model]>'); process.exit(2); }
3927
+ const idx = workers.indexOf(spec);
3928
+ if (idx >= 0) workers.splice(idx, 1);
3929
+ saveAndPrint({ ...orch, workers });
3930
+ return;
3931
+ }
3932
+ case 'clear': {
3933
+ saveAndPrint({ ...orch, workers: [] });
3934
+ return;
3935
+ }
3936
+ case 'set': {
3937
+ const raw = positional[1] || '';
3938
+ const specs = raw.split(',').map((s) => s.trim()).filter(Boolean);
3939
+ try {
3940
+ specs.forEach(validateSpec);
3941
+ saveAndPrint({ ...orch, workers: specs });
3942
+ } catch (e) { console.error(`orchestrator: ${e.message}`); process.exit(2); }
3943
+ return;
3944
+ }
3945
+ default: {
3946
+ console.error('Usage: lazyclaw orchestrator workers <add <spec> | remove <spec> | clear | set <spec,spec,...>>');
3947
+ process.exit(2);
3948
+ }
3949
+ }
3950
+ }
3951
+ case 'set-max-subtasks': {
3952
+ const n = parseInt(positional[0], 10);
3953
+ if (!Number.isFinite(n) || n < 1) { console.error('orchestrator: set-max-subtasks <N> (1..10)'); process.exit(2); }
3954
+ saveAndPrint({ ...orch, maxSubtasks: Math.min(10, Math.max(1, n)) });
3955
+ return;
3956
+ }
3957
+ case 'clear': {
3958
+ saveAndPrint(null);
3959
+ return;
3960
+ }
3961
+ default: {
3962
+ console.error(
3963
+ 'Usage:\n' +
3964
+ ' lazyclaw orchestrator status\n' +
3965
+ ' lazyclaw orchestrator set-planner <provider[:model]>\n' +
3966
+ ' lazyclaw orchestrator workers add <provider[:model]>\n' +
3967
+ ' lazyclaw orchestrator workers remove <provider[:model]>\n' +
3968
+ ' lazyclaw orchestrator workers set <provider[:model],...>\n' +
3969
+ ' lazyclaw orchestrator workers clear\n' +
3970
+ ' lazyclaw orchestrator set-max-subtasks <N>\n' +
3971
+ ' lazyclaw orchestrator clear'
3972
+ );
3973
+ process.exit(2);
3974
+ }
3975
+ }
3976
+ }
3977
+
3713
3978
  async function cmdSessions(sub, positional, flags = {}) {
3714
3979
  const sessionsMod = await import('./sessions.mjs');
3715
3980
  const cfgDir = path.dirname(configPath());
@@ -4602,6 +4867,11 @@ async function main() {
4602
4867
  await cmdProviders(sub, rest.positional.slice(1), rest.flags);
4603
4868
  break;
4604
4869
  }
4870
+ case 'orchestrator': {
4871
+ const sub = rest.positional[0];
4872
+ await cmdOrchestrator(sub, rest.positional.slice(1), rest.flags);
4873
+ break;
4874
+ }
4605
4875
  case 'skills': {
4606
4876
  const sub = rest.positional[0];
4607
4877
  await cmdSkills(sub, rest.positional.slice(1), rest.flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.20",
3
+ "version": "3.99.22",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,281 @@
1
+ // Orchestrator provider — "openclaw-style" multi-agent dispatch.
2
+ //
3
+ // A user message arriving at PROVIDERS.orchestrator is NOT forwarded
4
+ // 1:1 to a single backend. Instead the provider performs three phases:
5
+ //
6
+ // 1. PLAN — the configured planner provider decomposes the task
7
+ // into 2–5 self-contained subtasks (JSON shape).
8
+ // 2. EXECUTE — each subtask is dispatched to a worker provider
9
+ // (round-robin over cfg.orchestrator.workers). Workers
10
+ // stream their replies; the orchestrator surfaces them
11
+ // inline so the user can watch progress.
12
+ // 3. SYNTHESIS — the planner re-enters with all subtask outputs and
13
+ // produces the final answer.
14
+ //
15
+ // Provider/model spec is "<provider>:<model>" (same shape as the chat
16
+ // REPL's `/model anthropic/claude-opus-4-7` after normalisation). When
17
+ // the model part is omitted, the worker's defaultModel from
18
+ // PROVIDER_INFO is used.
19
+ //
20
+ // Config (~/.lazyclaw/config.json):
21
+ // {
22
+ // "orchestrator": {
23
+ // "planner": "claude-cli:claude-opus-4-7",
24
+ // "workers": [
25
+ // "claude-cli:claude-sonnet-4-6",
26
+ // "openai:gpt-4o",
27
+ // "gemini:gemini-2.5-pro"
28
+ // ],
29
+ // "maxSubtasks": 5, // optional, default 5
30
+ // "concurrency": 0 // optional, 0 = sequential (visible streaming)
31
+ // }
32
+ // }
33
+ //
34
+ // Defaults: planner = the user's currently configured `cfg.provider`
35
+ // (so `lazyclaw onboard --provider claude-cli` works without any extra
36
+ // step), workers = [planner] (degenerates to a single-agent chain that
37
+ // still benefits from plan + synthesis structure).
38
+
39
+ import { PROVIDERS, PROVIDER_INFO } from './registry.mjs';
40
+
41
+ function _parseSpec(spec) {
42
+ if (!spec || typeof spec !== 'string') return { provider: '', model: '' };
43
+ const colon = spec.indexOf(':');
44
+ if (colon < 0) return { provider: spec.trim(), model: '' };
45
+ return { provider: spec.slice(0, colon).trim(), model: spec.slice(colon + 1).trim() };
46
+ }
47
+
48
+ function _lookupProvider(spec) {
49
+ const { provider, model } = _parseSpec(spec);
50
+ const prov = PROVIDERS[provider];
51
+ if (!prov) return null;
52
+ const info = PROVIDER_INFO[provider] || {};
53
+ return {
54
+ name: provider,
55
+ model: model || info.defaultModel || '',
56
+ prov,
57
+ info,
58
+ };
59
+ }
60
+
61
+ function _bestPlanArray(text) {
62
+ // Planners sometimes wrap the JSON in prose / code fences. Try the
63
+ // raw response first, then the largest [...] / [...]-shaped span.
64
+ const tryParse = (s) => {
65
+ try { return JSON.parse(s); } catch { return null; }
66
+ };
67
+ let arr = tryParse(text);
68
+ if (Array.isArray(arr)) return arr;
69
+ // Strip ```json fences
70
+ const fence = text.match(/```(?:json)?\s*([\s\S]+?)```/);
71
+ if (fence) {
72
+ arr = tryParse(fence[1].trim());
73
+ if (Array.isArray(arr)) return arr;
74
+ }
75
+ // Largest [...] substring
76
+ const start = text.indexOf('[');
77
+ const end = text.lastIndexOf(']');
78
+ if (start >= 0 && end > start) {
79
+ arr = tryParse(text.slice(start, end + 1));
80
+ if (Array.isArray(arr)) return arr;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ const PLANNER_SYSTEM = `You are an orchestrator that decomposes a user request into independent subtasks for parallel worker agents.
86
+
87
+ Rules:
88
+ - Output ONLY a JSON array. No prose, no markdown, no code fences.
89
+ - Each entry has shape { "id": <int>, "task": "<one-sentence imperative>", "rationale": "<why this is a useful slice>" }.
90
+ - 2 to 5 subtasks. Each must be doable WITHOUT seeing the others' outputs (parallel-safe).
91
+ - If the request is genuinely atomic (e.g. "say hi"), return a single-element array.
92
+ - Do not add a synthesis / merge step — that runs separately after workers complete.
93
+ - Subtasks must be self-contained: include any context a worker needs to act on the task alone.`;
94
+
95
+ const SYNTHESIS_SYSTEM = `You are an orchestrator producing the final answer for the user.
96
+
97
+ You receive: (1) the user's original request, (2) the subtask plan you produced, (3) each worker's response.
98
+
99
+ Rules:
100
+ - Synthesize a single coherent answer. Distill — do not echo each worker verbatim.
101
+ - Cite worker findings briefly when they meaningfully diverge ("Worker A found …, Worker B confirmed").
102
+ - If a worker failed, acknowledge it but do not let it block the rest of the answer.
103
+ - Match the tone and length the user implied (one-line question → one-line answer; deep dive → deep dive).
104
+ - No JSON; this is the human-facing reply.`;
105
+
106
+ /**
107
+ * Build an orchestrator provider. The chat REPL / agent / daemon path
108
+ * treats it like any other provider — the `sendMessage` async iterable
109
+ * yields markdown chunks describing plan + subtasks + synthesis.
110
+ *
111
+ * @param {Object} [opts]
112
+ * @param {() => Record<string, unknown>} [opts.cfgGetter] reads ~/.lazyclaw/config.json
113
+ * @param {(cfg, provider) => string} [opts.keyResolver] returns api-key for a worker provider (mirrors cli.mjs::_resolveAuthKey)
114
+ */
115
+ export function makeOrchestratorProvider(opts = {}) {
116
+ const cfgGetter = typeof opts.cfgGetter === 'function' ? opts.cfgGetter : () => ({});
117
+ const keyResolver = typeof opts.keyResolver === 'function' ? opts.keyResolver : () => '';
118
+
119
+ return {
120
+ name: 'orchestrator',
121
+ async *sendMessage(messages, callerOpts = {}) {
122
+ const cfg = cfgGetter() || {};
123
+ const o = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
124
+ const fallbackSpec = cfg.provider && cfg.provider !== 'orchestrator'
125
+ ? `${cfg.provider}${cfg.model ? ':' + cfg.model : ''}`
126
+ : 'claude-cli';
127
+ // First-run hint when cfg.orchestrator is missing entirely. The
128
+ // fallback path below still works (planner = cfg.provider, single
129
+ // worker = same), but the user almost certainly meant to opt in
130
+ // explicitly — surface the shortest valid CLI to set it up.
131
+ if (!cfg.orchestrator) {
132
+ yield `> orchestrator: \`cfg.orchestrator\` is not set. Defaulting to a single-agent chain on \`${fallbackSpec}\`.\n` +
133
+ `> Configure properly: \`lazyclaw orchestrator set-planner ${fallbackSpec}\` then \`lazyclaw orchestrator workers add <provider:model>\` (one per agent).\n\n`;
134
+ }
135
+ const plannerSpec = String(o.planner || fallbackSpec);
136
+ const workerSpecs = Array.isArray(o.workers) && o.workers.length
137
+ ? o.workers.map(String)
138
+ : [plannerSpec];
139
+ const maxSubtasks = Number.isFinite(o.maxSubtasks) && o.maxSubtasks > 0 ? Math.min(10, o.maxSubtasks) : 5;
140
+
141
+ const planner = _lookupProvider(plannerSpec);
142
+ if (!planner) {
143
+ yield `⚠ orchestrator: planner provider "${plannerSpec}" is not registered. ` +
144
+ `Set cfg.orchestrator.planner to a valid "provider:model" (e.g. "claude-cli:claude-opus-4-7").\n`;
145
+ return;
146
+ }
147
+ // Self-recursion guard: a misconfigured cfg.orchestrator.planner =
148
+ // "orchestrator" would otherwise spin forever, with each call
149
+ // dispatching back to itself.
150
+ if (planner.name === 'orchestrator') {
151
+ yield `⚠ orchestrator: planner cannot be "orchestrator" — set cfg.orchestrator.planner to a real provider (e.g. "claude-cli:claude-opus-4-7").\n`;
152
+ return;
153
+ }
154
+ const workers = workerSpecs.map(_lookupProvider).filter(Boolean).filter(w => w.name !== 'orchestrator');
155
+ if (workers.length === 0) {
156
+ yield `⚠ orchestrator: no usable workers (cfg.orchestrator.workers is empty, all unknown, or only references "orchestrator" itself).\n`;
157
+ return;
158
+ }
159
+
160
+ const userText = (() => {
161
+ // Most recent user message becomes the orchestration target. We
162
+ // pass earlier turns as context to the planner only — workers
163
+ // see a self-contained subtask string, not chat history.
164
+ for (let i = messages.length - 1; i >= 0; i--) {
165
+ if (messages[i].role === 'user') return String(messages[i].content || '');
166
+ }
167
+ return '';
168
+ })();
169
+
170
+ // ── Phase 1: PLAN ───────────────────────────────────────────────
171
+ yield `## 🦞 Orchestrator\n\n`;
172
+ yield `Planner: \`${planner.name}${planner.model ? ':' + planner.model : ''}\` · Workers: ${workers.map(w => `\`${w.name}${w.model ? ':' + w.model : ''}\``).join(', ')}\n\n`;
173
+ yield `### 1. Planning\n\n`;
174
+
175
+ const plannerMessages = [
176
+ { role: 'system', content: PLANNER_SYSTEM },
177
+ ...messages.filter(m => m.role === 'user' || m.role === 'assistant'),
178
+ ];
179
+ let planRaw = '';
180
+ try {
181
+ for await (const chunk of planner.prov.sendMessage(plannerMessages, {
182
+ apiKey: keyResolver(cfg, planner.name),
183
+ model: planner.model || undefined,
184
+ signal: callerOpts.signal,
185
+ maxTokens: 1024,
186
+ })) {
187
+ planRaw += String(chunk);
188
+ }
189
+ } catch (e) {
190
+ yield `⚠ planner error: ${e?.message || String(e)}\n\n`;
191
+ // Fallback: hand the user message to the first worker directly.
192
+ const w = workers[0];
193
+ yield `Falling back to direct call on \`${w.name}${w.model ? ':' + w.model : ''}\`:\n\n`;
194
+ for await (const chunk of w.prov.sendMessage(messages, {
195
+ apiKey: keyResolver(cfg, w.name),
196
+ model: w.model || undefined,
197
+ signal: callerOpts.signal,
198
+ })) yield String(chunk);
199
+ return;
200
+ }
201
+
202
+ const plan = _bestPlanArray(planRaw);
203
+ if (!plan || plan.length === 0) {
204
+ yield `⚠ planner returned no parseable JSON plan. Raw output:\n\n\`\`\`\n${planRaw.trim().slice(0, 800)}\n\`\`\`\n\nFalling back to single-shot on \`${planner.name}${planner.model ? ':' + planner.model : ''}\`:\n\n`;
205
+ for await (const chunk of planner.prov.sendMessage(messages, {
206
+ apiKey: keyResolver(cfg, planner.name),
207
+ model: planner.model || undefined,
208
+ signal: callerOpts.signal,
209
+ })) yield String(chunk);
210
+ return;
211
+ }
212
+ const trimmed = plan.slice(0, maxSubtasks).map((p, i) => ({
213
+ id: Number.isFinite(p?.id) ? p.id : i + 1,
214
+ task: String(p?.task || '').trim(),
215
+ rationale: String(p?.rationale || '').trim(),
216
+ })).filter(p => p.task);
217
+ if (trimmed.length === 0) {
218
+ yield `⚠ plan parsed but contained no usable subtasks. Falling back.\n\n`;
219
+ for await (const chunk of planner.prov.sendMessage(messages, {
220
+ apiKey: keyResolver(cfg, planner.name),
221
+ model: planner.model || undefined,
222
+ signal: callerOpts.signal,
223
+ })) yield String(chunk);
224
+ return;
225
+ }
226
+
227
+ for (const p of trimmed) {
228
+ yield `${p.id}. **${p.task}**${p.rationale ? ` _— ${p.rationale}_` : ''}\n`;
229
+ }
230
+ yield `\n`;
231
+
232
+ // ── Phase 2: EXECUTE ────────────────────────────────────────────
233
+ yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}\n\n`;
234
+ const results = [];
235
+ for (let i = 0; i < trimmed.length; i++) {
236
+ const sub = trimmed[i];
237
+ const worker = workers[i % workers.length];
238
+ yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
239
+ let res = '';
240
+ try {
241
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
242
+ apiKey: keyResolver(cfg, worker.name),
243
+ model: worker.model || undefined,
244
+ signal: callerOpts.signal,
245
+ })) {
246
+ const s = String(chunk);
247
+ res += s;
248
+ yield s;
249
+ }
250
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: res, error: null });
251
+ } catch (e) {
252
+ const msg = e?.message || String(e);
253
+ yield `\n⚠ worker error: ${msg}\n`;
254
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: '', error: msg });
255
+ }
256
+ yield `\n\n---\n\n`;
257
+ }
258
+
259
+ // ── Phase 3: SYNTHESIS ──────────────────────────────────────────
260
+ yield `### 3. Synthesis\n\n`;
261
+ const synthUser = [
262
+ `Original request:\n${userText}`,
263
+ `\nSubtask plan and worker outputs:`,
264
+ ...results.map(r => `\n#### Subtask ${r.id} — ${r.task}\nWorker: ${r.worker}\n${r.error ? `Error: ${r.error}` : r.result.trim()}`),
265
+ `\nNow write the final answer for the user.`,
266
+ ].join('\n');
267
+ try {
268
+ for await (const chunk of planner.prov.sendMessage([
269
+ { role: 'system', content: SYNTHESIS_SYSTEM },
270
+ { role: 'user', content: synthUser },
271
+ ], {
272
+ apiKey: keyResolver(cfg, planner.name),
273
+ model: planner.model || undefined,
274
+ signal: callerOpts.signal,
275
+ })) yield String(chunk);
276
+ } catch (e) {
277
+ yield `⚠ synthesis error: ${e?.message || String(e)}. Worker outputs above are the final material — please review them directly.\n`;
278
+ }
279
+ },
280
+ };
281
+ }
@@ -13,6 +13,7 @@ import { ollamaProvider } from './ollama.mjs';
13
13
  import { geminiProvider } from './gemini.mjs';
14
14
  import { claudeCliProvider } from './claude_cli.mjs';
15
15
  import { makeOpenAICompatProvider, fetchOpenAICompatModels } from './openai_compat.mjs';
16
+ import { makeOrchestratorProvider } from './orchestrator.mjs';
16
17
 
17
18
  /**
18
19
  * @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
@@ -51,6 +52,7 @@ export const mockProvider = {
51
52
 
52
53
  export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
53
54
  export { makeOpenAICompatProvider, fetchOpenAICompatModels };
55
+ export { makeOrchestratorProvider };
54
56
 
55
57
  // Built-in OpenAI-compatible vendors. Same wire format → one factory call
56
58
  // each. The picker treats these like first-class providers so users don't
@@ -229,6 +231,13 @@ export const PROVIDERS = {
229
231
  mock: mockProvider,
230
232
  };
231
233
 
234
+ // Orchestrator — multi-agent dispatcher that composes other providers.
235
+ // Registered upfront with no cfg/keyResolver so a bare process can list
236
+ // it via `lazyclaw providers list`; `registerOrchestrator(...)` from
237
+ // cli.mjs::ensureRegistry wires in the live cfg + auth-key resolver so
238
+ // sendMessage can reach env vars / authProfiles / customProviders.
239
+ PROVIDERS.orchestrator = makeOrchestratorProvider();
240
+
232
241
  // Wire each OpenAI-compat builtin into PROVIDERS as a callable provider.
233
242
  // Insertion is between Tier 2 (anthropic) and Tier 4 (ollama) by reordering
234
243
  // the keys after the loop runs — JS objects honour insertion order and
@@ -348,6 +357,21 @@ export const PROVIDER_INFO = {
348
357
  },
349
358
  };
350
359
 
360
+ // Orchestrator metadata. Composes other providers; the planner/workers
361
+ // each carry their own keys (or none for claude-cli / ollama / mock),
362
+ // so the orchestrator itself reports requiresApiKey: false. The setup
363
+ // picker treats it as a CLI/Local-family entry — no api-key prompt.
364
+ PROVIDER_INFO.orchestrator = {
365
+ name: 'orchestrator',
366
+ label: 'Orchestrator (multi-agent)',
367
+ requiresApiKey: false,
368
+ docs: 'Orchestrator — decomposes the user message into 2-5 parallel subtasks, dispatches each to a worker provider, then synthesizes the answers. Configure cfg.orchestrator = { planner: "provider:model", workers: ["provider:model", ...], maxSubtasks?: 5 }. Composes any registered provider — Claude / OpenAI / Gemini / NIM / Groq / local Ollama / custom OpenAI-compat endpoints.',
369
+ endpoint: '(composes other providers)',
370
+ defaultModel: 'orchestrator',
371
+ suggestedModels: ['orchestrator'],
372
+ composite: true,
373
+ };
374
+
351
375
  // Mirror the OpenAI-compat builtins into PROVIDER_INFO so picker / docs /
352
376
  // `lazyclaw providers info` see them with the same shape as the hand-written
353
377
  // entries above.
@@ -370,6 +394,16 @@ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
370
394
  };
371
395
  }
372
396
 
397
+ /**
398
+ * Re-register PROVIDERS.orchestrator with a live config getter + auth-key
399
+ * resolver, so each phase's worker call can pick up env vars / authProfiles
400
+ * / customProviders. Called from cli.mjs::ensureRegistry on every entry
401
+ * — idempotent (overwrites the previous registration in place).
402
+ */
403
+ export function registerOrchestrator({ cfgGetter, keyResolver } = {}) {
404
+ PROVIDERS.orchestrator = makeOrchestratorProvider({ cfgGetter, keyResolver });
405
+ }
406
+
373
407
  /**
374
408
  * Resolve an api-key for a built-in OpenAI-compatible provider from the
375
409
  * environment, scanning {envKey} then any {altEnvKeys}. Returns '' when
@@ -422,6 +456,7 @@ export function parseProviderModel(s) {
422
456
  // `makeOpenAICompatProvider` — overriding is well-defined.
423
457
  const RESERVED_PROVIDER_NAMES = new Set([
424
458
  'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
459
+ 'orchestrator',
425
460
  '__add_custom__', '__custom_model__', '__fetch_models__',
426
461
  ]);
427
462