lazyclaw 6.3.1 → 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 +90 -33
  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 +196 -80
  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
@@ -11,6 +11,40 @@ 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
50
  // Returns `{ dispose, suspend, resume }`. Dispose detaches the keypress +
@@ -107,36 +141,15 @@ export function _attachGhostAutocomplete(rl) {
107
141
  };
108
142
  }
109
143
 
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.
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.
137
148
  //
138
- // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
139
- // 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.
140
153
 
141
154
  const _ORANGE_RGB = '241;130;70'; // #F18246
142
155
  export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
@@ -261,14 +274,7 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
261
274
  process.stderr.write(`${title}\n`);
262
275
  items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
263
276
  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
- });
277
+ const ans = await _readOneLine(process.stdin);
272
278
  if (!ans) return items[0];
273
279
  const byNum = parseInt(ans, 10);
274
280
  if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
@@ -286,9 +292,18 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
286
292
  // before drawing so the picker always receives the first keypress.
287
293
  process.stdin.resume();
288
294
  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`;
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);
292
307
 
293
308
  // Typeahead state. `query` accumulates printable chars when searchable
294
309
  // is on; the visible item slice is recomputed on every keystroke. We
@@ -381,7 +396,10 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
381
396
  cleanup();
382
397
  resolve(view[idx]);
383
398
  }
384
- 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'); }
385
403
  else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
386
404
  else if (key.name === 'escape') {
387
405
  if (searchable && query) { query = ''; recompute(); return; }
@@ -403,12 +421,50 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
403
421
  const cleanup = () => {
404
422
  process.stdin.off('keypress', onKey);
405
423
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
406
- 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();
407
436
  };
408
437
  process.stdin.on('keypress', onKey);
409
438
  });
410
439
  }
411
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
+
412
468
  // Bucket every registered provider into one of three auth-method
413
469
  // families. The picker's first step asks the user which family
414
470
  // they want before drilling into specific providers — much less
@@ -420,10 +476,10 @@ export function _providerFamilies() {
420
476
  // tui/provider_families.mjs; the ANSI tags below are readline-specific.
421
477
  const b = _bucketProviders(getRegistry());
422
478
  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 },
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 },
427
483
  };
428
484
  }
429
485
 
@@ -446,14 +502,7 @@ export async function _pickProviderInteractive() {
446
502
  // Non-TTY fallback — single-prompt picker, identical to before.
447
503
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
448
504
  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
- });
505
+ const ans = await _readOneLine(process.stdin);
457
506
  // v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
458
507
  // whatever happened to be first in the registry (currently
459
508
  // anthropic). Pin to claude-cli to match the interactive onboard
@@ -510,8 +559,8 @@ export async function _pickProviderInteractive() {
510
559
  label: name,
511
560
  desc,
512
561
  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'),
562
+ ? paint('38;5;213', '[custom]')
563
+ : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
515
564
  };
516
565
  });
517
566
  // Surface a "+ Add a new custom endpoint…" entry inside the API-key
@@ -523,7 +572,7 @@ export async function _pickProviderInteractive() {
523
572
  id: '__add_custom__',
524
573
  label: '+ Add a custom OpenAI-compatible endpoint…',
525
574
  desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
526
- tag: '\x1b[38;5;213m[new]\x1b[0m',
575
+ tag: paint('38;5;213', '[new]'),
527
576
  });
528
577
  }
529
578
  if (memberNames.length === 1 && family.id !== 'api') {
@@ -577,10 +626,10 @@ export async function _pickProviderInteractive() {
577
626
  // workers: multi-select with a running list + add/remove/done loop.
578
627
  // maxSubtasks: typed integer, default 5.
579
628
  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`;
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);
584
633
  const info = getRegistry().PROVIDER_INFO || {};
585
634
  const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
586
635
  if (eligibleNames.length === 0) {
@@ -735,25 +784,40 @@ export async function _pickModelInteractive(providerId, opts = {}) {
735
784
  let dynamicModels = [];
736
785
  while (true) {
737
786
  const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
738
- 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
+ }];
739
798
  if (supportsLiveFetch) {
740
- modelItems.unshift({
799
+ modelItems.push({
741
800
  id: '__fetch_models__',
742
801
  label: '↻ Fetch live model list from /v1/models',
743
802
  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',
803
+ tag: paint('38;5;245', '[live]'),
745
804
  });
746
805
  }
806
+ for (const m of allModels) modelItems.push({ id: m, label: m, desc: '' });
747
807
  modelItems.push({
748
808
  id: '__custom_model__',
749
809
  label: '… type a custom model id',
750
810
  desc: 'use any model id supported by this provider, even if not listed above',
751
- tag: '\x1b[38;5;245m[free]\x1b[0m',
811
+ tag: paint('38;5;245', '[free]'),
752
812
  });
753
813
 
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]));
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
+ }
757
821
  const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
758
822
  const picked = await _arrowMenu({
759
823
  title: `${titlePrefix}pick a model for ${providerId}`,
@@ -764,6 +828,8 @@ export async function _pickModelInteractive(providerId, opts = {}) {
764
828
  });
765
829
  if (picked === 'CANCEL') return 'CANCEL';
766
830
  if (picked === 'BACK') return 'BACK';
831
+ // Empty string = "no explicit model" (use the provider/CLI default).
832
+ if (picked.id === '__provider_default__') return '';
767
833
  if (picked.id === '__custom_model__') {
768
834
  const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
769
835
  if (!typed) continue;
@@ -774,7 +840,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
774
840
  process.stdout.write(`\n fetching ${providerId} model list…\n`);
775
841
  const fetched = await _fetchModelsForProvider(providerId);
776
842
  if (!fetched.length) {
777
- 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`);
778
844
  await _quickPrompt(' press Enter to continue ');
779
845
  } else {
780
846
  dynamicModels = fetched;
@@ -782,7 +848,7 @@ export async function _pickModelInteractive(providerId, opts = {}) {
782
848
  await _quickPrompt(' press Enter to pick one ');
783
849
  }
784
850
  } catch (e) {
785
- 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`);
786
852
  await _quickPrompt(' press Enter to continue ');
787
853
  }
788
854
  continue;
@@ -819,10 +885,10 @@ export async function _fetchModelsForProvider(providerId) {
819
885
  // Persists into cfg.customProviders[] and returns { name } on success,
820
886
  // or null when the user backs out.
821
887
  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`;
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);
826
892
 
827
893
  process.stdout.write('\x1b[2J\x1b[H');
828
894
  process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
@@ -844,7 +910,7 @@ export async function _addCustomProviderInteractive() {
844
910
  }
845
911
  try { name = validateCustomProviderName(raw); }
846
912
  catch (e) {
847
- process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
913
+ process.stdout.write(` ${paint(33, e.message)} — try again.\n`);
848
914
  continue;
849
915
  }
850
916
  // OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
@@ -854,9 +920,9 @@ export async function _addCustomProviderInteractive() {
854
920
  // the override so it isn't a silent surprise.
855
921
  if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
856
922
  process.stdout.write(
857
- ` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
923
+ paint(2, `Note: "${name}" is a built-in OpenAI-compatible provider; ` +
858
924
  `your custom entry will override the built-in baseUrl/api-key for this install. ` +
859
- `Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
925
+ `Remove with: lazyclaw providers remove ${name}`) + '\n'
860
926
  );
861
927
  }
862
928
  break;
@@ -864,7 +930,7 @@ export async function _addCustomProviderInteractive() {
864
930
  const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
865
931
  if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
866
932
  if (!/^https?:\/\//i.test(baseUrlRaw)) {
867
- 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`);
868
934
  return null;
869
935
  }
870
936
  const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
@@ -888,7 +954,7 @@ export async function _addCustomProviderInteractive() {
888
954
  } else if (result.probe.ok) {
889
955
  probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
890
956
  } else {
891
- 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`;
892
958
  }
893
959
  process.stdout.write('\n');
894
960
  process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${entry.name} ${dim('→')} ${entry.baseUrl}\n`);
@@ -897,7 +963,15 @@ export async function _addCustomProviderInteractive() {
897
963
  await _quickPrompt(' press Enter to continue ');
898
964
  return { name };
899
965
  }
900
- 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
+ }
901
975
  const readline = await import('node:readline');
902
976
  process.stdout.write('\n');
903
977
  // Make sure stdin is in cooked / line-buffered mode for the
@@ -914,3 +988,45 @@ export async function _quickPrompt(label) {
914
988
  rl.close();
915
989
  return ans.trim();
916
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
+ }