@rubytech/taskmaster 1.0.106 → 1.0.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/agents/skills-status.js +23 -3
  2. package/dist/agents/skills.js +1 -0
  3. package/dist/agents/system-prompt.js +3 -0
  4. package/dist/agents/taskmaster-tools.js +5 -0
  5. package/dist/agents/tool-policy.js +2 -1
  6. package/dist/agents/tools/authorize-admin-tool.js +1 -1
  7. package/dist/agents/tools/memory-tool.js +2 -1
  8. package/dist/agents/tools/software-update-tool.js +114 -0
  9. package/dist/auto-reply/reply/commands-status.js +5 -9
  10. package/dist/auto-reply/reply/get-reply-run.js +1 -1
  11. package/dist/auto-reply/reply/get-reply.js +1 -1
  12. package/dist/auto-reply/reply/model-selection.js +1 -1
  13. package/dist/browser/routes/screencast.js +1 -1
  14. package/dist/browser/screencast.js +1 -1
  15. package/dist/build-info.json +3 -3
  16. package/dist/commands/agent.js +2 -2
  17. package/dist/config/zod-schema.js +12 -1
  18. package/dist/control-ui/assets/index-B2FEGOCu.css +1 -0
  19. package/dist/control-ui/assets/index-nLVF-pVT.js +3762 -0
  20. package/dist/control-ui/assets/index-nLVF-pVT.js.map +1 -0
  21. package/dist/control-ui/index.html +2 -2
  22. package/dist/control-ui/maxy-icon.png +0 -0
  23. package/dist/cron/isolated-agent/recipients.js +70 -0
  24. package/dist/cron/isolated-agent/run.js +43 -13
  25. package/dist/gateway/config-reload.js +1 -0
  26. package/dist/gateway/control-ui.js +111 -5
  27. package/dist/gateway/protocol/index.js +6 -1
  28. package/dist/gateway/protocol/schema/agents-models-skills.js +23 -0
  29. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -1
  30. package/dist/gateway/server-http.js +6 -1
  31. package/dist/gateway/server-methods/access.js +3 -3
  32. package/dist/gateway/server-methods/brand.js +160 -0
  33. package/dist/gateway/server-methods/browser-screencast.js +3 -3
  34. package/dist/gateway/server-methods/skills.js +159 -3
  35. package/dist/gateway/server-methods/workspaces.js +7 -7
  36. package/dist/gateway/server-methods-list.js +5 -0
  37. package/dist/gateway/server-methods.js +2 -0
  38. package/dist/gateway/server.impl.js +1 -1
  39. package/dist/infra/heartbeat-runner.js +17 -0
  40. package/dist/infra/heartbeat-update-notify.js +120 -0
  41. package/dist/infra/tunnel.js +1 -1
  42. package/dist/memory/embeddings.js +0 -4
  43. package/dist/memory/manager.js +15 -6
  44. package/dist/web/inbound/media.js +1 -1
  45. package/dist/web/login-qr.js +0 -23
  46. package/dist/web/providers/cloud/receive.js +1 -1
  47. package/dist/web/providers/cloud/webhook.js +1 -1
  48. package/package.json +1 -1
  49. package/skills/skill-builder/SKILL.md +97 -0
  50. package/skills/skill-builder/references/lean-pattern.md +118 -0
  51. package/skills/zero-to-prototype/SKILL.md +35 -0
  52. package/skills/zero-to-prototype/references/discovery.md +64 -0
  53. package/skills/zero-to-prototype/references/prd.md +83 -0
  54. package/skills/zero-to-prototype/references/validation.md +67 -0
  55. package/taskmaster-docs/USER-GUIDE.md +65 -31
  56. package/templates/customer/agents/public/AGENTS.md +3 -10
  57. package/templates/taskmaster/agents/public/SOUL.md +0 -4
  58. package/templates/tradesupport/agents/public/AGENTS.md +3 -10
  59. package/dist/control-ui/assets/index-DjhCZlZd.css +0 -1
  60. package/dist/control-ui/assets/index-DtuDNTAC.js +0 -3539
  61. package/dist/control-ui/assets/index-DtuDNTAC.js.map +0 -1
  62. package/skills/taskmaster/SKILL.md +0 -164
@@ -6,8 +6,8 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-DtuDNTAC.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-DjhCZlZd.css">
9
+ <script type="module" crossorigin src="./assets/index-nLVF-pVT.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-B2FEGOCu.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
Binary file
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Extract admin phone numbers from config bindings.
3
+ * Admin phones are bindings where agentId is "admin" (or legacy "management"),
4
+ * channel is "whatsapp", and peer.kind is "dm".
5
+ */
6
+ export function resolveAdminBindingPhones(cfg, accountId) {
7
+ const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
8
+ const phones = [];
9
+ for (const binding of bindings) {
10
+ if (!binding || typeof binding !== "object")
11
+ continue;
12
+ const agentId = binding.agentId;
13
+ if (agentId !== "admin" && agentId !== "management")
14
+ continue;
15
+ const match = binding.match;
16
+ if (!match || typeof match !== "object")
17
+ continue;
18
+ const channel = typeof match.channel === "string" ? match.channel.toLowerCase() : "";
19
+ if (channel !== "whatsapp")
20
+ continue;
21
+ // Filter by accountId if specified
22
+ if (accountId && typeof match.accountId === "string" && match.accountId !== accountId)
23
+ continue;
24
+ const peer = match.peer;
25
+ if (!peer || typeof peer !== "object")
26
+ continue;
27
+ if (peer.kind !== "dm")
28
+ continue;
29
+ const id = typeof peer.id === "string" ? peer.id.trim() : "";
30
+ if (id)
31
+ phones.push(id);
32
+ }
33
+ return phones;
34
+ }
35
+ /**
36
+ * Expand the `to` field from a cron payload into individual recipient addresses.
37
+ *
38
+ * Supports:
39
+ * - Single phone: "+15551234567"
40
+ * - Comma-separated phones: "+15551234567,+15559876543"
41
+ * - "admins" token: resolves to all admin binding phones for the account
42
+ * - Mixed: "admins,+15559999999"
43
+ *
44
+ * Returns deduplicated list of phone numbers/addresses.
45
+ * Returns empty array if `to` is empty/undefined.
46
+ */
47
+ export function expandCronRecipients(cfg, to, accountId) {
48
+ if (!to?.trim())
49
+ return [];
50
+ const parts = to.split(",").map((s) => s.trim()).filter(Boolean);
51
+ const seen = new Set();
52
+ const result = [];
53
+ for (const part of parts) {
54
+ if (part.toLowerCase() === "admins") {
55
+ for (const phone of resolveAdminBindingPhones(cfg, accountId)) {
56
+ if (!seen.has(phone)) {
57
+ seen.add(phone);
58
+ result.push(phone);
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ if (!seen.has(part)) {
64
+ seen.add(part);
65
+ result.push(part);
66
+ }
67
+ }
68
+ }
69
+ return result;
70
+ }
@@ -18,10 +18,12 @@ import { createOutboundSendDeps } from "../../cli/outbound-send-deps.js";
18
18
  import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
19
19
  import { registerAgentRunContext } from "../../infra/agent-events.js";
20
20
  import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
21
+ import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
21
22
  import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
22
23
  import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
23
24
  import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
24
25
  import { resolveDeliveryTarget } from "./delivery-target.js";
26
+ import { expandCronRecipients } from "./recipients.js";
25
27
  import { isHeartbeatOnlyResponse, pickLastNonEmptyTextFromPayloads, pickSummaryFromOutput, pickSummaryFromPayloads, resolveHeartbeatAckMaxChars, } from "./helpers.js";
26
28
  import { resolveCronSession } from "./session.js";
27
29
  function matchesMessagingToolDeliveryTarget(target, delivery) {
@@ -308,7 +310,15 @@ export async function runCronIsolatedAgentTurn(params) {
308
310
  accountId: resolvedDelivery.accountId,
309
311
  }));
310
312
  if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
311
- if (!resolvedDelivery.to) {
313
+ // Expand multi-recipient targets (comma-separated, "admins" token).
314
+ const expandedRecipients = expandCronRecipients(cfgWithAgentDefaults, agentPayload?.to, effectiveAccountId);
315
+ // Use expanded list if multi-target, otherwise fall back to the resolved single target.
316
+ const deliveryTargets = expandedRecipients.length > 1
317
+ ? expandedRecipients
318
+ : resolvedDelivery.to
319
+ ? [resolvedDelivery.to]
320
+ : [];
321
+ if (deliveryTargets.length === 0) {
312
322
  const reason = resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
313
323
  if (!bestEffortDeliver) {
314
324
  return {
@@ -324,22 +334,42 @@ export async function runCronIsolatedAgentTurn(params) {
324
334
  outputText,
325
335
  };
326
336
  }
327
- try {
328
- await deliverOutboundPayloads({
329
- cfg: cfgWithAgentDefaults,
337
+ const errors = [];
338
+ for (const targetTo of deliveryTargets) {
339
+ // Validate each target against the channel.
340
+ const docked = resolveOutboundTarget({
330
341
  channel: resolvedDelivery.channel,
331
- to: resolvedDelivery.to,
342
+ to: targetTo,
343
+ cfg: cfgWithAgentDefaults,
332
344
  accountId: resolvedDelivery.accountId,
333
- payloads,
334
- bestEffort: bestEffortDeliver,
335
- deps: createOutboundSendDeps(params.deps),
345
+ mode: "explicit",
336
346
  });
337
- }
338
- catch (err) {
339
- if (!bestEffortDeliver) {
340
- return { status: "error", summary, outputText, error: String(err) };
347
+ if (!docked.ok) {
348
+ errors.push(`${targetTo}: ${docked.error.message}`);
349
+ continue;
350
+ }
351
+ try {
352
+ await deliverOutboundPayloads({
353
+ cfg: cfgWithAgentDefaults,
354
+ channel: resolvedDelivery.channel,
355
+ to: docked.to,
356
+ accountId: resolvedDelivery.accountId,
357
+ payloads,
358
+ bestEffort: bestEffortDeliver,
359
+ deps: createOutboundSendDeps(params.deps),
360
+ });
341
361
  }
342
- return { status: "ok", summary, outputText };
362
+ catch (err) {
363
+ errors.push(`${targetTo}: ${String(err)}`);
364
+ }
365
+ }
366
+ if (errors.length > 0 && !bestEffortDeliver) {
367
+ return {
368
+ status: "error",
369
+ summary,
370
+ outputText,
371
+ error: errors.join("; "),
372
+ };
343
373
  }
344
374
  }
345
375
  return { status: "ok", summary, outputText };
@@ -45,6 +45,7 @@ const BASE_RELOAD_RULES_TAIL = [
45
45
  { prefix: "license", kind: "none" },
46
46
  { prefix: "plugins", kind: "restart" },
47
47
  { prefix: "ui", kind: "none" },
48
+ { prefix: "workspaces", kind: "none" },
48
49
  { prefix: "gateway", kind: "restart" },
49
50
  { prefix: "discovery", kind: "restart" },
50
51
  { prefix: "canvasHost", kind: "restart" },
@@ -1,8 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
5
+ import { buildAgentSummaries } from "../commands/agents.config.js";
4
6
  import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
5
7
  import { resolvePublicAgentId } from "./public-chat/session.js";
8
+ import { findBrandLogo } from "./server-methods/brand.js";
6
9
  import { buildControlUiAvatarUrl, CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath, resolveAssistantAvatarUrl, } from "./control-ui-shared.js";
7
10
  const ROOT_PREFIX = "/";
8
11
  function resolveControlUiRoot() {
@@ -132,7 +135,7 @@ const DEFAULT_BRAND_NAME = "Taskmaster";
132
135
  const DEFAULT_BRAND_ICON_URL = "/taskmaster-icon.png";
133
136
  const DEFAULT_ACCENT_COLOR = "#00d4ff";
134
137
  function injectControlUiConfig(html, opts) {
135
- const { basePath, assistantName, assistantAvatar, brandName, brandIconUrl, accentColor } = opts;
138
+ const { basePath, assistantName, assistantAvatar, brandName, brandIconUrl, accentColor, backgroundColor, workspaceBrands, } = opts;
136
139
  const script = `<script>` +
137
140
  `window.__TASKMASTER_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};` +
138
141
  `window.__TASKMASTER_ASSISTANT_NAME__=${JSON.stringify(assistantName ?? DEFAULT_ASSISTANT_IDENTITY.name)};` +
@@ -140,6 +143,10 @@ function injectControlUiConfig(html, opts) {
140
143
  `window.__TASKMASTER_BRAND_NAME__=${JSON.stringify(brandName ?? DEFAULT_BRAND_NAME)};` +
141
144
  `window.__TASKMASTER_BRAND_ICON_URL__=${JSON.stringify(brandIconUrl ?? DEFAULT_BRAND_ICON_URL)};` +
142
145
  `window.__TASKMASTER_ACCENT_COLOR__=${JSON.stringify(accentColor ?? DEFAULT_ACCENT_COLOR)};` +
146
+ `window.__TASKMASTER_BACKGROUND_COLOR__=${JSON.stringify(backgroundColor ?? "")};` +
147
+ (workspaceBrands
148
+ ? `window.__TASKMASTER_WORKSPACE_BRANDS__=${JSON.stringify(workspaceBrands)};`
149
+ : "") +
143
150
  `</script>`;
144
151
  // Check if already injected
145
152
  if (html.includes("__TASKMASTER_ASSISTANT_NAME__"))
@@ -163,6 +170,37 @@ function resolveBrandIconUrl(iconKey, uiRoot) {
163
170
  return "/brand-icon.png";
164
171
  return DEFAULT_BRAND_ICON_URL;
165
172
  }
173
+ /**
174
+ * Build a map of workspace name → brand config for injection into the client.
175
+ * Includes both config colours and whether a logo file exists on disk.
176
+ */
177
+ function buildWorkspaceBrands(config) {
178
+ const wsConfig = config.workspaces ?? {};
179
+ const result = {};
180
+ const summaries = buildAgentSummaries(config);
181
+ const seen = new Set();
182
+ for (const summary of summaries) {
183
+ const root = resolveAgentWorkspaceRoot(config, summary.id);
184
+ const name = path.basename(root);
185
+ if (seen.has(name))
186
+ continue;
187
+ seen.add(name);
188
+ const wsBrand = wsConfig[name]?.brand;
189
+ const logoPath = findBrandLogo(root);
190
+ const entry = {};
191
+ if (wsBrand?.accentColor)
192
+ entry.accentColor = wsBrand.accentColor;
193
+ if (wsBrand?.backgroundColor)
194
+ entry.backgroundColor = wsBrand.backgroundColor;
195
+ if (logoPath)
196
+ entry.logoUrl = `/brand-logo/${encodeURIComponent(name)}`;
197
+ // Only include if there's something to inject
198
+ if (Object.keys(entry).length > 0) {
199
+ result[name] = entry;
200
+ }
201
+ }
202
+ return result;
203
+ }
166
204
  function serveIndexHtml(res, indexPath, opts) {
167
205
  const { basePath, config, agentId } = opts;
168
206
  const identity = config
@@ -180,6 +218,8 @@ function serveIndexHtml(res, indexPath, opts) {
180
218
  const brandName = config?.ui?.brand?.name;
181
219
  const brandIconUrl = resolveBrandIconUrl(config?.ui?.brand?.icon, uiRoot);
182
220
  const accentColor = config?.ui?.seamColor;
221
+ // Build workspace brand map for the control panel
222
+ const workspaceBrands = config ? buildWorkspaceBrands(config) : undefined;
183
223
  res.setHeader("Content-Type", "text/html; charset=utf-8");
184
224
  res.setHeader("Cache-Control", "no-cache");
185
225
  const raw = fs.readFileSync(indexPath, "utf8");
@@ -190,6 +230,7 @@ function serveIndexHtml(res, indexPath, opts) {
190
230
  brandName,
191
231
  brandIconUrl,
192
232
  accentColor,
233
+ workspaceBrands,
193
234
  }));
194
235
  }
195
236
  function isSafeRelativePath(relPath) {
@@ -346,6 +387,55 @@ export function handleBrandIconRequest(req, res, opts) {
346
387
  serveFile(res, filePath);
347
388
  return true;
348
389
  }
390
+ /**
391
+ * Dynamic route for `/brand-logo/:workspace`.
392
+ * Serves the uploaded brand logo from the workspace directory.
393
+ */
394
+ export function handleBrandLogoRequest(req, res, opts) {
395
+ const url = new URL(req.url ?? "/", "http://localhost");
396
+ if (!url.pathname.startsWith("/brand-logo/"))
397
+ return false;
398
+ if (req.method !== "GET" && req.method !== "HEAD")
399
+ return false;
400
+ const workspace = decodeURIComponent(url.pathname.slice("/brand-logo/".length)).split("/")[0];
401
+ if (!workspace || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(workspace)) {
402
+ respondNotFound(res);
403
+ return true;
404
+ }
405
+ const config = opts?.config;
406
+ if (!config) {
407
+ respondNotFound(res);
408
+ return true;
409
+ }
410
+ // Resolve workspace root by name
411
+ let workspaceDir = null;
412
+ const summaries = buildAgentSummaries(config);
413
+ for (const summary of summaries) {
414
+ const root = resolveAgentWorkspaceRoot(config, summary.id);
415
+ if (path.basename(root) === workspace) {
416
+ workspaceDir = root;
417
+ break;
418
+ }
419
+ }
420
+ if (!workspaceDir) {
421
+ respondNotFound(res);
422
+ return true;
423
+ }
424
+ const logoPath = findBrandLogo(workspaceDir);
425
+ if (!logoPath) {
426
+ respondNotFound(res);
427
+ return true;
428
+ }
429
+ if (req.method === "HEAD") {
430
+ res.statusCode = 200;
431
+ res.setHeader("Content-Type", contentTypeForExt(path.extname(logoPath).toLowerCase()));
432
+ res.setHeader("Cache-Control", "no-cache");
433
+ res.end();
434
+ return true;
435
+ }
436
+ serveFile(res, logoPath);
437
+ return true;
438
+ }
349
439
  /**
350
440
  * Serve `/public/chat` — the same SPA but with public-chat flags injected.
351
441
  */
@@ -439,15 +529,30 @@ export function handlePublicChatHttpRequest(req, res, opts) {
439
529
  const cookieTtlDays = config.publicChat.cookieTtlDays ?? 30;
440
530
  const brandName = config.ui?.brand?.name;
441
531
  const brandIconUrl = resolveBrandIconUrl(config.ui?.brand?.icon, root);
442
- const accentColor = config.ui?.seamColor;
532
+ // Resolve workspace-level brand — overrides device-level seamColor
533
+ const wsBrand = config.workspaces?.[accountId]?.brand;
534
+ const accentColor = wsBrand?.accentColor ?? config.ui?.seamColor;
535
+ const backgroundColor = wsBrand?.backgroundColor;
536
+ // Resolve workspace logo URL
537
+ const wsLogoUrl = (() => {
538
+ const summaries = buildAgentSummaries(config);
539
+ for (const s of summaries) {
540
+ const wsRoot = resolveAgentWorkspaceRoot(config, s.id);
541
+ if (path.basename(wsRoot) === accountId && findBrandLogo(wsRoot)) {
542
+ return `/brand-logo/${encodeURIComponent(accountId)}`;
543
+ }
544
+ }
545
+ return undefined;
546
+ })();
443
547
  const raw = fs.readFileSync(indexPath, "utf8");
444
548
  const injected = injectControlUiConfig(raw, {
445
549
  basePath: "",
446
550
  assistantName: identity.name,
447
551
  assistantAvatar: avatarValue,
448
552
  brandName,
449
- brandIconUrl,
553
+ brandIconUrl: wsLogoUrl ?? brandIconUrl,
450
554
  accentColor,
555
+ backgroundColor,
451
556
  });
452
557
  // Inject <base href="/"> right after <head> so relative asset paths (./assets/...)
453
558
  // resolve from root. The URL is /public/chat/:accountId — 3 levels deep, so without
@@ -474,7 +579,7 @@ export function handlePublicChatHttpRequest(req, res, opts) {
474
579
  /** Widget script content — self-contained JS for embedding. */
475
580
  const WIDGET_SCRIPT = `(function(){
476
581
  "use strict";
477
- var cfg={server:"",accountId:"",color:"#1a1a2e"};
582
+ var cfg={server:"",accountId:"",color:"#1a1a2e",bgColor:"#1a1a2e"};
478
583
  var isOpen=false;
479
584
  var btn,overlay,iframe;
480
585
 
@@ -482,6 +587,7 @@ const WIDGET_SCRIPT = `(function(){
482
587
  if(opts&&opts.server) cfg.server=opts.server.replace(/\\/$/,"");
483
588
  if(opts&&opts.accountId) cfg.accountId=opts.accountId;
484
589
  if(opts&&opts.color) cfg.color=opts.color;
590
+ if(opts&&opts.bgColor) cfg.bgColor=opts.bgColor;
485
591
  build();
486
592
  }
487
593
 
@@ -496,7 +602,7 @@ const WIDGET_SCRIPT = `(function(){
496
602
  ".tm-widget-overlay{position:fixed;bottom:78px;right:20px;width:400px;height:600px;",
497
603
  "max-width:calc(100vw - 40px);max-height:calc(100vh - 98px);",
498
604
  "border-radius:12px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.3);",
499
- "z-index:999998;display:none;background:#1a1a2e}",
605
+ "z-index:999998;display:none;background:"+cfg.bgColor+"}",
500
606
  ".tm-widget-overlay.open{display:block}",
501
607
  ".tm-widget-iframe{width:100%;height:100%;border:none}",
502
608
  "@media(max-width:480px){",
@@ -1,5 +1,5 @@
1
1
  import AjvPkg from "ajv";
2
- import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, AgentWaitParamsSchema, ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, ChatAbortParamsSchema, ChatEventSchema, ChatHistoryParamsSchema, ChatInjectParamsSchema, ChatSendParamsSchema, ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, ConnectParamsSchema, CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, DevicePairApproveParamsSchema, DevicePairListParamsSchema, DevicePairRejectParamsSchema, DeviceTokenRevokeParamsSchema, DeviceTokenRotateParamsSchema, ExecApprovalsGetParamsSchema, ExecApprovalsNodeGetParamsSchema, ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalRequestParamsSchema, ExecApprovalResolveParamsSchema, ErrorCodes, ErrorShapeSchema, EventFrameSchema, errorShape, GatewayFrameSchema, HelloOkSchema, LogsTailParamsSchema, LogsTailResultSchema, SessionsTranscriptParamsSchema, SessionsTranscriptResultSchema, ModelsListParamsSchema, NodeDescribeParamsSchema, NodeEventParamsSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeListParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, NodePairRequestParamsSchema, NodePairVerifyParamsSchema, NodeRenameParamsSchema, PollParamsSchema, PROTOCOL_VERSION, PresenceEntrySchema, ProtocolSchemas, RequestFrameSchema, ResponseFrameSchema, SendParamsSchema, SessionsCompactParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, ShutdownEventSchema, SkillsBinsParamsSchema, SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, SnapshotSchema, StateVersionSchema, TalkModeParamsSchema, TickEventSchema, UpdateRunParamsSchema, WakeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, WizardCancelParamsSchema, WizardNextParamsSchema, WizardNextResultSchema, WizardStartParamsSchema, WizardStartResultSchema, WizardStatusParamsSchema, WizardStatusResultSchema, WizardStepSchema, } from "./schema.js";
2
+ import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, AgentWaitParamsSchema, ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, ChatAbortParamsSchema, ChatEventSchema, ChatHistoryParamsSchema, ChatInjectParamsSchema, ChatSendParamsSchema, ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, ConnectParamsSchema, CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, DevicePairApproveParamsSchema, DevicePairListParamsSchema, DevicePairRejectParamsSchema, DeviceTokenRevokeParamsSchema, DeviceTokenRotateParamsSchema, ExecApprovalsGetParamsSchema, ExecApprovalsNodeGetParamsSchema, ExecApprovalsNodeSetParamsSchema, ExecApprovalsSetParamsSchema, ExecApprovalRequestParamsSchema, ExecApprovalResolveParamsSchema, ErrorCodes, ErrorShapeSchema, EventFrameSchema, errorShape, GatewayFrameSchema, HelloOkSchema, LogsTailParamsSchema, LogsTailResultSchema, SessionsTranscriptParamsSchema, SessionsTranscriptResultSchema, ModelsListParamsSchema, NodeDescribeParamsSchema, NodeEventParamsSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeListParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, NodePairRejectParamsSchema, NodePairRequestParamsSchema, NodePairVerifyParamsSchema, NodeRenameParamsSchema, PollParamsSchema, PROTOCOL_VERSION, PresenceEntrySchema, ProtocolSchemas, RequestFrameSchema, ResponseFrameSchema, SendParamsSchema, SessionsCompactParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, SessionsPreviewParamsSchema, SessionsResetParamsSchema, SessionsResolveParamsSchema, ShutdownEventSchema, SkillsBinsParamsSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, SnapshotSchema, StateVersionSchema, TalkModeParamsSchema, TickEventSchema, UpdateRunParamsSchema, WakeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, WizardCancelParamsSchema, WizardNextParamsSchema, WizardNextResultSchema, WizardStartParamsSchema, WizardStartResultSchema, WizardStatusParamsSchema, WizardStatusResultSchema, WizardStepSchema, } from "./schema.js";
3
3
  const ajv = new AjvPkg({
4
4
  allErrors: true,
5
5
  strict: false,
@@ -51,6 +51,11 @@ export const validateSkillsStatusParams = ajv.compile(SkillsStatusParamsSchema);
51
51
  export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema);
52
52
  export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema);
53
53
  export const validateSkillsUpdateParams = ajv.compile(SkillsUpdateParamsSchema);
54
+ export const validateSkillsReadParams = ajv.compile(SkillsReadParamsSchema);
55
+ export const validateSkillsCreateParams = ajv.compile(SkillsCreateParamsSchema);
56
+ export const validateSkillsDeleteParams = ajv.compile(SkillsDeleteParamsSchema);
57
+ export const validateSkillsDraftsParams = ajv.compile(SkillsDraftsParamsSchema);
58
+ export const validateSkillsDeleteDraftParams = ajv.compile(SkillsDeleteDraftParamsSchema);
54
59
  export const validateCronListParams = ajv.compile(CronListParamsSchema);
55
60
  export const validateCronStatusParams = ajv.compile(CronStatusParamsSchema);
56
61
  export const validateCronAddParams = ajv.compile(CronAddParamsSchema);
@@ -45,3 +45,26 @@ export const SkillsUpdateParamsSchema = Type.Object({
45
45
  apiKey: Type.Optional(Type.String()),
46
46
  env: Type.Optional(Type.Record(NonEmptyString, Type.String())),
47
47
  }, { additionalProperties: false });
48
+ export const SkillsReadParamsSchema = Type.Object({
49
+ name: NonEmptyString,
50
+ }, { additionalProperties: false });
51
+ const SkillReferenceFileSchema = Type.Object({
52
+ name: NonEmptyString,
53
+ content: Type.String(),
54
+ }, { additionalProperties: false });
55
+ export const SkillsCreateParamsSchema = Type.Object({
56
+ name: Type.String({
57
+ pattern: "^[a-z0-9][a-z0-9_-]*$",
58
+ minLength: 1,
59
+ description: "Skill directory name (lowercase, hyphens, underscores)",
60
+ }),
61
+ skillContent: NonEmptyString,
62
+ references: Type.Optional(Type.Array(SkillReferenceFileSchema)),
63
+ }, { additionalProperties: false });
64
+ export const SkillsDeleteParamsSchema = Type.Object({
65
+ name: NonEmptyString,
66
+ }, { additionalProperties: false });
67
+ export const SkillsDraftsParamsSchema = Type.Object({}, { additionalProperties: false });
68
+ export const SkillsDeleteDraftParamsSchema = Type.Object({
69
+ name: NonEmptyString,
70
+ }, { additionalProperties: false });
@@ -1,5 +1,5 @@
1
1
  import { AgentEventSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, AgentWaitParamsSchema, PollParamsSchema, SendParamsSchema, WakeParamsSchema, } from "./agent.js";
2
- import { AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, SkillsBinsParamsSchema, SkillsBinsResultSchema, SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, } from "./agents-models-skills.js";
2
+ import { AgentSummarySchema, AgentsListParamsSchema, AgentsListResultSchema, ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, SkillsBinsParamsSchema, SkillsBinsResultSchema, SkillsCreateParamsSchema, SkillsDeleteParamsSchema, SkillsDeleteDraftParamsSchema, SkillsDraftsParamsSchema, SkillsInstallParamsSchema, SkillsReadParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, } from "./agents-models-skills.js";
3
3
  import { ChannelsLogoutParamsSchema, ChannelsStatusParamsSchema, ChannelsStatusResultSchema, TalkModeParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, } from "./channels.js";
4
4
  import { ConfigApplyParamsSchema, ConfigGetParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, UpdateRunParamsSchema, } from "./config.js";
5
5
  import { CronAddParamsSchema, CronJobSchema, CronListParamsSchema, CronRemoveParamsSchema, CronRunLogEntrySchema, CronRunParamsSchema, CronRunsParamsSchema, CronStatusParamsSchema, CronUpdateParamsSchema, } from "./cron.js";
@@ -80,6 +80,11 @@ export const ProtocolSchemas = {
80
80
  SkillsBinsResult: SkillsBinsResultSchema,
81
81
  SkillsInstallParams: SkillsInstallParamsSchema,
82
82
  SkillsUpdateParams: SkillsUpdateParamsSchema,
83
+ SkillsReadParams: SkillsReadParamsSchema,
84
+ SkillsCreateParams: SkillsCreateParamsSchema,
85
+ SkillsDeleteParams: SkillsDeleteParamsSchema,
86
+ SkillsDraftsParams: SkillsDraftsParamsSchema,
87
+ SkillsDeleteDraftParams: SkillsDeleteDraftParamsSchema,
83
88
  CronJob: CronJobSchema,
84
89
  CronListParams: CronListParamsSchema,
85
90
  CronStatusParams: CronStatusParamsSchema,
@@ -5,7 +5,7 @@ import { loadConfig } from "../config/config.js";
5
5
  import { handleSlackHttpRequest } from "../slack/http/index.js";
6
6
  import { createCloudApiWebhookHandler } from "../web/providers/cloud/webhook-http.js";
7
7
  import { resolveAgentAvatar } from "../agents/identity-avatar.js";
8
- import { handleBrandIconRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, handlePublicChatHttpRequest, handlePublicWidgetRequest, } from "./control-ui.js";
8
+ import { handleBrandIconRequest, handleBrandLogoRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, handlePublicChatHttpRequest, handlePublicWidgetRequest, } from "./control-ui.js";
9
9
  import { handlePublicChatApiRequest } from "./public-chat-api.js";
10
10
  import { isLicensed } from "../license/state.js";
11
11
  import { getEffectiveTrustedProxies, isExternalRequest } from "./net.js";
@@ -162,6 +162,9 @@ export function createGatewayHttpServer(opts) {
162
162
  return;
163
163
  if (handlePublicWidgetRequest(req, res, { config: configSnapshot }))
164
164
  return;
165
+ // Brand logos may be referenced from public chat pages
166
+ if (handleBrandLogoRequest(req, res, { config: configSnapshot }))
167
+ return;
165
168
  // Funnel restriction: block non-local requests from accessing non-public paths.
166
169
  // /public/* is already handled above, so any request reaching here is non-public.
167
170
  const trustedProxies = getEffectiveTrustedProxies(configSnapshot);
@@ -238,6 +241,8 @@ export function createGatewayHttpServer(opts) {
238
241
  if (controlUiEnabled) {
239
242
  if (handleBrandIconRequest(req, res, { config: configSnapshot }))
240
243
  return;
244
+ if (handleBrandLogoRequest(req, res, { config: configSnapshot }))
245
+ return;
241
246
  if (handleControlUiAvatarRequest(req, res, {
242
247
  basePath: controlUiBasePath,
243
248
  resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
@@ -176,7 +176,7 @@ export const accessHandlers = {
176
176
  await writeConfigFile({
177
177
  ...config,
178
178
  access: {
179
- ...(config.access ?? {}),
179
+ ...config.access,
180
180
  masterPin: pin,
181
181
  },
182
182
  });
@@ -210,9 +210,9 @@ export const accessHandlers = {
210
210
  await writeConfigFile({
211
211
  ...config,
212
212
  access: {
213
- ...(config.access ?? {}),
213
+ ...config.access,
214
214
  pins: {
215
- ...(config.access?.pins ?? {}),
215
+ ...config.access?.pins,
216
216
  [workspace]: pin,
217
217
  },
218
218
  },
@@ -0,0 +1,160 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import path from "node:path";
4
+ import { loadConfig } from "../../config/config.js";
5
+ import { resolveAgentWorkspaceRoot } from "../../agents/agent-scope.js";
6
+ import { buildAgentSummaries } from "../../commands/agents.config.js";
7
+ import { ErrorCodes, errorShape } from "../protocol/index.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+ const BRAND_LOGO_FILENAME_PREFIX = "brand-logo";
12
+ const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
13
+ const MAX_LOGO_BYTES = 2 * 1024 * 1024; // 2 MB
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Resolve the workspace root directory for a given workspace name.
19
+ * Workspace name = basename of the workspace root (set during workspaces.create).
20
+ */
21
+ function resolveWorkspaceRootByName(cfg, workspaceName) {
22
+ const summaries = buildAgentSummaries(cfg);
23
+ for (const summary of summaries) {
24
+ const root = resolveAgentWorkspaceRoot(cfg, summary.id);
25
+ if (path.basename(root) === workspaceName)
26
+ return root;
27
+ }
28
+ return null;
29
+ }
30
+ /**
31
+ * Find an existing brand logo file in a workspace directory.
32
+ * Returns the full path if found, null otherwise.
33
+ */
34
+ export function findBrandLogo(workspaceDir) {
35
+ for (const ext of ALLOWED_EXTENSIONS) {
36
+ const candidate = path.join(workspaceDir, `${BRAND_LOGO_FILENAME_PREFIX}${ext}`);
37
+ if (fsSync.existsSync(candidate))
38
+ return candidate;
39
+ }
40
+ return null;
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // RPC Handlers
44
+ // ---------------------------------------------------------------------------
45
+ export const brandHandlers = {
46
+ /**
47
+ * Upload a brand logo for a workspace.
48
+ * Params: { workspace: string, data: string (base64), mimeType: string }
49
+ */
50
+ "brand.uploadLogo": async ({ params, respond }) => {
51
+ const workspace = typeof params.workspace === "string" ? params.workspace.trim() : "";
52
+ const data = typeof params.data === "string" ? params.data : "";
53
+ const mimeType = typeof params.mimeType === "string" ? params.mimeType.trim() : "";
54
+ if (!workspace || !data) {
55
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "workspace and data are required"));
56
+ return;
57
+ }
58
+ // Derive extension from mimeType
59
+ const ext = mimeToExt(mimeType);
60
+ if (!ext || !ALLOWED_EXTENSIONS.has(ext)) {
61
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unsupported image type "${mimeType}"; allowed: png, jpg, svg, webp`));
62
+ return;
63
+ }
64
+ const buffer = Buffer.from(data, "base64");
65
+ if (buffer.length > MAX_LOGO_BYTES) {
66
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `logo too large (${buffer.length} bytes, max ${MAX_LOGO_BYTES})`));
67
+ return;
68
+ }
69
+ const cfg = loadConfig();
70
+ const workspaceDir = resolveWorkspaceRootByName(cfg, workspace);
71
+ if (!workspaceDir) {
72
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `workspace "${workspace}" not found`));
73
+ return;
74
+ }
75
+ // Remove any existing brand logo (different extension)
76
+ for (const existingExt of ALLOWED_EXTENSIONS) {
77
+ const existing = path.join(workspaceDir, `${BRAND_LOGO_FILENAME_PREFIX}${existingExt}`);
78
+ try {
79
+ await fs.unlink(existing);
80
+ }
81
+ catch {
82
+ // ignore — file may not exist
83
+ }
84
+ }
85
+ const destPath = path.join(workspaceDir, `${BRAND_LOGO_FILENAME_PREFIX}${ext}`);
86
+ try {
87
+ await fs.writeFile(destPath, buffer);
88
+ respond(true, { path: destPath, size: buffer.length });
89
+ }
90
+ catch (err) {
91
+ respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
92
+ }
93
+ },
94
+ /**
95
+ * Remove the brand logo for a workspace.
96
+ * Params: { workspace: string }
97
+ */
98
+ "brand.removeLogo": async ({ params, respond }) => {
99
+ const workspace = typeof params.workspace === "string" ? params.workspace.trim() : "";
100
+ if (!workspace) {
101
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "workspace is required"));
102
+ return;
103
+ }
104
+ const cfg = loadConfig();
105
+ const workspaceDir = resolveWorkspaceRootByName(cfg, workspace);
106
+ if (!workspaceDir) {
107
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `workspace "${workspace}" not found`));
108
+ return;
109
+ }
110
+ let removed = false;
111
+ for (const ext of ALLOWED_EXTENSIONS) {
112
+ const existing = path.join(workspaceDir, `${BRAND_LOGO_FILENAME_PREFIX}${ext}`);
113
+ try {
114
+ await fs.unlink(existing);
115
+ removed = true;
116
+ }
117
+ catch {
118
+ // ignore
119
+ }
120
+ }
121
+ respond(true, { removed });
122
+ },
123
+ /**
124
+ * Check whether a workspace has a brand logo.
125
+ * Params: { workspace: string }
126
+ */
127
+ "brand.hasLogo": ({ params, respond }) => {
128
+ const workspace = typeof params.workspace === "string" ? params.workspace.trim() : "";
129
+ if (!workspace) {
130
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "workspace is required"));
131
+ return;
132
+ }
133
+ const cfg = loadConfig();
134
+ const workspaceDir = resolveWorkspaceRootByName(cfg, workspace);
135
+ if (!workspaceDir) {
136
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `workspace "${workspace}" not found`));
137
+ return;
138
+ }
139
+ const logoPath = findBrandLogo(workspaceDir);
140
+ respond(true, { hasLogo: Boolean(logoPath) });
141
+ },
142
+ };
143
+ // ---------------------------------------------------------------------------
144
+ // Helpers (private)
145
+ // ---------------------------------------------------------------------------
146
+ function mimeToExt(mime) {
147
+ switch (mime.toLowerCase()) {
148
+ case "image/png":
149
+ return ".png";
150
+ case "image/jpeg":
151
+ case "image/jpg":
152
+ return ".jpg";
153
+ case "image/svg+xml":
154
+ return ".svg";
155
+ case "image/webp":
156
+ return ".webp";
157
+ default:
158
+ return null;
159
+ }
160
+ }