velaclaw-dev 0.2.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.
Files changed (63) hide show
  1. package/.gitignore +14 -0
  2. package/ARCHITECTURE.md +143 -0
  3. package/README.dev.md +208 -0
  4. package/README.local-before-remote-sync.md +224 -0
  5. package/README.md +211 -0
  6. package/README.public.md +115 -0
  7. package/RELEASING.md +162 -0
  8. package/TESTING.md +195 -0
  9. package/dist/cli.js +213 -0
  10. package/dist/data.js +2988 -0
  11. package/dist/server.js +1020 -0
  12. package/dist/ui.js +1486 -0
  13. package/members/LAUNCH_CHECKLIST.md +13 -0
  14. package/members/README.md +17 -0
  15. package/members/member-template/README.md +9 -0
  16. package/members/member-template/private-docs/README.md +3 -0
  17. package/members/member-template/private-memory/README.md +3 -0
  18. package/members/member-template/private-skills/README.md +4 -0
  19. package/members/member-template/private-tools/README.md +4 -0
  20. package/members/member-template/runtime/config/README.md +3 -0
  21. package/members/member-template/runtime/config/local-plugins/member-quota-guard/index.js +123 -0
  22. package/members/member-template/runtime/config/local-plugins/member-quota-guard/openclaw.plugin.json +19 -0
  23. package/members/member-template/runtime/config/local-plugins/member-quota-guard/package.json +10 -0
  24. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/index.js +97 -0
  25. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/openclaw.plugin.json +21 -0
  26. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/package.json +10 -0
  27. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/index.js +548 -0
  28. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/openclaw.plugin.json +33 -0
  29. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/package.json +10 -0
  30. package/members/member-template/runtime/config/openclaw.json +104 -0
  31. package/members/member-template/runtime/docker-compose.yml +53 -0
  32. package/members/member-template/runtime/logs/README.md +3 -0
  33. package/members/member-template/runtime/secrets/.gitkeep +1 -0
  34. package/members/member-template/runtime/secrets/README.md +3 -0
  35. package/members/member-template/runtime/workspace/.gitkeep +1 -0
  36. package/members/member-template/runtime/workspace/README.md +3 -0
  37. package/package.json +57 -0
  38. package/pic/banner.jpg +0 -0
  39. package/provision-member.md +87 -0
  40. package/scripts/shared-asset-stack-test.mjs +369 -0
  41. package/scripts/shared-skill-combo-test.mjs +282 -0
  42. package/scripts/team-load-test.mjs +358 -0
  43. package/scripts/verify-install.mjs +44 -0
  44. package/services/litellm/config.yaml +35 -0
  45. package/services/litellm/docker-compose.yml +36 -0
  46. package/services/litellm/litellm.env.example +13 -0
  47. package/shared-snapshots/README.md +16 -0
  48. package/shared-snapshots/docs/README.md +3 -0
  49. package/shared-snapshots/memory/README.md +3 -0
  50. package/shared-snapshots/skills/README.md +3 -0
  51. package/shared-snapshots/tools/README.md +4 -0
  52. package/shared-snapshots/workflows/README.md +3 -0
  53. package/team-assets/README.md +11 -0
  54. package/team-assets/policies/README.md +7 -0
  55. package/team-assets/policies/asset-visibility.md +24 -0
  56. package/team-assets/policies/high-risk-action-approval.md +18 -0
  57. package/team-assets/policies/promotion-rules.md +25 -0
  58. package/team-assets/policies/tool-binding-rules.md +26 -0
  59. package/team-assets/shared-docs/README.md +3 -0
  60. package/team-assets/shared-memory/README.md +8 -0
  61. package/team-assets/shared-skills/README.md +8 -0
  62. package/team-assets/shared-tools/README.md +8 -0
  63. package/team-assets/shared-workflows/README.md +9 -0
package/dist/data.js ADDED
@@ -0,0 +1,2988 @@
1
+ import crypto from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ export class HttpError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.name = "HttpError";
11
+ this.status = status;
12
+ }
13
+ }
14
+ const ROOT = process.cwd();
15
+ const MEMBERS_ROOT = path.join(ROOT, "members");
16
+ const STATE_ROOT = path.join(ROOT, "state");
17
+ const TEAM_STATE_PATH = path.join(STATE_ROOT, "team.json");
18
+ const MEMBER_TEMPLATE_ID = "member-template";
19
+ const MEMBER_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
20
+ const DEFAULT_MEMBER_PORT = 18800;
21
+ const TEAM_OVERVIEW_CACHE_TTL_MS = 2000;
22
+ const MEMBER_UPGRADE_SCHEDULE_DELAY_MS = 1500;
23
+ let teamStateMutationQueue = Promise.resolve();
24
+ const execFileAsync = promisify(execFile);
25
+ const teamOverviewCache = new Map();
26
+ const teamOverviewInflight = new Map();
27
+ const memberRuntimeUpgradeInflight = new Map();
28
+ const TEAM_BUCKETS = [
29
+ ["shared-memory", "Shared memory"],
30
+ ["shared-skills", "Shared skills"],
31
+ ["shared-workflows", "Shared workflows"],
32
+ ["shared-docs", "Shared docs"],
33
+ ["policies", "Policies"]
34
+ ];
35
+ const SNAPSHOT_BUCKETS = [
36
+ ["memory", "Snapshot memory"],
37
+ ["skills", "Snapshot skills"],
38
+ ["workflows", "Snapshot workflows"],
39
+ ["docs", "Snapshot docs"]
40
+ ];
41
+ const MEMBER_BUCKETS = [
42
+ ["private-memory", "Private memory"],
43
+ ["private-skills", "Private skills"],
44
+ ["private-tools", "Private tools"],
45
+ ["private-docs", "Private docs"]
46
+ ];
47
+ const RESERVED_MEMBER_DIR_NAMES = new Set([
48
+ MEMBER_TEMPLATE_ID,
49
+ ...MEMBER_BUCKETS.map(([key]) => key)
50
+ ]);
51
+ const MEMBER_PROXY_URL = "http://host.docker.internal:17892";
52
+ const MEMBER_NO_PROXY = "127.0.0.1,localhost,::1,host.docker.internal";
53
+ const MEMBER_PROXY_ENV_LINES = [
54
+ ` HTTP_PROXY: ${MEMBER_PROXY_URL}`,
55
+ ` HTTPS_PROXY: ${MEMBER_PROXY_URL}`,
56
+ ` http_proxy: ${MEMBER_PROXY_URL}`,
57
+ ` https_proxy: ${MEMBER_PROXY_URL}`,
58
+ ` NO_PROXY: ${MEMBER_NO_PROXY}`,
59
+ ` no_proxy: ${MEMBER_NO_PROXY}`
60
+ ].join("\n");
61
+ async function safeStat(targetPath) {
62
+ try {
63
+ return await fs.stat(targetPath);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ async function fileExists(targetPath) {
70
+ return Boolean(await safeStat(targetPath));
71
+ }
72
+ async function readText(targetPath) {
73
+ return fs.readFile(targetPath, "utf8");
74
+ }
75
+ async function writeText(targetPath, content) {
76
+ await fs.writeFile(targetPath, content, "utf8");
77
+ }
78
+ function invalidateTeamOverviewCache(teamSlug) {
79
+ if (teamSlug) {
80
+ teamOverviewCache.delete(teamSlug);
81
+ teamOverviewInflight.delete(teamSlug);
82
+ return;
83
+ }
84
+ teamOverviewCache.clear();
85
+ teamOverviewInflight.clear();
86
+ }
87
+ async function writeSecretText(targetPath, content) {
88
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
89
+ await fs.writeFile(targetPath, content, { encoding: "utf8", mode: 0o600 });
90
+ await fs.chmod(targetPath, 0o600);
91
+ }
92
+ function parseTelegramTargetToChatId(rawValue) {
93
+ const value = String(rawValue || "").trim();
94
+ const match = value.match(/^telegram:(-?\d+)$/);
95
+ return match?.[1] || null;
96
+ }
97
+ async function loadLatestTelegramNotificationTarget(teamSlug, memberId) {
98
+ const sessionsPath = path.join(memberRoot(teamSlug, memberId), "runtime", "config", "agents", "main", "sessions", "sessions.json");
99
+ if (!(await fileExists(sessionsPath))) {
100
+ return null;
101
+ }
102
+ try {
103
+ const raw = JSON.parse(await readText(sessionsPath));
104
+ const candidates = Object.values(raw)
105
+ .filter((entry) => entry && typeof entry === "object")
106
+ .map((entry) => ({
107
+ updatedAt: Number(entry.updatedAt || 0),
108
+ channel: String(entry.deliveryContext?.channel || entry.lastChannel || ""),
109
+ to: String(entry.deliveryContext?.to || entry.lastTo || "")
110
+ }))
111
+ .filter((entry) => entry.channel === "telegram" && parseTelegramTargetToChatId(entry.to));
112
+ if (!candidates.length) {
113
+ return null;
114
+ }
115
+ candidates.sort((left, right) => right.updatedAt - left.updatedAt);
116
+ return {
117
+ channel: "telegram",
118
+ to: candidates[0].to
119
+ };
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ async function notifyTelegramUpgradeResult(teamSlug, memberId, target, text) {
126
+ const chatId = parseTelegramTargetToChatId(target.to);
127
+ if (!chatId) {
128
+ return false;
129
+ }
130
+ const tokenPath = path.join(memberRoot(teamSlug, memberId), "runtime", "secrets", "telegram-bot-token");
131
+ if (!(await fileExists(tokenPath))) {
132
+ return false;
133
+ }
134
+ const token = (await readText(tokenPath)).trim();
135
+ if (!token) {
136
+ return false;
137
+ }
138
+ const result = await runHostCommand("curl", [
139
+ "-sS",
140
+ "-X",
141
+ "POST",
142
+ `https://api.telegram.org/bot${token}/sendMessage`,
143
+ "-d",
144
+ `chat_id=${chatId}`,
145
+ "--data-urlencode",
146
+ `text=${text}`
147
+ ]);
148
+ if (!result.ok) {
149
+ console.error(`[upgrade-notify] Telegram send failed for ${memberId}: ${result.stderr || result.error || "curl failed"}`);
150
+ return false;
151
+ }
152
+ try {
153
+ const payload = JSON.parse(result.stdout || "{}");
154
+ if (!payload.ok) {
155
+ console.error(`[upgrade-notify] Telegram send failed for ${memberId}: ${payload.description || result.stdout}`);
156
+ return false;
157
+ }
158
+ }
159
+ catch {
160
+ console.error(`[upgrade-notify] Telegram send returned non-json for ${memberId}: ${result.stdout}`);
161
+ return false;
162
+ }
163
+ console.log(`[upgrade-notify] Telegram sent for ${memberId} -> ${chatId}`);
164
+ return true;
165
+ }
166
+ async function notifyMemberUpgradeResult(teamSlug, memberId, target, outcome) {
167
+ const resolvedTarget = target ?? (await loadLatestTelegramNotificationTarget(teamSlug, memberId));
168
+ if (!resolvedTarget || resolvedTarget.channel !== "telegram") {
169
+ return;
170
+ }
171
+ const text = outcome.ok
172
+ ? "升级完成,当前成员 runtime 已同步最新模板和镜像,可以继续聊天。"
173
+ : `升级失败:${outcome.detail || "unknown error"}`;
174
+ try {
175
+ await notifyTelegramUpgradeResult(teamSlug, memberId, resolvedTarget, text);
176
+ }
177
+ catch (error) {
178
+ console.error("[upgrade-notify] unexpected error:", error instanceof Error ? error.message : String(error));
179
+ }
180
+ }
181
+ async function runHostCommand(command, args, cwd) {
182
+ try {
183
+ const result = await execFileAsync(command, args, {
184
+ cwd,
185
+ maxBuffer: 1024 * 1024 * 4
186
+ });
187
+ return {
188
+ ok: true,
189
+ stdout: result.stdout.trim(),
190
+ stderr: result.stderr.trim()
191
+ };
192
+ }
193
+ catch (error) {
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ const stdout = typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string"
196
+ ? error.stdout.trim()
197
+ : "";
198
+ const stderr = typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string"
199
+ ? error.stderr.trim()
200
+ : message;
201
+ return {
202
+ ok: false,
203
+ stdout,
204
+ stderr,
205
+ error: message
206
+ };
207
+ }
208
+ }
209
+ function parseDockerHealth(statusText) {
210
+ if (!statusText) {
211
+ return undefined;
212
+ }
213
+ const match = statusText.match(/\(([^)]+)\)\s*$/);
214
+ if (!match) {
215
+ return undefined;
216
+ }
217
+ return match[1].replace(/^health:\s*/, "");
218
+ }
219
+ async function listDockerContainers() {
220
+ const result = await runHostCommand("sudo", ["-n", "docker", "ps", "-a", "--format", "{{json .}}"]);
221
+ if (!result.ok) {
222
+ return result;
223
+ }
224
+ const containersByName = new Map();
225
+ for (const line of result.stdout.split("\n").map((entry) => entry.trim()).filter(Boolean)) {
226
+ try {
227
+ const row = JSON.parse(line);
228
+ const containerName = typeof row.Names === "string" ? row.Names : "";
229
+ if (!containerName) {
230
+ continue;
231
+ }
232
+ const state = typeof row.State === "string" ? row.State : undefined;
233
+ const statusText = typeof row.Status === "string" ? row.Status : undefined;
234
+ containersByName.set(containerName, {
235
+ exists: true,
236
+ running: state === "running",
237
+ status: state,
238
+ health: parseDockerHealth(statusText)
239
+ });
240
+ }
241
+ catch {
242
+ // Skip malformed rows; docker ps --format json should emit one valid JSON object per line.
243
+ }
244
+ }
245
+ return {
246
+ ok: true,
247
+ stdout: result.stdout,
248
+ stderr: result.stderr,
249
+ containersByName
250
+ };
251
+ }
252
+ async function getDockerAccess() {
253
+ const result = await listDockerContainers();
254
+ if (result.ok) {
255
+ return {
256
+ available: true,
257
+ containersByName: result.containersByName
258
+ };
259
+ }
260
+ return {
261
+ available: false,
262
+ error: result.stderr || result.error || "docker unavailable"
263
+ };
264
+ }
265
+ function parsePublishedPort(composeText) {
266
+ const match = composeText.match(/127\.0\.0\.1:(\d+):18789/);
267
+ return match ? Number(match[1]) : null;
268
+ }
269
+ function containerNameForMember(teamSlug, memberId) {
270
+ const base = `openclaw-member-${teamSlug}-${memberId}`
271
+ .toLowerCase()
272
+ .replace(/[^a-z0-9_-]+/g, "-")
273
+ .replace(/^-+|-+$/g, "");
274
+ if (base.length <= 63) {
275
+ return base;
276
+ }
277
+ const hash = crypto.createHash("sha1").update(`container:${teamSlug}:${memberId}`).digest("hex").slice(0, 8);
278
+ return `${base.slice(0, 54)}-${hash}`;
279
+ }
280
+ function parseContainerName(teamSlug, memberId, composeText) {
281
+ const match = composeText.match(/container_name:\s*([^\s]+)/);
282
+ if (!match) {
283
+ return containerNameForMember(teamSlug, memberId);
284
+ }
285
+ return match[1]
286
+ .replace("REPLACE_CONTAINER_ID", containerNameForMember(teamSlug, memberId))
287
+ .replace("REPLACE_MEMBER_ID", memberId);
288
+ }
289
+ function composeProjectNameForMember(teamSlug, memberId) {
290
+ const base = `member-${teamSlug}-${memberId}`
291
+ .toLowerCase()
292
+ .replace(/[^a-z0-9_-]+/g, "-")
293
+ .replace(/^-+|-+$/g, "");
294
+ if (base.length <= 48) {
295
+ return base;
296
+ }
297
+ const hash = crypto.createHash("sha1").update(`${teamSlug}:${memberId}`).digest("hex").slice(0, 8);
298
+ return `${base.slice(0, 39)}-${hash}`;
299
+ }
300
+ function ensureMemberProxyComposeDefaults(composeText, composeProjectName, containerName) {
301
+ let next = composeText.replaceAll("http://172.19.0.1:17892", MEMBER_PROXY_URL);
302
+ next = next.replaceAll("HTTPS_PROXY: http://host.docker.internal:17892", `HTTPS_PROXY: ${MEMBER_PROXY_URL}`);
303
+ next = next.replaceAll("HTTP_PROXY: http://host.docker.internal:17892", `HTTP_PROXY: ${MEMBER_PROXY_URL}`);
304
+ next = next.replaceAll("http_proxy: http://host.docker.internal:17892", `http_proxy: ${MEMBER_PROXY_URL}`);
305
+ next = next.replaceAll("https_proxy: http://host.docker.internal:17892", `https_proxy: ${MEMBER_PROXY_URL}`);
306
+ next = next.replace(/^name:\s*.*$/m, `name: ${composeProjectName}`);
307
+ if (containerName) {
308
+ next = next.replace(/^(\s*container_name:\s*).+$/m, `$1${containerName}`);
309
+ next = next.replaceAll("REPLACE_CONTAINER_ID", containerName);
310
+ }
311
+ if (!/^\s+HTTP_PROXY:\s+/m.test(next)) {
312
+ next = next.replace(" OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace", ` OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace\n${MEMBER_PROXY_ENV_LINES}`);
313
+ }
314
+ if (!/^\s+NO_PROXY:\s+/m.test(next)) {
315
+ next = next.replace(` https_proxy: ${MEMBER_PROXY_URL}`, ` https_proxy: ${MEMBER_PROXY_URL}\n NO_PROXY: ${MEMBER_NO_PROXY}\n no_proxy: ${MEMBER_NO_PROXY}`);
316
+ }
317
+ if (!/^name:\s+/m.test(next)) {
318
+ next = `name: ${composeProjectName}\n${next}`;
319
+ }
320
+ return next;
321
+ }
322
+ async function ensureMemberRuntimeComposeDefaults(teamSlug, memberId) {
323
+ const composePath = path.join(memberRuntimeRoot(teamSlug, memberId), "docker-compose.yml");
324
+ if (!(await fileExists(composePath))) {
325
+ return;
326
+ }
327
+ const raw = await readText(composePath);
328
+ const next = ensureMemberProxyComposeDefaults(raw, composeProjectNameForMember(teamSlug, memberId), containerNameForMember(teamSlug, memberId));
329
+ if (next !== raw) {
330
+ await writeText(composePath, next);
331
+ }
332
+ }
333
+ async function inspectDockerContainer(containerName) {
334
+ const inspect = await runHostCommand("sudo", [
335
+ "-n",
336
+ "docker",
337
+ "inspect",
338
+ containerName,
339
+ "--format",
340
+ "{{json .State}}"
341
+ ]);
342
+ if (!inspect.ok) {
343
+ const combinedError = `${inspect.stderr || ""}\n${inspect.error || ""}`.toLowerCase();
344
+ if (combinedError.includes("no such object")) {
345
+ return { exists: false, running: false };
346
+ }
347
+ throw new HttpError(502, inspect.stderr || inspect.error || `docker inspect failed for ${containerName}`);
348
+ }
349
+ const state = JSON.parse(inspect.stdout || "{}");
350
+ return {
351
+ exists: true,
352
+ running: Boolean(state.Running),
353
+ status: typeof state.Status === "string" ? state.Status : undefined,
354
+ health: typeof state.Health === "object" &&
355
+ state.Health !== null &&
356
+ "Status" in state.Health &&
357
+ typeof state.Health.Status === "string"
358
+ ? state.Health.Status
359
+ : undefined,
360
+ startedAt: typeof state.StartedAt === "string" ? state.StartedAt : undefined
361
+ };
362
+ }
363
+ function memberRuntimeRoot(teamSlug, memberId) {
364
+ return path.join(memberRoot(teamSlug, memberId), "runtime");
365
+ }
366
+ function memberRuntimeWorkspaceRoot(teamSlug, memberId) {
367
+ return path.join(memberRuntimeRoot(teamSlug, memberId), "workspace");
368
+ }
369
+ function wrapMarkdownAsSkillDoc(skillName, description, content) {
370
+ const trimmed = content.trim();
371
+ if (trimmed.startsWith("---")) {
372
+ return `${trimmed}\n`;
373
+ }
374
+ return `---\nname: ${skillName}\ndescription: ${description}\n---\n\n${trimmed}\n`;
375
+ }
376
+ function summarizeMarkdownTitle(content, fallback) {
377
+ const line = content
378
+ .split("\n")
379
+ .map((entry) => entry.trim())
380
+ .find(Boolean);
381
+ return (line || fallback).replace(/^#+\s*/, "").slice(0, 120);
382
+ }
383
+ function wrapMarkdownAsMemorySkillDoc(skillName, title, content) {
384
+ const summary = summarizeMarkdownTitle(content, title);
385
+ const description = `Shared team memory for ${summary}. Use when the task needs team preferences, standing rules, or required phrasing.`;
386
+ const body = [
387
+ `# ${summary}`,
388
+ "",
389
+ "Apply this shared team memory when it is relevant to the current task.",
390
+ "If it contains a required phrase or standing preference, follow it exactly.",
391
+ "",
392
+ content.trim()
393
+ ].join("\n");
394
+ return wrapMarkdownAsSkillDoc(skillName, description, body);
395
+ }
396
+ function wrapMarkdownAsWorkflowSkillDoc(skillName, title, content) {
397
+ const summary = summarizeMarkdownTitle(content, title);
398
+ const description = `Follow the shared workflow ${summary}. Use when the task asks for a structured process, required sections, or a repeatable output format.`;
399
+ const body = [
400
+ `# ${summary}`,
401
+ "",
402
+ "Follow this shared workflow exactly when it matches the current task.",
403
+ "Preserve any required section headings, ordering, and output constraints.",
404
+ "",
405
+ content.trim()
406
+ ].join("\n");
407
+ return wrapMarkdownAsSkillDoc(skillName, description, body);
408
+ }
409
+ async function mirrorWorkspaceSkillsFromDirectory(params) {
410
+ const sourceExists = await safeStat(params.sourceDir);
411
+ if (!sourceExists) {
412
+ return;
413
+ }
414
+ const entries = await fs.readdir(params.sourceDir, { withFileTypes: true });
415
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
416
+ if (entry.name.startsWith(".")) {
417
+ continue;
418
+ }
419
+ if (entry.isDirectory()) {
420
+ const nestedSkillPath = path.join(params.sourceDir, entry.name, "SKILL.md");
421
+ if (!(await fileExists(nestedSkillPath))) {
422
+ continue;
423
+ }
424
+ const targetSkillDir = path.join(params.targetDir, `${params.namePrefix}${entry.name}`);
425
+ await fs.mkdir(targetSkillDir, { recursive: true });
426
+ await writeText(path.join(targetSkillDir, "SKILL.md"), await readText(nestedSkillPath));
427
+ continue;
428
+ }
429
+ if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.toLowerCase() === "readme.md") {
430
+ continue;
431
+ }
432
+ const skillName = slugifyAssetName(path.basename(entry.name, ".md")) || `skill-${crypto.randomUUID().slice(0, 8)}`;
433
+ const targetSkillName = `${params.namePrefix}${skillName}`;
434
+ const targetSkillDir = path.join(params.targetDir, targetSkillName);
435
+ await fs.mkdir(targetSkillDir, { recursive: true });
436
+ const sourceContent = await readText(path.join(params.sourceDir, entry.name));
437
+ await writeText(path.join(targetSkillDir, "SKILL.md"), wrapMarkdownAsSkillDoc(targetSkillName, `${params.descriptionPrefix} ${entry.name}`, sourceContent));
438
+ }
439
+ }
440
+ async function clearGeneratedSkillViews(skillsRoot) {
441
+ await fs.rm(path.join(skillsRoot, "_generated"), { recursive: true, force: true });
442
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
443
+ for (const entry of entries) {
444
+ if (!entry.isDirectory()) {
445
+ continue;
446
+ }
447
+ if (entry.name.startsWith("team-shared-") ||
448
+ entry.name.startsWith("private-") ||
449
+ entry.name.startsWith("team-shared-active-") ||
450
+ entry.name.startsWith("team-shared-workflow-")) {
451
+ await fs.rm(path.join(skillsRoot, entry.name), { recursive: true, force: true });
452
+ }
453
+ }
454
+ }
455
+ async function syncGeneratedSharedMemoryFile(teamSlug, workspaceRoot) {
456
+ const sourceDir = path.join(teamRoot(teamSlug), "assets", "current", "shared-memory");
457
+ const memoryPath = path.join(workspaceRoot, "MEMORY.md");
458
+ const stat = await safeStat(sourceDir);
459
+ if (!stat) {
460
+ await fs.rm(memoryPath, { force: true });
461
+ return;
462
+ }
463
+ const entries = (await fs.readdir(sourceDir, { withFileTypes: true }))
464
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
465
+ .sort((left, right) => left.name.localeCompare(right.name));
466
+ if (!entries.length) {
467
+ await fs.rm(memoryPath, { force: true });
468
+ return;
469
+ }
470
+ const sections = ["# MEMORY", "", "## Team Shared Memory", ""];
471
+ for (const entry of entries) {
472
+ const content = (await readText(path.join(sourceDir, entry.name))).trim();
473
+ if (!content) {
474
+ continue;
475
+ }
476
+ sections.push(`### ${path.basename(entry.name, ".md")}`);
477
+ sections.push(content);
478
+ sections.push("");
479
+ }
480
+ await writeText(memoryPath, `${sections.join("\n").trim()}\n`);
481
+ }
482
+ async function syncGeneratedSharedWorkflowSkills(teamSlug, skillsRoot) {
483
+ const sourceDir = path.join(teamRoot(teamSlug), "assets", "current", "shared-workflows");
484
+ const sourceExists = await safeStat(sourceDir);
485
+ if (!sourceExists) {
486
+ return;
487
+ }
488
+ const entries = (await fs.readdir(sourceDir, { withFileTypes: true }))
489
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
490
+ .sort((left, right) => left.name.localeCompare(right.name));
491
+ for (const entry of entries) {
492
+ const workflowName = slugifyAssetName(path.basename(entry.name, ".md")) || `workflow-${crypto.randomUUID().slice(0, 8)}`;
493
+ const targetSkillName = `team-shared-workflow-${workflowName}`;
494
+ const targetSkillDir = path.join(skillsRoot, targetSkillName);
495
+ await fs.mkdir(targetSkillDir, { recursive: true });
496
+ const sourceContent = await readText(path.join(sourceDir, entry.name));
497
+ await writeText(path.join(targetSkillDir, "SKILL.md"), wrapMarkdownAsWorkflowSkillDoc(targetSkillName, path.basename(entry.name, ".md"), sourceContent));
498
+ }
499
+ }
500
+ async function syncGeneratedSharedMemorySkills(teamSlug, skillsRoot) {
501
+ const sourceDir = path.join(teamRoot(teamSlug), "assets", "current", "shared-memory");
502
+ const sourceExists = await safeStat(sourceDir);
503
+ if (!sourceExists) {
504
+ return;
505
+ }
506
+ const entries = (await fs.readdir(sourceDir, { withFileTypes: true }))
507
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name.toLowerCase() !== "readme.md")
508
+ .sort((left, right) => left.name.localeCompare(right.name));
509
+ for (const entry of entries) {
510
+ const memoryName = slugifyAssetName(path.basename(entry.name, ".md")) || `memory-${crypto.randomUUID().slice(0, 8)}`;
511
+ const targetSkillName = `team-shared-memory-${memoryName}`;
512
+ const targetSkillDir = path.join(skillsRoot, targetSkillName);
513
+ await fs.mkdir(targetSkillDir, { recursive: true });
514
+ const sourceContent = await readText(path.join(sourceDir, entry.name));
515
+ await writeText(path.join(targetSkillDir, "SKILL.md"), wrapMarkdownAsMemorySkillDoc(targetSkillName, path.basename(entry.name, ".md"), sourceContent));
516
+ }
517
+ }
518
+ async function syncMemberGeneratedWorkspaceViews(teamSlugRaw, memberIdRaw) {
519
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
520
+ const memberId = validateMemberId(memberIdRaw);
521
+ const workspaceRoot = memberRuntimeWorkspaceRoot(teamSlug, memberId);
522
+ const skillsRoot = path.join(workspaceRoot, "skills");
523
+ await fs.mkdir(skillsRoot, { recursive: true });
524
+ await clearGeneratedSkillViews(skillsRoot);
525
+ await mirrorWorkspaceSkillsFromDirectory({
526
+ sourceDir: path.join(memberRoot(teamSlug, memberId), "private-skills"),
527
+ targetDir: skillsRoot,
528
+ namePrefix: "private-",
529
+ descriptionPrefix: "Generated private workspace skill from"
530
+ });
531
+ await mirrorWorkspaceSkillsFromDirectory({
532
+ sourceDir: path.join(teamRoot(teamSlug), "assets", "current", "shared-skills"),
533
+ targetDir: skillsRoot,
534
+ namePrefix: "team-shared-",
535
+ descriptionPrefix: "Generated team-shared workspace skill from"
536
+ });
537
+ await syncGeneratedSharedMemorySkills(teamSlug, skillsRoot);
538
+ await syncGeneratedSharedWorkflowSkills(teamSlug, skillsRoot);
539
+ await syncGeneratedSharedMemoryFile(teamSlug, workspaceRoot);
540
+ }
541
+ async function syncGeneratedWorkspaceViewsForTeamMembers(teamSlugRaw) {
542
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
543
+ const members = await getMembersForTeam(teamSlug);
544
+ await Promise.all(members
545
+ .filter((member) => member.id !== MEMBER_TEMPLATE_ID)
546
+ .map((member) => syncMemberGeneratedWorkspaceViews(teamSlug, member.id)));
547
+ }
548
+ function validateMemberId(rawMemberId) {
549
+ const memberId = rawMemberId.trim().toLowerCase();
550
+ if (!memberId) {
551
+ throw new HttpError(400, "memberId is required");
552
+ }
553
+ if (memberId === MEMBER_TEMPLATE_ID) {
554
+ throw new HttpError(400, "memberId cannot reuse the reserved template id");
555
+ }
556
+ if (!MEMBER_ID_RE.test(memberId)) {
557
+ throw new HttpError(400, "memberId must use lowercase letters, numbers, or dashes, start/end with an alphanumeric, and stay under 63 chars");
558
+ }
559
+ return memberId;
560
+ }
561
+ function validateMemberEmail(rawValue) {
562
+ const email = rawValue.trim().toLowerCase();
563
+ if (!email) {
564
+ throw new HttpError(400, "memberEmail is required");
565
+ }
566
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
567
+ throw new HttpError(400, "memberEmail must be a valid email address");
568
+ }
569
+ return email;
570
+ }
571
+ function deriveRuntimeMemberIdFromEmail(email) {
572
+ const base = email
573
+ .toLowerCase()
574
+ .replace(/[^a-z0-9]+/g, "-")
575
+ .replace(/^-+|-+$/g, "")
576
+ .slice(0, 48) || "member";
577
+ const hash = crypto.createHash("sha1").update(email).digest("hex").slice(0, 8);
578
+ return validateMemberId(`${base}-${hash}`.slice(0, 63));
579
+ }
580
+ function validateTelegramBotToken(rawToken) {
581
+ const token = rawToken.trim();
582
+ if (!token) {
583
+ throw new HttpError(400, "telegramBotToken is required");
584
+ }
585
+ if (!/^\d+:[A-Za-z0-9_-]{20,}$/.test(token)) {
586
+ throw new HttpError(400, "telegramBotToken does not look like a valid Telegram bot token");
587
+ }
588
+ return token;
589
+ }
590
+ function validateDiscordBotToken(rawToken) {
591
+ const token = rawToken.trim();
592
+ if (!token) {
593
+ throw new HttpError(400, "discordBotToken is required");
594
+ }
595
+ if (token.length < 20) {
596
+ throw new HttpError(400, "discordBotToken does not look valid");
597
+ }
598
+ return token;
599
+ }
600
+ function validateDiscordUserId(rawValue) {
601
+ const value = rawValue.trim();
602
+ const match = value.match(/^(?:<@!?(\d+)>|user:(\d+)|(\d+))$/);
603
+ const id = match?.[1] || match?.[2] || match?.[3] || "";
604
+ if (!id) {
605
+ throw new HttpError(400, "discord user id must be a Discord snowflake or <@id>");
606
+ }
607
+ return id;
608
+ }
609
+ function validateWhatsappPhone(rawValue) {
610
+ const trimmed = rawValue.trim();
611
+ const normalized = trimmed.startsWith("+") ? trimmed : `+${trimmed}`;
612
+ if (!/^\+[1-9]\d{7,15}$/.test(normalized)) {
613
+ throw new HttpError(400, "whatsapp phone must be in international format like +15551234567");
614
+ }
615
+ return normalized;
616
+ }
617
+ function resolveAcceptedInviteChannel(input) {
618
+ const requestedKind = input.channelKind?.trim().toLowerCase();
619
+ const hasChannelFields = Boolean(requestedKind || input.channelHandle?.trim() || input.botToken?.trim());
620
+ if (!hasChannelFields) {
621
+ return {
622
+ legacyTelegramUserId: input.telegramUserId?.trim() || "REPLACE_WITH_USER_ID"
623
+ };
624
+ }
625
+ const kind = (requestedKind || "telegram");
626
+ if (!["telegram", "discord", "whatsapp"].includes(kind)) {
627
+ throw new HttpError(400, `unsupported invite channel: ${input.channelKind}`);
628
+ }
629
+ if (kind === "telegram") {
630
+ const handle = (input.channelHandle?.trim() || input.telegramUserId?.trim() || "");
631
+ const token = input.botToken?.trim() ? validateTelegramBotToken(input.botToken) : undefined;
632
+ if (!handle) {
633
+ if (!token) {
634
+ throw new HttpError(400, "telegram bot token is required when telegram user id is omitted");
635
+ }
636
+ return {
637
+ setup: {
638
+ kind,
639
+ handle: "*",
640
+ requiresAdditionalLogin: false,
641
+ accessMode: "open"
642
+ },
643
+ botToken: token,
644
+ legacyTelegramUserId: "REPLACE_WITH_USER_ID"
645
+ };
646
+ }
647
+ return {
648
+ setup: {
649
+ kind,
650
+ handle,
651
+ requiresAdditionalLogin: !token,
652
+ accessMode: "allowlist"
653
+ },
654
+ botToken: token,
655
+ legacyTelegramUserId: handle
656
+ };
657
+ }
658
+ if (kind === "discord") {
659
+ const handle = validateDiscordUserId(input.channelHandle ?? "");
660
+ return {
661
+ setup: {
662
+ kind,
663
+ handle,
664
+ requiresAdditionalLogin: false
665
+ },
666
+ botToken: validateDiscordBotToken(input.botToken ?? ""),
667
+ legacyTelegramUserId: "REPLACE_WITH_USER_ID"
668
+ };
669
+ }
670
+ const handle = validateWhatsappPhone(input.channelHandle ?? "");
671
+ return {
672
+ setup: {
673
+ kind: "whatsapp",
674
+ handle,
675
+ requiresAdditionalLogin: true,
676
+ selfChatMode: Boolean(input.whatsappSelfChatMode)
677
+ },
678
+ legacyTelegramUserId: "REPLACE_WITH_USER_ID"
679
+ };
680
+ }
681
+ function slugifyTeamLabel(rawValue) {
682
+ return rawValue
683
+ .trim()
684
+ .toLowerCase()
685
+ .replace(/[^a-z0-9]+/g, "-")
686
+ .replace(/^-+|-+$/g, "")
687
+ .slice(0, 63);
688
+ }
689
+ function nowIso() {
690
+ return new Date().toISOString();
691
+ }
692
+ const LEGACY_DIRECT_MODEL_GATEWAY_BASE_URL = "https://saymycode.xyz/v1";
693
+ const LOCAL_LITELLM_GATEWAY_BASE_URL = "http://127.0.0.1:4000";
694
+ const LOCAL_LITELLM_GATEWAY_ENV = "LITELLM_MASTER_KEY";
695
+ function normalizeTeamModelGateway(gateway, fallback) {
696
+ const merged = {
697
+ ...fallback,
698
+ ...gateway
699
+ };
700
+ if (merged.upstreamBaseUrl === LEGACY_DIRECT_MODEL_GATEWAY_BASE_URL && merged.upstreamApiKeyEnv === "OPENAI_API_KEY") {
701
+ merged.upstreamBaseUrl = LOCAL_LITELLM_GATEWAY_BASE_URL;
702
+ merged.upstreamApiKeyEnv = LOCAL_LITELLM_GATEWAY_ENV;
703
+ }
704
+ return merged;
705
+ }
706
+ function defaultTeamModelGateway() {
707
+ return {
708
+ enabled: true,
709
+ providerId: "team-gateway",
710
+ upstreamBaseUrl: LOCAL_LITELLM_GATEWAY_BASE_URL,
711
+ upstreamApiKeyEnv: LOCAL_LITELLM_GATEWAY_ENV,
712
+ defaultModelId: "gpt-4.1-mini",
713
+ allowedModelIds: ["gpt-4.1-mini", "gpt-5.1-codex-mini", "gpt-5.4"],
714
+ token: crypto.randomBytes(24).toString("hex")
715
+ };
716
+ }
717
+ function defaultTeamState() {
718
+ const timestamp = nowIso();
719
+ return {
720
+ version: 1,
721
+ profile: {
722
+ name: "Velaclaw Team",
723
+ slug: "velaclaw-team",
724
+ description: "Private member runtimes, shared assets, and operator-controlled invitations.",
725
+ managerLabel: "Team Manager",
726
+ inviteBasePath: "/invite",
727
+ createdAt: timestamp,
728
+ updatedAt: timestamp
729
+ },
730
+ modelGateway: defaultTeamModelGateway(),
731
+ invitations: [],
732
+ memberPolicies: [],
733
+ assetRolePolicies: [
734
+ { role: "owner", canPropose: true, publishWithoutApproval: true, canApprove: true, canPromote: true },
735
+ { role: "manager", canPropose: true, publishWithoutApproval: true, canApprove: true, canPromote: true },
736
+ { role: "operator", canPropose: true, publishWithoutApproval: true, canApprove: true, canPromote: true },
737
+ { role: "publisher", canPropose: true, publishWithoutApproval: true, canApprove: false, canPromote: false },
738
+ { role: "contributor", canPropose: true, publishWithoutApproval: false, canApprove: false, canPromote: false },
739
+ { role: "member", canPropose: true, publishWithoutApproval: false, canApprove: false, canPromote: false },
740
+ { role: "viewer", canPropose: false, publishWithoutApproval: false, canApprove: false, canPromote: false }
741
+ ],
742
+ assets: []
743
+ };
744
+ }
745
+ function defaultTeamsState() {
746
+ return {
747
+ version: 2,
748
+ teams: [defaultTeamState()]
749
+ };
750
+ }
751
+ function teamMembersRoot(teamSlug) {
752
+ return path.join(MEMBERS_ROOT, teamSlug);
753
+ }
754
+ function memberRoot(teamSlug, memberId) {
755
+ return path.join(teamMembersRoot(teamSlug), memberId);
756
+ }
757
+ function teamsRoot() {
758
+ return path.join(ROOT, "teams");
759
+ }
760
+ function teamRoot(teamSlug) {
761
+ return path.join(teamsRoot(), teamSlug);
762
+ }
763
+ function teamAssetsRoot(teamSlug) {
764
+ return path.join(teamRoot(teamSlug), "assets");
765
+ }
766
+ function teamAssetStageRoot(teamSlug, stage) {
767
+ return path.join(teamAssetsRoot(teamSlug), stage);
768
+ }
769
+ function teamAssetCategoryDir(teamSlug, stage, category) {
770
+ return path.join(teamAssetStageRoot(teamSlug, stage), category);
771
+ }
772
+ function teamAssetApprovalsDir(teamSlug) {
773
+ return path.join(teamAssetStageRoot(teamSlug, "approvals"), "queue");
774
+ }
775
+ function teamAssetReleasesDir(teamSlug) {
776
+ return path.join(teamAssetStageRoot(teamSlug, "published"), "releases");
777
+ }
778
+ function resolveDefaultTeamSlug(state) {
779
+ const first = state.teams[0];
780
+ if (!first) {
781
+ throw new HttpError(500, "no teams configured");
782
+ }
783
+ return first.profile.slug;
784
+ }
785
+ function findTeamBySlugOrThrow(state, teamSlugRaw) {
786
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
787
+ const team = state.teams.find((entry) => entry.profile.slug === teamSlug);
788
+ if (!team) {
789
+ throw new HttpError(404, `team not found: ${teamSlugRaw}`);
790
+ }
791
+ return team;
792
+ }
793
+ async function ensureTeamStateFile() {
794
+ await fs.mkdir(STATE_ROOT, { recursive: true });
795
+ const stat = await safeStat(TEAM_STATE_PATH);
796
+ if (!stat) {
797
+ await writeText(TEAM_STATE_PATH, `${JSON.stringify(defaultTeamsState(), null, 2)}\n`);
798
+ }
799
+ }
800
+ async function readTeamsState() {
801
+ await ensureTeamStateFile();
802
+ const parsed = JSON.parse(await readText(TEAM_STATE_PATH));
803
+ if (Array.isArray(parsed.teams)) {
804
+ return {
805
+ version: 2,
806
+ teams: parsed.teams.map((team) => {
807
+ const fallback = defaultTeamState();
808
+ const assetRolePolicies = Array.isArray(team.assetRolePolicies) && team.assetRolePolicies.length > 0 ? team.assetRolePolicies : fallback.assetRolePolicies;
809
+ return {
810
+ version: 1,
811
+ profile: {
812
+ ...fallback.profile,
813
+ ...team.profile
814
+ },
815
+ modelGateway: normalizeTeamModelGateway(team.modelGateway, fallback.modelGateway),
816
+ invitations: Array.isArray(team.invitations)
817
+ ? team.invitations.map((entry) => {
818
+ const role = typeof entry?.role === "string" && entry.role ? entry.role : "member";
819
+ return {
820
+ ...entry,
821
+ teamSlug: entry?.teamSlug || team.profile.slug,
822
+ role,
823
+ quota: normalizeQuota(role, entry?.quota)
824
+ };
825
+ })
826
+ : [],
827
+ memberPolicies: Array.isArray(team.memberPolicies)
828
+ ? team.memberPolicies.map((entry) => {
829
+ const role = typeof entry?.role === "string" && entry.role ? entry.role : "member";
830
+ return {
831
+ ...entry,
832
+ role,
833
+ quota: normalizeQuota(role, entry?.quota),
834
+ assetPermissions: normalizeAssetPermissions(role, entry?.assetPermissions, assetRolePolicies)
835
+ };
836
+ })
837
+ : [],
838
+ assetRolePolicies,
839
+ assets: Array.isArray(team.assets) ? team.assets.map((asset) => ({ ...asset, teamSlug: asset?.teamSlug || team.profile.slug })) : []
840
+ };
841
+ })
842
+ };
843
+ }
844
+ const fallback = defaultTeamState();
845
+ const legacyProfile = {
846
+ ...fallback.profile,
847
+ ...parsed.profile
848
+ };
849
+ const legacyTeam = {
850
+ version: 1,
851
+ profile: legacyProfile,
852
+ modelGateway: normalizeTeamModelGateway(parsed.modelGateway, fallback.modelGateway),
853
+ invitations: Array.isArray(parsed.invitations)
854
+ ? parsed.invitations.map((entry) => {
855
+ const role = typeof entry?.role === "string" && entry.role ? entry.role : "member";
856
+ return {
857
+ ...entry,
858
+ teamSlug: entry?.teamSlug || legacyProfile.slug,
859
+ role,
860
+ quota: normalizeQuota(role, entry?.quota)
861
+ };
862
+ })
863
+ : [],
864
+ memberPolicies: Array.isArray(parsed.memberPolicies)
865
+ ? parsed.memberPolicies.map((entry) => {
866
+ const role = typeof entry?.role === "string" && entry.role ? entry.role : "member";
867
+ return {
868
+ ...entry,
869
+ role,
870
+ quota: normalizeQuota(role, entry?.quota),
871
+ assetPermissions: normalizeAssetPermissions(role, entry?.assetPermissions, fallback.assetRolePolicies)
872
+ };
873
+ })
874
+ : [],
875
+ assetRolePolicies: Array.isArray(parsed.assetRolePolicies) &&
876
+ parsed.assetRolePolicies.length > 0
877
+ ? parsed.assetRolePolicies
878
+ : fallback.assetRolePolicies,
879
+ assets: Array.isArray(parsed.assets) ? parsed.assets : []
880
+ };
881
+ return {
882
+ version: 2,
883
+ teams: [legacyTeam]
884
+ };
885
+ }
886
+ async function writeTeamsState(state) {
887
+ await fs.mkdir(STATE_ROOT, { recursive: true });
888
+ await writeText(TEAM_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
889
+ }
890
+ async function mutateTeamsState(mutator) {
891
+ const run = teamStateMutationQueue.then(async () => {
892
+ const state = await readTeamsState();
893
+ const result = await mutator(state);
894
+ await writeTeamsState(state);
895
+ invalidateTeamOverviewCache();
896
+ return result;
897
+ });
898
+ teamStateMutationQueue = run.then(() => undefined, () => undefined);
899
+ return run;
900
+ }
901
+ async function ensureTeamAssetLayout(teamSlugRaw) {
902
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
903
+ const dirs = [
904
+ teamRoot(teamSlug),
905
+ teamAssetsRoot(teamSlug),
906
+ teamAssetApprovalsDir(teamSlug),
907
+ teamAssetReleasesDir(teamSlug)
908
+ ];
909
+ for (const dir of dirs) {
910
+ await fs.mkdir(dir, { recursive: true });
911
+ }
912
+ const categoryDirs = [
913
+ "shared-memory",
914
+ "shared-skills",
915
+ "shared-workflows",
916
+ "shared-docs"
917
+ ];
918
+ for (const category of categoryDirs) {
919
+ await fs.mkdir(teamAssetCategoryDir(teamSlug, "drafts", category), { recursive: true });
920
+ await fs.mkdir(teamAssetCategoryDir(teamSlug, "collab", category), { recursive: true });
921
+ await fs.mkdir(teamAssetCategoryDir(teamSlug, "current", category), { recursive: true });
922
+ }
923
+ }
924
+ async function mutateNamedTeamState(teamSlug, mutator) {
925
+ return mutateTeamsState(async (root) => {
926
+ const team = findTeamBySlugOrThrow(root, teamSlug);
927
+ return mutator(team, root);
928
+ });
929
+ }
930
+ async function readTeamState() {
931
+ const root = await readTeamsState();
932
+ return findTeamBySlugOrThrow(root, resolveDefaultTeamSlug(root));
933
+ }
934
+ function findInvitationByIdOrThrow(state, invitationId) {
935
+ const invitation = state.invitations.find((entry) => entry.id === invitationId);
936
+ if (!invitation) {
937
+ throw new HttpError(404, `invitation not found: ${invitationId}`);
938
+ }
939
+ return invitation;
940
+ }
941
+ function findInvitationByCodeOrThrow(state, code) {
942
+ const invitation = state.invitations.find((entry) => entry.code === code);
943
+ if (!invitation) {
944
+ throw new HttpError(404, `invitation not found for code: ${code}`);
945
+ }
946
+ return invitation;
947
+ }
948
+ function ensurePositiveInteger(name, value) {
949
+ if (!Number.isInteger(value) || value <= 0) {
950
+ throw new HttpError(400, `${name} must be a positive integer`);
951
+ }
952
+ return value;
953
+ }
954
+ function ensureNonNegativeInteger(name, value) {
955
+ if (!Number.isInteger(value) || value < 0) {
956
+ throw new HttpError(400, `${name} must be a non-negative integer`);
957
+ }
958
+ return value;
959
+ }
960
+ function defaultQuotaForRole(role) {
961
+ if (role === "operator") {
962
+ return {
963
+ dailyMessages: 500,
964
+ monthlyMessages: 12000,
965
+ maxSubagents: 3,
966
+ maxThinking: "xhigh",
967
+ allowedModels: ["saymycode/gpt-5.4", "saymycode/gpt-5.1-codex-mini"],
968
+ status: "active"
969
+ };
970
+ }
971
+ return {
972
+ dailyMessages: 150,
973
+ monthlyMessages: 3000,
974
+ maxSubagents: 1,
975
+ maxThinking: "medium",
976
+ allowedModels: ["saymycode/gpt-5.1-codex-mini", "saymycode/gpt-5.4"],
977
+ status: "active"
978
+ };
979
+ }
980
+ function defaultAssetPermissionsForRole(role, rolePolicies) {
981
+ const normalizedRole = String(role || "member").trim().toLowerCase() || "member";
982
+ const policies = rolePolicies ?? defaultTeamState().assetRolePolicies;
983
+ const match = policies.find((entry) => entry.role === normalizedRole) ?? policies.find((entry) => entry.role === "member");
984
+ return {
985
+ canPropose: match?.canPropose ?? true,
986
+ canPublishWithoutApproval: match?.publishWithoutApproval ?? false,
987
+ canApprove: match?.canApprove ?? false
988
+ };
989
+ }
990
+ function normalizeAssetPermissions(role, input, rolePolicies) {
991
+ const base = defaultAssetPermissionsForRole(role, rolePolicies);
992
+ return {
993
+ canPropose: input?.canPropose ?? base.canPropose,
994
+ canPublishWithoutApproval: input?.canPublishWithoutApproval ?? base.canPublishWithoutApproval,
995
+ canApprove: input?.canApprove ?? base.canApprove
996
+ };
997
+ }
998
+ function normalizeAllowedModels(rawModels) {
999
+ if (!rawModels) {
1000
+ return undefined;
1001
+ }
1002
+ const models = rawModels.map((entry) => entry.trim()).filter(Boolean);
1003
+ if (!models.length) {
1004
+ throw new HttpError(400, "allowedModels cannot be empty");
1005
+ }
1006
+ return Array.from(new Set(models));
1007
+ }
1008
+ function buildRuntimeCommands(runtimeDir, composePath, composeProjectName) {
1009
+ const quotedRuntime = `"${runtimeDir}"`;
1010
+ const quotedCompose = `"${composePath}"`;
1011
+ const quotedProject = `"${composeProjectName}"`;
1012
+ return {
1013
+ start: `cd ${quotedRuntime} && sudo -n docker compose -p ${quotedProject} -f ${quotedCompose} up -d`,
1014
+ stop: `cd ${quotedRuntime} && sudo -n docker compose -p ${quotedProject} -f ${quotedCompose} stop`,
1015
+ restart: `cd ${quotedRuntime} && sudo -n docker compose -p ${quotedProject} -f ${quotedCompose} restart`,
1016
+ upgrade: `cd ${quotedRuntime} && sudo -n docker compose -p ${quotedProject} -f ${quotedCompose} up -d --pull always --force-recreate`,
1017
+ logs: `cd ${quotedRuntime} && sudo -n docker compose -p ${quotedProject} -f ${quotedCompose} logs --tail=200`
1018
+ };
1019
+ }
1020
+ function normalizeQuota(role, input, current) {
1021
+ const base = current ?? defaultQuotaForRole(role);
1022
+ const quota = {
1023
+ dailyMessages: input?.dailyMessages != null ? ensurePositiveInteger("dailyMessages", input.dailyMessages) : base.dailyMessages,
1024
+ monthlyMessages: input?.monthlyMessages != null ? ensurePositiveInteger("monthlyMessages", input.monthlyMessages) : base.monthlyMessages,
1025
+ maxSubagents: input?.maxSubagents != null ? ensureNonNegativeInteger("maxSubagents", input.maxSubagents) : base.maxSubagents,
1026
+ maxThinking: input?.maxThinking ?? base.maxThinking,
1027
+ allowedModels: normalizeAllowedModels(input?.allowedModels) ?? base.allowedModels,
1028
+ status: input?.status ?? base.status
1029
+ };
1030
+ if (!["low", "medium", "high", "xhigh"].includes(quota.maxThinking)) {
1031
+ throw new HttpError(400, "maxThinking must be one of low, medium, high, xhigh");
1032
+ }
1033
+ if (!["active", "paused"].includes(quota.status)) {
1034
+ throw new HttpError(400, "status must be active or paused");
1035
+ }
1036
+ if (quota.monthlyMessages < quota.dailyMessages) {
1037
+ throw new HttpError(400, "monthlyMessages must be greater than or equal to dailyMessages");
1038
+ }
1039
+ return quota;
1040
+ }
1041
+ async function writeMemberPolicyFile(profile, teamSlug, memberId, policy, invitationCode) {
1042
+ const policyPath = path.join(memberRoot(teamSlug, memberId), "runtime", "config", "team-policy.json");
1043
+ await fs.mkdir(path.dirname(policyPath), { recursive: true });
1044
+ await writeText(policyPath, `${JSON.stringify({
1045
+ team: {
1046
+ name: profile.name,
1047
+ slug: profile.slug,
1048
+ managerLabel: profile.managerLabel
1049
+ },
1050
+ isolation: {
1051
+ runtime: "docker",
1052
+ scope: "member-local"
1053
+ },
1054
+ member: {
1055
+ id: policy.memberId,
1056
+ role: policy.role,
1057
+ invitationId: policy.invitationId ?? null,
1058
+ invitationCode: invitationCode ?? null
1059
+ },
1060
+ quota: policy.quota,
1061
+ policyUpdatedAt: policy.updatedAt
1062
+ }, null, 2)}\n`);
1063
+ }
1064
+ async function syncManagedMemberLocalPlugin(teamSlug, memberId, pluginId) {
1065
+ const templatePluginPath = path.join(MEMBERS_ROOT, MEMBER_TEMPLATE_ID, "runtime", "config", "local-plugins", pluginId);
1066
+ const targetPluginPath = path.join(memberRoot(teamSlug, memberId), "runtime", "config", "local-plugins", pluginId);
1067
+ if (!(await safeStat(templatePluginPath))) {
1068
+ return;
1069
+ }
1070
+ await fs.mkdir(path.dirname(targetPluginPath), { recursive: true });
1071
+ await fs.rm(targetPluginPath, { recursive: true, force: true });
1072
+ await fs.cp(templatePluginPath, targetPluginPath, { recursive: true, force: true });
1073
+ }
1074
+ async function syncManagedMemberLocalPlugins(teamSlug, memberId) {
1075
+ await syncManagedMemberLocalPlugin(teamSlug, memberId, "member-quota-guard");
1076
+ await syncManagedMemberLocalPlugin(teamSlug, memberId, "shared-asset-injector");
1077
+ await syncManagedMemberLocalPlugin(teamSlug, memberId, "member-runtime-upgrader");
1078
+ }
1079
+ async function syncMemberRuntimeConfigFromPolicy(team, memberId, policy) {
1080
+ await syncManagedMemberLocalPlugins(team.profile.slug, memberId);
1081
+ await ensureMemberRuntimeComposeDefaults(team.profile.slug, memberId);
1082
+ await syncMemberGeneratedWorkspaceViews(team.profile.slug, memberId);
1083
+ const configPath = path.join(memberRoot(team.profile.slug, memberId), "runtime", "config", "openclaw.json");
1084
+ const raw = await readText(configPath);
1085
+ const parsed = JSON.parse(raw);
1086
+ const providerId = team.modelGateway.providerId;
1087
+ const primaryModel = `${providerId}/${team.modelGateway.defaultModelId}`;
1088
+ const allowedModels = team.modelGateway.allowedModelIds.map((id) => `${providerId}/${id}`);
1089
+ parsed.agents ??= {};
1090
+ parsed.agents.defaults ??= {};
1091
+ parsed.agents.defaults.model = {
1092
+ primary: primaryModel
1093
+ };
1094
+ parsed.agents.defaults.models = Object.fromEntries(allowedModels.map((modelRef) => [
1095
+ modelRef,
1096
+ {
1097
+ alias: modelRef.split("/")[1] ?? modelRef
1098
+ }
1099
+ ]));
1100
+ if (Array.isArray(parsed.agents.list)) {
1101
+ for (const agent of parsed.agents.list) {
1102
+ if (agent?.id === "main") {
1103
+ agent.model = primaryModel;
1104
+ agent.thinkingDefault = policy.quota.maxThinking;
1105
+ }
1106
+ }
1107
+ }
1108
+ parsed.plugins ??= {};
1109
+ parsed.plugins.entries ??= {};
1110
+ parsed.plugins.entries["member-quota-guard"] ??= {
1111
+ enabled: true,
1112
+ config: {}
1113
+ };
1114
+ parsed.plugins.entries["member-quota-guard"].enabled = true;
1115
+ parsed.plugins.entries["member-quota-guard"].config = {
1116
+ policyPath: "/home/node/.openclaw/team-policy.json",
1117
+ usagePath: "/home/node/.openclaw/team-usage.json"
1118
+ };
1119
+ parsed.plugins.entries["shared-asset-injector"] ??= {
1120
+ enabled: true,
1121
+ config: {}
1122
+ };
1123
+ parsed.plugins.entries["shared-asset-injector"].enabled = true;
1124
+ parsed.plugins.entries["shared-asset-injector"].hooks = {
1125
+ ...(typeof parsed.plugins.entries["shared-asset-injector"].hooks === "object" &&
1126
+ parsed.plugins.entries["shared-asset-injector"].hooks
1127
+ ? parsed.plugins.entries["shared-asset-injector"].hooks
1128
+ : {}),
1129
+ allowPromptInjection: true
1130
+ };
1131
+ parsed.plugins.entries["shared-asset-injector"].config = {
1132
+ assetServerBaseUrl: `http://host.docker.internal:4318/api/teams/${team.profile.slug}/asset-server`,
1133
+ assetServerToken: team.modelGateway.token,
1134
+ workspaceRoot: "/home/node/.openclaw/workspace",
1135
+ statePath: "/home/node/.openclaw/shared-assets-state.json",
1136
+ syncTtlMs: 30000,
1137
+ resolveLimitPerKind: 2
1138
+ };
1139
+ parsed.plugins.entries["member-runtime-upgrader"] ??= {
1140
+ enabled: true,
1141
+ config: {}
1142
+ };
1143
+ parsed.plugins.entries["member-runtime-upgrader"].enabled = true;
1144
+ parsed.plugins.entries["member-runtime-upgrader"].config = {
1145
+ apiBaseUrl: `http://host.docker.internal:${process.env.PORT || 4318}`,
1146
+ teamSlug: team.profile.slug,
1147
+ memberId
1148
+ };
1149
+ parsed.plugins.load ??= {};
1150
+ parsed.plugins.load.paths = Array.from(new Set([
1151
+ ...(Array.isArray(parsed.plugins.load.paths) ? parsed.plugins.load.paths : []),
1152
+ "/home/node/.openclaw/local-plugins/member-quota-guard",
1153
+ "/home/node/.openclaw/local-plugins/shared-asset-injector",
1154
+ "/home/node/.openclaw/local-plugins/member-runtime-upgrader"
1155
+ ]));
1156
+ parsed.tools ??= {};
1157
+ parsed.tools.alsoAllow = Array.from(new Set([...(Array.isArray(parsed.tools.alsoAllow) ? parsed.tools.alsoAllow : []), "read"]));
1158
+ parsed.tools.fs = {
1159
+ ...(typeof parsed.tools.fs === "object" && parsed.tools.fs ? parsed.tools.fs : {}),
1160
+ workspaceOnly: true
1161
+ };
1162
+ if (typeof parsed.mcp === "object" && parsed.mcp) {
1163
+ const nextMcp = { ...parsed.mcp };
1164
+ if (typeof nextMcp.servers === "object" && nextMcp.servers) {
1165
+ const nextServers = { ...nextMcp.servers };
1166
+ for (const key of Object.keys(nextServers)) {
1167
+ if (key.startsWith("team-shared-")) {
1168
+ delete nextServers[key];
1169
+ }
1170
+ }
1171
+ if (Object.keys(nextServers).length) {
1172
+ nextMcp.servers = nextServers;
1173
+ }
1174
+ else {
1175
+ delete nextMcp.servers;
1176
+ }
1177
+ }
1178
+ if (Object.keys(nextMcp).length) {
1179
+ parsed.mcp = nextMcp;
1180
+ }
1181
+ else {
1182
+ delete parsed.mcp;
1183
+ }
1184
+ }
1185
+ parsed.models = {
1186
+ providers: {
1187
+ [providerId]: {
1188
+ baseUrl: `http://host.docker.internal:4318/api/teams/${team.profile.slug}/model-gateway/v1`,
1189
+ apiKey: team.modelGateway.token,
1190
+ api: "openai-completions",
1191
+ request: {
1192
+ headers: {
1193
+ "User-Agent": "curl/8.5.0",
1194
+ "Accept": "*/*"
1195
+ }
1196
+ },
1197
+ models: team.modelGateway.allowedModelIds.map((id) => ({
1198
+ id,
1199
+ name: id,
1200
+ reasoning: true,
1201
+ input: ["text", "image"],
1202
+ contextWindow: id === "gpt-5.4" ? 272000 : 400000
1203
+ }))
1204
+ }
1205
+ }
1206
+ };
1207
+ await writeText(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
1208
+ await syncGeneratedSharedMemoryFile(team.profile.slug, memberRuntimeWorkspaceRoot(team.profile.slug, memberId));
1209
+ await syncGeneratedSharedWorkflowSkills(team.profile.slug, path.join(memberRuntimeWorkspaceRoot(team.profile.slug, memberId), "skills"));
1210
+ }
1211
+ async function applyAcceptedInviteChannelConfig(teamSlugRaw, memberIdRaw, channel, botToken) {
1212
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
1213
+ const memberId = validateMemberId(memberIdRaw);
1214
+ const configPath = path.join(memberRoot(teamSlug, memberId), "runtime", "config", "openclaw.json");
1215
+ const secretsDir = path.join(memberRoot(teamSlug, memberId), "runtime", "secrets");
1216
+ const raw = await readText(configPath);
1217
+ const parsed = JSON.parse(raw);
1218
+ parsed.channels = {};
1219
+ if (channel.kind === "telegram") {
1220
+ if (botToken?.trim()) {
1221
+ await writeSecretText(path.join(secretsDir, "telegram-bot-token"), `${validateTelegramBotToken(botToken)}\n`);
1222
+ }
1223
+ const accessMode = channel.accessMode === "open" ? "open" : "allowlist";
1224
+ parsed.channels.telegram = {
1225
+ enabled: true,
1226
+ tokenFile: "/home/node/.openclaw/secrets/telegram-bot-token",
1227
+ dmPolicy: accessMode,
1228
+ allowFrom: accessMode === "open" ? ["*"] : [channel.handle],
1229
+ groupPolicy: "disabled"
1230
+ };
1231
+ }
1232
+ else if (channel.kind === "discord") {
1233
+ parsed.channels.discord = {
1234
+ enabled: true,
1235
+ token: validateDiscordBotToken(botToken ?? ""),
1236
+ dmPolicy: "allowlist",
1237
+ allowFrom: [channel.handle],
1238
+ groupPolicy: "allowlist"
1239
+ };
1240
+ }
1241
+ else {
1242
+ parsed.channels.whatsapp = {
1243
+ dmPolicy: "allowlist",
1244
+ allowFrom: [channel.handle],
1245
+ groupPolicy: "allowlist",
1246
+ groupAllowFrom: [channel.handle],
1247
+ selfChatMode: Boolean(channel.selfChatMode)
1248
+ };
1249
+ }
1250
+ await writeText(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
1251
+ }
1252
+ export async function configureMemberChannelForTeam(teamSlugRaw, memberIdRaw, input) {
1253
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
1254
+ const memberId = validateMemberId(memberIdRaw);
1255
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
1256
+ if (!member) {
1257
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
1258
+ }
1259
+ const resolvedChannel = resolveAcceptedInviteChannel({
1260
+ telegramUserId: input.telegramUserId,
1261
+ channelKind: input.channelKind,
1262
+ channelHandle: input.channelHandle,
1263
+ botToken: input.botToken,
1264
+ whatsappSelfChatMode: input.whatsappSelfChatMode
1265
+ });
1266
+ if (!resolvedChannel.setup) {
1267
+ throw new HttpError(400, "channel configuration is incomplete");
1268
+ }
1269
+ await applyAcceptedInviteChannelConfig(teamSlug, memberId, resolvedChannel.setup, resolvedChannel.botToken);
1270
+ invalidateTeamOverviewCache(teamSlug);
1271
+ return getMemberRuntimeStatus(teamSlug, memberId);
1272
+ }
1273
+ async function getUsedMemberPorts() {
1274
+ const root = await readTeamsState();
1275
+ const ports = new Set();
1276
+ await Promise.all(root.teams.map(async (team) => {
1277
+ const members = await getMembersForTeam(team.profile.slug);
1278
+ await Promise.all(members.map(async (member) => {
1279
+ const composePath = path.join(ROOT, member.path, "runtime", "docker-compose.yml");
1280
+ const stat = await safeStat(composePath);
1281
+ if (!stat) {
1282
+ return;
1283
+ }
1284
+ const compose = await readText(composePath);
1285
+ const match = compose.match(/127\.0\.0\.1:(\d+):18789/);
1286
+ if (match) {
1287
+ ports.add(Number(match[1]));
1288
+ }
1289
+ }));
1290
+ }));
1291
+ return ports;
1292
+ }
1293
+ async function resolveMemberPort(requestedPort) {
1294
+ const usedPorts = await getUsedMemberPorts();
1295
+ if (requestedPort != null) {
1296
+ if (!Number.isInteger(requestedPort) || requestedPort < 1024 || requestedPort > 65535) {
1297
+ throw new HttpError(400, "port must be an integer between 1024 and 65535");
1298
+ }
1299
+ if (usedPorts.has(requestedPort)) {
1300
+ throw new HttpError(409, `port already in use by another member runtime: ${requestedPort}`);
1301
+ }
1302
+ return requestedPort;
1303
+ }
1304
+ let port = DEFAULT_MEMBER_PORT;
1305
+ while (usedPorts.has(port)) {
1306
+ port += 1;
1307
+ }
1308
+ return port;
1309
+ }
1310
+ async function writeGitkeep(directoryPath) {
1311
+ await fs.mkdir(directoryPath, { recursive: true });
1312
+ await writeText(path.join(directoryPath, ".gitkeep"), "");
1313
+ }
1314
+ function slugifyAssetName(rawValue) {
1315
+ return rawValue
1316
+ .trim()
1317
+ .toLowerCase()
1318
+ .replace(/[^a-z0-9]+/g, "-")
1319
+ .replace(/^-+|-+$/g, "")
1320
+ .slice(0, 80);
1321
+ }
1322
+ function resolveMemberAssetPermissions(team, memberId, fallbackRole = "manager") {
1323
+ if (!memberId) {
1324
+ return {
1325
+ role: fallbackRole,
1326
+ permissions: defaultAssetPermissionsForRole(fallbackRole, team.assetRolePolicies)
1327
+ };
1328
+ }
1329
+ const memberPolicy = team.memberPolicies.find((entry) => entry.memberId === memberId);
1330
+ if (!memberPolicy) {
1331
+ const normalizedActor = String(memberId).trim().toLowerCase();
1332
+ const privilegedRoles = new Set(["owner", "manager", "operator", "publisher", "contributor", "viewer", "member"]);
1333
+ if (privilegedRoles.has(normalizedActor)) {
1334
+ return {
1335
+ role: normalizedActor,
1336
+ permissions: defaultAssetPermissionsForRole(normalizedActor, team.assetRolePolicies)
1337
+ };
1338
+ }
1339
+ }
1340
+ const role = memberPolicy?.role ?? "member";
1341
+ return {
1342
+ role,
1343
+ permissions: memberPolicy?.assetPermissions ?? defaultAssetPermissionsForRole(role, team.assetRolePolicies)
1344
+ };
1345
+ }
1346
+ function resolveAssetFileExtension(category) {
1347
+ return category === "shared-tools" ? ".json" : ".md";
1348
+ }
1349
+ async function writeTeamAssetSourceFile(params) {
1350
+ const targetPath = path.join(teamAssetCategoryDir(params.teamSlug, params.sourceZone, params.category), params.fileName);
1351
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
1352
+ await writeText(targetPath, params.content);
1353
+ return path.relative(ROOT, targetPath);
1354
+ }
1355
+ async function writeTeamAssetApprovalMarker(params) {
1356
+ const targetPath = path.join(teamAssetApprovalsDir(params.teamSlug), `${params.assetId}.json`);
1357
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
1358
+ await writeText(targetPath, `${JSON.stringify({
1359
+ assetId: params.assetId,
1360
+ title: params.title,
1361
+ category: params.category
1362
+ }, null, 2)}\n`);
1363
+ return path.relative(ROOT, targetPath);
1364
+ }
1365
+ async function publishTeamAssetRecord(params) {
1366
+ const releaseId = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
1367
+ const sourceAbsolute = path.join(ROOT, params.asset.sourcePath);
1368
+ const content = await readText(sourceAbsolute);
1369
+ const releasePath = path.join(teamAssetReleasesDir(params.team.profile.slug), releaseId, params.asset.category, params.asset.filename);
1370
+ const currentPath = path.join(teamAssetCategoryDir(params.team.profile.slug, "current", params.asset.category), params.asset.filename);
1371
+ await fs.mkdir(path.dirname(releasePath), { recursive: true });
1372
+ await fs.mkdir(path.dirname(currentPath), { recursive: true });
1373
+ await writeText(releasePath, content);
1374
+ await writeText(currentPath, content);
1375
+ params.asset.status = "published";
1376
+ params.asset.updatedAt = nowIso();
1377
+ params.asset.publishedAt = params.asset.updatedAt;
1378
+ params.asset.publishedBy = params.actorId;
1379
+ params.asset.releaseId = releaseId;
1380
+ params.asset.publishedPath = path.relative(ROOT, releasePath);
1381
+ params.asset.currentPath = path.relative(ROOT, currentPath);
1382
+ params.asset.approvalPath = undefined;
1383
+ const approvalMarker = path.join(teamAssetApprovalsDir(params.team.profile.slug), `${params.asset.id}.json`);
1384
+ if (await fileExists(approvalMarker)) {
1385
+ await fs.rm(approvalMarker, { force: true });
1386
+ }
1387
+ if (params.asset.category === "shared-skills") {
1388
+ await syncGeneratedWorkspaceViewsForTeamMembers(params.team.profile.slug);
1389
+ }
1390
+ }
1391
+ function assetKindFromCategory(category) {
1392
+ switch (category) {
1393
+ case "shared-skills":
1394
+ return "skills";
1395
+ case "shared-memory":
1396
+ return "memory";
1397
+ case "shared-tools":
1398
+ return null;
1399
+ case "shared-workflows":
1400
+ return "workflows";
1401
+ case "shared-docs":
1402
+ default:
1403
+ return "docs";
1404
+ }
1405
+ }
1406
+ const ASSET_SERVER_KINDS = ["skills", "memory", "workflows", "docs"];
1407
+ const ASSET_MATCH_STOP_WORDS = new Set([
1408
+ "the",
1409
+ "and",
1410
+ "for",
1411
+ "with",
1412
+ "from",
1413
+ "this",
1414
+ "that",
1415
+ "into",
1416
+ "your",
1417
+ "team",
1418
+ "shared",
1419
+ "asset",
1420
+ "assets",
1421
+ "skill",
1422
+ "skills",
1423
+ "memory",
1424
+ "workflow",
1425
+ "workflows",
1426
+ "docs",
1427
+ "doc",
1428
+ "file",
1429
+ "files",
1430
+ "about",
1431
+ "have",
1432
+ "will",
1433
+ "should",
1434
+ "would",
1435
+ "just",
1436
+ "then",
1437
+ "than",
1438
+ "when",
1439
+ "what",
1440
+ "where",
1441
+ "which",
1442
+ "while",
1443
+ "also",
1444
+ "using",
1445
+ "used",
1446
+ "use",
1447
+ "able",
1448
+ "make",
1449
+ "need",
1450
+ "needs"
1451
+ ]);
1452
+ function summarizeAssetContent(content, fallback) {
1453
+ const line = content
1454
+ .split("\n")
1455
+ .map((entry) => entry.trim())
1456
+ .find(Boolean);
1457
+ if (!line) {
1458
+ return fallback;
1459
+ }
1460
+ return line.replace(/^#+\s*/, "").slice(0, 160);
1461
+ }
1462
+ function parseFrontmatterListValue(rawValue) {
1463
+ const trimmed = rawValue.trim();
1464
+ const normalized = trimmed.startsWith("[") && trimmed.endsWith("]")
1465
+ ? trimmed.slice(1, -1)
1466
+ : trimmed;
1467
+ return normalized
1468
+ .split(",")
1469
+ .map((entry) => entry.trim().replace(/^['"]|['"]$/g, ""))
1470
+ .filter(Boolean);
1471
+ }
1472
+ function parseSimpleFrontmatter(content) {
1473
+ if (!content.startsWith("---\n")) {
1474
+ return null;
1475
+ }
1476
+ const closingIndex = content.indexOf("\n---\n", 4);
1477
+ if (closingIndex < 0) {
1478
+ return null;
1479
+ }
1480
+ const header = content.slice(4, closingIndex);
1481
+ const data = {};
1482
+ for (const line of header.split("\n")) {
1483
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
1484
+ if (!match) {
1485
+ continue;
1486
+ }
1487
+ const [, keyRaw, valueRaw] = match;
1488
+ const key = keyRaw.trim();
1489
+ const value = valueRaw.trim();
1490
+ if (/^\[.*\]$/.test(value) || value.includes(",")) {
1491
+ data[key] = parseFrontmatterListValue(value);
1492
+ }
1493
+ else {
1494
+ data[key] = value.replace(/^['"]|['"]$/g, "");
1495
+ }
1496
+ }
1497
+ return {
1498
+ data,
1499
+ body: content.slice(closingIndex + "\n---\n".length)
1500
+ };
1501
+ }
1502
+ function capabilityDefaultsForKind(kind) {
1503
+ switch (kind) {
1504
+ case "skills":
1505
+ return {
1506
+ role: "instruction",
1507
+ consumptionMode: "skill",
1508
+ baseCapabilities: ["guide", "apply", "execute"],
1509
+ defaultHint: "Use when the agent needs reusable instructions for a concrete task."
1510
+ };
1511
+ case "memory":
1512
+ return {
1513
+ role: "knowledge",
1514
+ consumptionMode: "retrieval",
1515
+ baseCapabilities: ["recall", "inform", "prioritize"],
1516
+ defaultHint: "Use when persistent team knowledge should shape the response or decision."
1517
+ };
1518
+ case "workflows":
1519
+ return {
1520
+ role: "process",
1521
+ consumptionMode: "workflow",
1522
+ baseCapabilities: ["coordinate", "sequence", "repeat"],
1523
+ defaultHint: "Use when the task should follow a repeatable multi-step process."
1524
+ };
1525
+ case "docs":
1526
+ default:
1527
+ return {
1528
+ role: "reference",
1529
+ consumptionMode: "reference",
1530
+ baseCapabilities: ["reference", "ground", "clarify"],
1531
+ defaultHint: "Use when the agent needs reference material, guidance, or context."
1532
+ };
1533
+ }
1534
+ }
1535
+ function buildAssetCapabilityProfile(params) {
1536
+ const frontmatter = parseSimpleFrontmatter(params.content);
1537
+ const header = frontmatter?.data ?? {};
1538
+ const defaults = capabilityDefaultsForKind(params.kind);
1539
+ const fallbackKeywords = topKeywordsFromText(params.title, params.filename, params.summary, params.content.slice(0, 4000));
1540
+ const fromHeader = (key) => {
1541
+ const value = header[key];
1542
+ if (Array.isArray(value)) {
1543
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
1544
+ }
1545
+ if (typeof value === "string" && value.trim()) {
1546
+ return parseFrontmatterListValue(value);
1547
+ }
1548
+ return [];
1549
+ };
1550
+ const capabilities = Array.from(new Set([
1551
+ ...fromHeader("capabilities"),
1552
+ ...defaults.baseCapabilities,
1553
+ ...fallbackKeywords.slice(0, 3)
1554
+ ])).slice(0, 8);
1555
+ const tags = Array.from(new Set([...fromHeader("tags"), ...fallbackKeywords.slice(0, 6)])).slice(0, 10);
1556
+ const triggerTerms = Array.from(new Set([
1557
+ ...fromHeader("triggers"),
1558
+ ...fromHeader("triggerTerms"),
1559
+ ...fallbackKeywords.slice(0, 8)
1560
+ ])).slice(0, 12);
1561
+ const activationHints = Array.from(new Set([
1562
+ ...fromHeader("activationHints"),
1563
+ ...fromHeader("whenToUse"),
1564
+ ...fromHeader("useCases"),
1565
+ defaults.defaultHint
1566
+ ])).slice(0, 5);
1567
+ const roleRaw = typeof header.role === "string" ? header.role.trim().toLowerCase() : "";
1568
+ const role = (["instruction", "knowledge", "integration", "process", "reference"].includes(roleRaw)
1569
+ ? roleRaw
1570
+ : defaults.role);
1571
+ const consumptionRaw = typeof header.consumptionMode === "string" ? header.consumptionMode.trim().toLowerCase() : "";
1572
+ const consumptionMode = (["skill", "retrieval", "integration", "workflow", "reference"].includes(consumptionRaw)
1573
+ ? consumptionRaw
1574
+ : defaults.consumptionMode);
1575
+ return {
1576
+ role,
1577
+ consumptionMode,
1578
+ capabilities,
1579
+ tags,
1580
+ activationHints,
1581
+ triggerTerms
1582
+ };
1583
+ }
1584
+ function normalizeCapabilityRole(rawValue, fallback) {
1585
+ const normalized = String(rawValue || "").trim().toLowerCase();
1586
+ if (["instruction", "knowledge", "integration", "process", "reference"].includes(normalized)) {
1587
+ return normalized;
1588
+ }
1589
+ return fallback;
1590
+ }
1591
+ function normalizeConsumptionMode(rawValue, fallback) {
1592
+ const normalized = String(rawValue || "").trim().toLowerCase();
1593
+ if (["skill", "retrieval", "integration", "workflow", "reference"].includes(normalized)) {
1594
+ return normalized;
1595
+ }
1596
+ return fallback;
1597
+ }
1598
+ function normalizeListInput(rawValues) {
1599
+ return Array.from(new Set((rawValues ?? [])
1600
+ .map((entry) => String(entry || "").trim())
1601
+ .filter(Boolean)));
1602
+ }
1603
+ function prependCapabilityFrontmatter(params) {
1604
+ const kind = assetKindFromCategory(params.category) ?? "docs";
1605
+ const defaults = capabilityDefaultsForKind(kind);
1606
+ const role = normalizeCapabilityRole(params.capabilityRole, defaults.role);
1607
+ const consumptionMode = normalizeConsumptionMode(params.consumptionMode, defaults.consumptionMode);
1608
+ const capabilities = normalizeListInput(params.capabilityList);
1609
+ const tags = normalizeListInput(params.tagList);
1610
+ const activationHints = normalizeListInput(params.activationHintList);
1611
+ const triggerTerms = normalizeListInput(params.triggerTermList);
1612
+ if (!capabilities.length && !tags.length && !activationHints.length && !triggerTerms.length &&
1613
+ role === defaults.role && consumptionMode === defaults.consumptionMode) {
1614
+ return params.content.trim();
1615
+ }
1616
+ const body = params.content.trim();
1617
+ const frontmatterLines = [
1618
+ "---",
1619
+ `role: ${role}`,
1620
+ `consumptionMode: ${consumptionMode}`
1621
+ ];
1622
+ if (capabilities.length) {
1623
+ frontmatterLines.push(`capabilities: [${capabilities.map((entry) => JSON.stringify(entry)).join(", ")}]`);
1624
+ }
1625
+ if (tags.length) {
1626
+ frontmatterLines.push(`tags: [${tags.map((entry) => JSON.stringify(entry)).join(", ")}]`);
1627
+ }
1628
+ if (activationHints.length) {
1629
+ frontmatterLines.push(`activationHints: [${activationHints.map((entry) => JSON.stringify(entry)).join(", ")}]`);
1630
+ }
1631
+ if (triggerTerms.length) {
1632
+ frontmatterLines.push(`triggerTerms: [${triggerTerms.map((entry) => JSON.stringify(entry)).join(", ")}]`);
1633
+ }
1634
+ frontmatterLines.push("---", "", body);
1635
+ return frontmatterLines.join("\n");
1636
+ }
1637
+ function tokenizeAssetText(value) {
1638
+ return String(value || "")
1639
+ .toLowerCase()
1640
+ .normalize("NFKC")
1641
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
1642
+ .split(/\s+/)
1643
+ .map((entry) => entry.trim())
1644
+ .filter((entry) => entry && entry.length >= 3 && !ASSET_MATCH_STOP_WORDS.has(entry));
1645
+ }
1646
+ function topKeywordsFromText(...parts) {
1647
+ const frequencies = new Map();
1648
+ for (const part of parts) {
1649
+ for (const token of tokenizeAssetText(part)) {
1650
+ frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
1651
+ }
1652
+ }
1653
+ return [...frequencies.entries()]
1654
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
1655
+ .slice(0, 24)
1656
+ .map(([token]) => token);
1657
+ }
1658
+ function emptyAssetKindRecord(factory) {
1659
+ return {
1660
+ skills: factory(),
1661
+ memory: factory(),
1662
+ workflows: factory(),
1663
+ docs: factory()
1664
+ };
1665
+ }
1666
+ function scoreAssetMatch(query, item) {
1667
+ const queryTokens = Array.from(new Set(tokenizeAssetText(query)));
1668
+ if (!queryTokens.length) {
1669
+ return null;
1670
+ }
1671
+ const titleTokens = new Set(tokenizeAssetText(item.title));
1672
+ const summaryTokens = new Set(tokenizeAssetText(item.summary));
1673
+ const filenameTokens = new Set(tokenizeAssetText(item.filename.replace(/\.[^.]+$/, "")));
1674
+ const keywordTokens = new Set([
1675
+ ...item.keywords,
1676
+ ...item.capability.tags,
1677
+ ...item.capability.capabilities,
1678
+ ...item.capability.triggerTerms
1679
+ ]);
1680
+ const hintTokens = new Set(tokenizeAssetText(item.capability.activationHints.join(" ")));
1681
+ const matchedTerms = new Set();
1682
+ let score = 0;
1683
+ for (const token of queryTokens) {
1684
+ let matched = false;
1685
+ if (titleTokens.has(token)) {
1686
+ score += 7;
1687
+ matched = true;
1688
+ }
1689
+ if (summaryTokens.has(token)) {
1690
+ score += 4;
1691
+ matched = true;
1692
+ }
1693
+ if (filenameTokens.has(token)) {
1694
+ score += 3;
1695
+ matched = true;
1696
+ }
1697
+ if (keywordTokens.has(token)) {
1698
+ score += 2;
1699
+ matched = true;
1700
+ }
1701
+ if (hintTokens.has(token)) {
1702
+ score += 1;
1703
+ matched = true;
1704
+ }
1705
+ if (matched) {
1706
+ matchedTerms.add(token);
1707
+ }
1708
+ }
1709
+ if (!score || !matchedTerms.size) {
1710
+ return null;
1711
+ }
1712
+ return {
1713
+ score,
1714
+ matchedTerms: [...matchedTerms]
1715
+ };
1716
+ }
1717
+ async function buildAssetServerBundleForTeam(teamSlugRaw) {
1718
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
1719
+ const root = await readTeamsState();
1720
+ const team = findTeamBySlugOrThrow(root, teamSlug);
1721
+ const publishedAssets = [...team.assets]
1722
+ .filter((asset) => asset.status === "published")
1723
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
1724
+ const items = [];
1725
+ for (const asset of publishedAssets) {
1726
+ const kind = assetKindFromCategory(asset.category);
1727
+ if (!kind) {
1728
+ continue;
1729
+ }
1730
+ const contentPath = asset.currentPath ?? asset.publishedPath ?? asset.sourcePath;
1731
+ const content = await readText(path.join(ROOT, contentPath));
1732
+ const summary = summarizeAssetContent(content, asset.title);
1733
+ items.push({
1734
+ id: asset.id,
1735
+ kind,
1736
+ category: asset.category,
1737
+ title: asset.title,
1738
+ filename: asset.filename,
1739
+ updatedAt: asset.updatedAt,
1740
+ contentHash: crypto.createHash("sha256").update(content).digest("hex"),
1741
+ summary,
1742
+ keywords: topKeywordsFromText(asset.title, asset.filename, content.slice(0, 4000)),
1743
+ capability: buildAssetCapabilityProfile({
1744
+ kind,
1745
+ title: asset.title,
1746
+ filename: asset.filename,
1747
+ content,
1748
+ summary
1749
+ }),
1750
+ content,
1751
+ currentPath: asset.currentPath,
1752
+ publishedPath: asset.publishedPath
1753
+ });
1754
+ }
1755
+ const counts = {
1756
+ skills: 0,
1757
+ memory: 0,
1758
+ workflows: 0,
1759
+ docs: 0
1760
+ };
1761
+ for (const item of items) {
1762
+ counts[item.kind] += 1;
1763
+ }
1764
+ const manifestHash = crypto
1765
+ .createHash("sha256")
1766
+ .update(JSON.stringify(items.map((item) => [item.id, item.kind, item.updatedAt, item.contentHash])))
1767
+ .digest("hex");
1768
+ return {
1769
+ team: {
1770
+ slug: team.profile.slug,
1771
+ name: team.profile.name
1772
+ },
1773
+ generatedAt: nowIso(),
1774
+ manifestHash,
1775
+ counts,
1776
+ items: items.map(({ content: _content, ...rest }) => rest),
1777
+ byKind: {
1778
+ skills: items.filter((item) => item.kind === "skills"),
1779
+ memory: items.filter((item) => item.kind === "memory"),
1780
+ workflows: items.filter((item) => item.kind === "workflows"),
1781
+ docs: items.filter((item) => item.kind === "docs")
1782
+ }
1783
+ };
1784
+ }
1785
+ export async function getTeamAssetServerManifestBySlug(teamSlugRaw) {
1786
+ const bundle = await buildAssetServerBundleForTeam(teamSlugRaw);
1787
+ return {
1788
+ team: bundle.team,
1789
+ generatedAt: bundle.generatedAt,
1790
+ manifestHash: bundle.manifestHash,
1791
+ counts: bundle.counts,
1792
+ items: bundle.items
1793
+ };
1794
+ }
1795
+ export async function getTeamAssetServerBundleBySlug(teamSlugRaw) {
1796
+ return buildAssetServerBundleForTeam(teamSlugRaw);
1797
+ }
1798
+ export async function getTeamAssetCapabilityRegistryBySlug(teamSlugRaw) {
1799
+ const bundle = await buildAssetServerBundleForTeam(teamSlugRaw);
1800
+ return {
1801
+ team: bundle.team,
1802
+ generatedAt: bundle.generatedAt,
1803
+ manifestHash: bundle.manifestHash,
1804
+ counts: bundle.counts,
1805
+ byKind: {
1806
+ skills: bundle.byKind.skills.map(({ content: _content, ...rest }) => rest),
1807
+ memory: bundle.byKind.memory.map(({ content: _content, ...rest }) => rest),
1808
+ workflows: bundle.byKind.workflows.map(({ content: _content, ...rest }) => rest),
1809
+ docs: bundle.byKind.docs.map(({ content: _content, ...rest }) => rest)
1810
+ }
1811
+ };
1812
+ }
1813
+ export async function getTeamAssetServerItemById(teamSlugRaw, assetId) {
1814
+ const bundle = await buildAssetServerBundleForTeam(teamSlugRaw);
1815
+ for (const kind of ASSET_SERVER_KINDS) {
1816
+ const match = bundle.byKind[kind].find((item) => item.id === assetId);
1817
+ if (match) {
1818
+ return match;
1819
+ }
1820
+ }
1821
+ throw new HttpError(404, `shared asset not found: ${assetId}`);
1822
+ }
1823
+ export async function resolveTeamAssetServerMatchesBySlug(teamSlugRaw, input) {
1824
+ const query = String(input.query || "").trim();
1825
+ if (!query) {
1826
+ throw new HttpError(400, "asset resolve query is required");
1827
+ }
1828
+ const bundle = await buildAssetServerBundleForTeam(teamSlugRaw);
1829
+ const requestedKinds = Array.isArray(input.kinds) && input.kinds.length
1830
+ ? input.kinds.filter((kind) => ASSET_SERVER_KINDS.includes(kind))
1831
+ : ASSET_SERVER_KINDS;
1832
+ const allowedKinds = new Set(requestedKinds);
1833
+ const limitPerKind = Math.max(1, Math.min(6, Number(input.limitPerKind) || 2));
1834
+ const matches = emptyAssetKindRecord(() => []);
1835
+ for (const item of bundle.items) {
1836
+ if (!allowedKinds.has(item.kind)) {
1837
+ continue;
1838
+ }
1839
+ const scored = scoreAssetMatch(query, item);
1840
+ if (!scored) {
1841
+ continue;
1842
+ }
1843
+ matches[item.kind].push({
1844
+ ...item,
1845
+ score: scored.score,
1846
+ matchedTerms: scored.matchedTerms
1847
+ });
1848
+ }
1849
+ for (const kind of ASSET_SERVER_KINDS) {
1850
+ matches[kind] = matches[kind]
1851
+ .sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt))
1852
+ .slice(0, limitPerKind);
1853
+ }
1854
+ return {
1855
+ team: bundle.team,
1856
+ generatedAt: nowIso(),
1857
+ query,
1858
+ matches
1859
+ };
1860
+ }
1861
+ export async function createTeamAssetProposal(input) {
1862
+ return mutateNamedTeamState(input.teamSlug, async (team) => {
1863
+ await ensureTeamAssetLayout(team.profile.slug);
1864
+ const title = input.title.trim();
1865
+ const content = prependCapabilityFrontmatter({
1866
+ category: input.category,
1867
+ content: input.content,
1868
+ capabilityRole: input.capabilityRole,
1869
+ consumptionMode: input.consumptionMode,
1870
+ capabilityList: input.capabilityList,
1871
+ tagList: input.tagList,
1872
+ activationHintList: input.activationHintList,
1873
+ triggerTermList: input.triggerTermList
1874
+ }).trim();
1875
+ if (!title) {
1876
+ throw new HttpError(400, "asset title is required");
1877
+ }
1878
+ if (!content) {
1879
+ throw new HttpError(400, "asset content is required");
1880
+ }
1881
+ const actor = resolveMemberAssetPermissions(team, input.submittedByMemberId, "manager");
1882
+ if (!actor.permissions.canPropose) {
1883
+ throw new HttpError(403, `member cannot propose shared assets: ${input.submittedByMemberId}`);
1884
+ }
1885
+ const submittedBy = input.submittedByMemberId?.trim() || input.submittedByLabel?.trim() || "manager";
1886
+ const fileStem = slugifyAssetName(title) || `asset-${crypto.randomUUID().slice(0, 8)}`;
1887
+ const fileName = `${fileStem}${resolveAssetFileExtension(input.category)}`;
1888
+ const sourceZone = input.sourceZone ?? "collab";
1889
+ const createdAt = nowIso();
1890
+ const initialStatus = sourceZone === "drafts"
1891
+ ? "draft"
1892
+ : actor.permissions.canPublishWithoutApproval
1893
+ ? "approved"
1894
+ : "pending_approval";
1895
+ const asset = {
1896
+ id: crypto.randomUUID(),
1897
+ teamSlug: team.profile.slug,
1898
+ category: input.category,
1899
+ title,
1900
+ filename: fileName,
1901
+ submittedBy,
1902
+ role: actor.role,
1903
+ sourceZone,
1904
+ status: initialStatus,
1905
+ visibility: "team",
1906
+ approvalRequired: sourceZone === "drafts" ? false : !actor.permissions.canPublishWithoutApproval,
1907
+ note: input.note?.trim() || undefined,
1908
+ submittedAt: createdAt,
1909
+ updatedAt: createdAt,
1910
+ sourcePath: ""
1911
+ };
1912
+ asset.sourcePath = await writeTeamAssetSourceFile({
1913
+ teamSlug: team.profile.slug,
1914
+ sourceZone,
1915
+ category: input.category,
1916
+ fileName,
1917
+ content
1918
+ });
1919
+ if (asset.approvalRequired) {
1920
+ asset.approvalPath = await writeTeamAssetApprovalMarker({
1921
+ teamSlug: team.profile.slug,
1922
+ assetId: asset.id,
1923
+ title: asset.title,
1924
+ category: asset.category
1925
+ });
1926
+ }
1927
+ else if (asset.status === "approved") {
1928
+ asset.approvedAt = createdAt;
1929
+ asset.approvedBy = submittedBy;
1930
+ await publishTeamAssetRecord({
1931
+ team,
1932
+ asset,
1933
+ actorId: submittedBy
1934
+ });
1935
+ }
1936
+ team.assets.unshift(asset);
1937
+ return { asset, changed: true };
1938
+ });
1939
+ }
1940
+ export async function approveTeamAssetProposal(input) {
1941
+ return mutateNamedTeamState(input.teamSlug, async (team) => {
1942
+ const actor = resolveMemberAssetPermissions(team, input.approvedByMemberId, "manager");
1943
+ if (!actor.permissions.canApprove) {
1944
+ throw new HttpError(403, `member cannot approve shared assets: ${input.approvedByMemberId}`);
1945
+ }
1946
+ const asset = team.assets.find((entry) => entry.id === input.assetId);
1947
+ if (!asset) {
1948
+ throw new HttpError(404, `asset not found: ${input.assetId}`);
1949
+ }
1950
+ if (asset.status !== "pending_approval" && asset.status !== "approved") {
1951
+ throw new HttpError(409, `asset is not awaiting approval: ${asset.id}`);
1952
+ }
1953
+ asset.status = "approved";
1954
+ asset.approvedAt = nowIso();
1955
+ asset.approvedBy = input.approvedByMemberId;
1956
+ await publishTeamAssetRecord({
1957
+ team,
1958
+ asset,
1959
+ actorId: input.approvedByMemberId
1960
+ });
1961
+ return { asset, changed: true };
1962
+ });
1963
+ }
1964
+ export async function rejectTeamAssetProposal(input) {
1965
+ return mutateNamedTeamState(input.teamSlug, async (team) => {
1966
+ const actor = resolveMemberAssetPermissions(team, input.rejectedByMemberId, "manager");
1967
+ if (!actor.permissions.canApprove) {
1968
+ throw new HttpError(403, `member cannot reject shared assets: ${input.rejectedByMemberId}`);
1969
+ }
1970
+ const asset = team.assets.find((entry) => entry.id === input.assetId);
1971
+ if (!asset) {
1972
+ throw new HttpError(404, `asset not found: ${input.assetId}`);
1973
+ }
1974
+ asset.status = "rejected";
1975
+ asset.updatedAt = nowIso();
1976
+ asset.rejectedAt = asset.updatedAt;
1977
+ asset.rejectedBy = input.rejectedByMemberId;
1978
+ asset.rejectionReason = input.reason?.trim() || undefined;
1979
+ return { asset, changed: true };
1980
+ });
1981
+ }
1982
+ export async function promoteTeamAsset(teamSlug, assetId, actorId) {
1983
+ return mutateNamedTeamState(teamSlug, async (team) => {
1984
+ const actor = resolveMemberAssetPermissions(team, actorId, "manager");
1985
+ if (!actor.permissions.canPromote && !actor.permissions.canApprove) {
1986
+ throw new HttpError(403, `member cannot promote shared assets: ${actorId}`);
1987
+ }
1988
+ const asset = team.assets.find((entry) => entry.id === assetId);
1989
+ if (!asset) {
1990
+ throw new HttpError(404, `asset not found: ${assetId}`);
1991
+ }
1992
+ asset.approvedAt = nowIso();
1993
+ asset.approvedBy = actorId;
1994
+ await publishTeamAssetRecord({
1995
+ team,
1996
+ asset,
1997
+ actorId
1998
+ });
1999
+ return { asset, changed: true };
2000
+ });
2001
+ }
2002
+ async function listEntries(targetPath) {
2003
+ try {
2004
+ const entries = await fs.readdir(targetPath, { withFileTypes: true });
2005
+ const mapped = await Promise.all(entries
2006
+ .filter((entry) => !entry.name.startsWith("."))
2007
+ .sort((a, b) => a.name.localeCompare(b.name))
2008
+ .map(async (entry) => {
2009
+ const fullPath = path.join(targetPath, entry.name);
2010
+ const stat = entry.isDirectory() ? null : await safeStat(fullPath);
2011
+ return {
2012
+ name: entry.name,
2013
+ path: path.relative(ROOT, fullPath),
2014
+ type: entry.isDirectory() ? "directory" : "file",
2015
+ size: stat?.size
2016
+ };
2017
+ }));
2018
+ return mapped;
2019
+ }
2020
+ catch {
2021
+ return [];
2022
+ }
2023
+ }
2024
+ async function buildBucket(basePath, key, label) {
2025
+ const bucketPath = path.join(basePath, key);
2026
+ const exists = Boolean(await safeStat(bucketPath));
2027
+ return {
2028
+ key,
2029
+ label,
2030
+ path: path.relative(ROOT, bucketPath),
2031
+ exists,
2032
+ files: exists ? await listEntries(bucketPath) : []
2033
+ };
2034
+ }
2035
+ async function listMemberSummariesForTeam(teamSlugRaw) {
2036
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2037
+ const membersDir = teamMembersRoot(teamSlug);
2038
+ const rootStat = await safeStat(membersDir);
2039
+ if (!rootStat) {
2040
+ return [];
2041
+ }
2042
+ const entries = await fs.readdir(membersDir, { withFileTypes: true });
2043
+ const memberDirs = entries
2044
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !RESERVED_MEMBER_DIR_NAMES.has(entry.name))
2045
+ .sort((a, b) => a.name.localeCompare(b.name));
2046
+ return Promise.all(memberDirs.map(async (entry) => {
2047
+ const memberPath = path.join(membersDir, entry.name);
2048
+ const runtimePath = path.join(memberPath, "runtime");
2049
+ const composePath = path.join(runtimePath, "docker-compose.yml");
2050
+ const configPath = path.join(runtimePath, "config", "openclaw.json");
2051
+ return {
2052
+ id: entry.name,
2053
+ path: path.relative(ROOT, memberPath),
2054
+ hasRuntime: Boolean(await safeStat(runtimePath)),
2055
+ hasComposeFile: Boolean(await safeStat(composePath)),
2056
+ hasConfigFile: Boolean(await safeStat(configPath))
2057
+ };
2058
+ }));
2059
+ }
2060
+ function normalizePage(value, fallback = 1) {
2061
+ if (!Number.isInteger(value) || value == null || value < 1) {
2062
+ return fallback;
2063
+ }
2064
+ return value;
2065
+ }
2066
+ function paginateItems(items, pageRaw, pageSizeRaw, fallbackPageSize) {
2067
+ const pageSize = Math.max(1, normalizePage(pageSizeRaw, fallbackPageSize));
2068
+ const total = items.length;
2069
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
2070
+ const page = Math.min(normalizePage(pageRaw, 1), totalPages);
2071
+ const start = (page - 1) * pageSize;
2072
+ return {
2073
+ items: items.slice(start, start + pageSize),
2074
+ page,
2075
+ pageSize,
2076
+ total,
2077
+ totalPages,
2078
+ query: ""
2079
+ };
2080
+ }
2081
+ export async function getTeamAssets() {
2082
+ const teamRoot = path.join(ROOT, "team-assets");
2083
+ const snapshotRoot = path.join(ROOT, "shared-snapshots");
2084
+ return {
2085
+ root: ROOT,
2086
+ teamRoot: path.relative(ROOT, teamRoot),
2087
+ snapshotRoot: path.relative(ROOT, snapshotRoot),
2088
+ teamAssets: await Promise.all(TEAM_BUCKETS.map(([key, label]) => buildBucket(teamRoot, key, label))),
2089
+ sharedSnapshots: await Promise.all(SNAPSHOT_BUCKETS.map(([key, label]) => buildBucket(snapshotRoot, key, label)))
2090
+ };
2091
+ }
2092
+ export async function getTeamsCatalog() {
2093
+ const root = await readTeamsState();
2094
+ return Promise.all(root.teams.map(async (team) => {
2095
+ const members = await listMemberSummariesForTeam(team.profile.slug);
2096
+ return {
2097
+ profile: team.profile,
2098
+ modelGateway: team.modelGateway,
2099
+ summary: {
2100
+ memberCount: members.length,
2101
+ activeMemberCount: members.length,
2102
+ pendingInvitationCount: team.invitations.filter((invitation) => invitation.status === "pending").length
2103
+ }
2104
+ };
2105
+ }));
2106
+ }
2107
+ export async function createTeam(input) {
2108
+ const name = input.name.trim();
2109
+ if (!name) {
2110
+ throw new HttpError(400, "team name is required");
2111
+ }
2112
+ const slugSource = input.slug?.trim() || name;
2113
+ const slug = slugifyTeamLabel(slugSource);
2114
+ if (!slug) {
2115
+ throw new HttpError(400, "team slug cannot be empty after normalization");
2116
+ }
2117
+ if (slug === MEMBER_TEMPLATE_ID) {
2118
+ throw new HttpError(400, "team slug cannot reuse reserved template id");
2119
+ }
2120
+ return mutateTeamsState(async (root) => {
2121
+ if (root.teams.some((team) => team.profile.slug === slug)) {
2122
+ throw new HttpError(409, `team already exists: ${slug}`);
2123
+ }
2124
+ const timestamp = nowIso();
2125
+ const team = {
2126
+ version: 1,
2127
+ profile: {
2128
+ name,
2129
+ slug,
2130
+ description: input.description?.trim() || `Team ${name}`,
2131
+ managerLabel: input.managerLabel?.trim() || "Team Manager",
2132
+ inviteBasePath: "/invite",
2133
+ createdAt: timestamp,
2134
+ updatedAt: timestamp
2135
+ },
2136
+ modelGateway: defaultTeamModelGateway(),
2137
+ invitations: [],
2138
+ memberPolicies: [],
2139
+ assetRolePolicies: defaultTeamState().assetRolePolicies,
2140
+ assets: []
2141
+ };
2142
+ root.teams.push(team);
2143
+ await fs.mkdir(teamMembersRoot(slug), { recursive: true });
2144
+ await ensureTeamAssetLayout(slug);
2145
+ return team.profile;
2146
+ });
2147
+ }
2148
+ export async function getTeamOverviewBySlug(teamSlug) {
2149
+ teamSlug = slugifyTeamLabel(teamSlug);
2150
+ const cached = teamOverviewCache.get(teamSlug);
2151
+ if (cached && cached.expiresAt > Date.now()) {
2152
+ return cached.value;
2153
+ }
2154
+ const inflight = teamOverviewInflight.get(teamSlug);
2155
+ if (inflight) {
2156
+ return inflight;
2157
+ }
2158
+ const compute = (async () => {
2159
+ const root = await readTeamsState();
2160
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2161
+ const members = await listMemberSummariesForTeam(state.profile.slug);
2162
+ const docker = await getDockerAccess();
2163
+ const membersWithRuntime = await Promise.all(members.map(async (member) => {
2164
+ const policy = state.memberPolicies.find((entry) => entry.memberId === member.id) ?? null;
2165
+ return {
2166
+ ...member,
2167
+ policy,
2168
+ runtime: await getMemberRuntimeStatus(state.profile.slug, member.id, policy, docker)
2169
+ };
2170
+ }));
2171
+ const overview = {
2172
+ profile: state.profile,
2173
+ modelGateway: state.modelGateway,
2174
+ members: membersWithRuntime,
2175
+ invitations: [...state.invitations].sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
2176
+ assets: {
2177
+ records: [...state.assets].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
2178
+ summary: {
2179
+ draftCount: state.assets.filter((asset) => asset.status === "draft").length,
2180
+ pendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2181
+ publishedCount: state.assets.filter((asset) => asset.status === "published").length
2182
+ }
2183
+ },
2184
+ summary: {
2185
+ memberCount: members.length,
2186
+ activeMemberCount: members.length,
2187
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2188
+ assetDraftCount: state.assets.filter((asset) => asset.status === "draft").length,
2189
+ assetPendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2190
+ assetPublishedCount: state.assets.filter((asset) => asset.status === "published").length
2191
+ }
2192
+ };
2193
+ teamOverviewCache.set(teamSlug, {
2194
+ expiresAt: Date.now() + TEAM_OVERVIEW_CACHE_TTL_MS,
2195
+ value: overview
2196
+ });
2197
+ return overview;
2198
+ })();
2199
+ teamOverviewInflight.set(teamSlug, compute);
2200
+ try {
2201
+ return await compute;
2202
+ }
2203
+ finally {
2204
+ teamOverviewInflight.delete(teamSlug);
2205
+ }
2206
+ }
2207
+ export async function getTeamOverview() {
2208
+ const root = await readTeamsState();
2209
+ return getTeamOverviewBySlug(resolveDefaultTeamSlug(root));
2210
+ }
2211
+ export async function getTeamSummaryBySlug(teamSlugRaw) {
2212
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2213
+ const root = await readTeamsState();
2214
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2215
+ const members = await listMemberSummariesForTeam(state.profile.slug);
2216
+ return {
2217
+ profile: state.profile,
2218
+ modelGateway: state.modelGateway,
2219
+ summary: {
2220
+ memberCount: members.length,
2221
+ activeMemberCount: members.length,
2222
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2223
+ assetDraftCount: state.assets.filter((asset) => asset.status === "draft").length,
2224
+ assetPendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2225
+ assetPublishedCount: state.assets.filter((asset) => asset.status === "published").length
2226
+ }
2227
+ };
2228
+ }
2229
+ export async function getTeamRuntimeOverviewBySlug(teamSlugRaw) {
2230
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2231
+ const root = await readTeamsState();
2232
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2233
+ const members = await listMemberSummariesForTeam(state.profile.slug);
2234
+ const docker = await getDockerAccess();
2235
+ const membersWithRuntime = await Promise.all(members.map(async (member) => {
2236
+ const policy = state.memberPolicies.find((entry) => entry.memberId === member.id) ?? null;
2237
+ return {
2238
+ ...member,
2239
+ policy,
2240
+ runtime: await getMemberRuntimeStatus(state.profile.slug, member.id, policy, docker)
2241
+ };
2242
+ }));
2243
+ return {
2244
+ profile: state.profile,
2245
+ modelGateway: state.modelGateway,
2246
+ summary: {
2247
+ memberCount: members.length,
2248
+ activeMemberCount: members.length,
2249
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2250
+ assetDraftCount: state.assets.filter((asset) => asset.status === "draft").length,
2251
+ assetPendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2252
+ assetPublishedCount: state.assets.filter((asset) => asset.status === "published").length
2253
+ },
2254
+ members: membersWithRuntime
2255
+ };
2256
+ }
2257
+ export async function getTeamPageOverviewBySlug(teamSlugRaw, options) {
2258
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2259
+ const root = await readTeamsState();
2260
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2261
+ const memberQuery = options?.memberQuery?.trim().toLowerCase() || "";
2262
+ const assetQuery = options?.assetQuery?.trim().toLowerCase() || "";
2263
+ const pagedInvitations = paginateItems([...state.invitations].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), options?.invitePage, options?.invitePageSize, 12);
2264
+ const allMembers = await listMemberSummariesForTeam(state.profile.slug);
2265
+ const filteredMembers = memberQuery
2266
+ ? allMembers.filter((member) => member.id.toLowerCase().includes(memberQuery) || member.path.toLowerCase().includes(memberQuery))
2267
+ : allMembers;
2268
+ const pagedMembers = paginateItems(filteredMembers, options?.memberPage, options?.memberPageSize, 20);
2269
+ pagedMembers.query = options?.memberQuery?.trim() || "";
2270
+ const docker = await getDockerAccess();
2271
+ const membersWithRuntime = await Promise.all(pagedMembers.items.map(async (member) => {
2272
+ const policy = state.memberPolicies.find((entry) => entry.memberId === member.id) ?? null;
2273
+ return {
2274
+ ...member,
2275
+ policy,
2276
+ runtime: await getMemberRuntimeStatus(state.profile.slug, member.id, policy, docker)
2277
+ };
2278
+ }));
2279
+ const filteredAssets = assetQuery
2280
+ ? state.assets.filter((asset) => {
2281
+ const haystack = [asset.title, asset.filename, asset.category, asset.submittedBy, asset.status, asset.sourceZone].join(" ").toLowerCase();
2282
+ return haystack.includes(assetQuery);
2283
+ })
2284
+ : [...state.assets];
2285
+ const pagedAssets = paginateItems([...filteredAssets].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), options?.assetPage, options?.assetPageSize, 12);
2286
+ pagedAssets.query = options?.assetQuery?.trim() || "";
2287
+ return {
2288
+ profile: state.profile,
2289
+ modelGateway: state.modelGateway,
2290
+ summary: {
2291
+ memberCount: allMembers.length,
2292
+ activeMemberCount: allMembers.length,
2293
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2294
+ assetDraftCount: state.assets.filter((asset) => asset.status === "draft").length,
2295
+ assetPendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2296
+ assetPublishedCount: state.assets.filter((asset) => asset.status === "published").length
2297
+ },
2298
+ invitations: pagedInvitations,
2299
+ members: {
2300
+ ...pagedMembers,
2301
+ items: membersWithRuntime
2302
+ },
2303
+ assets: {
2304
+ records: pagedAssets,
2305
+ summary: {
2306
+ draftCount: state.assets.filter((asset) => asset.status === "draft").length,
2307
+ pendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2308
+ publishedCount: state.assets.filter((asset) => asset.status === "published").length
2309
+ }
2310
+ }
2311
+ };
2312
+ }
2313
+ export async function getTeamMemberWorkspaceById(teamSlugRaw, memberIdRaw) {
2314
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2315
+ const memberId = validateMemberId(memberIdRaw);
2316
+ const root = await readTeamsState();
2317
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2318
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2319
+ if (!member) {
2320
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2321
+ }
2322
+ const allMembers = await listMemberSummariesForTeam(state.profile.slug);
2323
+ const policy = state.memberPolicies.find((entry) => entry.memberId === memberId) ?? null;
2324
+ const docker = await getDockerAccess();
2325
+ const runtime = await getMemberRuntimeStatus(state.profile.slug, memberId, policy, docker);
2326
+ return {
2327
+ profile: state.profile,
2328
+ modelGateway: state.modelGateway,
2329
+ summary: {
2330
+ memberCount: allMembers.length,
2331
+ activeMemberCount: allMembers.length,
2332
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2333
+ assetDraftCount: state.assets.filter((asset) => asset.status === "draft").length,
2334
+ assetPendingApprovalCount: state.assets.filter((asset) => asset.status === "pending_approval").length,
2335
+ assetPublishedCount: state.assets.filter((asset) => asset.status === "published").length
2336
+ },
2337
+ member,
2338
+ policy,
2339
+ runtime
2340
+ };
2341
+ }
2342
+ export async function getTeamAssetWorkspaceById(teamSlugRaw, assetId) {
2343
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2344
+ const root = await readTeamsState();
2345
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2346
+ const asset = state.assets.find((entry) => entry.id === assetId);
2347
+ if (!asset) {
2348
+ throw new HttpError(404, `asset not found: ${assetId}`);
2349
+ }
2350
+ const allMembers = await listMemberSummariesForTeam(state.profile.slug);
2351
+ const sourceContent = await readText(path.join(ROOT, asset.sourcePath));
2352
+ const publishedContent = asset.publishedPath ? await readText(path.join(ROOT, asset.publishedPath)) : undefined;
2353
+ const currentContent = asset.currentPath ? await readText(path.join(ROOT, asset.currentPath)) : undefined;
2354
+ const { content: _content, ...assetRegistryEntry } = await getTeamAssetServerItemById(teamSlug, assetId);
2355
+ return {
2356
+ profile: state.profile,
2357
+ modelGateway: state.modelGateway,
2358
+ summary: {
2359
+ memberCount: allMembers.length,
2360
+ activeMemberCount: allMembers.length,
2361
+ pendingInvitationCount: state.invitations.filter((invitation) => invitation.status === "pending").length,
2362
+ assetDraftCount: state.assets.filter((entry) => entry.status === "draft").length,
2363
+ assetPendingApprovalCount: state.assets.filter((entry) => entry.status === "pending_approval").length,
2364
+ assetPublishedCount: state.assets.filter((entry) => entry.status === "published").length
2365
+ },
2366
+ asset,
2367
+ assetRegistryEntry,
2368
+ sourceContent,
2369
+ publishedContent,
2370
+ currentContent
2371
+ };
2372
+ }
2373
+ export async function getMemberRuntimeStatus(teamSlugRaw, memberIdRaw, policyOverride, dockerOverride) {
2374
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2375
+ const trimmedMemberId = memberIdRaw.trim().toLowerCase();
2376
+ const memberId = trimmedMemberId === MEMBER_TEMPLATE_ID ? MEMBER_TEMPLATE_ID : validateMemberId(memberIdRaw);
2377
+ const member = memberId === MEMBER_TEMPLATE_ID
2378
+ ? {
2379
+ id: MEMBER_TEMPLATE_ID,
2380
+ path: path.relative(ROOT, path.join(MEMBERS_ROOT, MEMBER_TEMPLATE_ID)),
2381
+ hasRuntime: true,
2382
+ hasComposeFile: true,
2383
+ hasConfigFile: true,
2384
+ buckets: []
2385
+ }
2386
+ : await getMemberByIdForTeam(teamSlug, memberId);
2387
+ if (!member) {
2388
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2389
+ }
2390
+ const runtimeDir = memberId === MEMBER_TEMPLATE_ID ? path.join(MEMBERS_ROOT, MEMBER_TEMPLATE_ID, "runtime") : memberRuntimeRoot(teamSlug, memberId);
2391
+ const composePath = path.join(runtimeDir, "docker-compose.yml");
2392
+ const configDir = path.join(runtimeDir, "config");
2393
+ const secretsDir = path.join(runtimeDir, "secrets");
2394
+ const teamPolicyPath = path.join(configDir, "team-policy.json");
2395
+ const quotaPluginPath = path.join(configDir, "local-plugins", "member-quota-guard", "index.js");
2396
+ const telegramTokenPath = path.join(secretsDir, "telegram-bot-token");
2397
+ const whatsappCredsPath = path.join(configDir, "credentials", "whatsapp", "default", "creds.json");
2398
+ const rawConfig = (await fileExists(path.join(configDir, "openclaw.json"))) ? await readText(path.join(configDir, "openclaw.json")) : "";
2399
+ const parsedConfig = rawConfig ? JSON.parse(rawConfig) : {};
2400
+ const channels = parsedConfig.channels ?? {};
2401
+ const channelKind = channels.telegram ? "telegram" : channels.discord ? "discord" : channels.whatsapp ? "whatsapp" : undefined;
2402
+ const composeText = (await fileExists(composePath)) ? await readText(composePath) : "";
2403
+ const publishedPort = composeText ? parsePublishedPort(composeText) : null;
2404
+ const containerName = composeText
2405
+ ? parseContainerName(teamSlug, memberId, composeText)
2406
+ : containerNameForMember(teamSlug, memberId);
2407
+ const commands = buildRuntimeCommands(runtimeDir, composePath, composeProjectNameForMember(teamSlug, memberId));
2408
+ const hasTelegramToken = Boolean((await safeStat(telegramTokenPath))?.size);
2409
+ const hasDiscordToken = typeof channels.discord?.token === "string" && channels.discord.token.trim().length > 0;
2410
+ const hasWhatsappCreds = Boolean((await safeStat(whatsappCredsPath))?.size);
2411
+ const hasTeamPolicy = await fileExists(teamPolicyPath);
2412
+ const hasQuotaPlugin = await fileExists(quotaPluginPath);
2413
+ const docker = dockerOverride ?? (await getDockerAccess());
2414
+ const policy = policyOverride ?? ((await readTeamsState()).teams.find((entry) => entry.profile.slug === teamSlug)?.memberPolicies.find((entry) => entry.memberId === memberId) ?? null);
2415
+ if (memberId === MEMBER_TEMPLATE_ID) {
2416
+ return {
2417
+ state: "template",
2418
+ label: "Template",
2419
+ channelKind,
2420
+ channelReady: false,
2421
+ containerName,
2422
+ runtimePath: path.relative(ROOT, runtimeDir),
2423
+ composePath: path.relative(ROOT, composePath),
2424
+ publishedPort,
2425
+ dockerAvailable: docker.available,
2426
+ dockerError: docker.available ? undefined : docker.error,
2427
+ hasTelegramToken,
2428
+ hasTeamPolicy,
2429
+ hasQuotaPlugin,
2430
+ quotaStatus: policy?.quota.status,
2431
+ container: { exists: false, running: false },
2432
+ commands
2433
+ };
2434
+ }
2435
+ let container = { exists: false, running: false };
2436
+ let state = "ready";
2437
+ let label = "Ready to launch";
2438
+ let dockerError;
2439
+ let channelReady = channelKind === "discord" ? hasDiscordToken : channelKind === "whatsapp" ? hasWhatsappCreds : hasTelegramToken;
2440
+ if (!docker.available) {
2441
+ state = "docker-error";
2442
+ label = "Docker control unavailable";
2443
+ dockerError = docker.error;
2444
+ }
2445
+ else {
2446
+ try {
2447
+ container = docker.containersByName.get(containerName) ?? { exists: false, running: false };
2448
+ if (container.exists && container.running) {
2449
+ state = "running";
2450
+ label = container.health === "healthy" ? "Running" : container.health ? `Running (${container.health})` : "Running";
2451
+ }
2452
+ else if (container.exists) {
2453
+ state = "stopped";
2454
+ label = `Container ${container.status ?? "stopped"}`;
2455
+ }
2456
+ }
2457
+ catch (error) {
2458
+ state = "docker-error";
2459
+ label = "Docker inspect failed";
2460
+ dockerError = error instanceof Error ? error.message : String(error);
2461
+ }
2462
+ }
2463
+ if (state !== "running" && state !== "stopped" && state !== "docker-error") {
2464
+ if (!hasTeamPolicy || !hasTelegramToken) {
2465
+ state = "needs-secrets";
2466
+ if (!hasTeamPolicy) {
2467
+ label = "Needs manager policy";
2468
+ }
2469
+ else if (channelKind === "discord") {
2470
+ label = hasDiscordToken ? "Ready to launch" : "Needs Discord bot token";
2471
+ }
2472
+ else if (channelKind === "whatsapp") {
2473
+ label = hasWhatsappCreds ? "Ready to launch" : "Needs WhatsApp QR login";
2474
+ }
2475
+ else {
2476
+ label = hasTelegramToken ? "Ready to launch" : "Needs Telegram bot token";
2477
+ }
2478
+ if ((channelKind === "discord" && hasDiscordToken) || (channelKind === "whatsapp" && hasWhatsappCreds) || (!channelKind && hasTelegramToken)) {
2479
+ state = "ready";
2480
+ }
2481
+ }
2482
+ }
2483
+ return {
2484
+ state,
2485
+ label,
2486
+ channelKind,
2487
+ channelReady,
2488
+ containerName,
2489
+ runtimePath: path.relative(ROOT, runtimeDir),
2490
+ composePath: path.relative(ROOT, composePath),
2491
+ publishedPort,
2492
+ dockerAvailable: docker.available,
2493
+ dockerError,
2494
+ hasTelegramToken,
2495
+ hasTeamPolicy,
2496
+ hasQuotaPlugin,
2497
+ quotaStatus: policy?.quota.status,
2498
+ container,
2499
+ commands
2500
+ };
2501
+ }
2502
+ export async function runMemberRuntimeAction(memberIdRaw, action) {
2503
+ const root = await readTeamsState();
2504
+ return runMemberRuntimeActionForTeam(resolveDefaultTeamSlug(root), memberIdRaw, action);
2505
+ }
2506
+ export async function runMemberRuntimeActionForTeam(teamSlugRaw, memberIdRaw, action) {
2507
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2508
+ const memberId = validateMemberId(memberIdRaw);
2509
+ if (memberId === MEMBER_TEMPLATE_ID) {
2510
+ throw new HttpError(400, "cannot manage runtime actions for the member template");
2511
+ }
2512
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2513
+ if (!member) {
2514
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2515
+ }
2516
+ const runtimeDir = path.join(memberRoot(teamSlug, memberId), "runtime");
2517
+ const composePath = path.join(runtimeDir, "docker-compose.yml");
2518
+ if (!(await fileExists(composePath))) {
2519
+ throw new HttpError(404, `compose file missing for member: ${memberId}`);
2520
+ }
2521
+ await ensureMemberRuntimeComposeDefaults(teamSlug, memberId);
2522
+ await syncMemberGeneratedWorkspaceViews(teamSlug, memberId);
2523
+ const composeProjectName = composeProjectNameForMember(teamSlug, memberId);
2524
+ const argsByAction = {
2525
+ start: ["-n", "docker", "compose", "-p", composeProjectName, "-f", composePath, "up", "-d"],
2526
+ stop: ["-n", "docker", "compose", "-p", composeProjectName, "-f", composePath, "stop"],
2527
+ restart: ["-n", "docker", "compose", "-p", composeProjectName, "-f", composePath, "restart"]
2528
+ };
2529
+ const result = await runHostCommand("sudo", argsByAction[action], runtimeDir);
2530
+ invalidateTeamOverviewCache(teamSlug);
2531
+ const runtime = await getMemberRuntimeStatus(teamSlug, memberId);
2532
+ return {
2533
+ memberId,
2534
+ action,
2535
+ ok: result.ok,
2536
+ stdout: result.stdout,
2537
+ stderr: result.stderr,
2538
+ runtime
2539
+ };
2540
+ }
2541
+ async function performMemberRuntimeUpgradeForTeam(teamSlugRaw, memberIdRaw) {
2542
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2543
+ const memberId = validateMemberId(memberIdRaw);
2544
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2545
+ if (!member) {
2546
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2547
+ }
2548
+ const root = await readTeamsState();
2549
+ const team = findTeamBySlugOrThrow(root, teamSlug);
2550
+ const policy = team.memberPolicies.find((entry) => entry.memberId === memberId) ?? null;
2551
+ const invitationCode = policy?.invitationId
2552
+ ? team.invitations.find((entry) => entry.id === policy.invitationId)?.code
2553
+ : undefined;
2554
+ if (policy) {
2555
+ await writeMemberPolicyFile(team.profile, teamSlug, memberId, policy, invitationCode);
2556
+ await syncMemberRuntimeConfigFromPolicy(team, memberId, policy);
2557
+ }
2558
+ else {
2559
+ await syncManagedMemberLocalPlugins(teamSlug, memberId);
2560
+ await ensureMemberRuntimeComposeDefaults(teamSlug, memberId);
2561
+ }
2562
+ const runtimeDir = path.join(memberRoot(teamSlug, memberId), "runtime");
2563
+ const composePath = path.join(runtimeDir, "docker-compose.yml");
2564
+ const composeProjectName = composeProjectNameForMember(teamSlug, memberId);
2565
+ const result = await runHostCommand("sudo", ["-n", "docker", "compose", "-p", composeProjectName, "-f", composePath, "up", "-d", "--pull", "always", "--force-recreate"], runtimeDir);
2566
+ invalidateTeamOverviewCache(teamSlug);
2567
+ if (!result.ok) {
2568
+ throw new HttpError(502, result.stderr || result.error || `upgrade failed for ${memberId}`);
2569
+ }
2570
+ }
2571
+ export async function scheduleMemberRuntimeUpgradeForTeam(teamSlugRaw, memberIdRaw, notificationTarget) {
2572
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2573
+ const memberId = validateMemberId(memberIdRaw);
2574
+ const startedAt = nowIso();
2575
+ const key = `${teamSlug}:${memberId}`;
2576
+ const existing = memberRuntimeUpgradeInflight.get(key);
2577
+ if (existing) {
2578
+ return {
2579
+ memberId,
2580
+ queued: false,
2581
+ status: "already_in_progress",
2582
+ startedAt: existing.startedAt
2583
+ };
2584
+ }
2585
+ const task = (async () => {
2586
+ try {
2587
+ await new Promise((resolve) => setTimeout(resolve, MEMBER_UPGRADE_SCHEDULE_DELAY_MS));
2588
+ await performMemberRuntimeUpgradeForTeam(teamSlug, memberId);
2589
+ await notifyMemberUpgradeResult(teamSlug, memberId, notificationTarget, { ok: true });
2590
+ }
2591
+ catch (error) {
2592
+ await notifyMemberUpgradeResult(teamSlug, memberId, notificationTarget, {
2593
+ ok: false,
2594
+ detail: error instanceof Error ? error.message : String(error)
2595
+ });
2596
+ throw error;
2597
+ }
2598
+ finally {
2599
+ memberRuntimeUpgradeInflight.delete(key);
2600
+ }
2601
+ })().catch((error) => {
2602
+ console.error("[member-upgrade] background upgrade failed:", error instanceof Error ? error.message : String(error));
2603
+ });
2604
+ memberRuntimeUpgradeInflight.set(key, { startedAt, task });
2605
+ return {
2606
+ memberId,
2607
+ queued: true,
2608
+ status: "scheduled",
2609
+ startedAt
2610
+ };
2611
+ }
2612
+ export async function setMemberTelegramBotToken(memberIdRaw, rawToken) {
2613
+ const root = await readTeamsState();
2614
+ return setMemberTelegramBotTokenForTeam(resolveDefaultTeamSlug(root), memberIdRaw, rawToken);
2615
+ }
2616
+ export async function setMemberTelegramBotTokenForTeam(teamSlugRaw, memberIdRaw, rawToken) {
2617
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2618
+ const memberId = validateMemberId(memberIdRaw);
2619
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2620
+ if (!member) {
2621
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2622
+ }
2623
+ const token = validateTelegramBotToken(rawToken);
2624
+ const secretPath = path.join(memberRoot(teamSlug, memberId), "runtime", "secrets", "telegram-bot-token");
2625
+ await writeSecretText(secretPath, `${token}\n`);
2626
+ invalidateTeamOverviewCache(teamSlug);
2627
+ const runtime = await getMemberRuntimeStatus(teamSlug, memberId);
2628
+ return {
2629
+ memberId,
2630
+ secretPath: path.relative(ROOT, secretPath),
2631
+ runtime
2632
+ };
2633
+ }
2634
+ export async function onboardMemberRuntime(memberIdRaw, params) {
2635
+ const root = await readTeamsState();
2636
+ return onboardMemberRuntimeForTeam(resolveDefaultTeamSlug(root), memberIdRaw, params);
2637
+ }
2638
+ export async function onboardMemberRuntimeForTeam(teamSlugRaw, memberIdRaw, params) {
2639
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2640
+ const memberId = validateMemberId(memberIdRaw);
2641
+ let secret;
2642
+ if (params.telegramBotToken?.trim()) {
2643
+ secret = await setMemberTelegramBotTokenForTeam(teamSlug, memberId, params.telegramBotToken);
2644
+ }
2645
+ let action;
2646
+ if (params.start) {
2647
+ action = await runMemberRuntimeActionForTeam(teamSlug, memberId, "start");
2648
+ }
2649
+ const runtime = await getMemberRuntimeStatus(teamSlug, memberId);
2650
+ return {
2651
+ memberId,
2652
+ secret,
2653
+ action,
2654
+ runtime
2655
+ };
2656
+ }
2657
+ export async function updateTeamProfile(input) {
2658
+ const root = await readTeamsState();
2659
+ return updateTeamProfileBySlug(resolveDefaultTeamSlug(root), input);
2660
+ }
2661
+ export async function updateTeamProfileBySlug(teamSlug, input) {
2662
+ teamSlug = slugifyTeamLabel(teamSlug);
2663
+ const name = input.name.trim();
2664
+ if (!name) {
2665
+ throw new HttpError(400, "team name is required");
2666
+ }
2667
+ const slugSource = input.slug?.trim() || name;
2668
+ const slug = slugifyTeamLabel(slugSource);
2669
+ if (!slug) {
2670
+ throw new HttpError(400, "team slug cannot be empty after normalization");
2671
+ }
2672
+ return mutateTeamsState(async (root) => {
2673
+ const state = findTeamBySlugOrThrow(root, teamSlug);
2674
+ const currentSlug = state.profile.slug;
2675
+ if (slug !== currentSlug && root.teams.some((team) => team.profile.slug === slug)) {
2676
+ throw new HttpError(409, `team already exists: ${slug}`);
2677
+ }
2678
+ if (slug !== currentSlug) {
2679
+ const from = teamMembersRoot(currentSlug);
2680
+ const to = teamMembersRoot(slug);
2681
+ if (await safeStat(from)) {
2682
+ if (await safeStat(to)) {
2683
+ throw new HttpError(409, `team member directory already exists: ${slug}`);
2684
+ }
2685
+ await fs.rename(from, to);
2686
+ }
2687
+ }
2688
+ state.profile = {
2689
+ ...state.profile,
2690
+ name,
2691
+ slug,
2692
+ description: input.description?.trim() || state.profile.description,
2693
+ managerLabel: input.managerLabel?.trim() || state.profile.managerLabel,
2694
+ updatedAt: nowIso()
2695
+ };
2696
+ return state.profile;
2697
+ });
2698
+ }
2699
+ export async function getTeamModelGatewayBySlug(teamSlug) {
2700
+ const root = await readTeamsState();
2701
+ return findTeamBySlugOrThrow(root, teamSlug).modelGateway;
2702
+ }
2703
+ export async function updateTeamModelGatewayBySlug(teamSlugRaw, input) {
2704
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2705
+ return mutateNamedTeamState(teamSlug, async (state) => {
2706
+ const nextAllowed = Array.isArray(input.allowedModelIds) && input.allowedModelIds.length > 0
2707
+ ? Array.from(new Set(input.allowedModelIds.map((entry) => entry.trim()).filter(Boolean)))
2708
+ : state.modelGateway.allowedModelIds;
2709
+ const nextDefault = (input.defaultModelId?.trim() || state.modelGateway.defaultModelId);
2710
+ if (!nextAllowed.includes(nextDefault)) {
2711
+ throw new HttpError(400, "defaultModelId must be included in allowedModelIds");
2712
+ }
2713
+ state.modelGateway = {
2714
+ ...state.modelGateway,
2715
+ enabled: input.enabled ?? state.modelGateway.enabled,
2716
+ defaultModelId: nextDefault,
2717
+ allowedModelIds: nextAllowed
2718
+ };
2719
+ return state.modelGateway;
2720
+ });
2721
+ }
2722
+ export async function createInvitation(input) {
2723
+ const root = await readTeamsState();
2724
+ return createInvitationForTeam(resolveDefaultTeamSlug(root), input);
2725
+ }
2726
+ export async function createInvitationForTeam(teamSlug, input) {
2727
+ teamSlug = slugifyTeamLabel(teamSlug);
2728
+ const inviteeLabel = input.inviteeLabel.trim();
2729
+ if (!inviteeLabel) {
2730
+ throw new HttpError(400, "inviteeLabel is required");
2731
+ }
2732
+ const rawMemberId = input.memberId?.trim() || "";
2733
+ const rawMemberEmail = input.memberEmail?.trim() || "";
2734
+ const memberEmail = rawMemberEmail
2735
+ ? validateMemberEmail(rawMemberEmail)
2736
+ : rawMemberId.includes("@")
2737
+ ? validateMemberEmail(rawMemberId)
2738
+ : undefined;
2739
+ const memberId = memberEmail ? deriveRuntimeMemberIdFromEmail(memberEmail) : validateMemberId(rawMemberId);
2740
+ const role = input.role?.trim() || "member";
2741
+ const note = input.note?.trim() || "";
2742
+ const createdBy = input.createdBy?.trim() || "operator";
2743
+ const quota = normalizeQuota(role, input.quota);
2744
+ const existingMember = await getMemberByIdForTeam(teamSlug, memberId);
2745
+ if (existingMember) {
2746
+ throw new HttpError(409, `member already exists: ${memberId}`);
2747
+ }
2748
+ return mutateNamedTeamState(teamSlug, async (state) => {
2749
+ const conflictingInvite = state.invitations.find((invitation) => invitation.memberId === memberId && invitation.status === "pending");
2750
+ if (conflictingInvite) {
2751
+ throw new HttpError(409, `pending invitation already exists for memberId: ${memberId}`);
2752
+ }
2753
+ const invitation = {
2754
+ id: crypto.randomUUID(),
2755
+ code: crypto.randomBytes(8).toString("hex"),
2756
+ teamSlug,
2757
+ status: "pending",
2758
+ inviteeLabel,
2759
+ memberId,
2760
+ memberEmail,
2761
+ role,
2762
+ note,
2763
+ quota,
2764
+ createdAt: nowIso(),
2765
+ createdBy
2766
+ };
2767
+ state.invitations.unshift(invitation);
2768
+ return invitation;
2769
+ });
2770
+ }
2771
+ export async function revokeInvitation(invitationId) {
2772
+ const root = await readTeamsState();
2773
+ return revokeInvitationForTeam(resolveDefaultTeamSlug(root), invitationId);
2774
+ }
2775
+ export async function revokeInvitationForTeam(teamSlug, invitationId) {
2776
+ teamSlug = slugifyTeamLabel(teamSlug);
2777
+ return mutateNamedTeamState(teamSlug, async (state) => {
2778
+ const invitation = findInvitationByIdOrThrow(state, invitationId);
2779
+ if (invitation.status !== "pending") {
2780
+ throw new HttpError(409, `invitation is not pending: ${invitationId}`);
2781
+ }
2782
+ invitation.status = "revoked";
2783
+ invitation.revokedAt = nowIso();
2784
+ return invitation;
2785
+ });
2786
+ }
2787
+ export async function getInvitationByCode(code) {
2788
+ const state = await readTeamsState();
2789
+ for (const team of state.teams) {
2790
+ const invitation = team.invitations.find((entry) => entry.code === code);
2791
+ if (invitation)
2792
+ return invitation;
2793
+ }
2794
+ return null;
2795
+ }
2796
+ export async function acceptInvitationByCode(code, input) {
2797
+ const resolvedChannel = resolveAcceptedInviteChannel(input);
2798
+ return mutateTeamsState(async (root) => {
2799
+ const state = root.teams.find((team) => team.invitations.some((entry) => entry.code === code));
2800
+ if (!state) {
2801
+ throw new HttpError(404, `invitation not found for code: ${code}`);
2802
+ }
2803
+ const invitation = findInvitationByCodeOrThrow(state, code);
2804
+ if (invitation.status !== "pending") {
2805
+ throw new HttpError(409, `invitation is not pending: ${code}`);
2806
+ }
2807
+ const provision = await provisionMemberForTeam(state.profile.slug, {
2808
+ memberId: invitation.memberId,
2809
+ telegramUserId: resolvedChannel.legacyTelegramUserId,
2810
+ identityName: input.identityName?.trim() || `小虾-${invitation.memberId}`
2811
+ });
2812
+ const timestamp = nowIso();
2813
+ const policy = {
2814
+ memberId: invitation.memberId,
2815
+ memberEmail: invitation.memberEmail,
2816
+ role: invitation.role,
2817
+ quota: invitation.quota,
2818
+ assetPermissions: defaultAssetPermissionsForRole(invitation.role, state.assetRolePolicies),
2819
+ createdAt: timestamp,
2820
+ updatedAt: timestamp,
2821
+ invitationId: invitation.id
2822
+ };
2823
+ state.memberPolicies = state.memberPolicies.filter((entry) => entry.memberId !== invitation.memberId);
2824
+ state.memberPolicies.unshift(policy);
2825
+ invitation.status = "accepted";
2826
+ invitation.acceptedAt = timestamp;
2827
+ invitation.acceptedMemberId = provision.member.id;
2828
+ invitation.acceptedTelegramUserId = resolvedChannel.setup?.kind === "telegram" ? resolvedChannel.setup.handle : undefined;
2829
+ await writeMemberPolicyFile(state.profile, state.profile.slug, invitation.memberId, policy, invitation.code);
2830
+ await syncMemberRuntimeConfigFromPolicy(state, invitation.memberId, policy);
2831
+ if (resolvedChannel.setup) {
2832
+ await applyAcceptedInviteChannelConfig(state.profile.slug, invitation.memberId, resolvedChannel.setup, resolvedChannel.botToken);
2833
+ }
2834
+ return { invitation, provision, policy, channel: resolvedChannel.setup };
2835
+ });
2836
+ }
2837
+ export async function updateMemberQuota(memberIdRaw, input) {
2838
+ const root = await readTeamsState();
2839
+ return updateMemberQuotaForTeam(resolveDefaultTeamSlug(root), memberIdRaw, input);
2840
+ }
2841
+ export async function updateMemberQuotaForTeam(teamSlugRaw, memberIdRaw, input) {
2842
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2843
+ const memberId = validateMemberId(memberIdRaw);
2844
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2845
+ if (!member) {
2846
+ throw new HttpError(404, `member not found in team ${teamSlug}: ${memberId}`);
2847
+ }
2848
+ return mutateNamedTeamState(teamSlug, async (state) => {
2849
+ const existing = state.memberPolicies.find((policy) => policy.memberId === memberId) ??
2850
+ {
2851
+ memberId,
2852
+ role: "member",
2853
+ quota: defaultQuotaForRole("member"),
2854
+ assetPermissions: defaultAssetPermissionsForRole("member", state.assetRolePolicies),
2855
+ createdAt: nowIso(),
2856
+ updatedAt: nowIso()
2857
+ };
2858
+ const nextRole = input.role?.trim() || existing.role;
2859
+ const updatedPolicy = {
2860
+ ...existing,
2861
+ role: nextRole,
2862
+ quota: normalizeQuota(nextRole, input, existing.quota),
2863
+ assetPermissions: normalizeAssetPermissions(nextRole, void 0, state.assetRolePolicies),
2864
+ updatedAt: nowIso()
2865
+ };
2866
+ state.memberPolicies = state.memberPolicies.filter((policy) => policy.memberId !== memberId);
2867
+ state.memberPolicies.unshift(updatedPolicy);
2868
+ await writeMemberPolicyFile(state.profile, teamSlug, memberId, updatedPolicy);
2869
+ await syncMemberRuntimeConfigFromPolicy(state, memberId, updatedPolicy);
2870
+ return updatedPolicy;
2871
+ });
2872
+ }
2873
+ export async function getMembers() {
2874
+ const root = await readTeamsState();
2875
+ return getMembersForTeam(resolveDefaultTeamSlug(root));
2876
+ }
2877
+ export async function getMembersForTeam(teamSlugRaw) {
2878
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2879
+ const membersDir = teamMembersRoot(teamSlug);
2880
+ const rootStat = await safeStat(membersDir);
2881
+ if (!rootStat) {
2882
+ return [];
2883
+ }
2884
+ const root = await readTeamsState();
2885
+ const teamState = root.teams.find((team) => team.profile.slug === teamSlug) ?? null;
2886
+ const emailByMemberId = new Map();
2887
+ for (const policy of teamState?.memberPolicies ?? []) {
2888
+ if (policy.memberEmail) {
2889
+ emailByMemberId.set(policy.memberId, policy.memberEmail);
2890
+ }
2891
+ }
2892
+ for (const invitation of teamState?.invitations ?? []) {
2893
+ if (invitation.memberEmail && !emailByMemberId.has(invitation.memberId)) {
2894
+ emailByMemberId.set(invitation.memberId, invitation.memberEmail);
2895
+ }
2896
+ }
2897
+ const entries = await fs.readdir(membersDir, { withFileTypes: true });
2898
+ const memberDirs = entries
2899
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !RESERVED_MEMBER_DIR_NAMES.has(entry.name))
2900
+ .sort((a, b) => a.name.localeCompare(b.name));
2901
+ return Promise.all(memberDirs.map(async (entry) => {
2902
+ const memberPath = path.join(membersDir, entry.name);
2903
+ const runtimePath = path.join(memberPath, "runtime");
2904
+ const composePath = path.join(runtimePath, "docker-compose.yml");
2905
+ const configPath = path.join(runtimePath, "config", "openclaw.json");
2906
+ return {
2907
+ id: entry.name,
2908
+ memberEmail: emailByMemberId.get(entry.name),
2909
+ path: path.relative(ROOT, memberPath),
2910
+ hasRuntime: Boolean(await safeStat(runtimePath)),
2911
+ hasComposeFile: Boolean(await safeStat(composePath)),
2912
+ hasConfigFile: Boolean(await safeStat(configPath)),
2913
+ buckets: await Promise.all(MEMBER_BUCKETS.map(([key, label]) => buildBucket(memberPath, key, label)))
2914
+ };
2915
+ }));
2916
+ }
2917
+ export async function getMemberById(memberId) {
2918
+ const root = await readTeamsState();
2919
+ return getMemberByIdForTeam(resolveDefaultTeamSlug(root), memberId);
2920
+ }
2921
+ export async function getMemberByIdForTeam(teamSlugRaw, memberId) {
2922
+ const members = await getMembersForTeam(teamSlugRaw);
2923
+ return members.find((member) => member.id === memberId) ?? null;
2924
+ }
2925
+ export async function provisionMember(input) {
2926
+ const root = await readTeamsState();
2927
+ return provisionMemberForTeam(resolveDefaultTeamSlug(root), input);
2928
+ }
2929
+ export async function provisionMemberForTeam(teamSlugRaw, input) {
2930
+ const teamSlug = slugifyTeamLabel(teamSlugRaw);
2931
+ const memberId = validateMemberId(input.memberId);
2932
+ const templatePath = path.join(MEMBERS_ROOT, MEMBER_TEMPLATE_ID);
2933
+ const memberPath = memberRoot(teamSlug, memberId);
2934
+ const existingMember = await safeStat(memberPath);
2935
+ if (existingMember) {
2936
+ throw new HttpError(409, `member already exists: ${memberId}`);
2937
+ }
2938
+ if (!(await safeStat(templatePath))) {
2939
+ throw new HttpError(500, `member template missing: ${path.relative(ROOT, templatePath)}`);
2940
+ }
2941
+ const port = await resolveMemberPort(input.port);
2942
+ const gatewayToken = input.gatewayToken?.trim() || crypto.randomBytes(24).toString("hex");
2943
+ const identityName = input.identityName?.trim() || `小虾-${memberId}`;
2944
+ const telegramUserId = input.telegramUserId?.trim() || "REPLACE_WITH_USER_ID";
2945
+ const pending = [];
2946
+ await fs.mkdir(teamMembersRoot(teamSlug), { recursive: true });
2947
+ await fs.cp(templatePath, memberPath, { recursive: true, errorOnExist: true });
2948
+ const composePath = path.join(memberPath, "runtime", "docker-compose.yml");
2949
+ const configPath = path.join(memberPath, "runtime", "config", "openclaw.json");
2950
+ const secretsPath = path.join(memberPath, "runtime", "secrets");
2951
+ const workspacePath = memberRuntimeWorkspaceRoot(teamSlug, memberId);
2952
+ await writeGitkeep(secretsPath);
2953
+ await writeGitkeep(workspacePath);
2954
+ const compose = await readText(composePath);
2955
+ const updatedCompose = compose
2956
+ .replaceAll("REPLACE_MEMBER_ID", memberId)
2957
+ .replaceAll("REPLACE_CONTAINER_ID", containerNameForMember(teamSlug, memberId))
2958
+ .replaceAll("REPLACE_COMPOSE_PROJECT", composeProjectNameForMember(teamSlug, memberId))
2959
+ .replaceAll("REPLACE_TEAM_SLUG", teamSlug)
2960
+ .replace("127.0.0.1:18800:18789", `127.0.0.1:${port}:18789`);
2961
+ await writeText(composePath, ensureMemberProxyComposeDefaults(updatedCompose, composeProjectNameForMember(teamSlug, memberId), containerNameForMember(teamSlug, memberId)));
2962
+ const config = await readText(configPath);
2963
+ const updatedConfig = config
2964
+ .replace("REPLACE_WITH_RANDOM_GATEWAY_TOKEN", gatewayToken)
2965
+ .replace("REPLACE_WITH_USER_ID", telegramUserId)
2966
+ .replace('"name": "小虾-member"', `"name": ${JSON.stringify(identityName)}`);
2967
+ await writeText(configPath, updatedConfig);
2968
+ await syncMemberGeneratedWorkspaceViews(teamSlug, memberId);
2969
+ invalidateTeamOverviewCache(teamSlug);
2970
+ const member = await getMemberByIdForTeam(teamSlug, memberId);
2971
+ if (!member) {
2972
+ throw new HttpError(500, `member provisioning did not produce a readable member: ${memberId}`);
2973
+ }
2974
+ return {
2975
+ created: true,
2976
+ member,
2977
+ port,
2978
+ gatewayToken,
2979
+ pending,
2980
+ paths: {
2981
+ memberPath: path.relative(ROOT, memberPath),
2982
+ composePath: path.relative(ROOT, composePath),
2983
+ configPath: path.relative(ROOT, configPath),
2984
+ secretsPath: path.relative(ROOT, secretsPath),
2985
+ workspacePath: path.relative(ROOT, workspacePath)
2986
+ }
2987
+ };
2988
+ }