lazyclaw 6.3.0 → 6.4.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 (165) 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 +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
package/tui/pickers.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // Interactive TUI helpers — readline pickers, banner/mascot renderers,
2
- // arrow-key menu, provider/model selection, and the _quickPrompt line reader.
3
- // Extracted from cli.mjs in Phase D4. Lives in tui/ so banner asset imports
4
- // are siblings (./banner.generated.mjs, ./wordmark.mjs).
1
+ // Interactive TUI helpers — readline pickers, banner renderers, arrow-key
2
+ // menu, provider/model selection, _quickPrompt. Extracted from cli.mjs (D4);
3
+ // lives in tui/ so banner asset imports are siblings.
5
4
  import { readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
5
+ import { SLASH_COMMANDS } from './slash_commands.mjs';
6
6
  import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
7
  import { bucketProviders as _bucketProviders } from './provider_families.mjs';
8
8
  import { addCustomProvider } from '../providers/custom_provider.mjs';
@@ -11,15 +11,47 @@ 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
+
16
+ // Read exactly ONE line from a non-TTY stream (piped stdin) and hand the rest
17
+ // back so the next reader (the next picker / a credential prompt) still gets
18
+ // its input. The old fallbacks resolved the whole buffered payload at the
19
+ // first newline — matching no id (skip) and swallowing every later line so the
20
+ // following prompt hung. Pause before unshift so the buffered remainder isn't
21
+ // dropped while no 'data' listener is attached.
22
+ export function _readOneLine(stream) {
23
+ return new Promise((resolve) => {
24
+ let buf = '';
25
+ const done = (line) => {
26
+ stream.off('data', onData);
27
+ stream.off('end', onEnd);
28
+ resolve(line.replace(/\r$/, '').trim());
29
+ };
30
+ const onData = (chunk) => {
31
+ buf += chunk.toString();
32
+ const nl = buf.indexOf('\n');
33
+ if (nl === -1) return;
34
+ stream.pause();
35
+ const rest = buf.slice(nl + 1);
36
+ if (rest) stream.unshift(Buffer.from(rest, 'utf8'));
37
+ done(buf.slice(0, nl));
38
+ };
39
+ const onEnd = () => done(buf);
40
+ stream.on('data', onData);
41
+ stream.on('end', onEnd);
42
+ // A prior _readOneLine pauses the stream after unshifting its remainder.
43
+ // Adding a 'data' listener does NOT auto-resume an explicitly-paused
44
+ // stream, so resume() here or the next read would block on buffered input.
45
+ stream.resume();
46
+ });
47
+ }
14
48
 
15
49
  export function _attachGhostAutocomplete(rl) {
16
- // Returns `{ dispose, suspend, resume }`. Dispose detaches the
17
- // keypress + rl 'line' listeners (failure to do so leaks the
18
- // event-loop ref, which is exactly the slow-exit bug v3.92
19
- // fixed). Suspend / resume gate the keypress handler so the
20
- // streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
21
- // ghost-render escapes — that interleaving is what surfaces as
22
- // visible gaps between Korean characters in long replies.
50
+ // Returns `{ dispose, suspend, resume }`. Dispose detaches the keypress +
51
+ // rl 'line' listeners (leaking them is the v3.92 slow-exit bug). Suspend /
52
+ // resume gate the keypress handler so streaming chat output isn't
53
+ // interleaved with `\x1b[s\x1b[K\x1b[u` ghost-render escapes that
54
+ // interleaving surfaced as visible gaps between Korean characters.
23
55
  const noop = () => {};
24
56
  if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
25
57
  const cmds = SLASH_COMMANDS.map((c) => c.cmd);
@@ -109,36 +141,15 @@ export function _attachGhostAutocomplete(rl) {
109
141
  };
110
142
  }
111
143
 
112
- // LazyClaw banner — printed once at the top of every interactive chat
113
- // session so users see the active provider/model before they start
114
- // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
115
- // invocations stay clean for tests/scripts).
116
- // Single source of truth for the LazyClaw banner — used by the chat
117
- // REPL header, the no-arg launcher, and the first-run welcome panel.
118
- // Returns an array of pre-formatted lines (with ANSI colour) so the
119
- // caller can splice in additional rows without re-implementing the
120
- // alignment.
121
- //
122
- // Width-management rule: every inner line is forced through
123
- // `.padEnd(W)` so a stray width miscount can't punch the right
124
- // border off the box (which is exactly the bug v3.99.5 shipped:
125
- // v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
126
- // previous mixed-colour banner (helmet-red letter art + ink-beige
127
- // caption) read as "two banners glued together" because the colour
128
- // changed mid-box. We use one warm orange (#F08246) for everything —
129
- // border, letter art, caption — so the eye reads it as one badge.
130
- //
131
- // Letter art is figlet "standard" (6 rows) rather than the v3.99.11
132
- // "small" (4 rows), because small renders as a pixel mush in most
133
- // terminal fonts. Standard's strokes are wide enough that the
134
- // letters read as `l a z y` even at small terminal sizes.
135
- //
136
- // Layout invariant: every inner row is exactly INNER_W visible cells
137
- // (no double-width glyphs, all chars are 1 cell in any monospace
138
- // font), so the right edge `│` always lands in the same column.
144
+ // LazyClaw banner — single source of truth (chat REPL header, no-arg
145
+ // launcher, first-run welcome). Printed once so users see the active
146
+ // provider/model; plain ANSI, auto-skipped when stdout isn't a TTY.
147
+ // Returns an array of pre-formatted lines the caller can splice rows into.
139
148
  //
140
- // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
141
- // caller doesn't crash; no state-coloured art is produced any more.
149
+ // Layout invariants: every inner row is forced through `.padEnd(INNER_W)`
150
+ // and is exactly INNER_W single-cell glyphs, so the right border `│` always
151
+ // lands in the same column. _renderMascot / _renderMascotTiny are stubs kept
152
+ // so any leftover caller doesn't crash.
142
153
 
143
154
  const _ORANGE_RGB = '241;130;70'; // #F18246
144
155
  export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
@@ -219,15 +230,16 @@ export async function _renderV5Banner(version) {
219
230
  return rows;
220
231
  }
221
232
 
222
- export function _printChatBanner(activeProvName, activeModel, version) {
233
+ export async function _printChatBanner(activeProvName, activeModel, version) {
223
234
  if (!process.stdout.isTTY) return;
224
- // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
225
- // four caption rows below the box read as part of the same warm badge.
235
+ // Single-hue header: labels dim-orange, values/emphasis full-orange. Uses
236
+ // the v5 sloth splash (NOT the retired v4 figlet box see _renderV5Banner;
237
+ // figlet remains only as the missing-asset last resort).
226
238
  const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
227
239
  const orange = _orange;
228
240
  const lines = [
229
241
  '',
230
- ..._renderBanner(version),
242
+ ...(await _renderV5Banner(version)),
231
243
  '',
232
244
  ` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
233
245
  ` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
@@ -262,14 +274,7 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
262
274
  process.stderr.write(`${title}\n`);
263
275
  items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
264
276
  process.stderr.write('pick (number / id, blank for first): ');
265
- const ans = await new Promise((resolve) => {
266
- let buf = '';
267
- const onData = (chunk) => {
268
- buf += chunk.toString();
269
- if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
270
- };
271
- process.stdin.on('data', onData);
272
- });
277
+ const ans = await _readOneLine(process.stdin);
273
278
  if (!ans) return items[0];
274
279
  const byNum = parseInt(ans, 10);
275
280
  if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
@@ -287,9 +292,18 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
287
292
  // before drawing so the picker always receives the first keypress.
288
293
  process.stdin.resume();
289
294
  if (process.stdin.ref) process.stdin.ref();
290
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
291
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
292
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
295
+ // Render on the ALTERNATE screen buffer (the same trick vim / less / fzf
296
+ // use). Without it, the menu's full-screen \x1b[2J clears land on the main
297
+ // buffer, interleave with the readline output from prior wizard steps, and
298
+ // every step pushes a screenful into scrollback — the "화면이 밀린다" bug.
299
+ // On the alt buffer the menu draws in isolation; leaving it (cleanup) restores
300
+ // the main buffer (and the wizard text on it) verbatim, with nothing pushed.
301
+ const altScreen = !!(process.stdout.isTTY);
302
+ if (altScreen) process.stdout.write('\x1b[?1049h');
303
+ // Menu chrome via the theme gate: plain text under NO_COLOR / dumb / non-TTY.
304
+ const accent = (s) => paint('38;5;208', s);
305
+ const dim = (s) => paint('2', s);
306
+ const bold = (s) => paint('1', s);
293
307
 
294
308
  // Typeahead state. `query` accumulates printable chars when searchable
295
309
  // is on; the visible item slice is recomputed on every keystroke. We
@@ -382,7 +396,10 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
382
396
  cleanup();
383
397
  resolve(view[idx]);
384
398
  }
385
- else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
399
+ // Ctrl+C mid-wizard cancels this step (resolve the CANCEL sentinel the
400
+ // callers already handle) instead of hard-killing the whole process —
401
+ // a wizard step must be abortable without taking the app down.
402
+ else if (key.ctrl && key.name === 'c') { cleanup(); resolve('CANCEL'); }
386
403
  else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
387
404
  else if (key.name === 'escape') {
388
405
  if (searchable && query) { query = ''; recompute(); return; }
@@ -404,12 +421,50 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
404
421
  const cleanup = () => {
405
422
  process.stdin.off('keypress', onKey);
406
423
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
407
- process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
424
+ // Show the cursor, then leave the alt screen → the main buffer (with the
425
+ // wizard's prior output) reappears exactly as it was, nothing pushed into
426
+ // scrollback. Fall back to a clear+home when the alt buffer wasn't used.
427
+ if (altScreen) process.stdout.write('\x1b[?25h\x1b[?1049l');
428
+ else process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
429
+ // Release stdin so a one-shot CLI caller (the setup / onboard wizard) can
430
+ // exit. We resume()+ref() stdin on entry to receive keypresses; if it's
431
+ // never unref'd, the event loop stays alive after the LAST picker and the
432
+ // process hangs at "Setup complete" instead of returning to the shell.
433
+ // The chat REPL re-refs stdin (via _pauseChatForSubMenu's finally) and
434
+ // its own readline keeps the loop alive, so this is safe there.
435
+ if (process.stdin.unref) process.stdin.unref();
408
436
  };
409
437
  process.stdin.on('keypress', onKey);
410
438
  });
411
439
  }
412
440
 
441
+ // Arrow-key yes/no — replaces the typed `[Y/n]` prompts in the wizard so the
442
+ // user never types a letter. Returns a boolean. Esc / q resolve to the default.
443
+ // Inherits _arrowMenu's non-TTY fallback (reads a line). `pick` is injectable
444
+ // for tests.
445
+ export async function _pickYesNo(title, { subtitle, yesLabel = 'Yes', noLabel = 'No', defaultYes = true, pick = _arrowMenu } = {}) {
446
+ const picked = await pick({
447
+ title,
448
+ subtitle,
449
+ items: [
450
+ { id: 'yes', label: yesLabel },
451
+ { id: 'no', label: noLabel },
452
+ ],
453
+ defaultIdx: defaultYes ? 0 : 1,
454
+ });
455
+ if (picked === 'BACK' || picked === 'CANCEL' || picked == null) return defaultYes;
456
+ const id = typeof picked === 'object' ? picked.id : picked;
457
+ return id === 'yes';
458
+ }
459
+
460
+ // Arrow-key single choice — `options` is [{ id, label, desc }]. Returns the
461
+ // chosen id, or `fallback` on Esc/cancel. `pick` injectable for tests.
462
+ export async function _pickChoice(title, options, { subtitle, defaultIdx = 0, fallback = null, pick = _arrowMenu } = {}) {
463
+ const picked = await pick({ title, subtitle, items: options, defaultIdx, searchable: false });
464
+ if (picked === 'BACK' || picked === 'CANCEL' || picked == null) return fallback;
465
+ return typeof picked === 'object' ? picked.id : picked;
466
+ }
467
+
413
468
  // Bucket every registered provider into one of three auth-method
414
469
  // families. The picker's first step asks the user which family
415
470
  // they want before drilling into specific providers — much less
@@ -421,10 +476,10 @@ export function _providerFamilies() {
421
476
  // tui/provider_families.mjs; the ANSI tags below are readline-specific.
422
477
  const b = _bucketProviders(getRegistry());
423
478
  return {
424
- api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: b.api },
425
- 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 },
426
- 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 },
427
- mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: b.mock },
479
+ api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: paint('38;5;245', '[needs key]'), members: b.api },
480
+ 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 },
481
+ meta: { label: 'Multi-agent', desc: 'orchestrator — fan a task out to a planner + workers (advanced)', tag: paint('38;5;245', '[meta]'), members: b.meta },
482
+ mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: paint('38;5;245', '[test]'), members: b.mock },
428
483
  };
429
484
  }
430
485
 
@@ -447,14 +502,7 @@ export async function _pickProviderInteractive() {
447
502
  // Non-TTY fallback — single-prompt picker, identical to before.
448
503
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
449
504
  process.stdout.write(`provider [${providers.join('|')}]: `);
450
- const ans = await new Promise((resolve) => {
451
- let buf = '';
452
- const onData = (chunk) => {
453
- buf += chunk.toString();
454
- if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
455
- };
456
- process.stdin.on('data', onData);
457
- });
505
+ const ans = await _readOneLine(process.stdin);
458
506
  // v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
459
507
  // whatever happened to be first in the registry (currently
460
508
  // anthropic). Pin to claude-cli to match the interactive onboard
@@ -511,8 +559,8 @@ export async function _pickProviderInteractive() {
511
559
  label: name,
512
560
  desc,
513
561
  tag: isCustom
514
- ? '\x1b[38;5;213m[custom]\x1b[0m'
515
- : (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
562
+ ? paint('38;5;213', '[custom]')
563
+ : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
516
564
  };
517
565
  });
518
566
  // Surface a "+ Add a new custom endpoint…" entry inside the API-key
@@ -524,7 +572,7 @@ export async function _pickProviderInteractive() {
524
572
  id: '__add_custom__',
525
573
  label: '+ Add a custom OpenAI-compatible endpoint…',
526
574
  desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
527
- tag: '\x1b[38;5;213m[new]\x1b[0m',
575
+ tag: paint('38;5;213', '[new]'),
528
576
  });
529
577
  }
530
578
  if (memberNames.length === 1 && family.id !== 'api') {
@@ -578,10 +626,10 @@ export async function _pickProviderInteractive() {
578
626
  // workers: multi-select with a running list + add/remove/done loop.
579
627
  // maxSubtasks: typed integer, default 5.
580
628
  export async function _setupOrchestratorInteractive() {
581
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
582
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
583
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
584
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
629
+ const accent = (s) => paint('38;5;208', s);
630
+ const dim = (s) => paint('2', s);
631
+ const bold = (s) => paint('1', s);
632
+ const ok = (s) => paint('32', s);
585
633
  const info = getRegistry().PROVIDER_INFO || {};
586
634
  const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
587
635
  if (eligibleNames.length === 0) {
@@ -736,25 +784,40 @@ export async function _pickModelInteractive(providerId, opts = {}) {
736
784
  let dynamicModels = [];
737
785
  while (true) {
738
786
  const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
739
- const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
787
+ // "Use the provider's own default" send no `-m`, the reliable path for
788
+ // keyless CLI providers (codex-cli / gemini-cli) whose allowed model set
789
+ // is fixed by the logged-in account: forcing a model the plan isn't
790
+ // entitled to makes the CLI reject the turn. Pre-selected when the
791
+ // provider declares no defaultModel.
792
+ const modelItems = [{
793
+ id: '__provider_default__',
794
+ label: "▷ Use the provider's own default model",
795
+ desc: 'no model override — the CLI/login picks (recommended for codex-cli / gemini-cli)',
796
+ tag: paint('38;5;208', '[default]'),
797
+ }];
740
798
  if (supportsLiveFetch) {
741
- modelItems.unshift({
799
+ modelItems.push({
742
800
  id: '__fetch_models__',
743
801
  label: '↻ Fetch live model list from /v1/models',
744
802
  desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
745
- tag: '\x1b[38;5;245m[live]\x1b[0m',
803
+ tag: paint('38;5;245', '[live]'),
746
804
  });
747
805
  }
806
+ for (const m of allModels) modelItems.push({ id: m, label: m, desc: '' });
748
807
  modelItems.push({
749
808
  id: '__custom_model__',
750
809
  label: '… type a custom model id',
751
810
  desc: 'use any model id supported by this provider, even if not listed above',
752
- tag: '\x1b[38;5;245m[free]\x1b[0m',
811
+ tag: paint('38;5;245', '[free]'),
753
812
  });
754
813
 
755
- const defaultIdx = supportsLiveFetch
756
- ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
757
- : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
814
+ // Land the cursor on the configured default model when there is one;
815
+ // otherwise pre-select "use the provider's own default" (index 0).
816
+ let defaultIdx = 0;
817
+ if (meta.defaultModel) {
818
+ const i = modelItems.findIndex((it) => it.id === meta.defaultModel);
819
+ if (i >= 0) defaultIdx = i;
820
+ }
758
821
  const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
759
822
  const picked = await _arrowMenu({
760
823
  title: `${titlePrefix}pick a model for ${providerId}`,
@@ -765,6 +828,8 @@ export async function _pickModelInteractive(providerId, opts = {}) {
765
828
  });
766
829
  if (picked === 'CANCEL') return 'CANCEL';
767
830
  if (picked === 'BACK') return 'BACK';
831
+ // Empty string = "no explicit model" (use the provider/CLI default).
832
+ if (picked.id === '__provider_default__') return '';
768
833
  if (picked.id === '__custom_model__') {
769
834
  const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
770
835
  if (!typed) continue;
@@ -775,7 +840,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
775
840
  process.stdout.write(`\n fetching ${providerId} model list…\n`);
776
841
  const fetched = await _fetchModelsForProvider(providerId);
777
842
  if (!fetched.length) {
778
- process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
843
+ process.stdout.write(` ${paint(33, 'no models returned')} — falling back to the suggested list.\n`);
779
844
  await _quickPrompt(' press Enter to continue ');
780
845
  } else {
781
846
  dynamicModels = fetched;
@@ -783,7 +848,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
783
848
  await _quickPrompt(' press Enter to pick one ');
784
849
  }
785
850
  } catch (e) {
786
- process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
851
+ process.stdout.write(`\n ${paint(33, 'fetch failed:')} ${e?.message || e}\n`);
787
852
  await _quickPrompt(' press Enter to continue ');
788
853
  }
789
854
  continue;
@@ -820,10 +885,10 @@ export async function _fetchModelsForProvider(providerId) {
820
885
  // Persists into cfg.customProviders[] and returns { name } on success,
821
886
  // or null when the user backs out.
822
887
  export async function _addCustomProviderInteractive() {
823
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
824
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
825
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
826
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
888
+ const accent = (s) => paint('38;5;208', s);
889
+ const dim = (s) => paint('2', s);
890
+ const bold = (s) => paint('1', s);
891
+ const ok = (s) => paint('32', s);
827
892
 
828
893
  process.stdout.write('\x1b[2J\x1b[H');
829
894
  process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
@@ -845,7 +910,7 @@ export async function _addCustomProviderInteractive() {
845
910
  }
846
911
  try { name = validateCustomProviderName(raw); }
847
912
  catch (e) {
848
- process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
913
+ process.stdout.write(` ${paint(33, e.message)} — try again.\n`);
849
914
  continue;
850
915
  }
851
916
  // OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
@@ -855,9 +920,9 @@ export async function _addCustomProviderInteractive() {
855
920
  // the override so it isn't a silent surprise.
856
921
  if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
857
922
  process.stdout.write(
858
- ` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
923
+ paint(2, `Note: "${name}" is a built-in OpenAI-compatible provider; ` +
859
924
  `your custom entry will override the built-in baseUrl/api-key for this install. ` +
860
- `Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
925
+ `Remove with: lazyclaw providers remove ${name}`) + '\n'
861
926
  );
862
927
  }
863
928
  break;
@@ -865,7 +930,7 @@ export async function _addCustomProviderInteractive() {
865
930
  const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
866
931
  if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
867
932
  if (!/^https?:\/\//i.test(baseUrlRaw)) {
868
- process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
933
+ process.stdout.write(` ${paint(33, 'baseUrl must start with http:// or https://')} — cancelled.\n`);
869
934
  return null;
870
935
  }
871
936
  const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
@@ -889,7 +954,7 @@ export async function _addCustomProviderInteractive() {
889
954
  } else if (result.probe.ok) {
890
955
  probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
891
956
  } else {
892
- probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${result.probe.error}\n`;
957
+ probeMsg = ` ${paint(33, '!')} registered, but /v1/models probe failed: ${result.probe.error}\n`;
893
958
  }
894
959
  process.stdout.write('\n');
895
960
  process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${entry.name} ${dim('→')} ${entry.baseUrl}\n`);
@@ -898,7 +963,15 @@ export async function _addCustomProviderInteractive() {
898
963
  await _quickPrompt(' press Enter to continue ');
899
964
  return { name };
900
965
  }
901
- export async function _quickPrompt(label) {
966
+ // Single-line readline prompt. Pass { secret: true } to mask the typed value
967
+ // (api keys, channel tokens) — the bytes are read in raw mode and echoed as
968
+ // bullets so the secret never appears on screen / scrollback / a screen-share.
969
+ // Non-TTY input can't be masked (no raw mode); it falls back to the plain read,
970
+ // which is fine for piped automation where there is no screen to leak to.
971
+ export async function _quickPrompt(label, opts = {}) {
972
+ if (opts.secret && process.stdin.isTTY && process.stdin.setRawMode) {
973
+ return _quickPromptSecret(label);
974
+ }
902
975
  const readline = await import('node:readline');
903
976
  process.stdout.write('\n');
904
977
  // Make sure stdin is in cooked / line-buffered mode for the
@@ -915,3 +988,45 @@ export async function _quickPrompt(label) {
915
988
  rl.close();
916
989
  return ans.trim();
917
990
  }
991
+
992
+ // Masked raw-mode reader: echoes one bullet per typed character, handles
993
+ // Backspace, Enter/Ctrl-D (submit), and Ctrl-C (abort → empty string). The real
994
+ // characters accumulate off-screen and are returned trimmed.
995
+ export async function _quickPromptSecret(label) {
996
+ const stdin = process.stdin;
997
+ process.stdout.write('\n' + label);
998
+ const wasRaw = !!stdin.isRaw;
999
+ try { stdin.setRawMode(true); } catch (_) {}
1000
+ stdin.resume();
1001
+ if (stdin.ref) stdin.ref();
1002
+ const prevEnc = stdin.readableEncoding;
1003
+ stdin.setEncoding('utf8');
1004
+ let buf = '';
1005
+ const value = await new Promise((resolve) => {
1006
+ const onData = (chunk) => {
1007
+ for (const ch of String(chunk)) {
1008
+ if (ch === '\r' || ch === '\n' || ch === '\x04') { // Enter / Ctrl-D
1009
+ cleanup(); resolve(buf); return;
1010
+ }
1011
+ if (ch === '\x03') { // Ctrl-C abort
1012
+ cleanup(); resolve(''); return;
1013
+ }
1014
+ if (ch === '\x7f' || ch === '\x08') { // Backspace / Delete
1015
+ if (buf.length) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
1016
+ continue;
1017
+ }
1018
+ if (ch < ' ') continue; // ignore other control chars (arrows, etc.)
1019
+ buf += ch;
1020
+ process.stdout.write('•');
1021
+ }
1022
+ };
1023
+ const cleanup = () => {
1024
+ stdin.removeListener('data', onData);
1025
+ try { stdin.setRawMode(wasRaw); } catch (_) {}
1026
+ if (prevEnc) { try { stdin.setEncoding(prevEnc); } catch (_) {} }
1027
+ process.stdout.write('\n');
1028
+ };
1029
+ stdin.on('data', onData);
1030
+ });
1031
+ return value.trim();
1032
+ }