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/ui.js ADDED
@@ -0,0 +1,1486 @@
1
+ function escapeHtml(value) {
2
+ return value
3
+ .replaceAll("&", "&")
4
+ .replaceAll("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("'", "&#39;");
8
+ }
9
+ function formatDate(value) {
10
+ if (!value) {
11
+ return "n/a";
12
+ }
13
+ return new Date(value).toLocaleString("en-US", {
14
+ year: "numeric",
15
+ month: "short",
16
+ day: "2-digit",
17
+ hour: "2-digit",
18
+ minute: "2-digit"
19
+ });
20
+ }
21
+ function pageShell(title, body) {
22
+ return `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="UTF-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
27
+ <title>${escapeHtml(title)}</title>
28
+ <style>
29
+ :root {
30
+ color-scheme: dark;
31
+ --bg: #07131f;
32
+ --panel: rgba(9, 20, 36, 0.78);
33
+ --panel-2: rgba(18, 33, 56, 0.92);
34
+ --text: #f4f7fb;
35
+ --muted: #9eb0c4;
36
+ --accent: #65d3c9;
37
+ --accent-2: #7ba7ff;
38
+ --danger: #ff7b8b;
39
+ --ok: #5ddd98;
40
+ --border: rgba(255,255,255,0.08);
41
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
42
+ }
43
+ * { box-sizing: border-box; }
44
+ body {
45
+ margin: 0;
46
+ font-family: "IBM Plex Sans", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
47
+ background:
48
+ radial-gradient(circle at 20% 0%, rgba(101, 211, 201, 0.18), transparent 28%),
49
+ radial-gradient(circle at 80% 10%, rgba(123, 167, 255, 0.18), transparent 28%),
50
+ linear-gradient(180deg, #0a1728 0%, var(--bg) 60%);
51
+ color: var(--text);
52
+ }
53
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 32px 20px 88px; }
54
+ .nav {
55
+ display: flex;
56
+ justify-content: space-between;
57
+ align-items: center;
58
+ margin-bottom: 18px;
59
+ gap: 16px;
60
+ flex-wrap: wrap;
61
+ }
62
+ .nav a {
63
+ color: var(--text);
64
+ text-decoration: none;
65
+ opacity: 0.9;
66
+ }
67
+ .nav .links {
68
+ display: flex;
69
+ gap: 14px;
70
+ flex-wrap: wrap;
71
+ }
72
+ .hero {
73
+ background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.015));
74
+ border: 1px solid var(--border);
75
+ border-radius: 26px;
76
+ padding: 28px;
77
+ box-shadow: var(--shadow);
78
+ }
79
+ .eyebrow { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; font-size: 12px; }
80
+ h1 { margin: 10px 0 12px; font-size: clamp(32px, 6vw, 56px); line-height: 1.02; }
81
+ p { color: var(--muted); font-size: 16px; line-height: 1.7; }
82
+ .grid { display: grid; gap: 16px; margin-top: 22px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
83
+ .stack { display: grid; gap: 16px; margin-top: 18px; }
84
+ .split { display: grid; gap: 18px; margin-top: 20px; grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); align-items: start; }
85
+ .card, .panel {
86
+ background: var(--panel);
87
+ border: 1px solid var(--border);
88
+ border-radius: 18px;
89
+ padding: 18px;
90
+ }
91
+ .card h3 { margin: 0 0 8px; font-size: 16px; }
92
+ .card p { margin: 0; font-size: 14px; }
93
+ .metric {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 6px;
97
+ }
98
+ .metric strong {
99
+ font-size: 30px;
100
+ line-height: 1;
101
+ }
102
+ .actions { margin-top: 24px; display: flex; gap: 12px; flex-wrap: wrap; }
103
+ a.button, button.button {
104
+ text-decoration: none;
105
+ color: var(--text);
106
+ background: var(--panel-2);
107
+ border: 1px solid var(--border);
108
+ padding: 12px 16px;
109
+ border-radius: 999px;
110
+ font-weight: 600;
111
+ cursor: pointer;
112
+ }
113
+ button.button.danger { border-color: rgba(255,123,139,0.3); color: #ffd3d9; }
114
+ code {
115
+ background: rgba(255,255,255,0.06);
116
+ padding: 2px 6px;
117
+ border-radius: 8px;
118
+ }
119
+ .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; }
120
+ .flash {
121
+ margin-top: 16px;
122
+ padding: 12px 14px;
123
+ border-radius: 14px;
124
+ border: 1px solid rgba(101,211,201,0.35);
125
+ background: rgba(101,211,201,0.08);
126
+ color: #d6fff7;
127
+ }
128
+ .danger {
129
+ border-color: rgba(255,123,139,0.28);
130
+ background: rgba(255,123,139,0.08);
131
+ color: #ffd3d9;
132
+ }
133
+ .list {
134
+ display: grid;
135
+ gap: 12px;
136
+ margin-top: 14px;
137
+ }
138
+ .list-item {
139
+ border: 1px solid var(--border);
140
+ border-radius: 16px;
141
+ padding: 14px 16px;
142
+ background: rgba(255,255,255,0.02);
143
+ }
144
+ .list-item-header {
145
+ display: flex;
146
+ justify-content: space-between;
147
+ gap: 12px;
148
+ align-items: center;
149
+ flex-wrap: wrap;
150
+ }
151
+ .chips {
152
+ display: flex;
153
+ gap: 8px;
154
+ flex-wrap: wrap;
155
+ margin-top: 10px;
156
+ }
157
+ .chip {
158
+ font-size: 12px;
159
+ border-radius: 999px;
160
+ padding: 6px 10px;
161
+ background: rgba(255,255,255,0.06);
162
+ border: 1px solid var(--border);
163
+ }
164
+ form.inline { display: inline; }
165
+ .fields {
166
+ display: grid;
167
+ gap: 12px;
168
+ margin-top: 14px;
169
+ }
170
+ .field {
171
+ display: grid;
172
+ gap: 6px;
173
+ }
174
+ input, textarea, select {
175
+ width: 100%;
176
+ color: var(--text);
177
+ background: rgba(255,255,255,0.04);
178
+ border: 1px solid var(--border);
179
+ border-radius: 12px;
180
+ padding: 12px 13px;
181
+ font: inherit;
182
+ }
183
+ textarea { min-height: 96px; resize: vertical; }
184
+ .footer { margin-top: 18px; font-size: 13px; color: var(--muted); }
185
+ .mono { font-family: "IBM Plex Mono", ui-monospace, monospace; }
186
+ @media (max-width: 900px) {
187
+ .split { grid-template-columns: 1fr; }
188
+ }
189
+ </style>
190
+ </head>
191
+ <body>
192
+ <main class="wrap">${body}</main>
193
+ </body>
194
+ </html>`;
195
+ }
196
+ function renderFlashMessage(query) {
197
+ if (!query || typeof query !== "object") {
198
+ return "";
199
+ }
200
+ if ("profile" in query && query.profile === "updated") {
201
+ return `<div class="flash">Team profile updated.</div>`;
202
+ }
203
+ if ("invite" in query && typeof query.invite === "string" && query.invite && query.invite !== "revoked") {
204
+ return `<div class="flash">Invite created. Share this path: <code>/invite/${escapeHtml(query.invite)}</code></div>`;
205
+ }
206
+ if ("invite" in query && query.invite === "revoked") {
207
+ return `<div class="flash danger">Invitation revoked.</div>`;
208
+ }
209
+ if ("quota" in query && query.quota === "updated") {
210
+ return `<div class="flash">Member quota updated.</div>`;
211
+ }
212
+ if ("runtime" in query && typeof query.runtime === "string") {
213
+ return `<div class="flash">Runtime action queued: ${escapeHtml(String(query.runtime))}.</div>`;
214
+ }
215
+ if ("secret" in query && query.secret === "updated") {
216
+ return `<div class="flash">Member secret updated.</div>`;
217
+ }
218
+ if ("channel" in query && query.channel === "updated") {
219
+ return `<div class="flash">Member channel updated.</div>`;
220
+ }
221
+ if ("created" in query && query.created === "team") {
222
+ return `<div class="flash">Team created.</div>`;
223
+ }
224
+ if ("asset" in query && typeof query.asset === "string") {
225
+ return `<div class="flash">Asset workflow updated: ${escapeHtml(String(query.asset))}</div>`;
226
+ }
227
+ return "";
228
+ }
229
+ function renderQuotaChips(policy) {
230
+ if (!policy) {
231
+ return `<span class="chip">quota: unmanaged</span>`;
232
+ }
233
+ return `
234
+ <span class="chip">daily: ${policy.quota.dailyMessages}</span>
235
+ <span class="chip">monthly: ${policy.quota.monthlyMessages}</span>
236
+ <span class="chip">subagents: ${policy.quota.maxSubagents}</span>
237
+ <span class="chip">thinking: ${escapeHtml(policy.quota.maxThinking)}</span>
238
+ <span class="chip">status: ${escapeHtml(policy.quota.status)}</span>
239
+ `;
240
+ }
241
+ function renderRuntimeChips(member) {
242
+ const runtime = member.runtime;
243
+ return `
244
+ <span class="chip">channel: ${escapeHtml(runtime.channelKind ?? "n/a")}</span>
245
+ <span class="chip">status: ${escapeHtml(runtime.label)}</span>
246
+ <span class="chip">access: ${runtime.channelReady ? "ready" : "needs setup"}</span>
247
+ `;
248
+ }
249
+ function assetCategoryLabel(category) {
250
+ switch (category) {
251
+ case "shared-skills":
252
+ return "Skill";
253
+ case "shared-memory":
254
+ return "Memory";
255
+ case "shared-tools":
256
+ return "Legacy";
257
+ case "shared-workflows":
258
+ return "Workflow";
259
+ case "shared-docs":
260
+ default:
261
+ return "Doc";
262
+ }
263
+ }
264
+ function assetCategoryBlurb(category) {
265
+ switch (category) {
266
+ case "shared-skills":
267
+ return "Reusable instructions the whole team can lean on.";
268
+ case "shared-memory":
269
+ return "Team knowledge that should stay available across members.";
270
+ case "shared-tools":
271
+ return "Legacy tool binding records kept for reference only.";
272
+ case "shared-workflows":
273
+ return "Repeatable ways of working that members can follow.";
274
+ case "shared-docs":
275
+ default:
276
+ return "Reference material, notes, and guidance for the workspace.";
277
+ }
278
+ }
279
+ function assetStageLabel(status) {
280
+ switch (status) {
281
+ case "published":
282
+ return "Live";
283
+ case "pending_approval":
284
+ return "In review";
285
+ case "approved":
286
+ return "Approved";
287
+ case "rejected":
288
+ return "Needs revision";
289
+ case "draft":
290
+ default:
291
+ return "Draft";
292
+ }
293
+ }
294
+ function renderAssetStageChip(asset) {
295
+ return `<span class="chip">stage: ${escapeHtml(assetStageLabel(asset.status))}</span>`;
296
+ }
297
+ function renderMemberWorkspaceBlurb(member) {
298
+ const runtime = member.runtime;
299
+ if (runtime.state === "running") {
300
+ return "This member space is live and ready to chat.";
301
+ }
302
+ if (runtime.channelKind === "telegram" && !runtime.channelReady) {
303
+ return "Connect the Telegram channel to let this member start working.";
304
+ }
305
+ if (runtime.channelKind === "whatsapp" && !runtime.channelReady) {
306
+ return "WhatsApp policy is written, but QR login still needs to happen.";
307
+ }
308
+ if (runtime.state === "ready") {
309
+ return "Everything is configured. Start the runtime when this member is ready to use it.";
310
+ }
311
+ return "This member space exists, but still needs finishing touches before it feels seamless.";
312
+ }
313
+ function renderLibraryKindChips(records) {
314
+ const counts = new Map();
315
+ for (const asset of records) {
316
+ const label = assetCategoryLabel(asset.category);
317
+ counts.set(label, (counts.get(label) ?? 0) + 1);
318
+ }
319
+ return [...counts.entries()]
320
+ .sort((left, right) => left[0].localeCompare(right[0]))
321
+ .map(([label, count]) => `<span class="chip">${escapeHtml(label)}: ${count}</span>`)
322
+ .join("");
323
+ }
324
+ function renderAssetSummary(overview) {
325
+ return `
326
+ <div class="grid">
327
+ <div class="card metric">
328
+ <span class="label">Shared library</span>
329
+ <strong>${overview.summary.assetPublishedCount}</strong>
330
+ <span class="muted">Entries people can use right now</span>
331
+ </div>
332
+ <div class="card metric">
333
+ <span class="label">In review</span>
334
+ <strong>${overview.summary.assetPendingApprovalCount}</strong>
335
+ <span class="muted">Waiting to join the shared library</span>
336
+ </div>
337
+ <div class="card metric">
338
+ <span class="label">Drafts</span>
339
+ <strong>${overview.summary.assetDraftCount}</strong>
340
+ <span class="muted">Work that is still being shaped</span>
341
+ </div>
342
+ </div>
343
+ `;
344
+ }
345
+ function buildTeamPageHref(slug, params) {
346
+ const search = new URLSearchParams();
347
+ if (params.invitePage && params.invitePage > 1) {
348
+ search.set("invitePage", String(params.invitePage));
349
+ }
350
+ if (params.memberPage && params.memberPage > 1) {
351
+ search.set("memberPage", String(params.memberPage));
352
+ }
353
+ if (params.memberQuery) {
354
+ search.set("memberQuery", params.memberQuery);
355
+ }
356
+ if (params.assetPage && params.assetPage > 1) {
357
+ search.set("assetPage", String(params.assetPage));
358
+ }
359
+ if (params.assetQuery) {
360
+ search.set("assetQuery", params.assetQuery);
361
+ }
362
+ const query = search.toString();
363
+ return `/team/${encodeURIComponent(slug)}${query ? `?${query}` : ""}`;
364
+ }
365
+ function renderPager(slug, kind, page, state) {
366
+ if (page.totalPages <= 1) {
367
+ return "";
368
+ }
369
+ const isInvite = kind === "invite";
370
+ const isMember = kind === "member";
371
+ const previousHref = buildTeamPageHref(slug, {
372
+ invitePage: isInvite ? page.page - 1 : state.invitePage,
373
+ memberPage: isMember ? page.page - 1 : state.memberPage,
374
+ memberQuery: state.memberQuery,
375
+ assetPage: isMember ? state.assetPage : page.page - 1,
376
+ assetQuery: state.assetQuery
377
+ });
378
+ const nextHref = buildTeamPageHref(slug, {
379
+ invitePage: isInvite ? page.page + 1 : state.invitePage,
380
+ memberPage: isMember ? page.page + 1 : state.memberPage,
381
+ memberQuery: state.memberQuery,
382
+ assetPage: isMember ? state.assetPage : page.page + 1,
383
+ assetQuery: state.assetQuery
384
+ });
385
+ return `
386
+ <div class="actions">
387
+ ${page.page > 1 ? `<a class="button" href="${previousHref}">Previous</a>` : ""}
388
+ <span class="chip">Page ${page.page} / ${page.totalPages}</span>
389
+ ${page.page < page.totalPages ? `<a class="button" href="${nextHref}">Next</a>` : ""}
390
+ </div>
391
+ `;
392
+ }
393
+ function renderMemberBuckets(workspace) {
394
+ return workspace.member.buckets
395
+ .map((bucket) => `
396
+ <div class="list-item">
397
+ <div class="list-item-header">
398
+ <div>
399
+ <strong>${escapeHtml(bucket.label)}</strong>
400
+ <div class="footer">${bucket.files.length ? `${bucket.files.length} item${bucket.files.length === 1 ? "" : "s"}` : "Empty"}</div>
401
+ </div>
402
+ <span class="chip">${bucket.exists ? "present" : "missing"}</span>
403
+ </div>
404
+ ${bucket.files.length
405
+ ? `<div class="chips">${bucket.files.map((file) => `<span class="chip">${escapeHtml(file.name)}</span>`).join("")}</div>`
406
+ : `<div class="footer">No files</div>`}
407
+ </div>
408
+ `)
409
+ .join("");
410
+ }
411
+ export function renderHomePage() {
412
+ return pageShell("Velaclaw Control Plane", `
413
+ <div class="nav">
414
+ <div class="eyebrow">Velaclaw MVP</div>
415
+ <div class="links">
416
+ <a href="/">Home</a>
417
+ <a href="/team">Team</a>
418
+ <a href="/api/team">API</a>
419
+ </div>
420
+ </div>
421
+ <section class="hero">
422
+ <div class="eyebrow">Velaclaw MVP</div>
423
+ <h1>Control plane for team AI</h1>
424
+ <p>
425
+ Velaclaw is no longer just a folder layout. The local control plane now exposes team state,
426
+ member provisioning, and invitation flows through both UI and API routes.
427
+ </p>
428
+ <div class="grid">
429
+ <div class="card">
430
+ <h3>🧠 Team assets</h3>
431
+ <p>Inspect shared memory, skills, tools, workflows, docs, and governance policies.</p>
432
+ </div>
433
+ <div class="card">
434
+ <h3>🐳 Docker isolation</h3>
435
+ <p>Each team member keeps an isolated runtime and their own 小虾 inside a dedicated Docker boundary.</p>
436
+ </div>
437
+ <div class="card">
438
+ <h3>👥 Team page</h3>
439
+ <p>Open <code>/team</code> to see your team, active members, and invitation queue.</p>
440
+ </div>
441
+ <div class="card">
442
+ <h3>📦 Member runtime</h3>
443
+ <p>Create isolated member runtimes from a controlled template and track their readiness.</p>
444
+ </div>
445
+ <div class="card">
446
+ <h3>✉️ Invitations</h3>
447
+ <p>Mint invite links, let invitees self-accept, and turn an invite into a member skeleton.</p>
448
+ </div>
449
+ </div>
450
+ <div class="actions">
451
+ <a class="button" href="/team">Open /team</a>
452
+ <a class="button" href="/api/team">/api/team</a>
453
+ <a class="button" href="/api/team/assets">/api/team/assets</a>
454
+ <a class="button" href="/api/members">/api/members</a>
455
+ </div>
456
+ <div class="footer">
457
+ Tip: run <code>npm install</code> then <code>npm run dev</code> in the repository root.
458
+ </div>
459
+ </section>
460
+ `);
461
+ }
462
+ export function renderTeamsIndexPage(teams, query) {
463
+ return pageShell("Teams · Velaclaw", `
464
+ <div class="nav">
465
+ <div class="eyebrow">/team</div>
466
+ <div class="links">
467
+ <a href="/">Home</a>
468
+ <a href="/api/teams">Teams API</a>
469
+ </div>
470
+ </div>
471
+ <section class="hero">
472
+ <div class="eyebrow">Teams</div>
473
+ <h1>Your teams</h1>
474
+ <p>Choose a team workspace or create a new one.</p>
475
+ ${renderFlashMessage(query)}
476
+ <div class="list">
477
+ ${teams.length
478
+ ? teams
479
+ .map((team) => `
480
+ <div class="list-item">
481
+ <div class="list-item-header">
482
+ <div>
483
+ <strong>${escapeHtml(team.profile.name)}</strong>
484
+ <div class="footer mono">${escapeHtml(team.profile.slug)}</div>
485
+ </div>
486
+ <a class="button" href="/team/${encodeURIComponent(team.profile.slug)}">Open team</a>
487
+ </div>
488
+ <div class="chips">
489
+ <span class="chip">members: ${team.summary.activeMemberCount}</span>
490
+ <span class="chip">pending invites: ${team.summary.pendingInvitationCount}</span>
491
+ </div>
492
+ </div>
493
+ `)
494
+ .join("")
495
+ : `<div class="list-item"><strong>No teams yet.</strong><div class="footer">Create the first team below.</div></div>`}
496
+ </div>
497
+ </section>
498
+ <section class="panel" style="margin-top:20px">
499
+ <div class="eyebrow">Create</div>
500
+ <h2>Create a new team</h2>
501
+ <form method="post" action="/team">
502
+ <div class="fields">
503
+ <label class="field">
504
+ <span class="label">Team name</span>
505
+ <input name="name" placeholder="AStar Research" required />
506
+ </label>
507
+ <label class="field">
508
+ <span class="label">Team slug</span>
509
+ <input name="slug" placeholder="astar-research" />
510
+ </label>
511
+ <label class="field">
512
+ <span class="label">Description</span>
513
+ <textarea name="description" placeholder="What this team is for"></textarea>
514
+ </label>
515
+ <label class="field">
516
+ <span class="label">Manager label</span>
517
+ <input name="managerLabel" value="Team Manager" />
518
+ </label>
519
+ </div>
520
+ <div class="actions">
521
+ <button class="button" type="submit">Create team</button>
522
+ </div>
523
+ </form>
524
+ </section>
525
+ `);
526
+ }
527
+ export function renderTeamPage(overview, query) {
528
+ const slug = overview.profile.slug;
529
+ const members = overview.members.items.filter((member) => member.id !== "member-template");
530
+ const invitations = overview.invitations.items;
531
+ const pageState = {
532
+ invitePage: overview.invitations.page,
533
+ memberPage: overview.members.page,
534
+ memberQuery: overview.members.query,
535
+ assetPage: overview.assets.records.page,
536
+ assetQuery: overview.assets.records.query
537
+ };
538
+ return pageShell(`${overview.profile.name} · Velaclaw`, `
539
+ <div class="nav">
540
+ <div class="eyebrow">Workspace</div>
541
+ <div class="links">
542
+ <a href="/">Home</a>
543
+ <a href="/team">All workspaces</a>
544
+ </div>
545
+ </div>
546
+ <section class="hero">
547
+ <div class="eyebrow">Shared workspace</div>
548
+ <h1>${escapeHtml(overview.profile.name)}</h1>
549
+ <p>${escapeHtml(overview.profile.description)}</p>
550
+ <div class="footer">
551
+ Workspace id: <strong>${escapeHtml(overview.profile.slug)}</strong> ·
552
+ Managed by <strong>${escapeHtml(overview.profile.managerLabel)}</strong>
553
+ </div>
554
+ ${renderFlashMessage(query)}
555
+ <div class="grid">
556
+ <div class="card metric">
557
+ <span class="label">People</span>
558
+ <strong>${overview.summary.activeMemberCount}</strong>
559
+ <span class="muted">Member spaces currently set up</span>
560
+ </div>
561
+ <div class="card metric">
562
+ <span class="label">Invites</span>
563
+ <strong>${overview.summary.pendingInvitationCount}</strong>
564
+ <span class="muted">People who can still join this workspace</span>
565
+ </div>
566
+ <div class="card metric">
567
+ <span class="label">Shared spaces</span>
568
+ <strong>${overview.summary.assetPublishedCount}</strong>
569
+ <span class="muted">Knowledge and capabilities the team can reuse</span>
570
+ </div>
571
+ </div>
572
+ ${renderAssetSummary(overview)}
573
+ <div class="footer">
574
+ Admin data:
575
+ <a href="/api/teams/${encodeURIComponent(slug)}/summary">Summary API</a> ·
576
+ <a href="/api/teams/${encodeURIComponent(slug)}/runtime">Runtime API</a>
577
+ </div>
578
+ </section>
579
+ <section class="split">
580
+ <div class="stack">
581
+ <section class="panel">
582
+ <div class="eyebrow">Members</div>
583
+ <h2>People in this workspace</h2>
584
+ <p>Every person gets a private member space, while still benefiting from the shared library above.</p>
585
+ <form method="get" action="/team/${encodeURIComponent(slug)}">
586
+ <div class="fields">
587
+ <label class="field">
588
+ <span class="label">Search members</span>
589
+ <input name="memberQuery" value="${escapeHtml(overview.members.query)}" placeholder="alice / ops / email" />
590
+ </label>
591
+ </div>
592
+ <input type="hidden" name="assetPage" value="${overview.assets.records.page}" />
593
+ <input type="hidden" name="assetQuery" value="${escapeHtml(overview.assets.records.query)}" />
594
+ <div class="actions">
595
+ <button class="button" type="submit">Filter members</button>
596
+ ${overview.members.query
597
+ ? `<a class="button" href="${buildTeamPageHref(slug, { assetPage: overview.assets.records.page, assetQuery: overview.assets.records.query })}">Clear</a>`
598
+ : ""}
599
+ <span class="chip">Showing ${members.length} of ${overview.members.total}</span>
600
+ </div>
601
+ </form>
602
+ <div class="list">
603
+ ${members.length
604
+ ? members
605
+ .map((member) => `
606
+ <div class="list-item">
607
+ <div class="list-item-header">
608
+ <div>
609
+ <strong>${escapeHtml(member.memberEmail ?? member.policy?.memberEmail ?? member.id)}</strong>
610
+ <div class="footer">${escapeHtml(member.policy?.role ?? "member")} · ${escapeHtml(member.runtime.channelKind ?? "channel not set")}</div>
611
+ </div>
612
+ <div class="actions" style="margin-top:0">
613
+ <a class="button" href="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}">Open member space</a>
614
+ </div>
615
+ </div>
616
+ <div class="chips">
617
+ ${renderQuotaChips(member.policy)}
618
+ ${renderRuntimeChips(member)}
619
+ </div>
620
+ <div class="footer" style="margin-top:10px">
621
+ ${escapeHtml(renderMemberWorkspaceBlurb(member))}
622
+ ${member.runtime.dockerError
623
+ ? `<br />Docker note: ${escapeHtml(member.runtime.dockerError)}`
624
+ : ""}
625
+ </div>
626
+ </div>
627
+ `)
628
+ .join("")
629
+ : `<div class="list-item"><strong>No members yet.</strong><div class="footer">Invite the first person on the right and their private member space will appear here.</div></div>`}
630
+ </div>
631
+ ${renderPager(slug, "member", overview.members, pageState)}
632
+ </section>
633
+ <section class="panel">
634
+ <div class="eyebrow">Invitations</div>
635
+ <h2>People joining soon</h2>
636
+ <p>Invite links let someone join the workspace first. Channel setup happens after they arrive.</p>
637
+ <div class="actions">
638
+ <span class="chip">Showing ${invitations.length} of ${overview.invitations.total}</span>
639
+ </div>
640
+ <div class="list">
641
+ ${invitations.length
642
+ ? invitations
643
+ .map((invitation) => `
644
+ <div class="list-item">
645
+ <div class="list-item-header">
646
+ <div>
647
+ <strong>${escapeHtml(invitation.memberEmail ?? invitation.inviteeLabel)}</strong>
648
+ <div class="footer mono">/invite/${escapeHtml(invitation.code)}</div>
649
+ </div>
650
+ ${invitation.status === "pending"
651
+ ? `
652
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/invitations/${encodeURIComponent(invitation.id)}/revoke">
653
+ <button class="button danger" type="submit">Revoke</button>
654
+ </form>
655
+ `
656
+ : `<span class="chip">${escapeHtml(invitation.status)}</span>`}
657
+ </div>
658
+ <div class="chips">
659
+ <span class="chip">invitee: ${escapeHtml(invitation.inviteeLabel)}</span>
660
+ <span class="chip">role: ${escapeHtml(invitation.role)}</span>
661
+ <span class="chip">created: ${escapeHtml(formatDate(invitation.createdAt))}</span>
662
+ ${invitation.acceptedAt
663
+ ? `<span class="chip">accepted: ${escapeHtml(formatDate(invitation.acceptedAt))}</span>`
664
+ : ""}
665
+ </div>
666
+ ${invitation.note
667
+ ? `<div class="footer" style="margin-top:10px">${escapeHtml(invitation.note)}</div>`
668
+ : ""}
669
+ </div>
670
+ `)
671
+ .join("")
672
+ : `<div class="list-item"><strong>No invitations yet.</strong><div class="footer">Use the invite form to create the first shareable link.</div></div>`}
673
+ </div>
674
+ ${renderPager(slug, "invite", overview.invitations, pageState)}
675
+ </section>
676
+ <section class="panel">
677
+ <div class="eyebrow">Shared Library</div>
678
+ <h2>Knowledge and capabilities</h2>
679
+ <p>This library is where the team keeps reusable skills, memory, workflows, docs, and connected services.</p>
680
+ <form method="get" action="/team/${encodeURIComponent(slug)}">
681
+ <div class="fields">
682
+ <label class="field">
683
+ <span class="label">Search assets</span>
684
+ <input name="assetQuery" value="${escapeHtml(overview.assets.records.query)}" placeholder="skill / memory / workflow / doc" />
685
+ </label>
686
+ </div>
687
+ <input type="hidden" name="memberPage" value="${overview.members.page}" />
688
+ <input type="hidden" name="memberQuery" value="${escapeHtml(overview.members.query)}" />
689
+ <div class="actions">
690
+ <button class="button" type="submit">Filter assets</button>
691
+ ${overview.assets.records.query
692
+ ? `<a class="button" href="${buildTeamPageHref(slug, { memberPage: overview.members.page, memberQuery: overview.members.query })}">Clear</a>`
693
+ : ""}
694
+ <span class="chip">Showing ${overview.assets.records.items.length} of ${overview.assets.records.total}</span>
695
+ ${renderLibraryKindChips(overview.assets.records.items)}
696
+ </div>
697
+ </form>
698
+ <div class="list">
699
+ ${overview.assets.records.items.length
700
+ ? overview.assets.records.items
701
+ .map((asset) => `
702
+ <div class="list-item">
703
+ <div class="list-item-header">
704
+ <div>
705
+ <strong>${escapeHtml(asset.title)}</strong>
706
+ <div class="footer">${escapeHtml(assetCategoryLabel(asset.category))} · ${escapeHtml(assetCategoryBlurb(asset.category))}</div>
707
+ </div>
708
+ ${renderAssetStageChip(asset)}
709
+ </div>
710
+ <div class="chips">
711
+ <span class="chip">owner: ${escapeHtml(asset.submittedBy)}</span>
712
+ <span class="chip">updated: ${escapeHtml(formatDate(asset.updatedAt))}</span>
713
+ ${asset.approvalRequired ? `<span class="chip">review: required</span>` : `<span class="chip">review: direct publish</span>`}
714
+ </div>
715
+ <div class="footer" style="margin-top:10px">
716
+ ${asset.note ? escapeHtml(asset.note) : "Open this entry to read the content and manage publication details."}
717
+ </div>
718
+ <div class="actions">
719
+ <a class="button" href="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}">Open entry</a>
720
+ ${asset.status === "pending_approval"
721
+ ? `
722
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/approve">
723
+ <button class="button" type="submit">Approve to Library</button>
724
+ </form>
725
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/reject">
726
+ <button class="button danger" type="submit">Reject</button>
727
+ </form>
728
+ `
729
+ : ""}
730
+ ${asset.status === "draft" || asset.status === "rejected"
731
+ ? `
732
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/promote">
733
+ <button class="button" type="submit">Publish to Library</button>
734
+ </form>
735
+ `
736
+ : ""}
737
+ </div>
738
+ </div>
739
+ `)
740
+ .join("")
741
+ : `<div class="list-item"><strong>No shared library entries yet.</strong><div class="footer">Use the form on the right to add the first reusable skill, memory entry, workflow, or doc.</div></div>`}
742
+ </div>
743
+ ${renderPager(slug, "asset", overview.assets.records, pageState)}
744
+ </section>
745
+ </div>
746
+ <div class="stack">
747
+ <section class="panel">
748
+ <div class="eyebrow">Workspace settings</div>
749
+ <h2>Make this workspace yours</h2>
750
+ <form method="post" action="/team/${encodeURIComponent(slug)}/profile">
751
+ <div class="fields">
752
+ <label class="field">
753
+ <span class="label">Team name</span>
754
+ <input name="name" value="${escapeHtml(overview.profile.name)}" required />
755
+ </label>
756
+ <label class="field">
757
+ <span class="label">Team slug</span>
758
+ <input name="slug" value="${escapeHtml(overview.profile.slug)}" />
759
+ </label>
760
+ <label class="field">
761
+ <span class="label">Description</span>
762
+ <textarea name="description">${escapeHtml(overview.profile.description)}</textarea>
763
+ </label>
764
+ <label class="field">
765
+ <span class="label">Manager label</span>
766
+ <input name="managerLabel" value="${escapeHtml(overview.profile.managerLabel)}" />
767
+ </label>
768
+ </div>
769
+ <div class="actions">
770
+ <button class="button" type="submit">Save team profile</button>
771
+ </div>
772
+ </form>
773
+ </section>
774
+ <section class="panel">
775
+ <div class="eyebrow">Invite</div>
776
+ <h2>Invite someone</h2>
777
+ <form method="post" action="/team/${encodeURIComponent(slug)}/invitations">
778
+ <div class="fields">
779
+ <label class="field">
780
+ <span class="label">Name or label</span>
781
+ <input name="inviteeLabel" placeholder="Alice / Research / Ops" required />
782
+ </label>
783
+ <label class="field">
784
+ <span class="label">Member email</span>
785
+ <input name="memberEmail" type="email" placeholder="alice@example.com" required />
786
+ </label>
787
+ <label class="field">
788
+ <span class="label">Role</span>
789
+ <input name="role" value="member" />
790
+ </label>
791
+ <div class="grid">
792
+ <label class="field">
793
+ <span class="label">Daily messages</span>
794
+ <input type="number" min="1" name="dailyMessages" value="150" />
795
+ </label>
796
+ <label class="field">
797
+ <span class="label">Monthly messages</span>
798
+ <input type="number" min="1" name="monthlyMessages" value="3000" />
799
+ </label>
800
+ <label class="field">
801
+ <span class="label">Max subagents</span>
802
+ <input type="number" min="0" name="maxSubagents" value="0" />
803
+ </label>
804
+ <label class="field">
805
+ <span class="label">Max thinking</span>
806
+ <select name="maxThinking">
807
+ <option value="low">low</option>
808
+ <option value="medium" selected>medium</option>
809
+ <option value="high">high</option>
810
+ <option value="xhigh">xhigh</option>
811
+ </select>
812
+ </label>
813
+ </div>
814
+ <label class="field">
815
+ <span class="label">Note</span>
816
+ <textarea name="note" placeholder="Optional operator note for this invite"></textarea>
817
+ </label>
818
+ </div>
819
+ <div class="actions">
820
+ <button class="button" type="submit">Create invite</button>
821
+ </div>
822
+ <div class="footer">Joining the workspace comes first. Channel setup and first-run guidance happen after they accept.</div>
823
+ </form>
824
+ </section>
825
+ <section class="panel">
826
+ <div class="eyebrow">Library</div>
827
+ <h2>Add to the shared library</h2>
828
+ <form method="post" action="/team/${encodeURIComponent(slug)}/assets">
829
+ <div class="fields">
830
+ <label class="field">
831
+ <span class="label">Library type</span>
832
+ <select name="category">
833
+ <option value="shared-memory">shared-memory</option>
834
+ <option value="shared-skills">shared-skills</option>
835
+ <option value="shared-workflows">shared-workflows</option>
836
+ <option value="shared-docs">shared-docs</option>
837
+ </select>
838
+ </label>
839
+ <label class="field">
840
+ <span class="label">Title</span>
841
+ <input name="title" placeholder="Nasdaq risk radar workflow" required />
842
+ </label>
843
+ <input type="hidden" name="submittedByLabel" value="manager" />
844
+ <input type="hidden" name="sourceZone" value="collab" />
845
+ <label class="field">
846
+ <span class="label">Note</span>
847
+ <input name="note" placeholder="Why this should live in the workspace library" />
848
+ </label>
849
+ <div class="grid">
850
+ <label class="field">
851
+ <span class="label">Capability role</span>
852
+ <select name="capabilityRole">
853
+ <option value="">auto</option>
854
+ <option value="instruction">instruction</option>
855
+ <option value="knowledge">knowledge</option>
856
+ <option value="integration">integration</option>
857
+ <option value="process">process</option>
858
+ <option value="reference">reference</option>
859
+ </select>
860
+ </label>
861
+ <label class="field">
862
+ <span class="label">Consumption mode</span>
863
+ <select name="consumptionMode">
864
+ <option value="">auto</option>
865
+ <option value="skill">skill</option>
866
+ <option value="retrieval">retrieval</option>
867
+ <option value="integration">integration</option>
868
+ <option value="workflow">workflow</option>
869
+ <option value="reference">reference</option>
870
+ </select>
871
+ </label>
872
+ </div>
873
+ <label class="field">
874
+ <span class="label">Capabilities</span>
875
+ <input name="capabilities" placeholder="guide, analyze, recall, connect" />
876
+ </label>
877
+ <label class="field">
878
+ <span class="label">Tags</span>
879
+ <input name="tags" placeholder="telegram, rollout, finance" />
880
+ </label>
881
+ <label class="field">
882
+ <span class="label">Activation hints</span>
883
+ <input name="activationHints" placeholder="use when the agent needs ..., use when the team is ..." />
884
+ </label>
885
+ <label class="field">
886
+ <span class="label">Trigger terms</span>
887
+ <input name="triggerTerms" placeholder="market, memory, deploy, telegram" />
888
+ </label>
889
+ <label class="field">
890
+ <span class="label">Content</span>
891
+ <textarea name="content" placeholder="Write the skill, memory, doc, or workflow here" required></textarea>
892
+ </label>
893
+ </div>
894
+ <div class="actions">
895
+ <button class="button" type="submit">Add entry</button>
896
+ </div>
897
+ <div class="footer">This form is for reusable team knowledge and capabilities. Capability metadata improves how agent resolvers discover and load the entry.</div>
898
+ </form>
899
+ </section>
900
+ </div>
901
+ </section>
902
+ `);
903
+ }
904
+ export function renderMemberPage(workspace, query) {
905
+ const slug = workspace.profile.slug;
906
+ const member = workspace.member;
907
+ const policy = workspace.policy;
908
+ const runtime = workspace.runtime;
909
+ return pageShell(`${member.id} · ${workspace.profile.name}`, `
910
+ <div class="nav">
911
+ <div class="eyebrow">/team/${escapeHtml(slug)}/members/${escapeHtml(member.id)}</div>
912
+ <div class="links">
913
+ <a href="/">Home</a>
914
+ <a href="/team/${encodeURIComponent(slug)}">Back to team</a>
915
+ </div>
916
+ </div>
917
+ <section class="hero">
918
+ <div class="eyebrow">${escapeHtml(workspace.profile.slug)}</div>
919
+ <h1>${escapeHtml(policy?.memberEmail ?? member.id)}</h1>
920
+ <p>This page is the home for one member space: channel setup, private library, access policy, and runtime health.</p>
921
+ ${renderFlashMessage(query)}
922
+ <div class="grid">
923
+ <div class="card metric">
924
+ <span class="label">Status</span>
925
+ <strong>${escapeHtml(runtime.label)}</strong>
926
+ <span class="muted">${escapeHtml(runtime.channelKind ?? "channel not set")}</span>
927
+ </div>
928
+ <div class="card metric">
929
+ <span class="label">Access</span>
930
+ <strong>${runtime.channelReady ? "Ready" : "Needs setup"}</strong>
931
+ <span class="muted">People can only chat once the channel is connected</span>
932
+ </div>
933
+ <div class="card metric">
934
+ <span class="label">Role</span>
935
+ <strong>${escapeHtml(policy?.role ?? "unmanaged")}</strong>
936
+ <span class="muted">Quota status: ${escapeHtml(policy?.quota.status ?? "unmanaged")}</span>
937
+ </div>
938
+ <div class="card metric">
939
+ <span class="label">Private library</span>
940
+ <strong>${workspace.member.buckets.reduce((count, bucket) => count + bucket.files.length, 0)}</strong>
941
+ <span class="muted">Files currently living in this member space</span>
942
+ </div>
943
+ <div class="card metric">
944
+ <span class="label">Workspace</span>
945
+ <strong>${escapeHtml(workspace.profile.name)}</strong>
946
+ <span class="muted">${escapeHtml(workspace.profile.name)}</span>
947
+ </div>
948
+ </div>
949
+ <div class="chips">
950
+ <span class="chip">channel: ${escapeHtml(runtime.channelKind ?? "n/a")}</span>
951
+ <span class="chip">credential: ${runtime.channelReady ? "ready" : "missing"}</span>
952
+ <span class="chip">team policy: ${runtime.hasTeamPolicy ? "present" : "missing"}</span>
953
+ <span class="chip">quota plugin: ${runtime.hasQuotaPlugin ? "present" : "missing"}</span>
954
+ </div>
955
+ </section>
956
+ <section class="split">
957
+ <div class="stack">
958
+ <section class="panel">
959
+ <div class="eyebrow">Start Here</div>
960
+ <h2>Get this member space ready</h2>
961
+ <div class="list">
962
+ <div class="list-item">
963
+ <strong>1. Connect a channel</strong>
964
+ <div class="footer">Choose how this person will talk to their member space.</div>
965
+ </div>
966
+ <div class="list-item">
967
+ <strong>2. Start the member space</strong>
968
+ <div class="footer">Once the channel is ready, start or restart the runtime here.</div>
969
+ </div>
970
+ <div class="list-item">
971
+ <strong>3. Keep it current</strong>
972
+ <div class="footer">Use Upgrade whenever the main template or runtime image changes.</div>
973
+ </div>
974
+ </div>
975
+ </section>
976
+ <section class="panel">
977
+ <div class="eyebrow">Connect</div>
978
+ <h2>Channel setup</h2>
979
+ <div class="footer">
980
+ Connect the channel this person will actually use. Telegram and Discord are supported directly here.
981
+ </div>
982
+ <form method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/channel">
983
+ <input type="hidden" name="channelKind" value="telegram" />
984
+ <div class="fields">
985
+ <label class="field">
986
+ <span class="label">Telegram user id (optional)</span>
987
+ <input name="channelHandle" placeholder="8555695623" />
988
+ </label>
989
+ <label class="field">
990
+ <span class="label">Telegram bot token</span>
991
+ <input name="botToken" type="password" placeholder="123456:AA..." />
992
+ </label>
993
+ </div>
994
+ <div class="actions">
995
+ <button class="button" type="submit">Configure Telegram</button>
996
+ </div>
997
+ <div class="footer">
998
+ If you leave the Telegram user id blank, the bot will start with open DMs first so the member can message it immediately. You can lock it back to a numeric allowlist later.
999
+ </div>
1000
+ </form>
1001
+ <form method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/channel">
1002
+ <input type="hidden" name="channelKind" value="discord" />
1003
+ <div class="fields">
1004
+ <label class="field">
1005
+ <span class="label">Discord user id</span>
1006
+ <input name="channelHandle" placeholder="123456789012345678" />
1007
+ </label>
1008
+ <label class="field">
1009
+ <span class="label">Discord bot token</span>
1010
+ <input name="botToken" type="password" placeholder="discord-bot-token" />
1011
+ </label>
1012
+ </div>
1013
+ <div class="actions">
1014
+ <button class="button" type="submit">Configure Discord</button>
1015
+ </div>
1016
+ </form>
1017
+ <div class="footer">
1018
+ WhatsApp still needs QR login after policy is written. Current channel: <strong>${escapeHtml(runtime.channelKind ?? "unconfigured")}</strong>.
1019
+ </div>
1020
+ </section>
1021
+ <section class="panel">
1022
+ <div class="eyebrow">Use</div>
1023
+ <h2>Runtime controls</h2>
1024
+ <div class="footer">
1025
+ Use these controls when you need to start, restart, stop, or refresh the member space.
1026
+ </div>
1027
+ <div class="actions">
1028
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/runtime/start?returnTo=member">
1029
+ <button class="button" type="submit">Start</button>
1030
+ </form>
1031
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/runtime/restart?returnTo=member">
1032
+ <button class="button" type="submit">Restart</button>
1033
+ </form>
1034
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/upgrade?returnTo=member">
1035
+ <button class="button" type="submit">Upgrade</button>
1036
+ </form>
1037
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/runtime/stop?returnTo=member">
1038
+ <button class="button danger" type="submit">Stop</button>
1039
+ </form>
1040
+ </div>
1041
+ <div class="footer">
1042
+ Runtime id: <code>${escapeHtml(member.id)}</code><br />
1043
+ Container: <code>${escapeHtml(runtime.containerName)}</code><br />
1044
+ Local port: <code>${escapeHtml(String(runtime.publishedPort ?? "n/a"))}</code>
1045
+ ${runtime.dockerError ? `<br />Docker note: ${escapeHtml(runtime.dockerError)}` : ""}
1046
+ </div>
1047
+ </section>
1048
+ <section class="panel">
1049
+ <div class="eyebrow">Assets</div>
1050
+ <h2>Private library</h2>
1051
+ <div class="list">
1052
+ ${renderMemberBuckets(workspace)}
1053
+ </div>
1054
+ </section>
1055
+ </div>
1056
+ <div class="stack">
1057
+ <section class="panel">
1058
+ <div class="eyebrow">Admin</div>
1059
+ <h2>Access and quota</h2>
1060
+ ${policy
1061
+ ? `
1062
+ <form method="post" action="/team/${encodeURIComponent(slug)}/members/${encodeURIComponent(member.id)}/quota?returnTo=member">
1063
+ <div class="fields">
1064
+ <div class="grid">
1065
+ <label class="field">
1066
+ <span class="label">Role</span>
1067
+ <select name="role">
1068
+ ${["viewer", "member", "contributor", "publisher", "manager", "owner", "operator"]
1069
+ .map((value) => `<option value="${value}" ${policy.role === value ? "selected" : ""}>${value}</option>`)
1070
+ .join("")}
1071
+ </select>
1072
+ </label>
1073
+ <label class="field">
1074
+ <span class="label">Daily messages</span>
1075
+ <input type="number" min="1" name="dailyMessages" value="${policy.quota.dailyMessages}" />
1076
+ </label>
1077
+ <label class="field">
1078
+ <span class="label">Monthly messages</span>
1079
+ <input type="number" min="1" name="monthlyMessages" value="${policy.quota.monthlyMessages}" />
1080
+ </label>
1081
+ <label class="field">
1082
+ <span class="label">Max subagents</span>
1083
+ <input type="number" min="0" name="maxSubagents" value="${policy.quota.maxSubagents}" />
1084
+ </label>
1085
+ <label class="field">
1086
+ <span class="label">Max thinking</span>
1087
+ <select name="maxThinking">
1088
+ ${["low", "medium", "high", "xhigh"]
1089
+ .map((value) => `<option value="${value}" ${policy.quota.maxThinking === value ? "selected" : ""}>${value}</option>`)
1090
+ .join("")}
1091
+ </select>
1092
+ </label>
1093
+ <label class="field">
1094
+ <span class="label">Status</span>
1095
+ <select name="status">
1096
+ ${["active", "paused"]
1097
+ .map((value) => `<option value="${value}" ${policy.quota.status === value ? "selected" : ""}>${value}</option>`)
1098
+ .join("")}
1099
+ </select>
1100
+ </label>
1101
+ </div>
1102
+ </div>
1103
+ <div class="actions">
1104
+ <button class="button" type="submit">Save quota</button>
1105
+ </div>
1106
+ </form>
1107
+ `
1108
+ : `<div class="flash danger">No managed policy exists for this member yet.</div>`}
1109
+ </section>
1110
+ <section class="panel">
1111
+ <div class="eyebrow">Workspace</div>
1112
+ <h2>Shared context</h2>
1113
+ <div class="chips">
1114
+ <span class="chip">members: ${workspace.summary.memberCount}</span>
1115
+ <span class="chip">pending invites: ${workspace.summary.pendingInvitationCount}</span>
1116
+ <span class="chip">published assets: ${workspace.summary.assetPublishedCount}</span>
1117
+ <span class="chip">default model: ${escapeHtml(workspace.modelGateway.defaultModelId)}</span>
1118
+ </div>
1119
+ <div class="footer">This member space still benefits from the shared workspace library and team defaults.</div>
1120
+ </section>
1121
+ </div>
1122
+ </section>
1123
+ `);
1124
+ }
1125
+ export function renderAssetPage(workspace, query) {
1126
+ const slug = workspace.profile.slug;
1127
+ const asset = workspace.asset;
1128
+ const capability = workspace.assetRegistryEntry.capability;
1129
+ return pageShell(`${asset.title} · ${workspace.profile.name}`, `
1130
+ <div class="nav">
1131
+ <div class="eyebrow">/team/${escapeHtml(slug)}/assets/${escapeHtml(asset.id)}</div>
1132
+ <div class="links">
1133
+ <a href="/">Home</a>
1134
+ <a href="/team/${encodeURIComponent(slug)}">Back to team</a>
1135
+ </div>
1136
+ </div>
1137
+ <section class="hero">
1138
+ <div class="eyebrow">${escapeHtml(assetCategoryLabel(asset.category))}</div>
1139
+ <h1>${escapeHtml(asset.title)}</h1>
1140
+ <p>${escapeHtml(assetCategoryBlurb(asset.category))}</p>
1141
+ ${renderFlashMessage(query)}
1142
+ <div class="grid">
1143
+ <div class="card metric">
1144
+ <span class="label">Stage</span>
1145
+ <strong>${escapeHtml(assetStageLabel(asset.status))}</strong>
1146
+ <span class="muted">Shared by ${escapeHtml(asset.submittedBy)}</span>
1147
+ </div>
1148
+ <div class="card metric">
1149
+ <span class="label">Type</span>
1150
+ <strong>${escapeHtml(assetCategoryLabel(asset.category))}</strong>
1151
+ <span class="muted">Role ${escapeHtml(asset.role)}</span>
1152
+ </div>
1153
+ <div class="card metric">
1154
+ <span class="label">Library size</span>
1155
+ <strong>${workspace.summary.assetPublishedCount}</strong>
1156
+ <span class="muted">Entries currently live for the team</span>
1157
+ </div>
1158
+ </div>
1159
+ <div class="footer">Updated ${escapeHtml(formatDate(asset.updatedAt))}${asset.note ? ` · ${escapeHtml(asset.note)}` : ""}</div>
1160
+ </section>
1161
+ <section class="split">
1162
+ <div class="stack">
1163
+ <section class="panel">
1164
+ <div class="eyebrow">Capability</div>
1165
+ <h2>How agents consume this</h2>
1166
+ <div class="chips">
1167
+ <span class="chip">role: ${escapeHtml(capability.role)}</span>
1168
+ <span class="chip">mode: ${escapeHtml(capability.consumptionMode)}</span>
1169
+ ${capability.capabilities.map((entry) => `<span class="chip">${escapeHtml(entry)}</span>`).join("")}
1170
+ </div>
1171
+ <div class="footer" style="margin-top:12px">
1172
+ Trigger terms: ${escapeHtml(capability.triggerTerms.join(", ") || "n/a")}<br />
1173
+ Activation hints: ${escapeHtml(capability.activationHints.join(" · ") || "n/a")}
1174
+ </div>
1175
+ </section>
1176
+ <section class="panel">
1177
+ <div class="eyebrow">Actions</div>
1178
+ <h2>Library workflow</h2>
1179
+ <div class="actions">
1180
+ ${asset.status === "pending_approval"
1181
+ ? `
1182
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/approve?returnTo=asset">
1183
+ <button class="button" type="submit">Approve to Library</button>
1184
+ </form>
1185
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/reject?returnTo=asset">
1186
+ <button class="button danger" type="submit">Reject</button>
1187
+ </form>
1188
+ `
1189
+ : ""}
1190
+ ${asset.status === "draft" || asset.status === "rejected"
1191
+ ? `
1192
+ <form class="inline" method="post" action="/team/${encodeURIComponent(slug)}/assets/${encodeURIComponent(asset.id)}/promote?returnTo=asset">
1193
+ <button class="button" type="submit">Publish to Library</button>
1194
+ </form>
1195
+ `
1196
+ : ""}
1197
+ </div>
1198
+ <div class="footer">
1199
+ Created: ${escapeHtml(formatDate(asset.submittedAt))}<br />
1200
+ Updated: ${escapeHtml(formatDate(asset.updatedAt))}<br />
1201
+ ${asset.note ? `Note: ${escapeHtml(asset.note)}<br />` : ""}
1202
+ ${asset.rejectionReason ? `Rejection: ${escapeHtml(asset.rejectionReason)}` : ""}
1203
+ </div>
1204
+ </section>
1205
+ <section class="panel">
1206
+ <div class="eyebrow">Draft</div>
1207
+ <h2>Submitted content</h2>
1208
+ <pre class="mono" style="white-space:pre-wrap;overflow:auto">${escapeHtml(workspace.sourceContent)}</pre>
1209
+ </section>
1210
+ </div>
1211
+ <div class="stack">
1212
+ ${workspace.publishedContent
1213
+ ? `
1214
+ <section class="panel">
1215
+ <div class="eyebrow">Published</div>
1216
+ <h2>Published version</h2>
1217
+ <pre class="mono" style="white-space:pre-wrap;overflow:auto">${escapeHtml(workspace.publishedContent)}</pre>
1218
+ </section>
1219
+ `
1220
+ : ""}
1221
+ ${workspace.currentContent
1222
+ ? `
1223
+ <section class="panel">
1224
+ <div class="eyebrow">Live</div>
1225
+ <h2>What members currently receive</h2>
1226
+ <pre class="mono" style="white-space:pre-wrap;overflow:auto">${escapeHtml(workspace.currentContent)}</pre>
1227
+ </section>
1228
+ `
1229
+ : ""}
1230
+ </div>
1231
+ </section>
1232
+ `);
1233
+ }
1234
+ export function renderInvitationPage(profile, invitation, query) {
1235
+ const statusText = invitation.status === "pending"
1236
+ ? "This invite is ready to be accepted."
1237
+ : invitation.status === "accepted"
1238
+ ? "This invite has already been accepted."
1239
+ : "This invite is no longer active.";
1240
+ return pageShell(`${profile.name} invite`, `
1241
+ <div class="nav">
1242
+ <div class="eyebrow">Invitation</div>
1243
+ <div class="links">
1244
+ <a href="/">Home</a>
1245
+ <a href="/team/${encodeURIComponent(profile.slug)}">Team</a>
1246
+ </div>
1247
+ </div>
1248
+ <section class="hero">
1249
+ <div class="eyebrow">${escapeHtml(profile.slug)}</div>
1250
+ <h1>${escapeHtml(profile.name)} invited you</h1>
1251
+ <p>${escapeHtml(statusText)}</p>
1252
+ ${"error" in (query ?? {}) ? `<div class="flash danger">${escapeHtml(String(query.error))}</div>` : ""}
1253
+ <div class="grid">
1254
+ <div class="card">
1255
+ <h3>Invitee</h3>
1256
+ <p>${escapeHtml(invitation.inviteeLabel)}</p>
1257
+ </div>
1258
+ <div class="card">
1259
+ <h3>Role</h3>
1260
+ <p>${escapeHtml(invitation.role)}</p>
1261
+ </div>
1262
+ <div class="card">
1263
+ <h3>Quota</h3>
1264
+ <p>${escapeHtml(`${invitation.quota.dailyMessages}/day · ${invitation.quota.monthlyMessages}/month · ${invitation.quota.maxThinking}`)}</p>
1265
+ </div>
1266
+ <div class="card">
1267
+ <h3>Workspace identity</h3>
1268
+ <p class="mono">${escapeHtml(invitation.memberEmail ?? invitation.memberId)}</p>
1269
+ </div>
1270
+ <div class="card">
1271
+ <h3>Created</h3>
1272
+ <p>${escapeHtml(formatDate(invitation.createdAt))}</p>
1273
+ </div>
1274
+ </div>
1275
+ </section>
1276
+ <section class="split">
1277
+ <div class="panel">
1278
+ <div class="eyebrow">Accept</div>
1279
+ <h2>Join workspace</h2>
1280
+ <p>
1281
+ Accepting this invite creates your private member space inside the workspace.
1282
+ Channel setup comes next so you can start chatting right away.
1283
+ </p>
1284
+ ${invitation.status === "pending"
1285
+ ? `
1286
+ <form method="post" action="/invite/${encodeURIComponent(invitation.code)}/accept">
1287
+ <div class="fields">
1288
+ <label class="field">
1289
+ <span class="label">Identity name</span>
1290
+ <input name="identityName" value="${escapeHtml(`小虾-${invitation.memberId}`)}" />
1291
+ </label>
1292
+ </div>
1293
+ <div class="actions">
1294
+ <button class="button" type="submit">Join team</button>
1295
+ </div>
1296
+ </form>
1297
+ `
1298
+ : `<div class="flash ${invitation.status === "revoked" ? "danger" : ""}">${escapeHtml(statusText)}</div>`}
1299
+ </div>
1300
+ <div class="panel">
1301
+ <div class="eyebrow">Details</div>
1302
+ <h2>What happens next</h2>
1303
+ <div class="list">
1304
+ <div class="list-item">
1305
+ <strong>1. Private member space</strong>
1306
+ <div class="footer">Velaclaw creates a private member space for this invite inside the workspace.</div>
1307
+ </div>
1308
+ <div class="list-item">
1309
+ <strong>2. Connect your channel</strong>
1310
+ <div class="footer">Open the member setup page and connect Telegram or Discord.</div>
1311
+ </div>
1312
+ <div class="list-item">
1313
+ <strong>3. Start chatting</strong>
1314
+ <div class="footer">Once the channel is connected, this member space is ready to work.</div>
1315
+ </div>
1316
+ </div>
1317
+ ${invitation.note
1318
+ ? `<div class="footer" style="margin-top:16px"><strong>Note:</strong> ${escapeHtml(invitation.note)}</div>`
1319
+ : ""}
1320
+ </div>
1321
+ </section>
1322
+ `);
1323
+ }
1324
+ export function renderInvitationAcceptedPage(profile, invitation, workspace, query) {
1325
+ const runtime = workspace.runtime;
1326
+ const member = workspace.member;
1327
+ const memberDisplay = workspace.policy?.memberEmail ?? member.id;
1328
+ return pageShell(`${profile.name} invite accepted`, `
1329
+ <div class="nav">
1330
+ <div class="eyebrow">Invite accepted</div>
1331
+ <div class="links">
1332
+ <a href="/team/${encodeURIComponent(profile.slug)}">Back to workspace</a>
1333
+ <a href="/team/${encodeURIComponent(profile.slug)}/members/${encodeURIComponent(member.id)}">Open full member space</a>
1334
+ </div>
1335
+ </div>
1336
+ <section class="hero">
1337
+ <div class="eyebrow">${escapeHtml(profile.slug)}</div>
1338
+ <h1>${escapeHtml(invitation.inviteeLabel)} joined the workspace</h1>
1339
+ <p>
1340
+ The invite was accepted and Velaclaw created a private member space for
1341
+ <code>${escapeHtml(memberDisplay)}</code>.
1342
+ </p>
1343
+ ${renderFlashMessage(query)}
1344
+ <div class="grid">
1345
+ <div class="card">
1346
+ <h3>Workspace</h3>
1347
+ <p>${escapeHtml(profile.name)}</p>
1348
+ </div>
1349
+ <div class="card">
1350
+ <h3>Role</h3>
1351
+ <p>${escapeHtml(invitation.role)}</p>
1352
+ </div>
1353
+ <div class="card">
1354
+ <h3>Member space</h3>
1355
+ <p>${escapeHtml(memberDisplay)}</p>
1356
+ </div>
1357
+ <div class="card">
1358
+ <h3>Status</h3>
1359
+ <p>${escapeHtml(runtime.label)}</p>
1360
+ </div>
1361
+ </div>
1362
+ </section>
1363
+ <section class="split">
1364
+ <div class="stack">
1365
+ <section class="panel">
1366
+ <div class="eyebrow">Start Here</div>
1367
+ <h2>Finish setup and start chatting</h2>
1368
+ <div class="list">
1369
+ <div class="list-item">
1370
+ <strong>1. Connect Telegram or Discord</strong>
1371
+ <div class="footer">Choose the channel this person will actually use.</div>
1372
+ </div>
1373
+ <div class="list-item">
1374
+ <strong>2. Start the member space</strong>
1375
+ <div class="footer">After the channel is connected, Velaclaw will start the runtime automatically.</div>
1376
+ </div>
1377
+ <div class="list-item">
1378
+ <strong>3. Start chatting</strong>
1379
+ <div class="footer">Once the status is Running, this member space is ready to work.</div>
1380
+ </div>
1381
+ </div>
1382
+ </section>
1383
+ <section class="panel">
1384
+ <div class="eyebrow">Connect</div>
1385
+ <h2>Telegram</h2>
1386
+ <form method="post" action="/invite/${encodeURIComponent(invitation.code)}/channel">
1387
+ <input type="hidden" name="channelKind" value="telegram" />
1388
+ <div class="fields">
1389
+ <label class="field">
1390
+ <span class="label">Telegram user id (optional)</span>
1391
+ <input name="channelHandle" placeholder="8555695623" />
1392
+ </label>
1393
+ <label class="field">
1394
+ <span class="label">Telegram bot token</span>
1395
+ <input name="botToken" type="password" placeholder="123456:AA..." />
1396
+ </label>
1397
+ </div>
1398
+ <div class="actions">
1399
+ <button class="button" type="submit">Connect Telegram And Start</button>
1400
+ </div>
1401
+ <div class="footer">
1402
+ If the user id is left blank, the bot starts with open DMs first, so the member can message it immediately.
1403
+ </div>
1404
+ </form>
1405
+ </section>
1406
+ <section class="panel">
1407
+ <div class="eyebrow">Connect</div>
1408
+ <h2>Discord</h2>
1409
+ <form method="post" action="/invite/${encodeURIComponent(invitation.code)}/channel">
1410
+ <input type="hidden" name="channelKind" value="discord" />
1411
+ <div class="fields">
1412
+ <label class="field">
1413
+ <span class="label">Discord user id</span>
1414
+ <input name="channelHandle" placeholder="123456789012345678" />
1415
+ </label>
1416
+ <label class="field">
1417
+ <span class="label">Discord bot token</span>
1418
+ <input name="botToken" type="password" placeholder="discord-bot-token" />
1419
+ </label>
1420
+ </div>
1421
+ <div class="actions">
1422
+ <button class="button" type="submit">Connect Discord And Start</button>
1423
+ </div>
1424
+ </form>
1425
+ </section>
1426
+ </div>
1427
+ <div class="stack">
1428
+ <section class="panel">
1429
+ <div class="eyebrow">Ready State</div>
1430
+ <h2>Current setup status</h2>
1431
+ <div class="grid">
1432
+ <div class="card metric">
1433
+ <span class="label">Channel</span>
1434
+ <strong>${escapeHtml(runtime.channelKind ?? "unconfigured")}</strong>
1435
+ <span class="muted">${runtime.channelReady ? "Connected" : "Needs setup"}</span>
1436
+ </div>
1437
+ <div class="card metric">
1438
+ <span class="label">Runtime</span>
1439
+ <strong>${escapeHtml(runtime.label)}</strong>
1440
+ <span class="muted">${runtime.container.running ? "Live now" : "Not running yet"}</span>
1441
+ </div>
1442
+ <div class="card metric">
1443
+ <span class="label">Quota</span>
1444
+ <strong>${escapeHtml(`${invitation.quota.dailyMessages}/day`)}</strong>
1445
+ <span class="muted">${escapeHtml(`${invitation.quota.monthlyMessages}/month · ${invitation.quota.maxThinking}`)}</span>
1446
+ </div>
1447
+ </div>
1448
+ <div class="actions">
1449
+ <form class="inline" method="post" action="/invite/${encodeURIComponent(invitation.code)}/start">
1450
+ <button class="button" type="submit">Start Member Space</button>
1451
+ </form>
1452
+ <a class="button" href="/team/${encodeURIComponent(profile.slug)}/members/${encodeURIComponent(member.id)}">Open full member space</a>
1453
+ </div>
1454
+ </section>
1455
+ <section class="panel">
1456
+ <div class="eyebrow">What They Get</div>
1457
+ <h2>Shared workspace context</h2>
1458
+ <div class="chips">
1459
+ <span class="chip">shared library: ${workspace.summary.assetPublishedCount}</span>
1460
+ <span class="chip">default model: ${escapeHtml(workspace.modelGateway.defaultModelId)}</span>
1461
+ <span class="chip">role: ${escapeHtml(invitation.role)}</span>
1462
+ </div>
1463
+ <div class="footer">
1464
+ This member space will inherit the shared workspace library while keeping its own private memory, skills, tools, and docs.
1465
+ </div>
1466
+ </section>
1467
+ </div>
1468
+ </section>
1469
+ `);
1470
+ }
1471
+ export function renderErrorPage(title, message) {
1472
+ return pageShell(title, `
1473
+ <div class="nav">
1474
+ <div class="eyebrow">Velaclaw</div>
1475
+ <div class="links">
1476
+ <a href="/">Home</a>
1477
+ <a href="/team">Team</a>
1478
+ </div>
1479
+ </div>
1480
+ <section class="hero">
1481
+ <div class="eyebrow">Error</div>
1482
+ <h1>${escapeHtml(title)}</h1>
1483
+ <p>${escapeHtml(message)}</p>
1484
+ </section>
1485
+ `);
1486
+ }