lazyclaw 6.7.0 → 6.9.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.
package/commands/chat.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from '../lib/config.mjs';
12
12
  import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
13
13
  import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
14
+ import { LAZYCLAW_META_GUARD } from '../lib/nl_config_command.mjs';
14
15
  import {
15
16
  _attachGhostAutocomplete, _fetchModelsForProvider,
16
17
  _pickProviderInteractive, _printChatBanner,
@@ -176,7 +177,7 @@ export async function cmdChat(flags = {}) {
176
177
  } catch (err) { console.error(`skill error: ${err.message}`); process.exit(2); }
177
178
  }
178
179
  if (_inkSysParts.length && !_inkMessages.some((m) => m.role === 'system')) {
179
- const merged = _inkSysParts.join('\n\n---\n\n');
180
+ const merged = [..._inkSysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
180
181
  _inkMessages.unshift({ role: 'system', content: merged });
181
182
  if (_inkSessionId) sessionsMod.appendTurn(_inkSessionId, 'system', merged, _inkCfgDir);
182
183
  }
@@ -219,6 +220,7 @@ export async function cmdChat(flags = {}) {
219
220
  setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
220
221
  getProv: () => prov,
221
222
  setProv: (next) => { prov = wrapInteractiveProv(next); },
223
+ lookupProv,
222
224
  getActiveProvName: () => activeProvName,
223
225
  // Persist provider/model picks so they survive a restart (was
224
226
  // in-memory only — a model chosen via /model reverted to cfg.model on
@@ -455,7 +457,7 @@ export async function cmdChat(flags = {}) {
455
457
  }
456
458
  }
457
459
  if (sysParts.length && !messages.some(m => m.role === 'system')) {
458
- const merged = sysParts.join('\n\n---\n\n');
460
+ const merged = [...sysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
459
461
  messages.unshift({ role: 'system', content: merged });
460
462
  if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
461
463
  }
@@ -651,11 +653,8 @@ export async function cmdChat(flags = {}) {
651
653
  // listener from a previous run doesn't crash the launch with EADDRINUSE.
652
654
  // Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
653
655
 
654
-
655
-
656
656
  // sandbox subcommands — list/test/add/use (Phase D).
657
657
 
658
-
659
658
  // Interactive launcher — fired when the user types `lazyclaw` with
660
659
  // no subcommand AND we're attached to a TTY. OpenClaw's launcher
661
660
  // pattern: ASCII banner + provider/model status + arrow-key menu of
@@ -12,6 +12,9 @@ import {
12
12
  } from '../lib/config.mjs';
13
13
  import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
14
14
  import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
15
+ import { runPermissionStep } from './setup_permission.mjs';
16
+ import { runWizardSteps } from '../tui/wizard_back.mjs';
17
+ import { promptWithBack } from '../tui/prompt_back.mjs';
15
18
  import {
16
19
  _attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
17
20
  _pickModelInteractive, _pickProviderInteractive, _printChatBanner,
@@ -94,10 +97,6 @@ export async function cmdOnboard(flags) {
94
97
  console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
95
98
  }
96
99
 
97
-
98
-
99
-
100
-
101
100
  export function cmdHelp(name) {
102
101
  if (!name) {
103
102
  process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
@@ -118,8 +117,6 @@ export function cmdHelp(name) {
118
117
  process.stdout.write(detail + '\n');
119
118
  }
120
119
 
121
-
122
-
123
120
  export async function cmdSetup(_sub, _positional, flags = {}) {
124
121
  await ensureRegistry();
125
122
  const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
@@ -175,9 +172,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
175
172
  }
176
173
  process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
177
174
 
178
- // Context window asked right after the model pick (optional; Enter keeps
179
- // defaults). Not a numbered step: it's part of the core model setup.
180
- await runContextStep({ prompt: _quickPrompt, colors });
175
+ // Context window + tool-permission mode core chat setup, looped so Esc on
176
+ // permission goes back one step to the context window (and Esc on the custom
177
+ // entries goes back too). Part of the model setup, not numbered steps.
178
+ const _backPrompt = (label) => promptWithBack(label);
179
+ await runWizardSteps(['context', 'permission'], (id) => (id === 'context'
180
+ ? runContextStep({ prompt: _quickPrompt, backPrompt: _backPrompt, colors })
181
+ : runPermissionStep({ prompt: _quickPrompt, backPrompt: _backPrompt, colors, cfg: cfgAfterOnboard })));
181
182
  }
182
183
 
183
184
  // ── Step 2/7: Verify one clean chat works ───────────────────
@@ -703,4 +704,3 @@ export async function cmdLauncher() {
703
704
  process.exit(0);
704
705
  }
705
706
 
706
-
@@ -303,10 +303,12 @@ export async function runWebhookStep({ prompt, colors, write = (s) => process.st
303
303
  // Context-window step (asked right after the model pick): how much past
304
304
  // conversation to keep each turn — a sliding history budget, NOT the model's
305
305
  // hard limit. Enter on both prompts keeps the defaults.
306
- export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
306
+ // Returns 'BACK' (Esc) / 'NEXT' for the wizard back-loop. backPrompt
307
+ // (promptWithBack) makes Esc reachable on the custom number entry.
308
+ export async function runContextStep({ prompt, backPrompt, colors, write = (s) => process.stdout.write(s) }) {
307
309
  const { dim, ok } = colors;
308
310
  const cf = await import('../config_features.mjs');
309
- const { _pickChoice } = await import('../tui/pickers.mjs');
311
+ const { _arrowMenu } = await import('../tui/pickers.mjs');
310
312
  const cur = cf.chatWindowGet(readConfig());
311
313
  write(` ${dim(`Context window — how much past conversation to send each turn (sliding budget, not the model's hard limit). Now: ${cur.turns} turns · ${cur.tokens} tokens.`)}\n\n`);
312
314
  // Pick a preset (or "custom" → type the two numbers). Presets cover the
@@ -318,12 +320,23 @@ export async function runContextStep({ prompt, colors, write = (s) => process.st
318
320
  { id: 'large', label: 'Large — 80 turns · 32k tokens', desc: 'more context, costs more', turns: 80, tokens: 32000 },
319
321
  { id: 'custom', label: 'Custom…', desc: 'type your own turns + token budget' },
320
322
  ];
321
- const choice = await _pickChoice('Context window', PRESETS, { subtitle: 'how much past conversation to send each turn', fallback: 'keep' });
322
- if (choice === 'keep') { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
323
+ // _arrowMenu (not _pickChoice) so Esc is distinguishable as BACK, not folded
324
+ // into the "keep" fallback.
325
+ const picked = await _arrowMenu({ title: 'Context window', subtitle: 'Esc to go back · how much past conversation to send each turn', items: PRESETS });
326
+ if (picked === 'BACK') return 'BACK';
327
+ const choice = (picked === 'CANCEL' || picked == null) ? 'keep' : (typeof picked === 'object' ? picked.id : picked);
328
+ if (choice === 'keep') { write(` ${dim('— kept defaults —')}\n\n`); return 'NEXT'; }
323
329
  let turns, tokens;
324
330
  if (choice === 'custom') {
325
- turns = parseInt((await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim(), 10);
326
- tokens = parseInt((await prompt(` token budget (Enter = ${cur.tokens}): `)).trim(), 10);
331
+ if (typeof backPrompt === 'function') {
332
+ const t = await backPrompt(` turns to keep (Esc = back · Enter = ${cur.turns}): `); if (t && t.back) return 'BACK';
333
+ const k = await backPrompt(` token budget (Esc = back · Enter = ${cur.tokens}): `); if (k && k.back) return 'BACK';
334
+ turns = parseInt(String((t && t.value) || '').trim(), 10);
335
+ tokens = parseInt(String((k && k.value) || '').trim(), 10);
336
+ } else {
337
+ turns = parseInt((await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim(), 10);
338
+ tokens = parseInt((await prompt(` token budget (Enter = ${cur.tokens}): `)).trim(), 10);
339
+ }
327
340
  } else {
328
341
  const p = PRESETS.find((x) => x.id === choice);
329
342
  turns = p ? p.turns : NaN;
@@ -338,7 +351,7 @@ export async function runContextStep({ prompt, colors, write = (s) => process.st
338
351
  const w = cf.chatWindowGet(cfg);
339
352
  write(` ${ok('✓ context window:')} ${w.turns} turns · ${w.tokens} tokens\n`);
340
353
  write(` ${dim('change later: /context turns <N> | tokens <N>')}\n\n`);
341
- return { skipped: false, ...w };
354
+ return 'NEXT';
342
355
  }
343
356
 
344
357
  // Optional orchestration step: enable the multi-agent planner+workers pipeline.
@@ -0,0 +1,45 @@
1
+ // commands/setup_permission.mjs — the setup-wizard "tool permissions" step,
2
+ // extracted from commands/setup.mjs to stay under the file-size gate (D8) and
3
+ // to match the other modular steps (runContextStep / runChannelStep / …).
4
+ //
5
+ // Asks how the claude-cli agent should handle tool permissions and stores the
6
+ // choice at cfg.chat.permissionMode. lib/permission_mode.resolvePermissionMode
7
+ // reads it for every claude spawn; unset defaults to bypass (no nagging).
8
+ //
9
+ // Returns 'BACK' when the user pressed Esc (the wizard re-runs the previous
10
+ // step), else 'NEXT'. A `backPrompt` (tui/prompt_back.promptWithBack) makes Esc
11
+ // reachable; without it we fall back to the plain string `prompt` (no Esc).
12
+
13
+ import { readConfig, writeConfig } from '../lib/config.mjs';
14
+ import { parsePermissionChoice } from '../lib/permission_mode.mjs';
15
+
16
+ export async function runPermissionStep({ prompt, backPrompt, colors, cfg } = {}) {
17
+ const accent = (colors && colors.accent) || ((s) => s);
18
+ const dim = (colors && colors.dim) || ((s) => s);
19
+ const ok = (colors && colors.ok) || ((s) => s);
20
+ const provider = String((cfg || readConfig()).provider || '');
21
+ // Only relevant to the claude-cli agent path (the one that spawns `claude`).
22
+ if (!/claude/.test(provider)) return 'NEXT';
23
+
24
+ process.stdout.write(` ${dim('Tool permissions — when the agent edits files or runs commands:')}\n`);
25
+ process.stdout.write(` ${dim(' bypass = never ask (fast) · ask = prompt each time · acceptEdits = auto-accept edits only · plan = read-only')}\n`);
26
+ const label = ` ${accent('permission')} [bypass/ask/acceptEdits/plan] ${dim('(Esc = back · Enter = bypass)')}: `;
27
+ let answer;
28
+ if (typeof backPrompt === 'function') {
29
+ const r = await backPrompt(label);
30
+ if (r && r.back) return 'BACK';
31
+ answer = r ? r.value : '';
32
+ } else {
33
+ answer = await prompt(label);
34
+ }
35
+ const mode = parsePermissionChoice(answer);
36
+ if (mode) {
37
+ const c = readConfig();
38
+ c.chat = { ...(c.chat || {}), permissionMode: mode };
39
+ writeConfig(c);
40
+ process.stdout.write(` ${ok('✓ permission mode:')} ${mode}\n\n`);
41
+ } else {
42
+ process.stdout.write(` ${dim('(unrecognised — kept the current setting)')}\n\n`);
43
+ }
44
+ return 'NEXT';
45
+ }
@@ -0,0 +1,122 @@
1
+ // lib/nl_config_command.mjs — turn a few high-signal natural-language requests
2
+ // into REAL lazyclaw config changes, so the chat model can't just reply
3
+ // "done" without anything happening (the user's bug: orchestrator on/off and
4
+ // planner/worker model swaps typed in chat were hallucinated as success).
5
+ //
6
+ // Deliberately CONSERVATIVE — it must never hijack a genuine chat turn. It only
7
+ // fires on short, imperative messages that clearly name a setting + an action,
8
+ // and bails on anything that looks like a question. Everything it doesn't catch
9
+ // falls through to the model (where the system-prompt honesty guard tells it to
10
+ // point at the real command instead of claiming success).
11
+
12
+ import { resolveModelAlias } from '../providers/claude_cli.mjs';
13
+ import { orchestratorGet, orchestratorSet, orchestratorEnable } from '../config_features.mjs';
14
+
15
+ // Appended to the chat system prompt (when one exists) so the model stops
16
+ // claiming it changed a setting it can't reach. The common orchestrator on/off
17
+ // and planner/worker swaps ARE applied for real by detectConfigCommand above;
18
+ // everything else must be redirected to a command, not faked.
19
+ export const LAZYCLAW_META_GUARD =
20
+ 'You are running inside the lazyclaw CLI. You CANNOT change lazyclaw\'s own configuration ' +
21
+ '(provider, orchestrator planner/workers, models, agents, teams) from this chat — you have no tool for it. ' +
22
+ 'If the user asks you to change a setting, do NOT claim you did it. Instead give the exact command: ' +
23
+ '`/orchestrator off | planner <provider:model> | worker add <provider:model>`, `lazyclaw agent add …`, ' +
24
+ '`lazyclaw team …`, or `lazyclaw config set <key> <value>`. ' +
25
+ '(Plain-language orchestrator on/off and planner/worker model changes are applied automatically — others are not.)';
26
+
27
+ const ORCH = /오케스트라|오케스트레이터|오케스트레이션|orchestrat/i;
28
+ const OFF = /\boff\b|\bdisable\b|끄|꺼|비활성/i;
29
+ const ON = /\bon\b|\benable\b|켜|활성/i;
30
+ const KO_MODEL = [['소넷', 'sonnet'], ['쏘넷', 'sonnet'], ['하이쿠', 'haiku'], ['오퍼스', 'opus'], ['오푸스', 'opus']];
31
+ const MODEL_TOKEN = /\b(opus|sonnet|haiku|gpt-?[0-9][\w.-]*|gemini-?[0-9][\w.-]*|o[0-9]|claude-[a-z0-9-]+)\b/i;
32
+
33
+ // Questions / explanations must pass straight through to the model.
34
+ function looksLikeQuestion(t) {
35
+ return /[??]/.test(t) ||
36
+ /(법|방법|어떻게|어케|뭐|무엇|설명|알려|차이|왜|how\b|what\b|why\b|explain|difference|\bvs\b)/i.test(t);
37
+ }
38
+
39
+ function extractModel(t) {
40
+ for (const [ko, en] of KO_MODEL) if (t.includes(ko)) return en;
41
+ const m = MODEL_TOKEN.exec(t);
42
+ if (!m) return null;
43
+ const tok = m[1].toLowerCase();
44
+ return resolveModelAlias(tok) || tok;
45
+ }
46
+
47
+ // Returns an intent object or null. Intents:
48
+ // { kind: 'orchestrator', enable: boolean }
49
+ // { kind: 'planner'|'worker', model: string }
50
+ export function detectConfigCommand(text) {
51
+ const t = String(text || '').trim();
52
+ if (!t || t.length > 80) return null; // long → a real task, not a command
53
+ if (looksLikeQuestion(t)) return null;
54
+
55
+ if (ORCH.test(t)) {
56
+ if (OFF.test(t)) return { kind: 'orchestrator', enable: false }; // 비활성 → off (checked first)
57
+ if (ON.test(t)) return { kind: 'orchestrator', enable: true };
58
+ }
59
+
60
+ const wantsPlanner = /플래너|planner/i.test(t);
61
+ const wantsWorker = /워커|worker/i.test(t);
62
+ if (wantsPlanner && wantsWorker) return null; // ambiguous — do them in separate messages
63
+ if (wantsPlanner || wantsWorker) {
64
+ const model = extractModel(t);
65
+ if (model) return { kind: wantsPlanner ? 'planner' : 'worker', model };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ const provOf = (spec) => {
71
+ const s = String(spec || '');
72
+ return s.includes(':') ? s.split(':')[0] : (s || 'claude-cli');
73
+ };
74
+
75
+ // Apply a detected intent for real. deps: { readConfig, writeConfig, ctxCfg? }.
76
+ // Returns a short confirmation string to show the user.
77
+ export function applyConfigCommand(intent, deps) {
78
+ const cfg = deps.readConfig();
79
+ const syncCtx = () => {
80
+ if (deps.ctxCfg) { deps.ctxCfg.provider = cfg.provider; deps.ctxCfg.orchestrator = cfg.orchestrator; }
81
+ };
82
+
83
+ if (intent.kind === 'orchestrator') {
84
+ orchestratorEnable(cfg, intent.enable);
85
+ deps.writeConfig(cfg); syncCtx();
86
+ return intent.enable
87
+ ? '✓ Orchestrator ON — chats route through planner + workers. Set models with `/orchestrator planner <provider:model>` and `/orchestrator worker add <provider:model>`.'
88
+ : `✓ Orchestrator off — chats route to \`${cfg.provider}\`.`;
89
+ }
90
+
91
+ const o = orchestratorGet(cfg);
92
+ if (intent.kind === 'planner') {
93
+ const spec = `${provOf(o.planner)}:${intent.model}`;
94
+ orchestratorSet(cfg, { planner: spec });
95
+ deps.writeConfig(cfg); syncCtx();
96
+ return `✓ Planner → \`${spec}\`.`;
97
+ }
98
+ // worker — set the workers list to the chosen model (preserves the provider).
99
+ const spec = `${provOf((o.workers && o.workers[0]) || o.planner)}:${intent.model}`;
100
+ orchestratorSet(cfg, { workers: [spec] });
101
+ deps.writeConfig(cfg); syncCtx();
102
+ return `✓ Workers → [\`${spec}\`].`;
103
+ }
104
+
105
+ // After an orchestrator on/off flips cfg.provider, re-point the REPL's LIVE
106
+ // provider state from cfg — otherwise the status bar AND the next turn keep
107
+ // using the old provider (the bug: "orchestrator off" but it still replies as
108
+ // orchestrator). The HUD reads activeProvName and run_turn reads ctx.getProv(),
109
+ // neither of which follows cfg.provider on its own. Best-effort; safe to call
110
+ // from any host (missing setters are skipped).
111
+ export function refreshLiveProvider(ctx) {
112
+ if (!ctx || !ctx.cfg) return;
113
+ const name = ctx.cfg.provider;
114
+ if (!name) return;
115
+ try { if (typeof ctx.setActiveProvName === 'function') ctx.setActiveProvName(name); } catch { /* ignore */ }
116
+ try {
117
+ const lookup = ctx.lookupProv || (ctx.registryMod && ((n) => ctx.registryMod.PROVIDERS && ctx.registryMod.PROVIDERS[n]));
118
+ const p = lookup && lookup(name);
119
+ if (p && typeof ctx.setProv === 'function') ctx.setProv(p);
120
+ } catch { /* ignore */ }
121
+ try { if (typeof ctx.setActiveModel === 'function') ctx.setActiveModel(ctx.cfg.model || null); } catch { /* ignore */ }
122
+ }
@@ -0,0 +1,36 @@
1
+ // lib/permission_mode.mjs — the claude-cli permission mode lazyclaw passes to
2
+ // the spawned `claude` (`--permission-mode <mode>`).
3
+ //
4
+ // lazyclaw is an autonomous-agent CLI: the agentic / team path already runs the
5
+ // agent with bypassPermissions so it doesn't stop to ask before every tool. The
6
+ // interactive chat path historically passed no flag, so claude fell back to its
7
+ // own "default" mode and prompted on each tool — annoying when you just want it
8
+ // to run. This centralises the choice: `cfg.chat.permissionMode` (asked at
9
+ // setup) drives every claude spawn; unset defaults to bypassPermissions so the
10
+ // out-of-the-box experience doesn't nag. Cautious users pick 'default' (prompt
11
+ // each time), 'acceptEdits' (auto-accept edits, prompt for the rest), or 'plan'.
12
+
13
+ export const PERMISSION_MODES = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
14
+
15
+ export const DEFAULT_PERMISSION_MODE = 'bypassPermissions';
16
+
17
+ // Resolve the effective mode from config. An unset or unrecognised value falls
18
+ // back to the default rather than handing claude an invalid --permission-mode.
19
+ export function resolvePermissionMode(cfg) {
20
+ const m = cfg && cfg.chat && cfg.chat.permissionMode;
21
+ return PERMISSION_MODES.includes(m) ? m : DEFAULT_PERMISSION_MODE;
22
+ }
23
+
24
+ // Map a setup-wizard answer to a permission mode. Empty (Enter) → the default
25
+ // (bypass). Returns null for an unrecognised answer so the caller can keep the
26
+ // current value rather than guess.
27
+ const CHOICE_MAP = {
28
+ '': DEFAULT_PERMISSION_MODE, bypass: 'bypassPermissions', b: 'bypassPermissions',
29
+ ask: 'default', default: 'default', d: 'default', prompt: 'default',
30
+ acceptedits: 'acceptEdits', accept: 'acceptEdits', edits: 'acceptEdits', e: 'acceptEdits',
31
+ plan: 'plan', p: 'plan',
32
+ };
33
+ export function parsePermissionChoice(answer) {
34
+ const k = String(answer ?? '').trim().toLowerCase();
35
+ return Object.prototype.hasOwnProperty.call(CHOICE_MAP, k) ? CHOICE_MAP[k] : null;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.7.0",
3
+ "version": "6.9.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -142,6 +142,10 @@ export function buildArgs(prompt, opts = {}) {
142
142
  const maxTurns = opts.maxTurns == null ? 1 : opts.maxTurns;
143
143
  if (maxTurns > 0) args.push('--max-turns', String(maxTurns));
144
144
  args.push('--tools', opts.tools == null ? '' : String(opts.tools));
145
+ // Permission mode (e.g. bypassPermissions) so the agent doesn't stop to ask
146
+ // before every tool. Only emitted when set — the caller centralises the
147
+ // default via lib/permission_mode.resolvePermissionMode.
148
+ if (opts.permissionMode) args.push('--permission-mode', String(opts.permissionMode));
145
149
  const modelAlias = resolveModelAlias(opts.model);
146
150
  if (modelAlias) args.push('--model', modelAlias);
147
151
  return args;
@@ -174,7 +178,7 @@ export const claudeCliProvider = {
174
178
  const { getSession } = await import('./claude_cli_session.mjs');
175
179
  const session = getSession(opts.sessionKey, {
176
180
  bin, model: opts.model, cwd: opts.cwd, lean: opts.lean,
177
- maxTurns: opts.maxTurns, tools: opts.tools,
181
+ maxTurns: opts.maxTurns, tools: opts.tools, permissionMode: opts.permissionMode,
178
182
  system: opts.sessionSystem || opts.system || messages.find((m) => m.role === 'system')?.content,
179
183
  });
180
184
  let yielded = false;
@@ -52,6 +52,7 @@ class ClaudeSession {
52
52
  const mt = o.maxTurns == null ? 1 : o.maxTurns;
53
53
  if (mt > 0) args.push('--max-turns', String(mt));
54
54
  args.push('--tools', o.tools == null ? '' : String(o.tools));
55
+ if (o.permissionMode) args.push('--permission-mode', String(o.permissionMode));
55
56
  const model = resolveModelAlias(o.model);
56
57
  if (model) args.push('--model', model);
57
58
  if (o.system && String(o.system).trim()) args.push('--append-system-prompt', String(o.system));
package/tui/pickers.mjs CHANGED
@@ -499,84 +499,82 @@ export async function _pickProviderInteractive() {
499
499
  }
500
500
 
501
501
  // ── Step 2 — provider in that family ──────────────────────────
502
- let provider = null;
503
- while (!provider) {
504
- const memberNames = families[family.id].members;
505
- const provItems = memberNames.map((name) => {
506
- const meta = info[name] || {};
507
- const isCustom = !!meta.custom;
508
- const isBuiltinCompat = !!meta.builtinOpenAICompat;
509
- // Step-2 desc used to preview four suggested model ids per provider.
510
- // That made the row read like "gemini · models: gemini-2.5-pro ·
511
- // gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
512
- // which is too dense and partly redundant — step 3 already shows the
513
- // full curated list. Keep the row to a vendor label + endpoint hint.
514
- let desc = '';
515
- if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
516
- else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
517
- else if (meta.label && meta.label !== name) desc = meta.label;
518
- return {
519
- id: name,
520
- label: name,
521
- desc,
522
- tag: isCustom
523
- ? paint('38;5;213', '[custom]')
524
- : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
525
- };
526
- });
527
- // Surface a "+ Add a new custom endpoint…" entry inside the API-key
528
- // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
529
- // speak the OpenAI Chat-Completions wire format — this single hook
530
- // covers every one of them without shipping a per-vendor provider.
531
- if (family.id === 'api') {
532
- provItems.push({
533
- id: '__add_custom__',
534
- label: '+ Add a custom OpenAI-compatible endpoint…',
535
- desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
536
- tag: paint('38;5;213', '[new]'),
502
+ for (;;) { // loop Steps 2+3: Esc in Step 3 returns to Step 2, not Step 1
503
+ let provider = null;
504
+ while (!provider) {
505
+ const memberNames = families[family.id].members;
506
+ const provItems = memberNames.map((name) => {
507
+ const meta = info[name] || {};
508
+ const isCustom = !!meta.custom;
509
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
510
+ // Step-2 row: vendor label + endpoint hint only (Step 3 shows models).
511
+ let desc = '';
512
+ if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
513
+ else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
514
+ else if (meta.label && meta.label !== name) desc = meta.label;
515
+ return {
516
+ id: name,
517
+ label: name,
518
+ desc,
519
+ tag: isCustom
520
+ ? paint('38;5;213', '[custom]')
521
+ : (meta.requiresApiKey ? paint('38;5;245', '[api key]') : paint('38;5;208', '[no key]')),
522
+ };
537
523
  });
524
+ // Surface a "+ Add a new custom endpoint…" entry inside the API-key
525
+ // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
526
+ // speak the OpenAI Chat-Completions wire format — this single hook
527
+ // covers every one of them without shipping a per-vendor provider.
528
+ if (family.id === 'api') {
529
+ provItems.push({
530
+ id: '__add_custom__',
531
+ label: '+ Add a custom OpenAI-compatible endpoint…',
532
+ desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
533
+ tag: paint('38;5;213', '[new]'),
534
+ });
535
+ }
536
+ if (memberNames.length === 1 && family.id !== 'api') {
537
+ // Auto-advance — no point making the user pick from a single row,
538
+ // unless we just appended the "+ Add custom" entry above.
539
+ provider = { id: memberNames[0] };
540
+ break;
541
+ }
542
+ const picked = await _arrowMenu({
543
+ title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
544
+ subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
545
+ items: provItems,
546
+ searchable: true,
547
+ });
548
+ if (picked === 'CANCEL') return null;
549
+ if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
550
+ if (picked && picked.id === '__add_custom__') {
551
+ const added = await _addCustomProviderInteractive();
552
+ if (!added) continue; // back to provider list
553
+ // Force the registry to pick up the new entry and recompute the
554
+ // family bucket for the next loop iteration.
555
+ await ensureRegistry();
556
+ Object.assign(families, _providerFamilies());
557
+ provider = { id: added.name };
558
+ break;
559
+ }
560
+ provider = picked;
538
561
  }
539
- if (memberNames.length === 1 && family.id !== 'api') {
540
- // Auto-advance no point making the user pick from a single row,
541
- // unless we just appended the "+ Add custom" entry above.
542
- provider = { id: memberNames[0] };
543
- break;
544
- }
545
- const picked = await _arrowMenu({
546
- title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
547
- subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
548
- items: provItems,
549
- searchable: true,
562
+
563
+ // ── Step 3 model picker ────────────────────────────────────────
564
+ // v5.3.2 the setup wizard no longer surfaces composite providers
565
+ // (orchestrator is filtered out of _providerFamilies above), so this
566
+ // step is just the regular model picker. The orchestrator wizard
567
+ // (_setupOrchestratorInteractive) stays reachable via the dedicated
568
+ // `lazyclaw orchestrator …` subcommand and an explicit
569
+ // `--provider orchestrator` invocation.
570
+ const picked = await _pickModelInteractive(provider.id, {
571
+ titlePrefix: 'LazyClaw setup — Step 3 of 3:',
572
+ onBack: 'restart',
550
573
  });
551
574
  if (picked === 'CANCEL') return null;
552
- if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
553
- if (picked && picked.id === '__add_custom__') {
554
- const added = await _addCustomProviderInteractive();
555
- if (!added) continue; // back to provider list
556
- // Force the registry to pick up the new entry and recompute the
557
- // family bucket for the next loop iteration.
558
- await ensureRegistry();
559
- Object.assign(families, _providerFamilies());
560
- provider = { id: added.name };
561
- break;
562
- }
563
- provider = picked;
575
+ if (picked === 'BACK') continue; // Esc in Step 3 -> back to Step 2 (one step)
576
+ return { provider: provider.id, model: picked };
564
577
  }
565
-
566
- // ── Step 3 — model picker ────────────────────────────────────────
567
- // v5.3.2 — the setup wizard no longer surfaces composite providers
568
- // (orchestrator is filtered out of _providerFamilies above), so this
569
- // step is just the regular model picker. The orchestrator wizard
570
- // (_setupOrchestratorInteractive) stays reachable via the dedicated
571
- // `lazyclaw orchestrator …` subcommand and an explicit
572
- // `--provider orchestrator` invocation.
573
- const picked = await _pickModelInteractive(provider.id, {
574
- titlePrefix: 'LazyClaw setup — Step 3 of 3:',
575
- onBack: 'restart',
576
- });
577
- if (picked === 'CANCEL') return null;
578
- if (picked === 'BACK') return _pickProviderInteractive();
579
- return { provider: provider.id, model: picked };
580
578
  }
581
579
 
582
580
  // _setupOrchestratorInteractive moved to tui/orchestrator_setup.mjs (it imports
@@ -0,0 +1,85 @@
1
+ // tui/prompt_back.mjs — a raw-mode line prompt that can return BACK on Esc.
2
+ //
3
+ // The setup wizard's typed questions used cooked-mode readline, which never
4
+ // sees Esc (it only resolves on Enter), so "Esc to go back a step" did nothing.
5
+ // This reads keys in raw mode: Enter submits, Esc goes back, arrow keys (which
6
+ // are Esc-PREFIXED sequences) are ignored, Backspace edits, Ctrl-C cancels.
7
+ //
8
+ // classifyKey is pure (unit-tested); promptWithBack is driven by an injectable
9
+ // input/output so it's testable with a fake TTY (no real keyboard).
10
+
11
+ // Map a raw input chunk to an action. A LONE Esc is "back"; an Esc that begins
12
+ // an escape sequence (\x1b[ or \x1bO — arrows, function keys) is "escseq" and
13
+ // must be ignored so navigation keys don't fire "back".
14
+ export function classifyKey(chunk) {
15
+ const s = String(chunk == null ? '' : chunk);
16
+ if (s === '\x1b') return { type: 'back' };
17
+ if (/^\x1b[[O]/.test(s)) return { type: 'escseq' };
18
+ if (s === '\r' || s === '\n' || s === '\r\n') return { type: 'submit' };
19
+ if (s === '\x03') return { type: 'cancel' }; // Ctrl-C
20
+ if (s === '\x7f' || s === '\b') return { type: 'backspace' };
21
+ const text = s.replace(/[\x00-\x1f\x7f]/g, ''); // strip control bytes
22
+ return text ? { type: 'text', text } : { type: 'ignore' };
23
+ }
24
+
25
+ // Prompt for a line, resolving { value, back }. `back` is true when the user
26
+ // pressed Esc. Falls back to a plain (no-Esc) readline on a non-TTY input.
27
+ // escDelayMs disambiguates a lone Esc from an Esc-sequence that arrives split
28
+ // across two data events (rare, but some terminals do it).
29
+ export function promptWithBack(label, opts = {}) {
30
+ const input = opts.input || process.stdin;
31
+ const output = opts.output || process.stdout;
32
+ const escDelayMs = opts.escDelayMs == null ? 40 : opts.escDelayMs;
33
+
34
+ if (!(input.isTTY && typeof input.setRawMode === 'function')) {
35
+ return _plainLine(label, input, output);
36
+ }
37
+
38
+ return new Promise((resolve) => {
39
+ output.write('\n' + label);
40
+ try { input.setRawMode(true); } catch { /* ignore */ }
41
+ input.resume();
42
+ let buf = '';
43
+ let escTimer = null;
44
+ const finish = (result) => {
45
+ if (escTimer) { clearTimeout(escTimer); escTimer = null; }
46
+ input.off('data', onData);
47
+ try { input.setRawMode(false); } catch { /* ignore */ }
48
+ output.write('\n');
49
+ resolve(result);
50
+ };
51
+ const onData = (d) => {
52
+ const s = d.toString('utf8');
53
+ // A lone Esc byte: wait briefly — if a [ / O follows it was an arrow/nav
54
+ // sequence (ignore), otherwise it's a real Esc (back).
55
+ if (s === '\x1b') {
56
+ escTimer = setTimeout(() => { escTimer = null; finish({ value: '', back: true }); }, escDelayMs);
57
+ return;
58
+ }
59
+ if (escTimer) {
60
+ clearTimeout(escTimer); escTimer = null;
61
+ if (/^[[O]/.test(s)) return; // the tail of a split Esc-sequence
62
+ }
63
+ const k = classifyKey(s);
64
+ switch (k.type) {
65
+ case 'escseq': case 'ignore': return;
66
+ case 'back': return finish({ value: '', back: true });
67
+ case 'submit': return finish({ value: buf.trim(), back: false });
68
+ case 'cancel': finish({ value: '', back: false, cancel: true }); try { process.exit(130); } catch { /* tests */ } return;
69
+ case 'backspace': if (buf) { buf = buf.slice(0, -1); output.write('\b \b'); } return;
70
+ case 'text': buf += k.text; output.write(k.text); return;
71
+ default: return;
72
+ }
73
+ };
74
+ input.on('data', onData);
75
+ });
76
+ }
77
+
78
+ async function _plainLine(label, input, output) {
79
+ const readline = await import('node:readline');
80
+ output.write('\n');
81
+ const rl = readline.createInterface({ input, output });
82
+ const value = await new Promise((res) => rl.question(label, res));
83
+ rl.close();
84
+ return { value: String(value).trim(), back: false };
85
+ }
package/tui/run_turn.mjs CHANGED
@@ -28,6 +28,9 @@
28
28
  import { Chalk } from 'chalk';
29
29
  import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
30
30
  import { defaultSandboxSpec } from '../sandbox/index.mjs';
31
+ import { resolvePermissionMode } from '../lib/permission_mode.mjs';
32
+ import { detectConfigCommand, applyConfigCommand, refreshLiveProvider } from '../lib/nl_config_command.mjs';
33
+ import { readConfig as _readCfg, writeConfig as _writeCfg } from '../lib/config.mjs';
31
34
 
32
35
  // Force ANSI on these turn-status markers regardless of stdout TTY detection:
33
36
  // the Ink path routes them through React state (Ink preserves embedded SGR
@@ -206,6 +209,29 @@ async function _runAgenticTurn({ ctx, messages, sysMsg, activeProvName, activeMo
206
209
  export function makeRunTurn({ ctx, writeFn }) {
207
210
  return async function runTurn(text, signal) {
208
211
  if (signal?.aborted) return;
212
+
213
+ // Natural-language config commands (orchestrator on/off, planner/worker
214
+ // model) are applied for REAL here — otherwise the model just replies
215
+ // "done" while nothing changes (the reported bug). Conservative matcher;
216
+ // anything it doesn't catch falls through to the model below.
217
+ const _cmd = detectConfigCommand(text);
218
+ if (_cmd) {
219
+ const _msgs = ctx.getMessages();
220
+ _msgs.push({ role: 'user', content: text });
221
+ ctx.persistTurn('user', text);
222
+ let reply;
223
+ try {
224
+ reply = applyConfigCommand(_cmd, { readConfig: _readCfg, writeConfig: _writeCfg, ctxCfg: ctx.cfg });
225
+ // orchestrator on/off flips the active provider — re-point the live REPL
226
+ // provider so it stops replying as orchestrator + the HUD refreshes.
227
+ if (_cmd.kind === 'orchestrator') refreshLiveProvider(ctx);
228
+ } catch (e) { reply = `⚠ couldn't apply that change: ${e?.message || e}`; }
229
+ try { writeFn(reply + '\n'); } catch { /* sink best-effort */ }
230
+ _msgs.push({ role: 'assistant', content: reply });
231
+ ctx.persistTurn('assistant', reply);
232
+ return;
233
+ }
234
+
209
235
  const messages = ctx.getMessages();
210
236
  messages.push({ role: 'user', content: text });
211
237
  try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
@@ -296,6 +322,9 @@ export function makeRunTurn({ ctx, writeFn }) {
296
322
  // a one-shot spawn on any session failure, and the session respawns if
297
323
  // the system prompt changes (plan-mode toggle) so it can't go stale.
298
324
  persistent: ctx.cfg?.chat?.persistentSession !== false,
325
+ // claude-cli permission mode (cfg.chat.permissionMode, asked at setup;
326
+ // defaults to bypassPermissions). claude-cli reads it; others ignore it.
327
+ permissionMode: resolvePermissionMode(ctx.cfg),
299
328
  sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
300
329
  ...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
301
330
  ...(maxTokens !== undefined ? { maxTokens } : {}),
@@ -4,16 +4,36 @@
4
4
  // Extracted from repl.mjs so the HUD can grow without pushing repl.mjs over the
5
5
  // file-size ratchet.
6
6
 
7
- import React from 'react';
7
+ import React, { useState, useEffect } from 'react';
8
8
  import { Box, Text } from 'ink';
9
9
  import { theme } from './theme.mjs';
10
10
  import { formatHudRow, formatGauge } from './hud.mjs';
11
11
 
12
+ // How fast the streaming dot pulses, in ms. Exported so it's not a magic number.
13
+ export const BLINK_MS = 450;
14
+
15
+ // The leading status glyph. While streaming, the dot pulses (bright ↔ dim) so
16
+ // there's a live "something is happening" signal; idle is a steady hollow dot.
17
+ // Pure (takes the current blink phase) so it's unit-testable without a timer.
18
+ export function streamingIndicator(streaming, blinkOn, t = theme) {
19
+ if (!streaming) return t.dim('○ idle');
20
+ return blinkOn ? t.accent('● streaming') : t.dim('● streaming');
21
+ }
22
+
12
23
  export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal, hud }) {
24
+ // Pulse the streaming dot. The interval only runs while streaming and is torn
25
+ // down as soon as the turn ends (or the bar unmounts).
26
+ const [blinkOn, setBlinkOn] = useState(true);
27
+ useEffect(() => {
28
+ if (!streaming) { setBlinkOn(true); return undefined; }
29
+ const id = setInterval(() => setBlinkOn((b) => !b), BLINK_MS);
30
+ return () => clearInterval(id);
31
+ }, [streaming]);
32
+
13
33
  // Numbers are computed upstream (chat-history budget, not provider self-report);
14
34
  // formatGauge only changes the RENDERING — adds percent + bar + warn marker.
15
35
  const ctx = (ctxUsed != null && ctxTotal != null) ? formatGauge(ctxUsed, ctxTotal) : '--';
16
- const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
36
+ const indicator = streamingIndicator(streaming, blinkOn);
17
37
  const prov = provider || '?';
18
38
  const mdl = model || '?';
19
39
  const hudRow = hud ? formatHudRow(hud) : '';
@@ -0,0 +1,19 @@
1
+ // tui/wizard_back.mjs — drive a sequence of setup-wizard steps with Esc-back.
2
+ //
3
+ // Each step runs its own I/O and returns one of:
4
+ // 'BACK' — the user pressed Esc → re-run the previous step
5
+ // 'CANCEL' — abort the whole group
6
+ // (anything else) — advance to the next step
7
+ // Esc on the FIRST step stays on it (there's nothing before it in the group).
8
+ // Pure control flow (the steps do the prompting) so it's unit-testable.
9
+
10
+ export async function runWizardSteps(stepIds, runStep) {
11
+ let i = 0;
12
+ while (i < stepIds.length) {
13
+ const r = await runStep(stepIds[i], i);
14
+ if (r === 'CANCEL') return 'CANCEL';
15
+ if (r === 'BACK') { i = i > 0 ? i - 1 : 0; continue; }
16
+ i += 1;
17
+ }
18
+ return 'DONE';
19
+ }