lazyclaw 5.4.3 → 6.0.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 (46) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/editor.mjs +44 -0
  32. package/tui/modal_filter.mjs +59 -0
  33. package/tui/modal_picker.mjs +12 -37
  34. package/tui/pickers.mjs +917 -0
  35. package/tui/provider_families.mjs +41 -0
  36. package/tui/repl.mjs +67 -36
  37. package/tui/slash_commands.mjs +8 -7
  38. package/tui/slash_dispatcher.mjs +923 -118
  39. package/tui/splash.mjs +5 -12
  40. package/tui/subcommands.mjs +17 -0
  41. package/tui/terminal_approve.mjs +37 -0
  42. package/web/dashboard.css +275 -0
  43. package/web/dashboard.html +2 -1685
  44. package/web/dashboard.js +1406 -0
  45. package/workflow/persistent.mjs +13 -6
  46. package/mas/tools/skill_view.mjs +0 -43
package/mas/tools/web.mjs CHANGED
@@ -11,20 +11,40 @@ const PRIVATE_V4 = [
11
11
  /^127\./, /^169\.254\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
12
12
  ];
13
13
 
14
- async function isSafeUrl(url) {
14
+ // True for an address (or literal-IP host) that must never be reached from a
15
+ // tool: RFC1918 / loopback / link-local v4, IPv6 loopback (::1), link-local
16
+ // (fe80::/10), unique-local (fc00::/7), unspecified (::), and IPv4-mapped
17
+ // private v6 (::ffff:a.b.c.d). Pure/synchronous — no DNS.
18
+ export function isPrivateAddr(addr) {
19
+ const a = String(addr || '').toLowerCase().replace(/^\[|\]$/g, '');
20
+ if (PRIVATE_V4.some((re) => re.test(a))) return true;
21
+ if (a === '::1' || a === '::' || a === '0:0:0:0:0:0:0:1') return true;
22
+ if (/^fe[89ab][0-9a-f]:/.test(a)) return true; // fe80::/10 link-local
23
+ if (/^f[cd][0-9a-f]{2}:/.test(a)) return true; // fc00::/7 unique-local
24
+ const mapped = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
25
+ if (mapped && PRIVATE_V4.some((re) => re.test(mapped[1]))) return true;
26
+ return false;
27
+ }
28
+
29
+ export async function isSafeUrl(url) {
15
30
  let u;
16
31
  try { u = new URL(url); } catch { return { ok: false, error: 'bad URL' }; }
17
32
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: `scheme ${u.protocol} blocked` };
18
33
  const host = u.hostname.replace(/^\[|\]$/g, '');
19
34
  if (host === 'localhost' || host === '0.0.0.0') return { ok: false, error: 'loopback blocked (SSRF)' };
20
- if (PRIVATE_V4.some(re => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
21
- if (host.includes(':')) return { ok: false, error: 'IPv6 disabled' };
35
+ // Literal IP host (v4 or v6): block private/loopback directly, no DNS.
36
+ if (host.includes(':')) {
37
+ if (isPrivateAddr(host)) return { ok: false, error: 'private address blocked (SSRF)' };
38
+ return { ok: true }; // public IPv6 literal
39
+ }
40
+ if (PRIVATE_V4.some((re) => re.test(host))) return { ok: false, error: 'private address blocked (SSRF)' };
22
41
  if (!/^[a-z0-9.-]+$/i.test(host)) return { ok: false, error: 'bad host' };
42
+ // Resolve and reject any A/AAAA that lands on a private/loopback address
43
+ // (anti-rebinding for the entry URL).
23
44
  try {
24
45
  const addrs = await dns.lookup(host, { all: true });
25
46
  for (const a of addrs) {
26
- if (PRIVATE_V4.some(re => re.test(a.address))) return { ok: false, error: 'resolves to private address (SSRF)' };
27
- if (a.address === '127.0.0.1' || a.address === '::1') return { ok: false, error: 'resolves to loopback (SSRF)' };
47
+ if (isPrivateAddr(a.address)) return { ok: false, error: 'resolves to private address (SSRF)' };
28
48
  }
29
49
  } catch (e) {
30
50
  return { ok: false, error: `dns: ${e.message}` };
@@ -51,12 +71,28 @@ const web_fetch = {
51
71
  if (!safe.ok) return { ok: false, error: `web_fetch: ${safe.error}` };
52
72
  const maxBytes = Math.min(args.maxBytes || 2_000_000, 5_000_000);
53
73
  try {
54
- const res = await fetch(args.url, {
55
- method: args.method || 'GET',
56
- headers: args.headers || {},
57
- body: args.body,
58
- redirect: 'follow',
59
- });
74
+ // Follow redirects manually, re-validating every hop. undici's
75
+ // redirect:'follow' would chase a 30x into a private IP (or a
76
+ // DNS-rebind) without re-checking — the classic SSRF-via-redirect.
77
+ let current = args.url;
78
+ let res;
79
+ for (let hop = 0; hop <= 5; hop++) {
80
+ res = await fetch(current, {
81
+ method: args.method || 'GET',
82
+ headers: args.headers || {},
83
+ body: args.body,
84
+ redirect: 'manual',
85
+ });
86
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
87
+ if (hop === 5) return { ok: false, error: 'web_fetch: too many redirects' };
88
+ const next = new URL(res.headers.get('location'), current).toString();
89
+ const ns = await isSafeUrl(next);
90
+ if (!ns.ok) return { ok: false, error: `web_fetch: redirect ${ns.error}` };
91
+ current = next;
92
+ continue;
93
+ }
94
+ break;
95
+ }
60
96
  const buf = Buffer.from(await res.arrayBuffer());
61
97
  const truncated = buf.length > maxBytes;
62
98
  return {
@@ -147,10 +147,13 @@ export async function get(id, opts = {}) {
147
147
  for (const bucket of fs.readdirSync(root)) {
148
148
  const file = recordPath(configDir, bucket, id);
149
149
  if (fs.existsSync(file)) {
150
- const raw = fs.readFileSync(file, 'utf8').trim();
151
- const rec = JSON.parse(raw);
152
- cachePush(id, rec);
153
- return rec;
150
+ // Guard the parse like the sibling listByTaskId does — a truncated record
151
+ // (crash during _trajPut) must return null, not throw an uncaught error.
152
+ try {
153
+ const rec = JSON.parse(fs.readFileSync(file, 'utf8').trim());
154
+ cachePush(id, rec);
155
+ return rec;
156
+ } catch { return null; }
154
157
  }
155
158
  }
156
159
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.4.3",
3
+ "version": "6.0.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -31,7 +31,8 @@
31
31
  "lazyclaw": "cli.mjs"
32
32
  },
33
33
  "scripts": {
34
- "test": "node --test tests/phaseC-*.test.mjs && playwright test",
34
+ "test": "node --test tests/*.test.mjs && playwright test",
35
+ "lint:size": "node scripts/lint-file-size.mjs",
35
36
  "test:bench": "node scripts/bench-providers.mjs",
36
37
  "test:bench:index": "node --test tests/index_store.bench.mjs",
37
38
  "test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
@@ -0,0 +1,22 @@
1
+ // providers/auth_store.mjs — persist an api key for a provider into the
2
+ // authProfiles config shape that _resolveAuthKey reads (cfg.authProfiles
3
+ // [provider] = [{ label, key }], cfg.authActiveProfile[provider] = label).
4
+ //
5
+ // Extracted as a tiny DI'd helper so the Ink /provider key-entry flow can
6
+ // store a key without re-implementing the config shape. No disk — readConfig/
7
+ // writeConfig are injected.
8
+
9
+ export function setAuthKey({ readConfig, writeConfig, provider, key, label = 'default' }) {
10
+ const cfg = readConfig();
11
+ cfg.authProfiles = cfg.authProfiles && typeof cfg.authProfiles === 'object' ? cfg.authProfiles : {};
12
+ cfg.authActiveProfile = cfg.authActiveProfile && typeof cfg.authActiveProfile === 'object' ? cfg.authActiveProfile : {};
13
+ const arr = Array.isArray(cfg.authProfiles[provider]) ? cfg.authProfiles[provider].slice() : [];
14
+ const idx = arr.findIndex((p) => p && p.label === label);
15
+ const entry = { label, key: String(key) };
16
+ if (idx >= 0) arr[idx] = { ...arr[idx], ...entry };
17
+ else arr.push(entry);
18
+ cfg.authProfiles[provider] = arr;
19
+ cfg.authActiveProfile[provider] = label;
20
+ writeConfig(cfg);
21
+ return cfg;
22
+ }
@@ -65,7 +65,14 @@ const _CLI_MODEL_ALIASES = {
65
65
  function resolveModelAlias(model) {
66
66
  if (!model) return '';
67
67
  const lower = String(model).toLowerCase();
68
- return _CLI_MODEL_ALIASES[lower] ?? '';
68
+ if (_CLI_MODEL_ALIASES[lower]) return _CLI_MODEL_ALIASES[lower];
69
+ // Unknown but already a bare short alias (e.g. a new tier the table doesn't
70
+ // enumerate yet, like a future "opusplus") → pass it through rather than
71
+ // dropping to '' (which makes the CLI silently ignore the user's model
72
+ // choice). Full canonical ids (with digits/dashes) stay mapped-or-dropped,
73
+ // because passing a full id to `claude --model` hangs the CLI (FF1).
74
+ if (/^[a-z]+$/.test(lower)) return lower;
75
+ return '';
69
76
  }
70
77
 
71
78
  // Flatten the chat-style messages array into a single -p prompt the
@@ -152,6 +159,19 @@ export const claudeCliProvider = {
152
159
  };
153
160
  if (opts.signal) opts.signal.addEventListener('abort', onAbort);
154
161
 
162
+ // child_process reports spawn failures (ENOENT when `claude` isn't on
163
+ // PATH, EACCES, ...) ASYNCHRONOUSLY via an 'error' event — the
164
+ // synchronous try/catch around spawn above never sees them. Without a
165
+ // listener Node escalates the unhandled 'error' event to an
166
+ // uncaughtException and crashes the whole process; on a box with no
167
+ // claude binary (e.g. CI) this took down `providers test` for the CLI
168
+ // and the daemon entirely. Capture it and surface it through the stream
169
+ // as a normal, catchable error so callers report it per-provider.
170
+ let spawnError = null;
171
+ const spawnErrorPromise = new Promise((resolve) => {
172
+ proc.once('error', (err) => { spawnError = err; resolve(); });
173
+ });
174
+
155
175
  let stderr = '';
156
176
  proc.stderr.setEncoding('utf8');
157
177
  proc.stderr.on('data', (chunk) => { stderr += chunk; });
@@ -203,7 +223,13 @@ export const claudeCliProvider = {
203
223
  if (text) yield text;
204
224
  } catch (_) { /* incomplete tail — drop */ }
205
225
  }
206
- await exitPromise;
226
+ // Wait for either a clean exit or an async spawn error. On ENOENT
227
+ // the process never starts, so 'close' never fires — racing against
228
+ // spawnErrorPromise keeps this from hanging forever.
229
+ await Promise.race([exitPromise, spawnErrorPromise]);
230
+ if (spawnError) {
231
+ throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
232
+ }
207
233
  if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
208
234
  throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
209
235
  }
@@ -0,0 +1,46 @@
1
+ // claude_cli_detect.mjs — detect a usable claude-cli subscription session.
2
+ //
3
+ // resolveTrainer's `auto` branch routes the $0 learning loop to claude-cli
4
+ // only when a Pro/Max session is detected. The original detector keyed solely
5
+ // on an exported CLAUDE_CODE_OAUTH_TOKEN env var — which a normal `claude
6
+ // login` never sets (it writes the OS keychain / ~/.claude) — so `auto`
7
+ // silently fell back to the paid chat provider for real subscribers. This
8
+ // detector also accepts the credential store and the `claude` binary on PATH.
9
+ // Pure + offline (no network); the binary probe is the only subprocess and is
10
+ // bounded by a short timeout.
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ import { execFileSync } from 'node:child_process';
16
+
17
+ export function detectClaudeCliSession({ env = process.env, home = os.homedir() } = {}) {
18
+ // 1. Explicit token (CI / headless) — definitive.
19
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) {
20
+ return { available: true, source: 'env', reason: 'CLAUDE_CODE_OAUTH_TOKEN set' };
21
+ }
22
+ // 2. Credential store written by `claude login` (Linux / non-keychain).
23
+ for (const rel of ['.claude/.credentials.json', '.claude.json', '.config/claude/.credentials.json']) {
24
+ try { if (fs.existsSync(path.join(home, rel))) return { available: true, source: 'credentials', reason: rel }; }
25
+ catch { /* ignore unreadable */ }
26
+ }
27
+ // 3. `claude` on PATH. On macOS the login lives in the Keychain with no
28
+ // credential file, so binary presence is the best offline signal we have.
29
+ // If it turns out not to be logged in, the trainer call fails (best-effort,
30
+ // swallowed) — no billing — rather than silently charging the paid path.
31
+ try {
32
+ execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 4000 });
33
+ return { available: true, source: 'binary', reason: 'claude on PATH' };
34
+ } catch { /* ENOENT / nonzero / timeout */ }
35
+ return { available: false, source: 'none', reason: 'no env token, credential store, or claude binary' };
36
+ }
37
+
38
+ let _cache = null; // memoize per-process — detection does not change mid-run
39
+
40
+ // Boolean for the hot resolveTrainer path. Explicit opts bypass the cache
41
+ // (tests inject env/home); the no-arg call memoizes.
42
+ export function hasClaudeCliSession(opts) {
43
+ if (opts) return detectClaudeCliSession(opts).available;
44
+ if (_cache === null) _cache = detectClaudeCliSession().available;
45
+ return _cache;
46
+ }
@@ -0,0 +1,70 @@
1
+ // providers/custom_provider.mjs — register a custom OpenAI-compatible
2
+ // endpoint (NIM / OpenRouter / Together / Groq / vLLM / LM Studio / …).
3
+ //
4
+ // Extracted from cli.mjs `_addCustomProviderInteractive` so the persistence +
5
+ // live-probe logic is shared between the legacy readline wizard and the Ink
6
+ // provider picker (which lost this affordance in v5.4). The original mixed
7
+ // raw-ANSI prompts with the persistence; this is the IO-free core, dependency-
8
+ // injected on registry + config readers/writers so it unit-tests with no disk
9
+ // or network. The caller owns collecting name/baseUrl/apiKey and rendering.
10
+
11
+ export function validateCustomBaseUrl(raw) {
12
+ const s = String(raw || '').trim();
13
+ if (!s) throw new Error('baseUrl is required');
14
+ if (!/^https?:\/\//i.test(s)) throw new Error('baseUrl must start with http:// or https://');
15
+ return s.replace(/\/+$/, '');
16
+ }
17
+
18
+ /**
19
+ * Validate, persist, hot-register, and best-effort probe a custom provider.
20
+ *
21
+ * @param {object} deps
22
+ * @param {object} deps.registry registry module — needs validateCustomProviderName,
23
+ * registerCustomProviders, fetchOpenAICompatModels,
24
+ * and (optional) isBuiltinOpenAICompatName
25
+ * @param {()=>object} deps.readConfig
26
+ * @param {(cfg:object)=>void} deps.writeConfig
27
+ * @param {string} deps.name
28
+ * @param {string} deps.baseUrl
29
+ * @param {string} [deps.apiKey]
30
+ * @returns {Promise<{name:string, baseUrl:string, builtinOverride:boolean, probe:{ok:boolean,count:number,error:?string,models:string[]}}>}
31
+ */
32
+ export async function addCustomProvider({ registry, readConfig, writeConfig, name, baseUrl, apiKey }) {
33
+ const vName = registry.validateCustomProviderName(name); // throws on bad name
34
+ const vUrl = validateCustomBaseUrl(baseUrl);
35
+ const builtinOverride = typeof registry.isBuiltinOpenAICompatName === 'function'
36
+ && !!registry.isBuiltinOpenAICompatName(vName);
37
+
38
+ // Persist to cfg.customProviders[], overwriting an existing same-name entry.
39
+ const cfg = readConfig();
40
+ cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
41
+ const idx = cfg.customProviders.findIndex((p) => p && p.name === vName);
42
+ const entry = { name: vName, baseUrl: vUrl, apiKey: apiKey ? String(apiKey) : undefined };
43
+ if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
44
+ else cfg.customProviders.push(entry);
45
+ writeConfig(cfg);
46
+ registry.registerCustomProviders(cfg);
47
+
48
+ // Best-effort live model probe. Registration still succeeds on failure —
49
+ // the model picker's free-text/refetch row covers it.
50
+ const probe = { ok: false, count: 0, error: null, models: [] };
51
+ try {
52
+ const list = await registry.fetchOpenAICompatModels({ baseUrl: vUrl, apiKey: entry.apiKey || '' });
53
+ probe.ok = true;
54
+ probe.models = Array.isArray(list) ? list.slice(0, 50) : [];
55
+ probe.count = probe.models.length;
56
+ if (probe.models.length) {
57
+ const updated = readConfig();
58
+ const i = (updated.customProviders || []).findIndex((p) => p && p.name === vName);
59
+ if (i >= 0) {
60
+ updated.customProviders[i].suggestedModels = probe.models;
61
+ if (!updated.customProviders[i].defaultModel) updated.customProviders[i].defaultModel = probe.models[0];
62
+ writeConfig(updated);
63
+ registry.registerCustomProviders(updated);
64
+ }
65
+ }
66
+ } catch (e) {
67
+ probe.error = e && e.message ? e.message : String(e);
68
+ }
69
+ return { name: vName, baseUrl: vUrl, builtinOverride, probe };
70
+ }
@@ -0,0 +1,86 @@
1
+ // providers/model_catalogue.mjs — shared OpenAI-compatible model-catalogue
2
+ // resolution.
3
+ //
4
+ // Extracted from cli.mjs (`_modelCatalogueFor` / `_fetchModelsForProvider`)
5
+ // so BOTH the legacy readline picker (cli.mjs) and the Ink slash dispatcher
6
+ // (tui/slash_dispatcher.mjs) can offer the same live `/v1/models` fetch
7
+ // without duplicating the provider -> endpoint resolution. v5.4's Ink port
8
+ // dropped this affordance from `/model`; this module restores it for both
9
+ // paths from one place.
10
+ //
11
+ // Dependency-injected (no cli.mjs internals) so it stays import-light and
12
+ // unit-testable with no network.
13
+
14
+ /**
15
+ * Whether a provider exposes an OpenAI-compatible `/v1/models` catalogue we
16
+ * can live-fetch. True for openai, ollama, any builtin OpenAI-compat vendor
17
+ * (nim / openrouter / groq / together / xai / deepseek / mistral /
18
+ * fireworks), and any provider carrying an explicit `baseUrl` (custom
19
+ * endpoints). False for anthropic / gemini / claude-cli / mock /
20
+ * orchestrator.
21
+ *
22
+ * @param {object} meta PROVIDER_INFO[providerId]
23
+ * @param {string} providerId
24
+ * @returns {boolean}
25
+ */
26
+ export function supportsLiveFetch(meta, providerId) {
27
+ const m = meta || {};
28
+ return !!m.baseUrl
29
+ || providerId === 'openai'
30
+ || providerId === 'ollama'
31
+ || !!m.builtinOpenAICompat;
32
+ }
33
+
34
+ /**
35
+ * Resolve `{ baseUrl, apiKey }` for a provider's OpenAI-compatible
36
+ * `/v1/models` endpoint. Returns `null` when the provider has no such
37
+ * catalogue (anthropic / gemini / claude-cli / mock / orchestrator).
38
+ *
39
+ * @param {object} deps
40
+ * @param {object} deps.cfg on-disk config (for cfg.customProviders / cfg['api-key'])
41
+ * @param {object} deps.registryMod provides PROVIDER_INFO
42
+ * @param {(providerId:string)=>string} deps.resolveAuthKey env/profile key resolver
43
+ * @param {string} deps.providerId
44
+ * @returns {{baseUrl:string, apiKey:string}|null}
45
+ */
46
+ export function modelCatalogueFor({ cfg, registryMod, resolveAuthKey, providerId } = {}) {
47
+ const info = (registryMod && registryMod.PROVIDER_INFO) || {};
48
+ const meta = info[providerId] || {};
49
+ const key = (id) => (typeof resolveAuthKey === 'function' ? resolveAuthKey(id) : '') || '';
50
+
51
+ if (meta.custom && meta.baseUrl) {
52
+ const list = (cfg && cfg.customProviders) || [];
53
+ const entry = list.find((p) => p && p.name === providerId) || {};
54
+ return { baseUrl: meta.baseUrl, apiKey: entry.apiKey || (cfg && cfg['api-key']) || '' };
55
+ }
56
+ // Built-in OpenAI-compatible vendors expose a baseUrl; the auth-key
57
+ // resolver already knows the env-var fallback chain.
58
+ if (meta.builtinOpenAICompat && meta.baseUrl) {
59
+ return { baseUrl: meta.baseUrl, apiKey: key(providerId) };
60
+ }
61
+ if (providerId === 'openai') {
62
+ return { baseUrl: 'https://api.openai.com/v1', apiKey: key('openai') };
63
+ }
64
+ if (providerId === 'ollama') {
65
+ const host = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
66
+ return { baseUrl: `${host.replace(/\/$/, '')}/v1`, apiKey: '' };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Live-fetch the provider's `/v1/models` list. Throws when the provider has
73
+ * no OpenAI-compatible catalogue. Returns a string[] of model ids.
74
+ *
75
+ * @param {object} deps same shape as {@link modelCatalogueFor}
76
+ * @returns {Promise<string[]>}
77
+ */
78
+ export async function fetchModelsForProvider(deps) {
79
+ const c = modelCatalogueFor(deps);
80
+ const providerId = deps && deps.providerId;
81
+ if (!c) {
82
+ throw new Error(`provider "${providerId}" does not expose an OpenAI-compatible /v1/models endpoint`);
83
+ }
84
+ const { fetchOpenAICompatModels } = await import('./openai_compat.mjs');
85
+ return fetchOpenAICompatModels({ baseUrl: c.baseUrl, apiKey: c.apiKey });
86
+ }
@@ -36,7 +36,10 @@
36
36
  // step), workers = [planner] (degenerates to a single-agent chain that
37
37
  // still benefits from plan + synthesis structure).
38
38
 
39
- import { PROVIDERS, PROVIDER_INFO } from './registry.mjs';
39
+ // This module must NOT statically import ./registry.mjs — that formed a static
40
+ // import cycle (registry → orchestrator → registry). Provider lookup is now
41
+ // injected via makeOrchestratorProvider({ lookup }), so the dependency is
42
+ // one-directional (registry → orchestrator only).
40
43
 
41
44
  function _parseSpec(spec) {
42
45
  if (!spec || typeof spec !== 'string') return { provider: '', model: '' };
@@ -45,11 +48,12 @@ function _parseSpec(spec) {
45
48
  return { provider: spec.slice(0, colon).trim(), model: spec.slice(colon + 1).trim() };
46
49
  }
47
50
 
48
- function _lookupProvider(spec) {
51
+ function _lookupProvider(spec, lookup) {
49
52
  const { provider, model } = _parseSpec(spec);
50
- const prov = PROVIDERS[provider];
53
+ const found = (typeof lookup === 'function' ? lookup(provider) : null) || {};
54
+ const prov = found.prov;
51
55
  if (!prov) return null;
52
- const info = PROVIDER_INFO[provider] || {};
56
+ const info = found.info || {};
53
57
  return {
54
58
  name: provider,
55
59
  model: model || info.defaultModel || '',
@@ -115,6 +119,9 @@ Rules:
115
119
  export function makeOrchestratorProvider(opts = {}) {
116
120
  const cfgGetter = typeof opts.cfgGetter === 'function' ? opts.cfgGetter : () => ({});
117
121
  const keyResolver = typeof opts.keyResolver === 'function' ? opts.keyResolver : () => '';
122
+ // Injected provider lookup: (provider) => { prov, info }. Supplied by the
123
+ // registry so orchestrator never imports registry (breaks the static cycle).
124
+ const lookup = typeof opts.lookup === 'function' ? opts.lookup : () => ({});
118
125
 
119
126
  return {
120
127
  name: 'orchestrator',
@@ -134,7 +141,7 @@ export function makeOrchestratorProvider(opts = {}) {
134
141
  // the user's stated intent. Do a real passthrough instead.
135
142
  const hasWorkers = Array.isArray(o.workers) && o.workers.length > 0;
136
143
  if (!cfg.orchestrator || !hasWorkers) {
137
- const direct = _lookupProvider(fallbackSpec);
144
+ const direct = _lookupProvider(fallbackSpec, lookup);
138
145
  if (!direct || direct.name === 'orchestrator') {
139
146
  yield `⚠ orchestrator: not configured and fallback provider \`${fallbackSpec}\` is not registered. ` +
140
147
  `Set \`cfg.orchestrator.planner\` + \`cfg.orchestrator.workers\`, or set \`cfg.provider\` to a real backend.\n`;
@@ -153,7 +160,7 @@ export function makeOrchestratorProvider(opts = {}) {
153
160
  const workerSpecs = o.workers.map(String);
154
161
  const maxSubtasks = Number.isFinite(o.maxSubtasks) && o.maxSubtasks > 0 ? Math.min(10, o.maxSubtasks) : 5;
155
162
 
156
- const planner = _lookupProvider(plannerSpec);
163
+ const planner = _lookupProvider(plannerSpec, lookup);
157
164
  if (!planner) {
158
165
  yield `⚠ orchestrator: planner provider "${plannerSpec}" is not registered. ` +
159
166
  `Set cfg.orchestrator.planner to a valid "provider:model" (e.g. "claude-cli:claude-opus-4-7").\n`;
@@ -166,7 +173,7 @@ export function makeOrchestratorProvider(opts = {}) {
166
173
  yield `⚠ orchestrator: planner cannot be "orchestrator" — set cfg.orchestrator.planner to a real provider (e.g. "claude-cli:claude-opus-4-7").\n`;
167
174
  return;
168
175
  }
169
- const workers = workerSpecs.map(_lookupProvider).filter(Boolean).filter(w => w.name !== 'orchestrator');
176
+ const workers = workerSpecs.map((s) => _lookupProvider(s, lookup)).filter(Boolean).filter(w => w.name !== 'orchestrator');
170
177
  if (workers.length === 0) {
171
178
  yield `⚠ orchestrator: no usable workers (cfg.orchestrator.workers is empty, all unknown, or only references "orchestrator" itself).\n`;
172
179
  return;
@@ -306,8 +313,22 @@ export function makeOrchestratorProvider(opts = {}) {
306
313
  }
307
314
  return { sub, worker, chunks, error };
308
315
  }
309
- const settled = await Promise.all(
310
- trimmed.map((sub, i) => _runSubtask(sub, workers[i % workers.length])),
316
+ // Bounded worker pool (E1): run at most `concurrency` subtasks at
317
+ // once instead of firing all of them via one Promise.all. A large
318
+ // plan would otherwise open N simultaneous provider streams —
319
+ // over-subscribing rate limits and buffering every worker's chunks
320
+ // at the same time. Results are stored by index so the plan-order
321
+ // flush below is unchanged; for plans with <= concurrency subtasks
322
+ // every subtask still starts immediately (identical to before).
323
+ const settled = new Array(trimmed.length);
324
+ let nextIdx = 0;
325
+ async function _poolWorker() {
326
+ for (let i = nextIdx++; i < trimmed.length; i = nextIdx++) {
327
+ settled[i] = await _runSubtask(trimmed[i], workers[i % workers.length]);
328
+ }
329
+ }
330
+ await Promise.all(
331
+ Array.from({ length: Math.min(concurrency, trimmed.length) }, () => _poolWorker()),
311
332
  );
312
333
  // Flush in plan order so the synthesis prompt + user view see
313
334
  // subtask 1, then 2, etc.
@@ -35,11 +35,21 @@
35
35
  * @returns {{ cost: number, currency: string, breakdown: object } | null}
36
36
  */
37
37
  export function costFromUsage(call, rates) {
38
- if (!call || !rates) return null;
38
+ if (!call) return null;
39
+ const u = call.usage || {};
40
+ // Prefer a provider-reported dollar cost when present. claude-cli / codex-cli
41
+ // / gemini-cli emit total_cost_usd from the CLI's own `result` event, so this
42
+ // makes spend observable — and the daemon cost cap enforceable — for the
43
+ // subscription path even when the user has authored no rate card (the card
44
+ // ships zero-filled). Rate-card arithmetic is the fallback for API providers.
45
+ const reported = Number(u.totalCostUsd);
46
+ if (Number.isFinite(reported) && reported > 0) {
47
+ return { cost: round6(reported), currency: 'USD', breakdown: { reported: round6(reported) } };
48
+ }
49
+ if (!rates) return null;
39
50
  const key = `${call.provider}/${call.model}`;
40
51
  const r = rates[key];
41
52
  if (!r) return null;
42
- const u = call.usage || {};
43
53
  const million = 1_000_000;
44
54
  const inputCost = ((Number(u.inputTokens) || 0) / million) * (Number(r.inputPer1M) || 0);
45
55
  const outputCost = ((Number(u.outputTokens) || 0) / million) * (Number(r.outputPer1M) || 0);
@@ -12,6 +12,7 @@ import { openaiProvider } from './openai.mjs';
12
12
  import { ollamaProvider } from './ollama.mjs';
13
13
  import { geminiProvider } from './gemini.mjs';
14
14
  import { claudeCliProvider } from './claude_cli.mjs';
15
+ import { hasClaudeCliSession } from './claude_cli_detect.mjs';
15
16
  import { makeOpenAICompatProvider, fetchOpenAICompatModels } from './openai_compat.mjs';
16
17
  import { makeOrchestratorProvider } from './orchestrator.mjs';
17
18
 
@@ -83,11 +84,10 @@ export function parseProviderModel(spec) {
83
84
  }
84
85
 
85
86
  function _defaultDetectClaudeCli() {
86
- // Phase A stub: real Pro/Max session detection arrives in Phase B
87
- // (it requires reading the claude-cli OAuth token cache). Until
88
- // then, treat presence of CLAUDE_CODE_OAUTH_TOKEN as a positive
89
- // signal so users can opt-in explicitly.
90
- return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
87
+ // Real Pro/Max session detection: env token, credential store, or the
88
+ // `claude` binary on PATH (a normal `claude login` does NOT export
89
+ // CLAUDE_CODE_OAUTH_TOKEN). See providers/claude_cli_detect.mjs.
90
+ return hasClaudeCliSession();
91
91
  }
92
92
 
93
93
  export function resolveTrainer(cfg, opts = {}) {
@@ -298,7 +298,10 @@ export const PROVIDERS = {
298
298
  // it via `lazyclaw providers list`; `registerOrchestrator(...)` from
299
299
  // cli.mjs::ensureRegistry wires in the live cfg + auth-key resolver so
300
300
  // sendMessage can reach env vars / authProfiles / customProviders.
301
- PROVIDERS.orchestrator = makeOrchestratorProvider();
301
+ // Inject the provider lookup so orchestrator never imports this module back
302
+ // (one-directional dep). The closure reads PROVIDERS/PROVIDER_INFO lazily.
303
+ const _orchestratorLookup = (p) => ({ prov: PROVIDERS[p], info: PROVIDER_INFO[p] });
304
+ PROVIDERS.orchestrator = makeOrchestratorProvider({ lookup: _orchestratorLookup });
302
305
 
303
306
  // Wire each OpenAI-compat builtin into PROVIDERS as a callable provider.
304
307
  // Insertion is between Tier 2 (anthropic) and Tier 4 (ollama) by reordering
@@ -463,7 +466,7 @@ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
463
466
  * — idempotent (overwrites the previous registration in place).
464
467
  */
465
468
  export function registerOrchestrator({ cfgGetter, keyResolver } = {}) {
466
- PROVIDERS.orchestrator = makeOrchestratorProvider({ cfgGetter, keyResolver });
469
+ PROVIDERS.orchestrator = makeOrchestratorProvider({ cfgGetter, keyResolver, lookup: _orchestratorLookup });
467
470
  }
468
471
 
469
472
  /**
@@ -1,14 +1,20 @@
1
1
  // sandbox/confiners/landlock.mjs — Linux Landlock helper.
2
2
  //
3
3
  // Landlock is enforced from *inside* the process via the
4
- // landlock_create_ruleset() syscall. With no native bindings available
5
- // in plain Node, we currently emit the argv unchanged and let
6
- // downstream tooling (e.g. a future `lazyclaw-landlock-shim` binary)
7
- // install the ruleset. Returns argv unchanged on non-Linux.
4
+ // landlock_create_ruleset() syscall, which needs a native binding / preloader
5
+ // shim that lazyclaw does not ship yet. The previous implementation returned
6
+ // the argv UNCHANGED, so selecting `confiner: landlock` ran the command with
7
+ // ZERO confinement while reporting itself available a false security
8
+ // guarantee that is worse than `none`. Until a real enforcer ships we report
9
+ // unavailable and refuse to build an argv, so the request fails closed instead
10
+ // of silently running unconfined.
8
11
 
9
- export function available() { return process.platform === 'linux'; }
12
+ export function available() { return false; }
10
13
 
11
- export function buildArgv(argv, _opts = {}) {
12
- // Pass-through. Spec §0.1 C8 leaves room for a preloader binary.
13
- return [...argv];
14
+ export function buildArgv() {
15
+ throw new Error(
16
+ 'landlock confiner is not implemented (no enforcement shim is shipped) — ' +
17
+ 'it would run the command unconfined. Use confiner bubblewrap or firejail ' +
18
+ 'on Linux, or set confiner:none deliberately.',
19
+ );
14
20
  }
@@ -9,6 +9,22 @@ export function available() {
9
9
  catch { return false; }
10
10
  }
11
11
 
12
+ // Escape a path for an SBPL double-quoted string literal. Without this a path
13
+ // containing `"` (or a backslash) could close the string and inject arbitrary
14
+ // SBPL directives — e.g. re-enabling `(allow network*)` or widening file
15
+ // access — neutering the sandbox. Reject control characters outright; escape
16
+ // backslash and double-quote.
17
+ function sbplPath(p) {
18
+ const s = String(p);
19
+ for (let i = 0; i < s.length; i++) {
20
+ const c = s.charCodeAt(i);
21
+ if (c < 0x20 || c === 0x7f) {
22
+ throw new Error('seatbelt: path contains control characters; refusing to build profile');
23
+ }
24
+ }
25
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ }
27
+
12
28
  export function buildArgv(argv, opts = {}) {
13
29
  const readOnly = opts.readOnly || [];
14
30
  const readWrite = opts.readWrite || [process.cwd()];
@@ -21,8 +37,8 @@ export function buildArgv(argv, opts = {}) {
21
37
  '(allow signal)',
22
38
  '(allow sysctl-read)',
23
39
  allowNet ? '(allow network*)' : '(deny network*)',
24
- ...readOnly.map(p => `(allow file-read* (subpath "${p}"))`),
25
- ...readWrite.map(p => `(allow file-read* file-write* (subpath "${p}"))`),
40
+ ...readOnly.map(p => `(allow file-read* (subpath "${sbplPath(p)}"))`),
41
+ ...readWrite.map(p => `(allow file-read* file-write* (subpath "${sbplPath(p)}"))`),
26
42
  ].join('\n');
27
43
  return ['sandbox-exec', '-p', profile, ...argv];
28
44
  }