lazyclaw 5.4.4 → 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.
- package/channels/handoff.mjs +36 -0
- package/cli.mjs +73 -7399
- package/daemon.mjs +23 -2085
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +3 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- 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
|
-
|
|
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
|
-
|
|
21
|
-
if (host.includes(':'))
|
|
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 (
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 {
|
package/mas/trajectory_store.mjs
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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": "
|
|
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
|
|
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
|
+
}
|
package/providers/claude_cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
53
|
+
const found = (typeof lookup === 'function' ? lookup(provider) : null) || {};
|
|
54
|
+
const prov = found.prov;
|
|
51
55
|
if (!prov) return null;
|
|
52
|
-
const info =
|
|
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
|
-
|
|
310
|
-
|
|
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.
|
package/providers/rates.mjs
CHANGED
|
@@ -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
|
|
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);
|
package/providers/registry.mjs
CHANGED
|
@@ -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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
12
|
+
export function available() { return false; }
|
|
10
13
|
|
|
11
|
-
export function buildArgv(
|
|
12
|
-
|
|
13
|
-
|
|
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
|
}
|