lazyclaw 6.3.1 → 6.5.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 (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
package/tui/pickers.mjs CHANGED
@@ -11,6 +11,58 @@ import {
11
11
  fetchModelsForProvider as _fetchModelsResolve,
12
12
  supportsLiveFetch as _supportsLiveFetch,
13
13
  } from '../providers/model_catalogue.mjs';
14
+ import { paint } from './theme.mjs';
15
+ // Banner renderers live in a leaf sibling module (tui/banner.mjs). We import
16
+ // the ones pickers' own code still calls (_orange / _ORANGE_RGB in
17
+ // _printChatBanner, _loadBannerAssets / _renderBanner / _orange in
18
+ // _renderV5Banner) and re-export the public surface so existing importers
19
+ // (cli.mjs / commands/setup.mjs / commands/chat.mjs) keep resolving them here.
20
+ import {
21
+ _ORANGE_RGB,
22
+ _orange,
23
+ _renderBanner,
24
+ _loadBannerAssets,
25
+ } from './banner.mjs';
26
+ export {
27
+ _orange,
28
+ _renderMascot,
29
+ _renderMascotTiny,
30
+ _renderBanner,
31
+ _loadBannerAssets,
32
+ } from './banner.mjs';
33
+
34
+ // Read exactly ONE line from a non-TTY stream (piped stdin) and hand the rest
35
+ // back so the next reader (the next picker / a credential prompt) still gets
36
+ // its input. The old fallbacks resolved the whole buffered payload at the
37
+ // first newline — matching no id (skip) and swallowing every later line so the
38
+ // following prompt hung. Pause before unshift so the buffered remainder isn't
39
+ // dropped while no 'data' listener is attached.
40
+ export function _readOneLine(stream) {
41
+ return new Promise((resolve) => {
42
+ let buf = '';
43
+ const done = (line) => {
44
+ stream.off('data', onData);
45
+ stream.off('end', onEnd);
46
+ resolve(line.replace(/\r$/, '').trim());
47
+ };
48
+ const onData = (chunk) => {
49
+ buf += chunk.toString();
50
+ const nl = buf.indexOf('\n');
51
+ if (nl === -1) return;
52
+ stream.pause();
53
+ const rest = buf.slice(nl + 1);
54
+ if (rest) stream.unshift(Buffer.from(rest, 'utf8'));
55
+ done(buf.slice(0, nl));
56
+ };
57
+ const onEnd = () => done(buf);
58
+ stream.on('data', onData);
59
+ stream.on('end', onEnd);
60
+ // A prior _readOneLine pauses the stream after unshifting its remainder.
61
+ // Adding a 'data' listener does NOT auto-resume an explicitly-paused
62
+ // stream, so resume() here or the next read would block on buffered input.
63
+ stream.resume();
64
+ });
65
+ }
14
66
 
15
67
  export function _attachGhostAutocomplete(rl) {
16
68
  // Returns `{ dispose, suspend, resume }`. Dispose detaches the keypress +
@@ -107,94 +159,16 @@ export function _attachGhostAutocomplete(rl) {
107
159
  };
108
160
  }
109
161
 
110
- // LazyClaw banner printed once at the top of every interactive chat
111
- // session so users see the active provider/model before they start
112
- // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
113
- // invocations stay clean for tests/scripts).
114
- // Single source of truth for the LazyClaw banner used by the chat
115
- // REPL header, the no-arg launcher, and the first-run welcome panel.
116
- // Returns an array of pre-formatted lines (with ANSI colour) so the
117
- // caller can splice in additional rows without re-implementing the
118
- // alignment.
119
- //
120
- // Width-management rule: every inner line is forced through
121
- // `.padEnd(W)` so a stray width miscount can't punch the right
122
- // border off the box (which is exactly the bug v3.99.5 shipped:
123
- // v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
124
- // previous mixed-colour banner (helmet-red letter art + ink-beige
125
- // caption) read as "two banners glued together" because the colour
126
- // changed mid-box. We use one warm orange (#F08246) for everything —
127
- // border, letter art, caption — so the eye reads it as one badge.
128
- //
129
- // Letter art is figlet "standard" (6 rows) rather than the v3.99.11
130
- // "small" (4 rows), because small renders as a pixel mush in most
131
- // terminal fonts. Standard's strokes are wide enough that the
132
- // letters read as `l a z y` even at small terminal sizes.
133
- //
134
- // Layout invariant: every inner row is exactly INNER_W visible cells
135
- // (no double-width glyphs, all chars are 1 cell in any monospace
136
- // font), so the right edge `│` always lands in the same column.
137
- //
138
- // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
139
- // caller doesn't crash; no state-coloured art is produced any more.
140
-
141
- const _ORANGE_RGB = '241;130;70'; // #F18246
142
- export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
143
-
144
- export function _renderMascot() {
145
- return ['lazyclaw'];
146
- }
147
-
148
- export function _renderMascotTiny() {
149
- return 'lazyclaw';
150
- }
151
-
152
- // figlet "standard" "lazy", trimmed of leading blank line. Each row
153
- // is left-padded by two spaces inside the box, and every row is then
154
- // padded to INNER_W cells.
155
- const _LAZY_STANDARD = [
156
- ' _ ',
157
- '| | __ _ _____ _ ',
158
- '| |/ _` |_ / | | | ',
159
- '| | (_| |/ /| |_| | ',
160
- '|_|\\__,_/___|\\__, | ',
161
- ' |___/ ',
162
- ];
163
-
164
- const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
165
-
166
- export function _renderBanner(version) {
167
- const v = String(version || '?.?.?');
168
- const cap = ` LazyClaw v${v}`;
169
- const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
170
- const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
171
- const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
172
- const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
173
- return [
174
- top,
175
- ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
176
- wrap(padInner(cap)),
177
- bot,
178
- ];
179
- }
162
+ // Banner renderers (_ORANGE_RGB / _orange / _renderMascot / _renderMascotTiny
163
+ // / _renderBanner / _loadBannerAssets) moved to tui/banner.mjs imported at
164
+ // the top of this file and re-exported there for existing callers. The v5
165
+ // splash composer and chat header below still live here because they read
166
+ // pickers-local state and stay close to the wizard that prints them.
180
167
 
181
168
  // v5 hero banner — ANSI Shadow LAZYCLAW wordmark stacked on top of the
182
169
  // braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Left-aligned
183
170
  // with a 2-cell margin so wide terminals don't push the art to the right.
184
171
  // Opt out with LAZYCLAW_LEGACY_MENU=1 to fall back to the v4 figlet box.
185
- let _bannerAssetsCache = null;
186
- export async function _loadBannerAssets() {
187
- if (_bannerAssetsCache !== null) return _bannerAssetsCache;
188
- try {
189
- const { banner } = await import('./banner.generated.mjs');
190
- const { wordmark } = await import('./wordmark.mjs');
191
- _bannerAssetsCache = { banner, wordmark };
192
- } catch {
193
- _bannerAssetsCache = null;
194
- }
195
- return _bannerAssetsCache;
196
- }
197
-
198
172
  export async function _renderV5Banner(version) {
199
173
  const a = await _loadBannerAssets();
200
174
  if (!a) return _renderBanner(version); // missing tarball asset → v4 figlet
@@ -261,14 +235,7 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
261
235
  process.stderr.write(`${title}\n`);
262
236
  items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
263
237
  process.stderr.write('pick (number / id, blank for first): ');
264
- const ans = await new Promise((resolve) => {
265
- let buf = '';
266
- const onData = (chunk) => {
267
- buf += chunk.toString();
268
- if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
269
- };
270
- process.stdin.on('data', onData);
271
- });
238
+ const ans = await _readOneLine(process.stdin);
272
239
  if (!ans) return items[0];
273
240
  const byNum = parseInt(ans, 10);
274
241
  if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
@@ -286,9 +253,18 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
286
253
  // before drawing so the picker always receives the first keypress.
287
254
  process.stdin.resume();
288
255
  if (process.stdin.ref) process.stdin.ref();
289
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
290
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
291
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
256
+ // Render on the ALTERNATE screen buffer (the same trick vim / less / fzf
257
+ // use). Without it, the menu's full-screen \x1b[2J clears land on the main
258
+ // buffer, interleave with the readline output from prior wizard steps, and
259
+ // every step pushes a screenful into scrollback — the "화면이 밀린다" bug.
260
+ // On the alt buffer the menu draws in isolation; leaving it (cleanup) restores
261
+ // the main buffer (and the wizard text on it) verbatim, with nothing pushed.
262
+ const altScreen = !!(process.stdout.isTTY);
263
+ if (altScreen) process.stdout.write('\x1b[?1049h');
264
+ // Menu chrome via the theme gate: plain text under NO_COLOR / dumb / non-TTY.
265
+ const accent = (s) => paint('38;5;208', s);
266
+ const dim = (s) => paint('2', s);
267
+ const bold = (s) => paint('1', s);
292
268
 
293
269
  // Typeahead state. `query` accumulates printable chars when searchable
294
270
  // is on; the visible item slice is recomputed on every keystroke. We
@@ -381,7 +357,10 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
381
357
  cleanup();
382
358
  resolve(view[idx]);
383
359
  }
384
- else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
360
+ // Ctrl+C mid-wizard cancels this step (resolve the CANCEL sentinel the
361
+ // callers already handle) instead of hard-killing the whole process —
362
+ // a wizard step must be abortable without taking the app down.
363
+ else if (key.ctrl && key.name === 'c') { cleanup(); resolve('CANCEL'); }
385
364
  else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
386
365
  else if (key.name === 'escape') {
387
366
  if (searchable && query) { query = ''; recompute(); return; }
@@ -403,12 +382,50 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
403
382
  const cleanup = () => {
404
383
  process.stdin.off('keypress', onKey);
405
384
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
406
- process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
385
+ // Show the cursor, then leave the alt screen → the main buffer (with the
386
+ // wizard's prior output) reappears exactly as it was, nothing pushed into
387
+ // scrollback. Fall back to a clear+home when the alt buffer wasn't used.
388
+ if (altScreen) process.stdout.write('\x1b[?25h\x1b[?1049l');
389
+ else process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
390
+ // Release stdin so a one-shot CLI caller (the setup / onboard wizard) can
391
+ // exit. We resume()+ref() stdin on entry to receive keypresses; if it's
392
+ // never unref'd, the event loop stays alive after the LAST picker and the
393
+ // process hangs at "Setup complete" instead of returning to the shell.
394
+ // The chat REPL re-refs stdin (via _pauseChatForSubMenu's finally) and
395
+ // its own readline keeps the loop alive, so this is safe there.
396
+ if (process.stdin.unref) process.stdin.unref();
407
397
  };
408
398
  process.stdin.on('keypress', onKey);
409
399
  });
410
400
  }
411
401
 
402
+ // Arrow-key yes/no — replaces the typed `[Y/n]` prompts in the wizard so the
403
+ // user never types a letter. Returns a boolean. Esc / q resolve to the default.
404
+ // Inherits _arrowMenu's non-TTY fallback (reads a line). `pick` is injectable
405
+ // for tests.
406
+ export async function _pickYesNo(title, { subtitle, yesLabel = 'Yes', noLabel = 'No', defaultYes = true, pick = _arrowMenu } = {}) {
407
+ const picked = await pick({
408
+ title,
409
+ subtitle,
410
+ items: [
411
+ { id: 'yes', label: yesLabel },
412
+ { id: 'no', label: noLabel },
413
+ ],
414
+ defaultIdx: defaultYes ? 0 : 1,
415
+ });
416
+ if (picked === 'BACK' || picked === 'CANCEL' || picked == null) return defaultYes;
417
+ const id = typeof picked === 'object' ? picked.id : picked;
418
+ return id === 'yes';
419
+ }
420
+
421
+ // Arrow-key single choice — `options` is [{ id, label, desc }]. Returns the
422
+ // chosen id, or `fallback` on Esc/cancel. `pick` injectable for tests.
423
+ export async function _pickChoice(title, options, { subtitle, defaultIdx = 0, fallback = null, pick = _arrowMenu } = {}) {
424
+ const picked = await pick({ title, subtitle, items: options, defaultIdx, searchable: false });
425
+ if (picked === 'BACK' || picked === 'CANCEL' || picked == null) return fallback;
426
+ return typeof picked === 'object' ? picked.id : picked;
427
+ }
428
+
412
429
  // Bucket every registered provider into one of three auth-method
413
430
  // families. The picker's first step asks the user which family
414
431
  // they want before drilling into specific providers — much less
@@ -420,10 +437,10 @@ export function _providerFamilies() {
420
437
  // tui/provider_families.mjs; the ANSI tags below are readline-specific.
421
438
  const b = _bucketProviders(getRegistry());
422
439
  return {
423
- api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: b.api },
424
- cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: b.cli },
425
- meta: { label: 'Multi-agent', desc: 'orchestrator — fan a task out to a planner + workers (advanced)', tag: '\x1b[38;5;245m[meta]\x1b[0m', members: b.meta },
426
- mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: b.mock },
440
+ api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: paint('38;5;245', '[needs key]'), members: b.api },
441
+ cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: paint('38;5;208', '[no key]'), members: b.cli },
442
+ meta: { label: 'Multi-agent', desc: 'orchestrator — fan a task out to a planner + workers (advanced)', tag: paint('38;5;245', '[meta]'), members: b.meta },
443
+ mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: paint('38;5;245', '[test]'), members: b.mock },
427
444
  };
428
445
  }
429
446
 
@@ -446,14 +463,7 @@ export async function _pickProviderInteractive() {
446
463
  // Non-TTY fallback — single-prompt picker, identical to before.
447
464
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
448
465
  process.stdout.write(`provider [${providers.join('|')}]: `);
449
- const ans = await new Promise((resolve) => {
450
- let buf = '';
451
- const onData = (chunk) => {
452
- buf += chunk.toString();
453
- if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
454
- };
455
- process.stdin.on('data', onData);
456
- });
466
+ const ans = await _readOneLine(process.stdin);
457
467
  // v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
458
468
  // whatever happened to be first in the registry (currently
459
469
  // anthropic). Pin to claude-cli to match the interactive onboard
@@ -510,8 +520,8 @@ export async function _pickProviderInteractive() {
510
520
  label: name,
511
521
  desc,
512
522
  tag: isCustom
513
- ? '\x1b[38;5;213m[custom]\x1b[0m'
514
- : (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
523
+ ? paint('38;5;213', '[custom]')
524
+ : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
515
525
  };
516
526
  });
517
527
  // Surface a "+ Add a new custom endpoint…" entry inside the API-key
@@ -523,7 +533,7 @@ export async function _pickProviderInteractive() {
523
533
  id: '__add_custom__',
524
534
  label: '+ Add a custom OpenAI-compatible endpoint…',
525
535
  desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
526
- tag: '\x1b[38;5;213m[new]\x1b[0m',
536
+ tag: paint('38;5;213', '[new]'),
527
537
  });
528
538
  }
529
539
  if (memberNames.length === 1 && family.id !== 'api') {
@@ -569,124 +579,10 @@ export async function _pickProviderInteractive() {
569
579
  return { provider: provider.id, model: picked };
570
580
  }
571
581
 
572
- // Step-3 alternative for composite providers (currently only the
573
- // orchestrator). Builds `cfg.orchestrator = { planner, workers,
574
- // maxSubtasks }` interactively and persists it before returning.
575
- //
576
- // planner: single picker over registered non-composite providers.
577
- // workers: multi-select with a running list + add/remove/done loop.
578
- // maxSubtasks: typed integer, default 5.
579
- export async function _setupOrchestratorInteractive() {
580
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
581
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
582
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
583
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
584
- const info = getRegistry().PROVIDER_INFO || {};
585
- const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
586
- if (eligibleNames.length === 0) {
587
- process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
588
- await _quickPrompt(' press Enter to continue ');
589
- return 'CANCEL';
590
- }
591
- const cfg = readConfig();
592
- const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
593
-
594
- // ── Pick planner ─────────────────────────────────────────────────
595
- const plannerItems = eligibleNames.map((name) => {
596
- const m = info[name] || {};
597
- const defaultModel = m.defaultModel || '';
598
- return {
599
- id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
600
- label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
601
- desc: defaultModel ? `default model: ${defaultModel}` : '',
602
- };
603
- });
604
- const plannerPick = await _arrowMenu({
605
- title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
606
- subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
607
- items: plannerItems,
608
- searchable: true,
609
- defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
610
- });
611
- if (plannerPick === 'CANCEL') return 'CANCEL';
612
- if (plannerPick === 'BACK') return 'BACK';
613
- const planner = plannerPick.id;
614
-
615
- // ── Pick workers (iterative add/remove) ──────────────────────────
616
- const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
617
- while (true) {
618
- process.stdout.write('\x1b[2J\x1b[H');
619
- process.stdout.write(accent('Orchestrator workers') + '\n');
620
- process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
621
- if (workers.length === 0) {
622
- process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
623
- } else {
624
- workers.forEach((w, i) => {
625
- process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
626
- });
627
- process.stdout.write('\n');
628
- }
629
- const items = [
630
- { id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
631
- { id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
632
- { id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
633
- ];
634
- const action = await _arrowMenu({
635
- title: 'LazyClaw setup — orchestrator workers',
636
- subtitle: `Planner: ${planner}`,
637
- items,
638
- });
639
- if (action === 'CANCEL') return 'CANCEL';
640
- if (action === 'BACK') return 'BACK';
641
- if (action.id === '__add__') {
642
- const wPick = await _arrowMenu({
643
- title: 'Add worker',
644
- subtitle: 'Picked entries are appended to the workers list.',
645
- items: plannerItems.filter((p) => !workers.includes(p.id)),
646
- searchable: true,
647
- });
648
- if (wPick === 'CANCEL' || wPick === 'BACK') continue;
649
- workers.push(wPick.id);
650
- continue;
651
- }
652
- if (action.id === '__remove__') {
653
- if (!workers.length) continue;
654
- const rPick = await _arrowMenu({
655
- title: 'Remove worker',
656
- subtitle: 'Highlighted entry is removed from the list.',
657
- items: workers.map((w) => ({ id: w, label: w })),
658
- });
659
- if (rPick === 'CANCEL' || rPick === 'BACK') continue;
660
- const idx = workers.indexOf(rPick.id);
661
- if (idx >= 0) workers.splice(idx, 1);
662
- continue;
663
- }
664
- if (action.id === '__done__') {
665
- if (workers.length === 0) continue;
666
- break;
667
- }
668
- }
669
-
670
- // ── maxSubtasks ──────────────────────────────────────────────────
671
- const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
672
- ? Math.min(10, existing.maxSubtasks)
673
- : 5;
674
- const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
675
- let maxSubtasks = defaultMax;
676
- if (rawMax) {
677
- const n = parseInt(rawMax, 10);
678
- if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
679
- }
680
-
681
- // ── Persist ──────────────────────────────────────────────────────
682
- cfg.orchestrator = { planner, workers, maxSubtasks };
683
- writeConfig(cfg);
684
- process.stdout.write('\n');
685
- process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
686
- `planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
687
- await _quickPrompt(' press Enter to continue ');
688
- return { ok: true };
689
- }
582
+ // _setupOrchestratorInteractive moved to tui/orchestrator_setup.mjs (it imports
583
+ // _arrowMenu / _quickPrompt back from here at call time → a safe cycle). The
584
+ // re-export line sits at the BOTTOM of this file, after those helpers are
585
+ // declared, so the live bindings resolve.
690
586
 
691
587
  // Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
692
588
  // (provider / model arrow menu) takes over the terminal. The sub-picker
@@ -735,25 +631,40 @@ export async function _pickModelInteractive(providerId, opts = {}) {
735
631
  let dynamicModels = [];
736
632
  while (true) {
737
633
  const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
738
- const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
634
+ // "Use the provider's own default" send no `-m`, the reliable path for
635
+ // keyless CLI providers (codex-cli / gemini-cli) whose allowed model set
636
+ // is fixed by the logged-in account: forcing a model the plan isn't
637
+ // entitled to makes the CLI reject the turn. Pre-selected when the
638
+ // provider declares no defaultModel.
639
+ const modelItems = [{
640
+ id: '__provider_default__',
641
+ label: "▷ Use the provider's own default model",
642
+ desc: 'no model override — the CLI/login picks (recommended for codex-cli / gemini-cli)',
643
+ tag: paint('38;5;208', '[default]'),
644
+ }];
739
645
  if (supportsLiveFetch) {
740
- modelItems.unshift({
646
+ modelItems.push({
741
647
  id: '__fetch_models__',
742
648
  label: '↻ Fetch live model list from /v1/models',
743
649
  desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
744
- tag: '\x1b[38;5;245m[live]\x1b[0m',
650
+ tag: paint('38;5;245', '[live]'),
745
651
  });
746
652
  }
653
+ for (const m of allModels) modelItems.push({ id: m, label: m, desc: '' });
747
654
  modelItems.push({
748
655
  id: '__custom_model__',
749
656
  label: '… type a custom model id',
750
657
  desc: 'use any model id supported by this provider, even if not listed above',
751
- tag: '\x1b[38;5;245m[free]\x1b[0m',
658
+ tag: paint('38;5;245', '[free]'),
752
659
  });
753
660
 
754
- const defaultIdx = supportsLiveFetch
755
- ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
756
- : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
661
+ // Land the cursor on the configured default model when there is one;
662
+ // otherwise pre-select "use the provider's own default" (index 0).
663
+ let defaultIdx = 0;
664
+ if (meta.defaultModel) {
665
+ const i = modelItems.findIndex((it) => it.id === meta.defaultModel);
666
+ if (i >= 0) defaultIdx = i;
667
+ }
757
668
  const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
758
669
  const picked = await _arrowMenu({
759
670
  title: `${titlePrefix}pick a model for ${providerId}`,
@@ -764,6 +675,8 @@ export async function _pickModelInteractive(providerId, opts = {}) {
764
675
  });
765
676
  if (picked === 'CANCEL') return 'CANCEL';
766
677
  if (picked === 'BACK') return 'BACK';
678
+ // Empty string = "no explicit model" (use the provider/CLI default).
679
+ if (picked.id === '__provider_default__') return '';
767
680
  if (picked.id === '__custom_model__') {
768
681
  const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
769
682
  if (!typed) continue;
@@ -774,7 +687,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
774
687
  process.stdout.write(`\n fetching ${providerId} model list…\n`);
775
688
  const fetched = await _fetchModelsForProvider(providerId);
776
689
  if (!fetched.length) {
777
- process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
690
+ process.stdout.write(` ${paint(33, 'no models returned')} — falling back to the suggested list.\n`);
778
691
  await _quickPrompt(' press Enter to continue ');
779
692
  } else {
780
693
  dynamicModels = fetched;
@@ -782,7 +695,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
782
695
  await _quickPrompt(' press Enter to pick one ');
783
696
  }
784
697
  } catch (e) {
785
- process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
698
+ process.stdout.write(`\n ${paint(33, 'fetch failed:')} ${e?.message || e}\n`);
786
699
  await _quickPrompt(' press Enter to continue ');
787
700
  }
788
701
  continue;
@@ -819,10 +732,10 @@ export async function _fetchModelsForProvider(providerId) {
819
732
  // Persists into cfg.customProviders[] and returns { name } on success,
820
733
  // or null when the user backs out.
821
734
  export async function _addCustomProviderInteractive() {
822
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
823
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
824
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
825
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
735
+ const accent = (s) => paint('38;5;208', s);
736
+ const dim = (s) => paint('2', s);
737
+ const bold = (s) => paint('1', s);
738
+ const ok = (s) => paint('32', s);
826
739
 
827
740
  process.stdout.write('\x1b[2J\x1b[H');
828
741
  process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
@@ -844,7 +757,7 @@ export async function _addCustomProviderInteractive() {
844
757
  }
845
758
  try { name = validateCustomProviderName(raw); }
846
759
  catch (e) {
847
- process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
760
+ process.stdout.write(` ${paint(33, e.message)} — try again.\n`);
848
761
  continue;
849
762
  }
850
763
  // OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
@@ -854,9 +767,9 @@ export async function _addCustomProviderInteractive() {
854
767
  // the override so it isn't a silent surprise.
855
768
  if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
856
769
  process.stdout.write(
857
- ` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
770
+ paint(2, `Note: "${name}" is a built-in OpenAI-compatible provider; ` +
858
771
  `your custom entry will override the built-in baseUrl/api-key for this install. ` +
859
- `Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
772
+ `Remove with: lazyclaw providers remove ${name}`) + '\n'
860
773
  );
861
774
  }
862
775
  break;
@@ -864,7 +777,7 @@ export async function _addCustomProviderInteractive() {
864
777
  const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
865
778
  if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
866
779
  if (!/^https?:\/\//i.test(baseUrlRaw)) {
867
- process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
780
+ process.stdout.write(` ${paint(33, 'baseUrl must start with http:// or https://')} — cancelled.\n`);
868
781
  return null;
869
782
  }
870
783
  const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
@@ -888,7 +801,7 @@ export async function _addCustomProviderInteractive() {
888
801
  } else if (result.probe.ok) {
889
802
  probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
890
803
  } else {
891
- probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${result.probe.error}\n`;
804
+ probeMsg = ` ${paint(33, '!')} registered, but /v1/models probe failed: ${result.probe.error}\n`;
892
805
  }
893
806
  process.stdout.write('\n');
894
807
  process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${entry.name} ${dim('→')} ${entry.baseUrl}\n`);
@@ -897,7 +810,15 @@ export async function _addCustomProviderInteractive() {
897
810
  await _quickPrompt(' press Enter to continue ');
898
811
  return { name };
899
812
  }
900
- export async function _quickPrompt(label) {
813
+ // Single-line readline prompt. Pass { secret: true } to mask the typed value
814
+ // (api keys, channel tokens) — the bytes are read in raw mode and echoed as
815
+ // bullets so the secret never appears on screen / scrollback / a screen-share.
816
+ // Non-TTY input can't be masked (no raw mode); it falls back to the plain read,
817
+ // which is fine for piped automation where there is no screen to leak to.
818
+ export async function _quickPrompt(label, opts = {}) {
819
+ if (opts.secret && process.stdin.isTTY && process.stdin.setRawMode) {
820
+ return _quickPromptSecret(label);
821
+ }
901
822
  const readline = await import('node:readline');
902
823
  process.stdout.write('\n');
903
824
  // Make sure stdin is in cooked / line-buffered mode for the
@@ -914,3 +835,52 @@ export async function _quickPrompt(label) {
914
835
  rl.close();
915
836
  return ans.trim();
916
837
  }
838
+
839
+ // Masked raw-mode reader: echoes one bullet per typed character, handles
840
+ // Backspace, Enter/Ctrl-D (submit), and Ctrl-C (abort → empty string). The real
841
+ // characters accumulate off-screen and are returned trimmed.
842
+ export async function _quickPromptSecret(label) {
843
+ const stdin = process.stdin;
844
+ process.stdout.write('\n' + label);
845
+ const wasRaw = !!stdin.isRaw;
846
+ try { stdin.setRawMode(true); } catch (_) {}
847
+ stdin.resume();
848
+ if (stdin.ref) stdin.ref();
849
+ const prevEnc = stdin.readableEncoding;
850
+ stdin.setEncoding('utf8');
851
+ let buf = '';
852
+ const value = await new Promise((resolve) => {
853
+ const onData = (chunk) => {
854
+ for (const ch of String(chunk)) {
855
+ if (ch === '\r' || ch === '\n' || ch === '\x04') { // Enter / Ctrl-D
856
+ cleanup(); resolve(buf); return;
857
+ }
858
+ if (ch === '\x03') { // Ctrl-C abort
859
+ cleanup(); resolve(''); return;
860
+ }
861
+ if (ch === '\x7f' || ch === '\x08') { // Backspace / Delete
862
+ if (buf.length) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
863
+ continue;
864
+ }
865
+ if (ch < ' ') continue; // ignore other control chars (arrows, etc.)
866
+ buf += ch;
867
+ process.stdout.write('•');
868
+ }
869
+ };
870
+ const cleanup = () => {
871
+ stdin.removeListener('data', onData);
872
+ try { stdin.setRawMode(wasRaw); } catch (_) {}
873
+ if (prevEnc) { try { stdin.setEncoding(prevEnc); } catch (_) {} }
874
+ process.stdout.write('\n');
875
+ };
876
+ stdin.on('data', onData);
877
+ });
878
+ return value.trim();
879
+ }
880
+
881
+ // Re-export the orchestrator setup wizard (moved to tui/orchestrator_setup.mjs).
882
+ // Kept at the BOTTOM so _arrowMenu / _quickPrompt — which that module imports
883
+ // back from here — are already declared when the cycle resolves. The helpers
884
+ // are hoisted top-level function declarations and used only at call time, so
885
+ // the pickers ↔ orchestrator_setup cycle never reads an uninitialised binding.
886
+ export { _setupOrchestratorInteractive } from './orchestrator_setup.mjs';