lazyclaw 6.0.0 → 6.1.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/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// lib/service_install.mjs — install a lazyclaw long-running command as an
|
|
2
|
+
// always-on OS service across three backends:
|
|
3
|
+
// - launchd (macOS): plist with RunAtLoad + KeepAlive
|
|
4
|
+
// - systemd (Linux user unit): Restart=always
|
|
5
|
+
// - fallback (no service manager): detached child + pidfile
|
|
6
|
+
//
|
|
7
|
+
// The unit-file builders + backend detection are PURE. The apply layer
|
|
8
|
+
// (install/uninstall/status) takes injectable deps (fs/spawn/spawnSync/isAlive)
|
|
9
|
+
// so it is fully testable without touching the real OS.
|
|
10
|
+
//
|
|
11
|
+
// NOTE: this is distinct from cron.mjs's plist machinery, which emits a
|
|
12
|
+
// StartCalendarInterval (scheduled) plist with RunAtLoad=false — the opposite
|
|
13
|
+
// of an always-on service.
|
|
14
|
+
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import fsReal from 'node:fs';
|
|
18
|
+
import { spawn as spawnReal, spawnSync as spawnSyncReal } from 'node:child_process';
|
|
19
|
+
import { isProcessAlive } from '../loops.mjs';
|
|
20
|
+
|
|
21
|
+
export class ServiceError extends Error {
|
|
22
|
+
constructor(message, code) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ServiceError';
|
|
25
|
+
this.code = code || 'SERVICE_ERR';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function xmlEscape(s) {
|
|
30
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---- pure helpers ----
|
|
34
|
+
|
|
35
|
+
export function servicePaths(name, { home = os.homedir(), configDir } = {}) {
|
|
36
|
+
const cfg = configDir || path.join(home, '.lazyclaw');
|
|
37
|
+
return {
|
|
38
|
+
launchd: path.join(home, 'Library', 'LaunchAgents', `com.lazyclaw.${name}.plist`),
|
|
39
|
+
systemd: path.join(home, '.config', 'systemd', 'user', `lazyclaw-${name}.service`),
|
|
40
|
+
pidfile: path.join(cfg, `${name}.pid`),
|
|
41
|
+
logfile: path.join(cfg, `${name}.log`),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function detectBackend({ platform = process.platform, hasSystemctl = false, override = null } = {}) {
|
|
46
|
+
if (override) return override;
|
|
47
|
+
if (platform === 'darwin') return 'launchd';
|
|
48
|
+
if (platform === 'linux' && hasSystemctl) return 'systemd';
|
|
49
|
+
return 'fallback';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildLaunchdPlist({ name, execPath, args, workingDir, logfile, env = {} }) {
|
|
53
|
+
const progArgs = [execPath, ...args]
|
|
54
|
+
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
|
55
|
+
.join('\n');
|
|
56
|
+
const envBlock = Object.keys(env).length
|
|
57
|
+
? ` <key>EnvironmentVariables</key>\n <dict>\n` +
|
|
58
|
+
Object.entries(env).map(([k, v]) => ` <key>${xmlEscape(k)}</key><string>${xmlEscape(v)}</string>`).join('\n') +
|
|
59
|
+
`\n </dict>\n`
|
|
60
|
+
: '';
|
|
61
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>Label</key><string>com.lazyclaw.${xmlEscape(name)}</string>
|
|
66
|
+
<key>ProgramArguments</key>
|
|
67
|
+
<array>
|
|
68
|
+
${progArgs}
|
|
69
|
+
</array>
|
|
70
|
+
<key>RunAtLoad</key>
|
|
71
|
+
<true/>
|
|
72
|
+
<key>KeepAlive</key>
|
|
73
|
+
<true/>
|
|
74
|
+
<key>WorkingDirectory</key><string>${xmlEscape(workingDir)}</string>
|
|
75
|
+
<key>StandardOutPath</key><string>${xmlEscape(logfile)}</string>
|
|
76
|
+
<key>StandardErrorPath</key><string>${xmlEscape(logfile)}</string>
|
|
77
|
+
${envBlock}</dict>
|
|
78
|
+
</plist>
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildSystemdUnit({ description, execPath, args, workingDir, env = {} }) {
|
|
83
|
+
// systemd ExecStart wants an absolute path + a single command line. We keep
|
|
84
|
+
// the argv simple (paths/flags lazyclaw produces have no shell metachars),
|
|
85
|
+
// so a plain space-join is correct and readable.
|
|
86
|
+
const exec = [execPath, ...args].join(' ');
|
|
87
|
+
const envLines = Object.entries(env).map(([k, v]) => `Environment=${k}=${v}`).join('\n');
|
|
88
|
+
return `[Unit]
|
|
89
|
+
Description=${description}
|
|
90
|
+
After=network-online.target
|
|
91
|
+
Wants=network-online.target
|
|
92
|
+
|
|
93
|
+
[Service]
|
|
94
|
+
Type=simple
|
|
95
|
+
ExecStart=${exec}
|
|
96
|
+
WorkingDirectory=${workingDir}
|
|
97
|
+
Restart=always
|
|
98
|
+
RestartSec=3
|
|
99
|
+
${envLines ? envLines + '\n' : ''}
|
|
100
|
+
[Install]
|
|
101
|
+
WantedBy=default.target
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- apply layer (side-effectful, injectable deps) ----
|
|
106
|
+
|
|
107
|
+
function resolveDeps(deps = {}) {
|
|
108
|
+
return {
|
|
109
|
+
fs: deps.fs || fsReal,
|
|
110
|
+
spawnSync: deps.spawnSync || spawnSyncReal,
|
|
111
|
+
spawn: deps.spawn || spawnReal,
|
|
112
|
+
isAlive: deps.isAlive || isProcessAlive,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function installService(spec, depsIn = {}) {
|
|
117
|
+
const { name, execPath, args, workingDir, env = {}, backend, description, home, configDir, logfile } = spec;
|
|
118
|
+
const deps = resolveDeps(depsIn);
|
|
119
|
+
const p = servicePaths(name, { home, configDir });
|
|
120
|
+
const log = logfile || p.logfile;
|
|
121
|
+
|
|
122
|
+
if (backend === 'launchd') {
|
|
123
|
+
deps.fs.mkdirSync(path.dirname(p.launchd), { recursive: true });
|
|
124
|
+
deps.fs.writeFileSync(p.launchd, buildLaunchdPlist({ name, execPath, args, workingDir, logfile: log, env }));
|
|
125
|
+
deps.spawnSync('launchctl', ['unload', p.launchd], { stdio: 'ignore' });
|
|
126
|
+
const r = deps.spawnSync('launchctl', ['load', p.launchd], { encoding: 'utf8' });
|
|
127
|
+
if (r && r.status !== 0) throw new ServiceError(`launchctl load failed: ${r.stderr || r.status}`, 'SERVICE_LAUNCHD_FAIL');
|
|
128
|
+
return { backend, target: p.launchd };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (backend === 'systemd') {
|
|
132
|
+
deps.fs.mkdirSync(path.dirname(p.systemd), { recursive: true });
|
|
133
|
+
deps.fs.writeFileSync(p.systemd, buildSystemdUnit({ description, execPath, args, workingDir, env }));
|
|
134
|
+
deps.spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
135
|
+
const r = deps.spawnSync('systemctl', ['--user', 'enable', '--now', `lazyclaw-${name}.service`], { encoding: 'utf8' });
|
|
136
|
+
if (r && r.status !== 0) throw new ServiceError(`systemctl enable failed: ${r.stderr || r.status}`, 'SERVICE_SYSTEMD_FAIL');
|
|
137
|
+
return { backend, target: p.systemd };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (backend === 'fallback') {
|
|
141
|
+
// No service manager: detach a child and record its pid. This survives
|
|
142
|
+
// the launching terminal but NOT a reboot — the caller is told so.
|
|
143
|
+
deps.fs.mkdirSync(path.dirname(p.pidfile), { recursive: true });
|
|
144
|
+
const child = deps.spawn(execPath, args, {
|
|
145
|
+
detached: true,
|
|
146
|
+
stdio: 'ignore',
|
|
147
|
+
env: { ...process.env, ...env },
|
|
148
|
+
});
|
|
149
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
150
|
+
deps.fs.writeFileSync(p.pidfile, String(child.pid));
|
|
151
|
+
return { backend, pid: child.pid, pidfile: p.pidfile };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function uninstallService(spec, depsIn = {}) {
|
|
158
|
+
const { name, backend, home, configDir } = spec;
|
|
159
|
+
const deps = resolveDeps(depsIn);
|
|
160
|
+
const p = servicePaths(name, { home, configDir });
|
|
161
|
+
|
|
162
|
+
if (backend === 'launchd') {
|
|
163
|
+
if (deps.fs.existsSync(p.launchd)) {
|
|
164
|
+
deps.spawnSync('launchctl', ['unload', p.launchd], { stdio: 'ignore' });
|
|
165
|
+
deps.fs.rmSync(p.launchd);
|
|
166
|
+
}
|
|
167
|
+
return { backend, removed: p.launchd };
|
|
168
|
+
}
|
|
169
|
+
if (backend === 'systemd') {
|
|
170
|
+
deps.spawnSync('systemctl', ['--user', 'disable', '--now', `lazyclaw-${name}.service`], { stdio: 'ignore' });
|
|
171
|
+
if (deps.fs.existsSync(p.systemd)) deps.fs.rmSync(p.systemd);
|
|
172
|
+
deps.spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
173
|
+
return { backend, removed: p.systemd };
|
|
174
|
+
}
|
|
175
|
+
if (backend === 'fallback') {
|
|
176
|
+
let pid = null;
|
|
177
|
+
if (deps.fs.existsSync(p.pidfile)) {
|
|
178
|
+
pid = parseInt(deps.fs.readFileSync(p.pidfile, 'utf8'), 10);
|
|
179
|
+
if (Number.isFinite(pid)) { try { process.kill(pid); } catch { /* already gone */ } }
|
|
180
|
+
deps.fs.rmSync(p.pidfile);
|
|
181
|
+
}
|
|
182
|
+
return { backend, killed: pid };
|
|
183
|
+
}
|
|
184
|
+
throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function serviceStatus(spec, depsIn = {}) {
|
|
188
|
+
const { name, backend, home, configDir } = spec;
|
|
189
|
+
const deps = resolveDeps(depsIn);
|
|
190
|
+
const p = servicePaths(name, { home, configDir });
|
|
191
|
+
|
|
192
|
+
if (backend === 'launchd') {
|
|
193
|
+
return { backend, installed: deps.fs.existsSync(p.launchd), target: p.launchd };
|
|
194
|
+
}
|
|
195
|
+
if (backend === 'systemd') {
|
|
196
|
+
const installed = deps.fs.existsSync(p.systemd);
|
|
197
|
+
const r = deps.spawnSync('systemctl', ['--user', 'is-active', `lazyclaw-${name}.service`], { encoding: 'utf8' });
|
|
198
|
+
const running = !!r && typeof r.stdout === 'string' && r.stdout.trim() === 'active';
|
|
199
|
+
return { backend, installed, running, target: p.systemd };
|
|
200
|
+
}
|
|
201
|
+
if (backend === 'fallback') {
|
|
202
|
+
if (!deps.fs.existsSync(p.pidfile)) return { backend, installed: false, running: false };
|
|
203
|
+
const pid = parseInt(deps.fs.readFileSync(p.pidfile, 'utf8'), 10);
|
|
204
|
+
return { backend, installed: true, running: Number.isFinite(pid) && deps.isAlive(pid), pid: Number.isFinite(pid) ? pid : null };
|
|
205
|
+
}
|
|
206
|
+
throw new ServiceError(`unknown service backend: ${backend}`, 'SERVICE_BAD_BACKEND');
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.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",
|
|
@@ -33,15 +33,27 @@
|
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test tests/*.test.mjs && playwright test",
|
|
35
35
|
"lint:size": "node scripts/lint-file-size.mjs",
|
|
36
|
+
"lint:pack": "node scripts/check-pack.mjs",
|
|
36
37
|
"test:bench": "node scripts/bench-providers.mjs",
|
|
37
38
|
"test:bench:index": "node --test tests/index_store.bench.mjs",
|
|
38
39
|
"test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
|
|
39
|
-
"migrate:v5": "node scripts/migrate-v5.mjs"
|
|
40
|
-
"build:splash": "node scripts/build-splash.mjs"
|
|
40
|
+
"migrate:v5": "node scripts/migrate-v5.mjs"
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"cli.mjs",
|
|
44
44
|
"daemon.mjs",
|
|
45
|
+
"daemon/",
|
|
46
|
+
"lib/",
|
|
47
|
+
"commands/",
|
|
48
|
+
"first_run.mjs",
|
|
49
|
+
"dotenv_min.mjs",
|
|
50
|
+
"goals_cron.mjs",
|
|
51
|
+
"secure_write.mjs",
|
|
52
|
+
"channels-discord/",
|
|
53
|
+
"channels-email/",
|
|
54
|
+
"channels-signal/",
|
|
55
|
+
"channels-voice/",
|
|
56
|
+
"channels-whatsapp/",
|
|
45
57
|
"sessions.mjs",
|
|
46
58
|
"skills.mjs",
|
|
47
59
|
"logger.mjs",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// providers/probe.mjs — smoke-test a single provider with a tiny prompt.
|
|
2
|
+
//
|
|
3
|
+
// Returns the result object; it never logs and never calls process.exit, so
|
|
4
|
+
// callers decide how to render and whether to exit. The CLI `providers test`
|
|
5
|
+
// prints JSON and exits; the setup wizard's verify step prints one concise
|
|
6
|
+
// line and KEEPS GOING (a process.exit here would kill the rest of the
|
|
7
|
+
// wizard — the bug this split fixes).
|
|
8
|
+
import { getRegistry } from '../lib/registry_boot.mjs';
|
|
9
|
+
|
|
10
|
+
export async function probeProvider({ name, model, prompt = 'ping', apiKey = '' }) {
|
|
11
|
+
const provider = getRegistry().PROVIDERS[name];
|
|
12
|
+
if (!provider) {
|
|
13
|
+
return { ok: false, provider: name, model, durationMs: 0, error: `unknown provider: ${name}`, code: 'UNKNOWN_PROVIDER' };
|
|
14
|
+
}
|
|
15
|
+
const t0 = Date.now();
|
|
16
|
+
try {
|
|
17
|
+
let reply = '';
|
|
18
|
+
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
19
|
+
for await (const chunk of stream) {
|
|
20
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
21
|
+
}
|
|
22
|
+
const durationMs = Date.now() - t0;
|
|
23
|
+
const ok = reply.length > 0;
|
|
24
|
+
return { ok, provider: name, model, durationMs, replyLength: reply.length, reply: reply.slice(0, 200) + (reply.length > 200 ? '…' : '') };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return { ok: false, provider: name, model, durationMs: Date.now() - t0, error: err?.message || String(err), code: err?.code || null };
|
|
27
|
+
}
|
|
28
|
+
}
|
package/secure_write.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// secure_write.mjs — atomic file writes with owner-only (0600/0700) perms,
|
|
2
|
+
// for files that hold secrets: config.json (plaintext API keys / auth
|
|
3
|
+
// profiles), workflow state (transcript content), any .env the tool writes.
|
|
4
|
+
//
|
|
5
|
+
// Lifted from gateway/device_auth.mjs::writeAtomic: set restrictive modes on
|
|
6
|
+
// create AND re-assert with chmod after rename, because the active umask can
|
|
7
|
+
// clear bits at mkdir/open time and a pre-existing file keeps its old (looser)
|
|
8
|
+
// mode otherwise. chmod is best-effort (a no-op / unsupported on some
|
|
9
|
+
// filesystems and on Windows); the {mode} on write is the primary guard.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const SECURE_DIR_MODE = 0o700;
|
|
15
|
+
export const SECURE_FILE_MODE = 0o600;
|
|
16
|
+
|
|
17
|
+
function chmodQuiet(p, mode) {
|
|
18
|
+
try { fs.chmodSync(p, mode); } catch { /* unsupported FS / platform — mode on write stands */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writeTextSecure(filePath, text) {
|
|
22
|
+
const dir = path.dirname(filePath);
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
|
|
24
|
+
chmodQuiet(dir, SECURE_DIR_MODE);
|
|
25
|
+
const tmp = `${filePath}.tmp`;
|
|
26
|
+
fs.writeFileSync(tmp, text, { mode: SECURE_FILE_MODE });
|
|
27
|
+
chmodQuiet(tmp, SECURE_FILE_MODE);
|
|
28
|
+
fs.renameSync(tmp, filePath);
|
|
29
|
+
chmodQuiet(filePath, SECURE_FILE_MODE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeJsonSecure(filePath, obj) {
|
|
33
|
+
writeTextSecure(filePath, JSON.stringify(obj, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Tighten an existing secrets file to 0600 if it is currently group/other
|
|
37
|
+
// accessible. Best-effort + idempotent — used to migrate already-deployed
|
|
38
|
+
// world-readable config.json the first time it is read. Returns true if it
|
|
39
|
+
// changed the mode.
|
|
40
|
+
export function tightenIfLoose(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(filePath);
|
|
43
|
+
if ((st.mode & 0o077) !== 0) { fs.chmodSync(filePath, SECURE_FILE_MODE); return true; }
|
|
44
|
+
} catch { /* missing / unreadable → nothing to tighten */ }
|
|
45
|
+
return false;
|
|
46
|
+
}
|
package/tui/editor.mjs
CHANGED
|
@@ -359,7 +359,13 @@ export function Editor({
|
|
|
359
359
|
// sees at most a one-tick flicker. The IME-correctness win is worth
|
|
360
360
|
// the cosmetic cost. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
|
|
361
361
|
useEffect(() => {
|
|
362
|
-
|
|
362
|
+
// Runs in BOTH the alt-buffer and the default (non-alt Static) layouts: the
|
|
363
|
+
// editor is the last child either way, so the cursor parks below it and the
|
|
364
|
+
// rowsUp math (editor geometry only) is identical. Anchoring in non-alt is
|
|
365
|
+
// what makes the terminal cursor visible AT the caret (so you can see where
|
|
366
|
+
// you're typing) and what keeps a CJK/Hangul IME pre-edit inside the box
|
|
367
|
+
// instead of leaking onto the row below. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
|
|
368
|
+
void altEnabled;
|
|
363
369
|
if (process.env.LAZYCLAW_NO_CURSOR_ANCHOR === '1') return;
|
|
364
370
|
if (!(process.stdout && process.stdout.isTTY)) return;
|
|
365
371
|
const cols = Math.max(20, process.stdout.columns || 80);
|
|
@@ -402,9 +408,19 @@ export function Editor({
|
|
|
402
408
|
const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
403
409
|
const colTarget = 3 + prefixWidth + colInLine;
|
|
404
410
|
_installAnchorShim();
|
|
411
|
+
// If a previous anchor moved the cursor up and NO render's eraseLines has
|
|
412
|
+
// consumed that offset yet (two state updates between redraws — e.g. fast
|
|
413
|
+
// typing or backspace), the cursor is still parked up inside the editor.
|
|
414
|
+
// Undo that move first (\x1b[<pending>B) so we re-anchor from the true
|
|
415
|
+
// "below the frame" baseline. Without this the moves stacked, the shim
|
|
416
|
+
// only compensated for the LAST one, and eraseLines walked up into — and
|
|
417
|
+
// erased — the scrollback above the editor (corruption was invisible in
|
|
418
|
+
// the fixed alt-buffer canvas, visible in the default Static layout).
|
|
419
|
+
const pending = _anchorState.offset;
|
|
420
|
+
const undo = pending > 0 ? `\x1b[${pending}B\r` : '';
|
|
405
421
|
_anchorState.offset = rowsUp;
|
|
406
422
|
try {
|
|
407
|
-
process.stdout.write(
|
|
423
|
+
process.stdout.write(`${undo}\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
|
|
408
424
|
} catch { /* stdout closed — swallow */ }
|
|
409
425
|
}, [state.buffer, state.cursor, altEnabled]);
|
|
410
426
|
|
package/tui/repl.mjs
CHANGED
|
@@ -154,9 +154,25 @@ export function onEscape(state) {
|
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
//
|
|
157
|
+
// Stream chunk arrives. Completed lines are committed to the <Static>
|
|
158
|
+
// scrollback immediately (so they scroll up ABOVE the sticky editor), and only
|
|
159
|
+
// the in-progress trailing partial stays in the live region. Without this, a
|
|
160
|
+
// reply taller than the terminal grew the live frame past the viewport and
|
|
161
|
+
// spilled BELOW the input box (long orchestrator replies). Chunks without a
|
|
162
|
+
// newline still just accumulate (the prior behaviour), so short replies and the
|
|
163
|
+
// existing reducer tests are unchanged.
|
|
158
164
|
export function onStreamChunk(state, { chunk }) {
|
|
159
|
-
|
|
165
|
+
const buf = state.liveAssistant + chunk;
|
|
166
|
+
const nl = buf.lastIndexOf('\n');
|
|
167
|
+
if (nl < 0) return { ...state, liveAssistant: buf };
|
|
168
|
+
const complete = buf.slice(0, nl); // one or more whole lines
|
|
169
|
+
const remainder = buf.slice(nl + 1); // trailing partial (may be '')
|
|
170
|
+
const id = `as-${state.turnCounter}-${state.scrollback.length}`;
|
|
171
|
+
return {
|
|
172
|
+
...state,
|
|
173
|
+
scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
|
|
174
|
+
liveAssistant: remainder,
|
|
175
|
+
};
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
export function onTurnComplete(state, { reason, error } = {}) {
|
|
@@ -463,10 +479,15 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
463
479
|
const modalOpen = !!modal;
|
|
464
480
|
|
|
465
481
|
// Slash popup is suppressed whenever a modal picker is active so the
|
|
466
|
-
// overlays don't stack
|
|
482
|
+
// overlays don't stack, AND once the buffer has a space (the user is typing
|
|
483
|
+
// ARGS, e.g. `/orchestrator off`). Without the space guard the popup stayed
|
|
484
|
+
// open as a one-row hint and the editor treated Enter as "fill the matched
|
|
485
|
+
// command", which dropped the args and reverted the buffer to the bare
|
|
486
|
+
// command. With args, Enter must submit the full line instead.
|
|
467
487
|
const showSlashPopup =
|
|
468
488
|
!modalOpen &&
|
|
469
|
-
bufferPeek.startsWith('/') &&
|
|
489
|
+
bufferPeek.startsWith('/') && bufferPeek.indexOf(' ') < 0 &&
|
|
490
|
+
filtered.length > 0 && !_exactOnly;
|
|
470
491
|
|
|
471
492
|
// Outer column height: pinned to rows-1 in alt-buffer mode so the
|
|
472
493
|
// Editor truly sticks to the bottom. Non-alt keeps content-sized layout
|
package/tui/slash_commands.mjs
CHANGED
|
@@ -26,6 +26,10 @@ export const SLASH_COMMANDS = [
|
|
|
26
26
|
{ cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
|
|
27
27
|
{ cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
|
|
28
28
|
{ cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
|
|
29
|
+
{ cmd: '/config', help: 'leave chat and re-run the setup wizard (provider, model, channels)' },
|
|
30
|
+
{ cmd: '/channels', help: 'view configured channels; /channels <name> on|off to toggle' },
|
|
31
|
+
{ cmd: '/orchestrator', help: 'multi-agent: status | on | off | planner <spec> | worker add|remove <spec>' },
|
|
32
|
+
{ cmd: '/context', help: 'chat history window: status | turns <N> | tokens <N>' },
|
|
29
33
|
{ cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
|
|
30
34
|
{ cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
|
|
31
35
|
{ cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
|
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -1670,6 +1670,119 @@ async function _menu(args, ctx) {
|
|
|
1670
1670
|
].join('\n');
|
|
1671
1671
|
}
|
|
1672
1672
|
|
|
1673
|
+
// /channels — view configured channels and toggle them. `/channels` lists;
|
|
1674
|
+
// `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
|
|
1675
|
+
// available, else lib/config directly, so it works on both REPL paths.
|
|
1676
|
+
async function _channels(args, ctx = {}) {
|
|
1677
|
+
const cf = await import('../config_features.mjs');
|
|
1678
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
1679
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1680
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1681
|
+
const [name, action] = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1682
|
+
if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
|
|
1683
|
+
const en = /^(on|enable)$/i.test(action);
|
|
1684
|
+
const cfg = read();
|
|
1685
|
+
const key = name.toLowerCase();
|
|
1686
|
+
// Reject unknown names so a typo can't silently create a bogus
|
|
1687
|
+
// cfg.channels.<name> section (which would then leak into the list).
|
|
1688
|
+
// Stay permissive for pre-existing custom sections.
|
|
1689
|
+
const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
1690
|
+
if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
|
|
1691
|
+
return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
1692
|
+
}
|
|
1693
|
+
cf.channelSetEnabled(cfg, key, en); write(cfg);
|
|
1694
|
+
// Legacy fallback path: the readline ctx (_legacyCtx) has no
|
|
1695
|
+
// readConfig/writeConfig, so we read/wrote disk above against a fresh
|
|
1696
|
+
// cfg object. Mirror the toggle onto the in-session ctx.cfg so a
|
|
1697
|
+
// follow-up `/channels` (list) or other in-session read stays
|
|
1698
|
+
// consistent instead of showing the stale pre-toggle value.
|
|
1699
|
+
if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
|
|
1700
|
+
cf.channelSetEnabled(ctx.cfg, key, en);
|
|
1701
|
+
}
|
|
1702
|
+
return `channel ${key} → ${en ? 'enabled' : 'disabled'}`;
|
|
1703
|
+
}
|
|
1704
|
+
const rows = cf.channelStatusList(read());
|
|
1705
|
+
if (!rows.length) return 'no channels configured. add creds with /config (re-runs setup) or `lazyclaw setup`.';
|
|
1706
|
+
const lines = ['configured channels:'];
|
|
1707
|
+
for (const c of rows) lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}`);
|
|
1708
|
+
lines.push('toggle: /channels <name> on|off · add creds: /config');
|
|
1709
|
+
return lines.join('\n');
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// /orchestrator — view/edit multi-agent config. `status` (default), `on`/`off`,
|
|
1713
|
+
// `planner <spec>`, `worker add|remove <spec>`, `maxsubtasks <N>`. ctx-or-
|
|
1714
|
+
// lib/config fallback so it works on both REPL paths.
|
|
1715
|
+
async function _orchestrator(args, ctx = {}) {
|
|
1716
|
+
const cf = await import('../config_features.mjs');
|
|
1717
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
1718
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1719
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1720
|
+
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
1721
|
+
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1722
|
+
const fmt = () => {
|
|
1723
|
+
const s = cf.orchestratorGet(read());
|
|
1724
|
+
return `orchestrator: ${s.active ? 'ON' : 'off'} · planner: ${s.planner || '(default)'} · workers: ${s.workers.length ? s.workers.join(', ') : '(none)'} · maxSubtasks: ${s.maxSubtasks}`;
|
|
1725
|
+
};
|
|
1726
|
+
// Bare `/orchestrator` → arrow-key picker (Ink). Pick ON/OFF/Status instead
|
|
1727
|
+
// of typing the subcommand. Legacy path (no openPicker) shows status text.
|
|
1728
|
+
if (parts.length === 0 && typeof ctx.openPicker === 'function') {
|
|
1729
|
+
const s = cf.orchestratorGet(read());
|
|
1730
|
+
const picked = await ctx.openPicker({
|
|
1731
|
+
title: 'Orchestration',
|
|
1732
|
+
subtitle: `now ${s.active ? 'ON' : 'off'} · planner ${s.planner || '(default)'} · ${s.workers.length} worker(s)`,
|
|
1733
|
+
items: [
|
|
1734
|
+
{ id: 'on', label: 'Turn ON', desc: 'route chats through planner + workers' },
|
|
1735
|
+
{ id: 'off', label: 'Turn OFF', desc: 'back to a single provider' },
|
|
1736
|
+
{ id: 'status', label: 'Status', desc: 'show current config' },
|
|
1737
|
+
],
|
|
1738
|
+
});
|
|
1739
|
+
if (!picked || typeof picked !== 'string') return fmt();
|
|
1740
|
+
return _orchestrator(picked, ctx);
|
|
1741
|
+
}
|
|
1742
|
+
const sub = (parts[0] || 'status').toLowerCase();
|
|
1743
|
+
if (sub === 'status') return fmt();
|
|
1744
|
+
const cfg = read();
|
|
1745
|
+
if (sub === 'on' || sub === 'enable') {
|
|
1746
|
+
if (!cf.orchestratorGet(cfg).planner) {
|
|
1747
|
+
const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
|
|
1748
|
+
cf.orchestratorSet(cfg, { planner: base });
|
|
1749
|
+
}
|
|
1750
|
+
cf.orchestratorEnable(cfg, true); persist(cfg);
|
|
1751
|
+
const after = cf.orchestratorGet(read());
|
|
1752
|
+
return after.workers.length ? 'orchestration ON.\n' + fmt() : 'orchestration ON — but no workers yet. Add one: /orchestrator worker add <provider[:model]>';
|
|
1753
|
+
}
|
|
1754
|
+
if (sub === 'off' || sub === 'disable') { cf.orchestratorEnable(cfg, false); persist(cfg); return 'orchestration off. provider → ' + read().provider; }
|
|
1755
|
+
if (sub === 'planner') { if (!parts[1]) return 'usage: /orchestrator planner <provider[:model]>'; cf.orchestratorSet(cfg, { planner: parts[1] }); persist(cfg); return 'planner → ' + parts[1]; }
|
|
1756
|
+
if (sub === 'maxsubtasks') { const n = parseInt(parts[1], 10); if (!Number.isFinite(n)) return 'usage: /orchestrator maxsubtasks <N>'; cf.orchestratorSet(cfg, { maxSubtasks: Math.max(1, Math.min(10, n)) }); persist(cfg); return fmt(); }
|
|
1757
|
+
if (sub === 'worker') {
|
|
1758
|
+
const action = (parts[1] || '').toLowerCase(); const spec = parts[2];
|
|
1759
|
+
const workers = [...cf.orchestratorGet(cfg).workers];
|
|
1760
|
+
if (action === 'add' && spec) { if (!workers.includes(spec)) workers.push(spec); cf.orchestratorSet(cfg, { workers }); persist(cfg); return 'workers: ' + workers.join(', '); }
|
|
1761
|
+
if ((action === 'remove' || action === 'rm') && spec) { const next = workers.filter((w) => w !== spec); cf.orchestratorSet(cfg, { workers: next }); persist(cfg); return 'workers: ' + (next.join(', ') || '(none)'); }
|
|
1762
|
+
return 'usage: /orchestrator worker add|remove <provider[:model]>';
|
|
1763
|
+
}
|
|
1764
|
+
return 'usage: /orchestrator [status|on|off|planner <spec>|worker add|remove <spec>|maxsubtasks <N>]';
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// /context — view/set the chat history window (turns + token budget). This is
|
|
1768
|
+
// the sliding history budget sent each turn, NOT the model's hard context
|
|
1769
|
+
// limit. ctx-or-lib/config fallback so it works on both REPL paths.
|
|
1770
|
+
async function _context(args, ctx = {}) {
|
|
1771
|
+
const cf = await import('../config_features.mjs');
|
|
1772
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
1773
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
1774
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
1775
|
+
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
1776
|
+
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1777
|
+
const sub = (parts[0] || 'status').toLowerCase();
|
|
1778
|
+
const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
|
|
1779
|
+
if (sub === 'status') return fmt();
|
|
1780
|
+
const n = parseInt(parts[1], 10);
|
|
1781
|
+
if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
|
|
1782
|
+
if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
|
|
1783
|
+
return 'usage: /context [status | turns <N> | tokens <N>]';
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1673
1786
|
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
1674
1787
|
|
|
1675
1788
|
export const SLASH_HANDLERS = new Map([
|
|
@@ -1698,6 +1811,11 @@ export const SLASH_HANDLERS = new Map([
|
|
|
1698
1811
|
['/trainer', _trainer],
|
|
1699
1812
|
['/dashboard', _dashboard],
|
|
1700
1813
|
['/menu', _menu],
|
|
1814
|
+
['/channels', _channels],
|
|
1815
|
+
['/orchestrator', _orchestrator],
|
|
1816
|
+
['/context', _context],
|
|
1817
|
+
// /config — unmount and let chat.mjs run the setup wizard (ctx.requestSetup).
|
|
1818
|
+
['/config', async (_a, ctx) => { ctx.requestSetup = true; return 'EXIT'; }],
|
|
1701
1819
|
['/exit', async () => 'EXIT'],
|
|
1702
1820
|
['/quit', async () => 'EXIT'],
|
|
1703
1821
|
]);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// tui/splash_props.mjs — gather the dynamic props the splash panel needs
|
|
2
|
+
// (tool groups + skill groups), shared by the chat REPL and the setup wizard
|
|
3
|
+
// so both surfaces render the same lazyclaw splash instead of drifting apart
|
|
4
|
+
// (the setup wizard used to show a small figlet banner instead).
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { configPath } from '../lib/config.mjs';
|
|
7
|
+
|
|
8
|
+
// Re-export the renderer so setup/onboarding callers need one import.
|
|
9
|
+
export { renderSplashToString } from './splash.mjs';
|
|
10
|
+
|
|
11
|
+
// Verbatim from the chat REPL's former inline block: collapse the v5 tool
|
|
12
|
+
// registry to one row per category, and group installed skills by their
|
|
13
|
+
// filename hyphen-prefix. Failures degrade to empty lists, never throw.
|
|
14
|
+
export async function gatherToolAndSkillGroups(cfgDir) {
|
|
15
|
+
let tools = [];
|
|
16
|
+
try {
|
|
17
|
+
const registry = await import('../mas/tools/registry.mjs');
|
|
18
|
+
const byCat = registry.byCategory();
|
|
19
|
+
tools = Object.entries(byCat).map(([category, items]) => ({
|
|
20
|
+
category,
|
|
21
|
+
sensitive: items.some((t) => t.sensitive),
|
|
22
|
+
verbs: items.map((t) => t.name.replace(/^[a-z]+_/, '')).slice(0, 6),
|
|
23
|
+
})).sort((a, b) => a.category.localeCompare(b.category));
|
|
24
|
+
} catch { /* registry unavailable → empty list */ }
|
|
25
|
+
|
|
26
|
+
let skills = [];
|
|
27
|
+
try {
|
|
28
|
+
const { listSkills } = await import('../skills.mjs');
|
|
29
|
+
const flat = listSkills(cfgDir);
|
|
30
|
+
const byGroup = new Map();
|
|
31
|
+
for (const s of flat) {
|
|
32
|
+
const i = s.name.indexOf('-');
|
|
33
|
+
const group = i > 0 ? s.name.slice(0, i) : 'general';
|
|
34
|
+
const sub = i > 0 ? s.name.slice(i + 1) : s.name;
|
|
35
|
+
if (!byGroup.has(group)) byGroup.set(group, []);
|
|
36
|
+
byGroup.get(group).push(sub);
|
|
37
|
+
}
|
|
38
|
+
skills = [...byGroup.entries()]
|
|
39
|
+
.map(([group, names]) => ({ group, names: names.slice(0, 6) }))
|
|
40
|
+
.sort((a, b) => a.group.localeCompare(b.group));
|
|
41
|
+
} catch { /* skills dir unavailable → empty list */ }
|
|
42
|
+
|
|
43
|
+
return { tools, skills };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build the full splash props for the setup wizard. Self-contained (resolves
|
|
47
|
+
// the config dir itself) so call sites stay one line.
|
|
48
|
+
export async function splashPropsForSetup({ version = '', provider = '', model = '' } = {}) {
|
|
49
|
+
const cfgDir = path.dirname(configPath());
|
|
50
|
+
const { tools, skills } = await gatherToolAndSkillGroups(cfgDir);
|
|
51
|
+
return { provider, model, trainer: {}, sessionId: '', cwd: process.cwd(), version, tools, skills };
|
|
52
|
+
}
|
package/web/dashboard.css
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
--border: #2a2a36;
|
|
5
5
|
--text: #e8e8ea;
|
|
6
6
|
--dim: #a8a8b8;
|
|
7
|
-
--accent: #
|
|
7
|
+
--accent: #d9b35a;
|
|
8
|
+
--accent-ink: #1a1610;
|
|
8
9
|
--ok: #4ade80;
|
|
9
10
|
--warn: #f59e0b;
|
|
10
11
|
--err: #ef4444;
|
|
@@ -27,14 +28,7 @@
|
|
|
27
28
|
gap: 14px;
|
|
28
29
|
}
|
|
29
30
|
.logo { font-weight: 700; font-size: 16px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
|
|
30
|
-
.logo .mascot { width: 44px; height: 44px; flex: none; image-rendering: pixelated; image-rendering: crisp-edges; }
|
|
31
31
|
.ver { color: var(--dim); font-size: 11px; }
|
|
32
|
-
/* lazyclaw 16x16 pixel mascot — Claude Design handoff (mascot sheet
|
|
33
|
-
v0.1, "claude original" palette). Claude's asterisk star (#d97757)
|
|
34
|
-
worn under a crustacean helmet (#c33d2a) with two antenna-claws.
|
|
35
|
-
Idle pose (sleepy slits). Hover gently brightens the helmet. */
|
|
36
|
-
.mascot { transition: filter 0.2s ease; }
|
|
37
|
-
.logo:hover .mascot { filter: drop-shadow(0 0 6px rgba(217, 119, 87, 0.45)); }
|
|
38
32
|
nav.tabs {
|
|
39
33
|
display: flex;
|
|
40
34
|
flex-wrap: wrap;
|
|
@@ -81,7 +75,7 @@
|
|
|
81
75
|
.dim { color: var(--dim); font-size: 12px; }
|
|
82
76
|
button.btn {
|
|
83
77
|
background: var(--accent);
|
|
84
|
-
color:
|
|
78
|
+
color: var(--accent-ink);
|
|
85
79
|
border: 0;
|
|
86
80
|
border-radius: 6px;
|
|
87
81
|
padding: 8px 14px;
|
|
@@ -119,7 +113,7 @@
|
|
|
119
113
|
gap: 14px;
|
|
120
114
|
}
|
|
121
115
|
.msg { padding: 8px 12px; border-radius: 6px; max-width: 90%; white-space: pre-wrap; word-wrap: break-word; }
|
|
122
|
-
.msg.user { align-self: flex-end; background: rgba(217,
|
|
116
|
+
.msg.user { align-self: flex-end; background: rgba(217, 179, 90, 0.15); border: 1px solid rgba(217, 179, 90, 0.3); }
|
|
123
117
|
.msg.assistant { align-self: flex-start; background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
|
|
124
118
|
.msg.error { align-self: stretch; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.3); color: #ffd3d3; }
|
|
125
119
|
.input-row {
|
|
@@ -230,9 +224,9 @@
|
|
|
230
224
|
.modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
|
|
231
225
|
.modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
|
|
232
226
|
.clickable { cursor: pointer; }
|
|
233
|
-
.clickable:hover { background: rgba(217,
|
|
227
|
+
.clickable:hover { background: rgba(217, 179, 90, 0.05); }
|
|
234
228
|
.turn { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; }
|
|
235
|
-
.turn.user { background: rgba(217,
|
|
229
|
+
.turn.user { background: rgba(217, 179, 90, 0.10); border: 1px solid rgba(217, 179, 90, 0.25); }
|
|
236
230
|
.turn.assistant { background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
|
|
237
231
|
.turn.system { background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.20); }
|
|
238
232
|
.turn .role-tag { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|