lazyclaw 6.7.0 → 6.8.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
  }
@@ -455,7 +456,7 @@ export async function cmdChat(flags = {}) {
455
456
  }
456
457
  }
457
458
  if (sysParts.length && !messages.some(m => m.role === 'system')) {
458
- const merged = sysParts.join('\n\n---\n\n');
459
+ const merged = [...sysParts, LAZYCLAW_META_GUARD].join('\n\n---\n\n');
459
460
  messages.unshift({ role: 'system', content: merged });
460
461
  if (sessionId) sessionsMod.appendTurn(sessionId, 'system', merged, cfgDir);
461
462
  }
@@ -651,11 +652,8 @@ export async function cmdChat(flags = {}) {
651
652
  // listener from a previous run doesn't crash the launch with EADDRINUSE.
652
653
  // Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
653
654
 
654
-
655
-
656
655
  // sandbox subcommands — list/test/add/use (Phase D).
657
656
 
658
-
659
657
  // Interactive launcher — fired when the user types `lazyclaw` with
660
658
  // no subcommand AND we're attached to a TTY. OpenClaw's launcher
661
659
  // pattern: ASCII banner + provider/model status + arrow-key menu of
@@ -12,6 +12,7 @@ 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';
15
16
  import {
16
17
  _attachGhostAutocomplete, _fetchModelsForProvider, _pauseChatForSubMenu,
17
18
  _pickModelInteractive, _pickProviderInteractive, _printChatBanner,
@@ -94,10 +95,6 @@ export async function cmdOnboard(flags) {
94
95
  console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
95
96
  }
96
97
 
97
-
98
-
99
-
100
-
101
98
  export function cmdHelp(name) {
102
99
  if (!name) {
103
100
  process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
@@ -118,8 +115,6 @@ export function cmdHelp(name) {
118
115
  process.stdout.write(detail + '\n');
119
116
  }
120
117
 
121
-
122
-
123
118
  export async function cmdSetup(_sub, _positional, flags = {}) {
124
119
  await ensureRegistry();
125
120
  const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
@@ -178,6 +173,10 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
178
173
  // Context window — asked right after the model pick (optional; Enter keeps
179
174
  // defaults). Not a numbered step: it's part of the core model setup.
180
175
  await runContextStep({ prompt: _quickPrompt, colors });
176
+
177
+ // Tool-permission mode (cfg.chat.permissionMode) — part of the core chat
178
+ // setup. Extracted to ./setup_permission.mjs (file-size gate).
179
+ await runPermissionStep({ prompt: _quickPrompt, colors, cfg: cfgAfterOnboard });
181
180
  }
182
181
 
183
182
  // ── Step 2/7: Verify one clean chat works ───────────────────
@@ -703,4 +702,3 @@ export async function cmdLauncher() {
703
702
  process.exit(0);
704
703
  }
705
704
 
706
-
@@ -0,0 +1,31 @@
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
+ import { readConfig, writeConfig } from '../lib/config.mjs';
10
+ import { parsePermissionChoice } from '../lib/permission_mode.mjs';
11
+
12
+ export async function runPermissionStep({ prompt, colors, cfg } = {}) {
13
+ const accent = (colors && colors.accent) || ((s) => s);
14
+ const dim = (colors && colors.dim) || ((s) => s);
15
+ const ok = (colors && colors.ok) || ((s) => s);
16
+ const provider = String((cfg || readConfig()).provider || '');
17
+ // Only relevant to the claude-cli agent path (the one that spawns `claude`).
18
+ if (!/claude/.test(provider)) return;
19
+
20
+ process.stdout.write(` ${dim('Tool permissions — when the agent edits files or runs commands:')}\n`);
21
+ process.stdout.write(` ${dim(' bypass = never ask (fast) · ask = prompt each time · acceptEdits = auto-accept edits only · plan = read-only')}\n`);
22
+ const mode = parsePermissionChoice(await prompt(` ${accent('permission')} [bypass/ask/acceptEdits/plan] ${dim('(Enter = bypass)')}: `));
23
+ if (mode) {
24
+ const c = readConfig();
25
+ c.chat = { ...(c.chat || {}), permissionMode: mode };
26
+ writeConfig(c);
27
+ process.stdout.write(` ${ok('✓ permission mode:')} ${mode}\n\n`);
28
+ } else {
29
+ process.stdout.write(` ${dim('(unrecognised — kept the current setting)')}\n\n`);
30
+ }
31
+ }
@@ -0,0 +1,103 @@
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
+ }
@@ -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.8.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/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 } 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,25 @@ 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 { reply = applyConfigCommand(_cmd, { readConfig: _readCfg, writeConfig: _writeCfg, ctxCfg: ctx.cfg }); }
224
+ catch (e) { reply = `⚠ couldn't apply that change: ${e?.message || e}`; }
225
+ try { writeFn(reply + '\n'); } catch { /* sink best-effort */ }
226
+ _msgs.push({ role: 'assistant', content: reply });
227
+ ctx.persistTurn('assistant', reply);
228
+ return;
229
+ }
230
+
209
231
  const messages = ctx.getMessages();
210
232
  messages.push({ role: 'user', content: text });
211
233
  try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
@@ -296,6 +318,9 @@ export function makeRunTurn({ ctx, writeFn }) {
296
318
  // a one-shot spawn on any session failure, and the session respawns if
297
319
  // the system prompt changes (plan-mode toggle) so it can't go stale.
298
320
  persistent: ctx.cfg?.chat?.persistentSession !== false,
321
+ // claude-cli permission mode (cfg.chat.permissionMode, asked at setup;
322
+ // defaults to bypassPermissions). claude-cli reads it; others ignore it.
323
+ permissionMode: resolvePermissionMode(ctx.cfg),
299
324
  sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
300
325
  ...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
301
326
  ...(maxTokens !== undefined ? { maxTokens } : {}),