lazyclaw 5.4.4 → 6.0.1

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 (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -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
  }
@@ -85,7 +85,13 @@ const max = Number(args.max) || loopEngine.LOOP_MAX_DEFAULT;
85
85
  loops.patchMeta(loopId, { status: 'running', startedAt: new Date().toISOString() }, cfgDir);
86
86
 
87
87
  const ac = new AbortController();
88
+ // When a signal arrives, onTerm writes the authoritative 'killed' result and
89
+ // owns the exit. `terminating` stops the normal-completion path (which the
90
+ // aborted runLoop returns into within the same ~50ms window) from racing a
91
+ // second writeResult onto the same file.
92
+ let terminating = false;
88
93
  function onTerm(sig) {
94
+ terminating = true;
89
95
  ac.abort();
90
96
  loops.patchMeta(loopId, { status: 'killed', finishedAt: new Date().toISOString(), signal: sig }, cfgDir);
91
97
  loops.writeResult(loopId, { stoppedBy: 'kill', signal: sig }, cfgDir);
@@ -149,12 +155,17 @@ try {
149
155
  onIteration,
150
156
  signal: ac.signal,
151
157
  });
152
- const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
153
- loops.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
154
- loops.writeResult(loopId, result, cfgDir);
155
- process.exit(0);
158
+ if (!terminating) {
159
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
160
+ loops.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
161
+ loops.writeResult(loopId, result, cfgDir);
162
+ process.exit(0);
163
+ }
164
+ // else: a signal is terminating us — onTerm wrote the result and owns exit.
156
165
  } catch (err) {
157
- loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
158
- loops.writeResult(loopId, { error: err?.message || String(err), stack: err?.stack }, cfgDir);
159
- process.exit(1);
166
+ if (!terminating) {
167
+ loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
168
+ loops.writeResult(loopId, { error: err?.message || String(err), stack: err?.stack }, cfgDir);
169
+ process.exit(1);
170
+ }
160
171
  }
@@ -20,7 +20,7 @@
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import os from 'node:os';
23
- import { openIndex, rebuild, indexSessionTurn, indexSkill, indexMemory } from '../mas/index_db.mjs';
23
+ import { reindexAll } from '../mas/index_db.mjs';
24
24
  import { parseFrontmatter } from '../skills.mjs';
25
25
 
26
26
  function defaultConfigDir() {
@@ -96,67 +96,11 @@ function upgradeAllSkills(configDir) {
96
96
  return { upgraded: n };
97
97
  }
98
98
 
99
+ // Rebuild + repopulate the FTS index. The walk now lives in index_db.reindexAll
100
+ // (shared with the daemon POST /index/rebuild route) so a "rebuild" is always a
101
+ // repopulate, never a silent zeroing.
99
102
  function rebuildIndex(configDir) {
100
- rebuild(configDir);
101
- openIndex(configDir);
102
-
103
- // Sessions.
104
- const sessDir = path.join(configDir, 'sessions');
105
- if (fs.existsSync(sessDir)) {
106
- for (const f of fs.readdirSync(sessDir)) {
107
- if (!f.endsWith('.jsonl')) continue;
108
- const id = f.slice(0, -'.jsonl'.length);
109
- const raw = fs.readFileSync(path.join(sessDir, f), 'utf8');
110
- let idx = 0;
111
- for (const line of raw.split('\n')) {
112
- if (!line) continue;
113
- try {
114
- const obj = JSON.parse(line);
115
- indexSessionTurn({
116
- session_id: id, turn_idx: idx++, role: obj.role || 'user',
117
- ts: obj.ts || 0, content: obj.content || '',
118
- }, configDir);
119
- } catch { /* skip malformed */ }
120
- }
121
- }
122
- }
123
-
124
- // Skills.
125
- const skillsDir = path.join(configDir, 'skills');
126
- if (fs.existsSync(skillsDir)) {
127
- for (const f of fs.readdirSync(skillsDir)) {
128
- if (!f.endsWith('.md')) continue;
129
- const name = f.slice(0, -'.md'.length);
130
- const raw = fs.readFileSync(path.join(skillsDir, f), 'utf8');
131
- const { meta, body } = parseFrontmatter(raw);
132
- indexSkill({
133
- skill_name: name,
134
- trained_by: meta.trained_by || 'legacy',
135
- group_name: meta.group || (name.includes('-') ? name.split('-')[0] : 'legacy'),
136
- content: body,
137
- }, configDir);
138
- }
139
- }
140
-
141
- // Memory (core + episodic).
142
- const memDir = path.join(configDir, 'memory');
143
- if (fs.existsSync(memDir)) {
144
- const corePath = path.join(memDir, 'core.md');
145
- if (fs.existsSync(corePath)) {
146
- indexMemory({ topic: 'core', kind: 'core',
147
- content: fs.readFileSync(corePath, 'utf8') }, configDir);
148
- }
149
- const epi = path.join(memDir, 'episodic');
150
- if (fs.existsSync(epi)) {
151
- for (const f of fs.readdirSync(epi)) {
152
- if (!f.endsWith('.md')) continue;
153
- indexMemory({
154
- topic: f.slice(0, -'.md'.length), kind: 'episodic',
155
- content: fs.readFileSync(path.join(epi, f), 'utf8'),
156
- }, configDir);
157
- }
158
- }
159
- }
103
+ reindexAll(configDir);
160
104
  }
161
105
 
162
106
  export async function migrateV5(opts = {}) {
@@ -0,0 +1,46 @@
1
+ // secure_write.mjs — atomic file writes with owner-only (0600/0700) perms,
2
+ // for files that hold secrets: config.json (plaintext API keys / auth
3
+ // profiles), workflow state (transcript content), any .env the tool writes.
4
+ //
5
+ // Lifted from gateway/device_auth.mjs::writeAtomic: set restrictive modes on
6
+ // create AND re-assert with chmod after rename, because the active umask can
7
+ // clear bits at mkdir/open time and a pre-existing file keeps its old (looser)
8
+ // mode otherwise. chmod is best-effort (a no-op / unsupported on some
9
+ // filesystems and on Windows); the {mode} on write is the primary guard.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ export const SECURE_DIR_MODE = 0o700;
15
+ export const SECURE_FILE_MODE = 0o600;
16
+
17
+ function chmodQuiet(p, mode) {
18
+ try { fs.chmodSync(p, mode); } catch { /* unsupported FS / platform — mode on write stands */ }
19
+ }
20
+
21
+ export function writeTextSecure(filePath, text) {
22
+ const dir = path.dirname(filePath);
23
+ fs.mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
24
+ chmodQuiet(dir, SECURE_DIR_MODE);
25
+ const tmp = `${filePath}.tmp`;
26
+ fs.writeFileSync(tmp, text, { mode: SECURE_FILE_MODE });
27
+ chmodQuiet(tmp, SECURE_FILE_MODE);
28
+ fs.renameSync(tmp, filePath);
29
+ chmodQuiet(filePath, SECURE_FILE_MODE);
30
+ }
31
+
32
+ export function writeJsonSecure(filePath, obj) {
33
+ writeTextSecure(filePath, JSON.stringify(obj, null, 2));
34
+ }
35
+
36
+ // Tighten an existing secrets file to 0600 if it is currently group/other
37
+ // accessible. Best-effort + idempotent — used to migrate already-deployed
38
+ // world-readable config.json the first time it is read. Returns true if it
39
+ // changed the mode.
40
+ export function tightenIfLoose(filePath) {
41
+ try {
42
+ const st = fs.statSync(filePath);
43
+ if ((st.mode & 0o077) !== 0) { fs.chmodSync(filePath, SECURE_FILE_MODE); return true; }
44
+ } catch { /* missing / unreadable → nothing to tighten */ }
45
+ return false;
46
+ }
package/sessions.mjs CHANGED
Binary file
@@ -0,0 +1,59 @@
1
+ // tui/modal_filter.mjs — pure (react-free) primitives for the Ink modal
2
+ // picker. Split out from modal_picker.mjs so the filtering / windowing /
3
+ // pick-resolution logic is unit-testable without pulling in react + ink
4
+ // (which are only present in the running TUI). modal_picker.mjs re-exports
5
+ // these for back-compat.
6
+
7
+ // Pure filter — prefix > substring > subsequence. Stable order within each
8
+ // tier (original list order is the tiebreaker).
9
+ //
10
+ // `pinned` rows bypass the filter entirely and are always appended after
11
+ // the matches. This keeps sentinel rows (e.g. "↻ fetch live models",
12
+ // "… type a custom model id") on screen while the user types an id that
13
+ // matches no listed model — the typed filter doubles as the custom-id
14
+ // buffer for the free-text row.
15
+ export function filterModalItems(query, items) {
16
+ const q = String(query || '').trim().toLowerCase();
17
+ const list = Array.isArray(items) ? items : [];
18
+ if (!q) return list.slice();
19
+ const prefix = [], substr = [], subseq = [], pinned = [];
20
+ for (const it of list) {
21
+ if (it && it.pinned) { pinned.push(it); continue; }
22
+ const hay = `${it.label || it.id || ''} ${it.desc || ''}`.toLowerCase();
23
+ if (hay.startsWith(q)) prefix.push(it);
24
+ else if (hay.includes(q)) substr.push(it);
25
+ else if (_isSubseq(q, hay)) subseq.push(it);
26
+ }
27
+ return [...prefix, ...substr, ...subseq, ...pinned];
28
+ }
29
+
30
+ function _isSubseq(needle, hay) {
31
+ let i = 0;
32
+ for (const ch of hay) {
33
+ if (ch === needle[i]) i++;
34
+ if (i === needle.length) return true;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ // Pure window computation — slide a window of `maxRows` items so that
40
+ // `selectedIndex` is always visible. Mirrors the pattern in
41
+ // tui/slash_popup.mjs._computeWindow.
42
+ export function _computeWindow(idx, total, maxRows) {
43
+ const n = Math.max(0, total);
44
+ const m = Math.max(1, maxRows);
45
+ if (n <= m) return { start: 0, end: n };
46
+ let start = Math.max(0, Math.min(n - m, idx - Math.floor(m / 2)));
47
+ return { start, end: start + m };
48
+ }
49
+
50
+ // Pure pick resolver — maps the highlighted row + current filter buffer to
51
+ // what openPicker resolves with. A `freeText` row resolves to
52
+ // `{ id, query }` so the caller can use the typed filter as a custom value
53
+ // (e.g. an unlisted model id); every other row resolves to its plain id.
54
+ // No selection resolves to null (caller treats as cancel).
55
+ export function resolveModalPick(pickedItem, query) {
56
+ if (!pickedItem) return null;
57
+ if (pickedItem.freeText) return { id: pickedItem.id, query: String(query || '') };
58
+ return pickedItem.id;
59
+ }