lazyclaw 6.5.0 → 6.6.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/agents.mjs CHANGED
@@ -90,6 +90,16 @@ function defaultShape(name) {
90
90
  tools: [...DEFAULT_TOOLS],
91
91
  tags: [],
92
92
  iconEmoji: '',
93
+ // Explicit Team Live sprite choice: an integer 1..20 picks one of the 20
94
+ // built-in pixel-art avatars (web/avatars/NN.png); null lets the dashboard
95
+ // keep inferring one from the agent's name/role/tags. (dashboard.js
96
+ // avatarIndexFor already honours rec.avatar — this is the registry side.)
97
+ avatar: null,
98
+ // Optional custom character image: a ready-to-use <img src> — either a
99
+ // remote http(s) URL or a daemon-served '/agent-avatars/<file>' path for a
100
+ // photo the user supplied (copied under <configDir>/agent-avatars/). null =
101
+ // none. Takes precedence over `avatar` and the keyword inference.
102
+ avatarImage: null,
93
103
  // Optional parent agent (hierarchy). '' = top-level. A team's org tree is
94
104
  // derived from members' manager links (see teams.teamTree). Validated to
95
105
  // reference a registered agent and to never form a cycle.
@@ -124,6 +134,18 @@ function writeAtomic(filePath, obj) {
124
134
  const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
125
135
  const VALID_SKILL_WRITE = ['auto', 'manual', 'off'];
126
136
 
137
+ // Normalise an explicit avatar choice. null/''/undefined → null (keep the
138
+ // dashboard's keyword inference). A value that parses to an integer 1..20 picks
139
+ // that built-in sprite. Anything else (0, 21, fractional, non-numeric) throws.
140
+ function validateAvatar(v) {
141
+ if (v === undefined || v === null || v === '') return null;
142
+ const n = typeof v === 'number' ? v : Number(String(v).trim());
143
+ if (!Number.isInteger(n) || n < 1 || n > 20) {
144
+ throw new AgentError('avatar must be an integer 1..20 (or null to clear)', 'AGENT_BAD_AVATAR');
145
+ }
146
+ return n;
147
+ }
148
+
127
149
  // Validate a proposed `manager` (parent agent) for `name`: it must reference a
128
150
  // registered agent, may not be the agent itself, and may not close a cycle
129
151
  // (i.e. `name` must not already be an ancestor of the proposed manager).
@@ -151,7 +173,7 @@ function validateManager(name, manager, configDir) {
151
173
  return mgr;
152
174
  }
153
175
 
154
- export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
176
+ export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', avatar, memoryWrite, memoryMaxChars, skillWrite, manager } = {}, configDir = defaultConfigDir()) {
155
177
  ensureValidName(name);
156
178
  const p = agentPath(name, configDir);
157
179
  if (fs.existsSync(p)) {
@@ -175,6 +197,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
175
197
  tools: toolsClean,
176
198
  tags: Array.isArray(tags) ? tags : [],
177
199
  iconEmoji: String(iconEmoji || ''),
200
+ avatar: validateAvatar(avatar),
178
201
  memoryWrite: mw,
179
202
  memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
180
203
  skillWrite: sw,
@@ -220,6 +243,12 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
220
243
  if (patch.skillWrite !== undefined && !VALID_SKILL_WRITE.includes(patch.skillWrite)) {
221
244
  throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
222
245
  }
246
+ if (patch.avatar !== undefined) {
247
+ next.avatar = validateAvatar(patch.avatar);
248
+ }
249
+ if (patch.avatarImage !== undefined) {
250
+ next.avatarImage = (patch.avatarImage == null || patch.avatarImage === '') ? null : String(patch.avatarImage);
251
+ }
223
252
  if (patch.manager !== undefined) {
224
253
  next.manager = validateManager(name, patch.manager, configDir);
225
254
  }
@@ -227,6 +256,44 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
227
256
  return next;
228
257
  }
229
258
 
259
+ // Supported custom-avatar image types → their served content-type. svg is
260
+ // deliberately excluded (inline-script XSS risk in an <img>/object context).
261
+ export const AVATAR_IMAGE_TYPES = {
262
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
263
+ '.gif': 'image/gif', '.webp': 'image/webp',
264
+ };
265
+
266
+ // Point an agent at a custom character image. `src` is either a remote http(s)
267
+ // URL (stored verbatim) or a path to a local image file (copied into
268
+ // <configDir>/agent-avatars/<name><ext> and stored as a daemon-served
269
+ // '/agent-avatars/<name><ext>' path). Returns the patched record. The image
270
+ // takes precedence over the numeric sprite + keyword inference in the dashboard.
271
+ export function setAgentAvatarImage(name, src, configDir = defaultConfigDir()) {
272
+ if (!getAgent(name, configDir)) throw new AgentError(`no agent "${name}"`, 'AGENT_NO_AGENT');
273
+ const s = String(src ?? '').trim();
274
+ if (!s) throw new AgentError('avatar image source required (a file path or http(s) URL)', 'AGENT_BAD_AVATAR_IMAGE');
275
+ if (/^https?:\/\//i.test(s)) {
276
+ return patchAgent(name, { avatarImage: s }, configDir);
277
+ }
278
+ const ext = path.extname(s).toLowerCase();
279
+ if (!AVATAR_IMAGE_TYPES[ext]) {
280
+ throw new AgentError(`unsupported image type "${ext || '(none)'}" — use png/jpg/jpeg/gif/webp or an http(s) URL`, 'AGENT_BAD_AVATAR_IMAGE');
281
+ }
282
+ if (!fs.existsSync(s) || !fs.statSync(s).isFile()) {
283
+ throw new AgentError(`no such image file: ${s}`, 'AGENT_BAD_AVATAR_IMAGE');
284
+ }
285
+ const destDir = path.join(configDir, 'agent-avatars');
286
+ fs.mkdirSync(destDir, { recursive: true });
287
+ // Drop any previously stored image for this agent (it may have a different
288
+ // extension) so a re-point doesn't leave a stale file shadowing the new one.
289
+ for (const e of Object.keys(AVATAR_IMAGE_TYPES)) {
290
+ const old = path.join(destDir, `${name}${e}`);
291
+ if (fs.existsSync(old)) fs.unlinkSync(old);
292
+ }
293
+ fs.copyFileSync(s, path.join(destDir, `${name}${ext}`));
294
+ return patchAgent(name, { avatarImage: `/agent-avatars/${name}${ext}` }, configDir);
295
+ }
296
+
230
297
  export function removeAgent(name, configDir = defaultConfigDir()) {
231
298
  const p = agentPath(name, configDir);
232
299
  if (!fs.existsSync(p)) {
@@ -35,6 +35,7 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
35
35
  model: flags.model || '',
36
36
  tools: tools === null ? undefined : tools,
37
37
  tags: agentsMod.parseToolsFlag(flags.tags) || [],
38
+ avatar: flags.avatar,
38
39
  skillWrite: flags['skill-write'],
39
40
  manager: flags.manager,
40
41
  }, cfgDir);
@@ -64,6 +65,7 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
64
65
  if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
65
66
  if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
66
67
  if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
68
+ if (flags.avatar !== undefined) patch.avatar = /^(none|clear|off)$/i.test(String(flags.avatar)) ? null : flags.avatar;
67
69
  if (Object.keys(patch).length === 0) {
68
70
  console.error('agent edit: no fields to update');
69
71
  process.exit(2);
@@ -72,6 +74,26 @@ export async function cmdAgentRegistry(sub, positional, flags = {}) {
72
74
  catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
73
75
  return;
74
76
  }
77
+ case 'set-avatar': {
78
+ const val = positional[1];
79
+ if (!name || val === undefined) {
80
+ console.error('Usage: lazyclaw agent set-avatar <name> <1-20 | none | <image-file> | http(s)://url>\n 1-20 pick a built-in pixel-art sprite\n none keep the role-inferred default\n file/url use a custom character photo');
81
+ process.exit(2);
82
+ }
83
+ const v = String(val).trim();
84
+ try {
85
+ if (/^(none|clear|off)$/i.test(v)) {
86
+ emitJson(agentsMod.patchAgent(name, { avatar: null, avatarImage: null }, cfgDir));
87
+ } else if (/^\d+$/.test(v)) {
88
+ // a built-in sprite index — clear any custom image so it shows through
89
+ emitJson(agentsMod.patchAgent(name, { avatar: v, avatarImage: null }, cfgDir));
90
+ } else {
91
+ // a local image file or a remote URL
92
+ emitJson(agentsMod.setAgentAvatarImage(name, val, cfgDir));
93
+ }
94
+ } catch (err) { console.error(`agent set-avatar: ${err?.message || err}`); process.exit(2); }
95
+ return;
96
+ }
75
97
  case 'remove':
76
98
  case 'rm':
77
99
  case 'delete': {
@@ -28,6 +28,7 @@ export const ROUTES = [
28
28
  { m: (c) => c.route === 'GET /dashboard.css', h: meta.dashboardCss },
29
29
  { m: (c) => c.route === 'GET /dashboard.js', h: meta.dashboardJs },
30
30
  { m: (c) => c.req.method === 'GET' && /^\/avatars\/\d{2}\.png$/.test(c.path || ''), h: meta.avatar },
31
+ { m: (c) => c.req.method === 'GET' && /^\/agent-avatars\/[A-Za-z0-9_-]+\.[a-z]+$/.test(c.path || ''), h: meta.agentAvatar },
31
32
  { m: (c) => c.route === 'GET /version', h: meta.version },
32
33
  { m: (c) => c.route === 'POST /exec/request', h: conversation.execRequest },
33
34
  { m: (c) => c.route === 'GET /health' || c.route === 'GET /healthz', h: meta.health },
@@ -65,6 +65,35 @@ export async function dashboardJs(c) {
65
65
  return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
66
66
  }
67
67
 
68
+ // Serve a custom agent character image (web app: <img src="/agent-avatars/NN">).
69
+ // These are user-supplied photos copied under <configDir>/agent-avatars/ by
70
+ // `lazyclaw agent set-avatar`. Served from the config dir (NOT the package web/
71
+ // dir) since they're per-install user data. The filename is constrained to
72
+ // `<word>.<ext>` so no path traversal escapes the agent-avatars directory.
73
+ const _AGENT_AVATAR_CT = {
74
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
75
+ '.gif': 'image/gif', '.webp': 'image/webp',
76
+ };
77
+ export async function agentAvatar(c) {
78
+ const { res } = c;
79
+ const m = /^\/agent-avatars\/([A-Za-z0-9_-]+\.[a-z]+)$/.exec(c.path || '');
80
+ const file = m && m[1];
81
+ const ct = file && _AGENT_AVATAR_CT[nodePath.extname(file).toLowerCase()];
82
+ const base = c.gwConfigDir;
83
+ if (!file || file.includes('..') || !ct || !base) {
84
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
85
+ return res.end('not found\n');
86
+ }
87
+ try {
88
+ const body = _readAssetCached(nodePath.join(base, 'agent-avatars', file));
89
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-cache' });
90
+ return res.end(body);
91
+ } catch (e) {
92
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
93
+ return res.end(`not found: ${file} (${e?.message || e})\n`);
94
+ }
95
+ }
96
+
68
97
  export async function dashboard(c) {
69
98
  const { res } = c;
70
99
  // Serve the lazyclaw-only web dashboard (a single static
package/lib/args.mjs CHANGED
@@ -277,6 +277,6 @@ _lazyclaw "$@"
277
277
  // AGENT_REG_SUBS to decide whether a bare `agent <sub>` routes to the agent
278
278
  // registry vs a one-shot agent run. TEAM_SUBS/TASK_SUBS mirror their command's
279
279
  // subcommands for the same disambiguation pattern.
280
- export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
280
+ export const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth', 'set-avatar']);
281
281
  export const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
282
282
  export const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "6.5.0",
3
+ "version": "6.6.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",
package/web/dashboard.js CHANGED
@@ -1409,7 +1409,13 @@
1409
1409
  }
1410
1410
  return 1; // generic PM look
1411
1411
  }
1412
- function avatarSrc(rec) { return `/avatars/${String(avatarIndexFor(rec)).padStart(2, '0')}.png`; }
1412
+ // A user-supplied custom image (set via `lazyclaw agent set-avatar`) wins
1413
+ // over the picked/inferred built-in sprite. rec.avatarImage is already a
1414
+ // ready-to-use src (a remote URL or a daemon-served /agent-avatars/ path).
1415
+ function avatarSrc(rec) {
1416
+ if (rec && rec.avatarImage) return rec.avatarImage;
1417
+ return `/avatars/${String(avatarIndexFor(rec)).padStart(2, '0')}.png`;
1418
+ }
1413
1419
  // Build the { name, children[] } tree rooted at the lead (mirrors teamTree).
1414
1420
  function buildTeamTree(team, byId) {
1415
1421
  const lead = team.lead;