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
@@ -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 = {}) {
package/sessions.mjs CHANGED
Binary file
package/tui/editor.mjs CHANGED
@@ -50,6 +50,48 @@ export function displayWidth(text) {
50
50
  return stringWidth(String(text));
51
51
  }
52
52
 
53
+ // ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
54
+ //
55
+ // v5.4.3 shipped an anchor that moved the cursor inside the editor
56
+ // after every render so IME pre-edit composition appeared in the
57
+ // editor box. It also caused visible flicker because Ink's log-update
58
+ // (node_modules/ink/build/log-update.js) emits an eraseLines sequence
59
+ // (`\x1b[2K\x1b[1A...`) on every redraw — and that sequence walks UP
60
+ // from the CURRENT cursor position. With our anchor up inside the
61
+ // editor, eraseLines erased rows ABOVE the frame, then wrote the new
62
+ // frame starting one editor-height higher than the previous one.
63
+ //
64
+ // v5.4.4 fix — monkey-patch process.stdout.write the first time the
65
+ // anchor fires. When the patched writer sees a chunk that BEGINS with
66
+ // `\x1b[2K` (the start of log-update's eraseLines) AND the anchor
67
+ // offset is non-zero, it prepends `\x1b[<offset>B\r` to move the
68
+ // cursor BACK DOWN to the row log-update expects (one below the
69
+ // previous frame's last line). The user sees no flicker; IME still
70
+ // reads the editor cursor position because the anchor lives across
71
+ // the gap between renders.
72
+ const _anchorState = { offset: 0, shimmed: false };
73
+
74
+ function _installAnchorShim() {
75
+ if (_anchorState.shimmed) return;
76
+ if (!(process.stdout && typeof process.stdout.write === 'function')) return;
77
+ const orig = process.stdout.write.bind(process.stdout);
78
+ process.stdout.write = function patchedWrite(chunk, ...rest) {
79
+ try {
80
+ if (
81
+ _anchorState.offset > 0 &&
82
+ typeof chunk === 'string' &&
83
+ chunk.startsWith('\x1b[2K')
84
+ ) {
85
+ const off = _anchorState.offset;
86
+ _anchorState.offset = 0;
87
+ return orig.call(this, `\x1b[${off}B\r` + chunk, ...rest);
88
+ }
89
+ } catch { /* fall through to unmodified write */ }
90
+ return orig.call(this, chunk, ...rest);
91
+ };
92
+ _anchorState.shimmed = true;
93
+ }
94
+
53
95
  // Cell-aware soft-wrap. Returns an array of visual rows whose width
54
96
  // respects the budget (first row uses `firstBudget`, subsequent rows
55
97
  // use `contBudget`). Hoisted to module level (was inner-fn) so the
@@ -359,6 +401,8 @@ export function Editor({
359
401
  // starts at col 3. Cursor sits one cell past the typed content.
360
402
  const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
361
403
  const colTarget = 3 + prefixWidth + colInLine;
404
+ _installAnchorShim();
405
+ _anchorState.offset = rowsUp;
362
406
  try {
363
407
  process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
364
408
  } catch { /* stdout closed — swallow */ }
@@ -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
+ }
@@ -15,44 +15,14 @@ import React from 'react';
15
15
  import { Box, Text } from 'ink';
16
16
  import stringWidth from 'string-width';
17
17
  import { theme } from './theme.mjs';
18
+ // Pure (react-free) primitives live in modal_filter.mjs so they can be unit
19
+ // tested without the Ink runtime. Re-exported here for back-compat with
20
+ // existing importers (tui/repl.mjs).
21
+ import { filterModalItems, _computeWindow, resolveModalPick } from './modal_filter.mjs';
18
22
 
19
- const DEFAULT_MAX_ROWS = 12;
20
-
21
- // Pure filter — prefix > substring > subsequence. Stable order within
22
- // each tier (original list order is the tiebreaker).
23
- export function filterModalItems(query, items) {
24
- const q = String(query || '').trim().toLowerCase();
25
- const list = Array.isArray(items) ? items : [];
26
- if (!q) return list.slice();
27
- const prefix = [], substr = [], subseq = [];
28
- for (const it of list) {
29
- const hay = `${it.label || it.id || ''} ${it.desc || ''}`.toLowerCase();
30
- if (hay.startsWith(q)) prefix.push(it);
31
- else if (hay.includes(q)) substr.push(it);
32
- else if (_isSubseq(q, hay)) subseq.push(it);
33
- }
34
- return [...prefix, ...substr, ...subseq];
35
- }
23
+ export { filterModalItems, _computeWindow, resolveModalPick };
36
24
 
37
- function _isSubseq(needle, hay) {
38
- let i = 0;
39
- for (const ch of hay) {
40
- if (ch === needle[i]) i++;
41
- if (i === needle.length) return true;
42
- }
43
- return false;
44
- }
45
-
46
- // Pure window computation — slide a window of `maxRows` items so that
47
- // `selectedIndex` is always visible. Mirrors the pattern in
48
- // tui/slash_popup.mjs._computeWindow.
49
- export function _computeWindow(idx, total, maxRows) {
50
- const n = Math.max(0, total);
51
- const m = Math.max(1, maxRows);
52
- if (n <= m) return { start: 0, end: n };
53
- let start = Math.max(0, Math.min(n - m, idx - Math.floor(m / 2)));
54
- return { start, end: start + m };
55
- }
25
+ const DEFAULT_MAX_ROWS = 12;
56
26
 
57
27
  // Presentational component.
58
28
  //
@@ -126,11 +96,16 @@ export function ModalPicker({
126
96
  const marker = selected ? '❯ ' : ' ';
127
97
  const label = String(it.label || it.id || '').padEnd(labelW);
128
98
  const desc = it.desc ? ` ${it.desc}` : '';
129
- // Render selected row inverse-bold for contrast.
99
+ // Render selected row inverse-bold for contrast. A row `tag` (e.g.
100
+ // "api key" / "no key" / "custom") renders as a dim trailing pill so
101
+ // the picker signals at a glance which providers need a key.
130
102
  rows.push(React.createElement(
131
103
  Text,
132
104
  { key: `it-${absoluteIdx}`, bold: selected, inverse: selected },
133
105
  `${marker}${label}${desc}`,
106
+ it.tag
107
+ ? React.createElement(Text, { key: 'tag', dimColor: true }, ` [${it.tag}]`)
108
+ : null,
134
109
  ));
135
110
  }
136
111
  if (end < total) {