@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,537 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Infra — worktrees, shared workspaces, presence
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import { useState } from "preact/hooks";
6
+ import htm from "htm";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ import { haptic, showConfirm } from "../modules/telegram.js";
11
+ import { apiFetch } from "../modules/api.js";
12
+ import {
13
+ worktreeData,
14
+ sharedWorkspaces,
15
+ presenceInstances,
16
+ coordinatorInfo,
17
+ showToast,
18
+ refreshTab,
19
+ runOptimistic,
20
+ scheduleRefresh,
21
+ } from "../modules/state.js";
22
+ import { ICONS } from "../modules/icons.js";
23
+ import { cloneValue, formatRelative, formatBytes, downloadFile } from "../modules/utils.js";
24
+ import {
25
+ Card,
26
+ Badge,
27
+ StatCard,
28
+ SkeletonCard,
29
+ EmptyState,
30
+ } from "../components/shared.js";
31
+ import { ProgressBar } from "../components/charts.js";
32
+ import { Collapsible } from "../components/forms.js";
33
+
34
+ /* ─── Worktree health indicator ─── */
35
+ function healthColor(wt) {
36
+ if (wt.status === "stale" || wt.status === "error")
37
+ return "var(--color-error)";
38
+ const ageMin = Math.round((wt.age || 0) / 60000);
39
+ if (ageMin > 180) return "var(--color-inreview)"; // yellow — old
40
+ return "var(--color-done)"; // green — healthy
41
+ }
42
+
43
+ function HealthDot({ wt }) {
44
+ return html`<span
45
+ class="health-dot"
46
+ style="background:${healthColor(wt)}"
47
+ ></span>`;
48
+ }
49
+
50
+ function ageString(ms) {
51
+ const min = Math.round((ms || 0) / 60000);
52
+ if (min >= 1440) return `${Math.round(min / 1440)}d`;
53
+ if (min >= 60) return `${Math.round(min / 60)}h`;
54
+ return `${min}m`;
55
+ }
56
+
57
+ /* ─── InfraTab ─── */
58
+ export function InfraTab() {
59
+ /* Worktrees — work with either the new signal name or a compatible shape */
60
+ const wtRaw = worktreeData?.value;
61
+ const wts = Array.isArray(wtRaw)
62
+ ? wtRaw
63
+ : wtRaw?.worktrees || wtRaw?.data || [];
64
+ const wStats = (wtRaw && !Array.isArray(wtRaw) ? wtRaw.stats : null) || {};
65
+
66
+ /* Shared workspaces */
67
+ const swRaw = sharedWorkspaces?.value;
68
+ const registry = swRaw || {};
69
+ const workspaces =
70
+ registry?.workspaces || (Array.isArray(swRaw) ? swRaw : []);
71
+ const availability = registry?.availability || {};
72
+
73
+ /* Presence */
74
+ const instances = presenceInstances?.value || [];
75
+ const coordinator = coordinatorInfo?.value || null;
76
+
77
+ /* Local form state */
78
+ const [releaseInput, setReleaseInput] = useState("");
79
+ const [sharedOwner, setSharedOwner] = useState("");
80
+ const [sharedTtl, setSharedTtl] = useState("");
81
+ const [sharedNote, setSharedNote] = useState("");
82
+ const [expandedWt, setExpandedWt] = useState(null);
83
+
84
+ /* ── Worktree actions ── */
85
+ const handlePrune = async () => {
86
+ const ok = await showConfirm("Prune all stale worktrees?");
87
+ if (!ok) return;
88
+ haptic("medium");
89
+ await apiFetch("/api/worktrees/prune", { method: "POST" }).catch(() => {});
90
+ showToast("Prune initiated", "success");
91
+ scheduleRefresh(120);
92
+ };
93
+
94
+ const handleRelease = async (key, branch) => {
95
+ haptic("medium");
96
+ const prev = cloneValue(wts);
97
+ await runOptimistic(
98
+ () => {
99
+ const setter = worktreeData || {};
100
+ if (Array.isArray(setter.value)) {
101
+ setter.value = setter.value.filter(
102
+ (w) => w.taskKey !== key && w.branch !== branch,
103
+ );
104
+ }
105
+ },
106
+ () =>
107
+ apiFetch("/api/worktrees/release", {
108
+ method: "POST",
109
+ body: JSON.stringify({ taskKey: key, branch }),
110
+ }),
111
+ () => {
112
+ if (worktreeData) worktreeData.value = prev;
113
+ },
114
+ ).catch(() => {});
115
+ scheduleRefresh(120);
116
+ };
117
+
118
+ const handleReleaseInput = async () => {
119
+ if (!releaseInput.trim()) return;
120
+ haptic("medium");
121
+ await apiFetch("/api/worktrees/release", {
122
+ method: "POST",
123
+ body: JSON.stringify({
124
+ taskKey: releaseInput.trim(),
125
+ branch: releaseInput.trim(),
126
+ }),
127
+ }).catch(() => {});
128
+ setReleaseInput("");
129
+ scheduleRefresh(120);
130
+ };
131
+
132
+ /* ── Shared workspace actions ── */
133
+ const handleClaim = async (wsId) => {
134
+ haptic("medium");
135
+ const prev = cloneValue(sharedWorkspaces?.value);
136
+ await runOptimistic(
137
+ () => {
138
+ const w = (sharedWorkspaces?.value?.workspaces || []).find(
139
+ (x) => x.id === wsId,
140
+ );
141
+ if (w) {
142
+ w.availability = "leased";
143
+ w.lease = {
144
+ owner: sharedOwner || "telegram-ui",
145
+ lease_expires_at: new Date(
146
+ Date.now() + (Number(sharedTtl) || 60) * 60000,
147
+ ).toISOString(),
148
+ note: sharedNote,
149
+ };
150
+ }
151
+ },
152
+ () =>
153
+ apiFetch("/api/shared-workspaces/claim", {
154
+ method: "POST",
155
+ body: JSON.stringify({
156
+ workspaceId: wsId,
157
+ owner: sharedOwner,
158
+ ttlMinutes: Number(sharedTtl) || undefined,
159
+ note: sharedNote,
160
+ }),
161
+ }),
162
+ () => {
163
+ if (sharedWorkspaces) sharedWorkspaces.value = prev;
164
+ },
165
+ ).catch(() => {});
166
+ scheduleRefresh(120);
167
+ };
168
+
169
+ const handleRenew = async (wsId) => {
170
+ haptic("medium");
171
+ const prev = cloneValue(sharedWorkspaces?.value);
172
+ await runOptimistic(
173
+ () => {
174
+ const w = (sharedWorkspaces?.value?.workspaces || []).find(
175
+ (x) => x.id === wsId,
176
+ );
177
+ if (w?.lease) {
178
+ w.lease.owner = sharedOwner || w.lease.owner;
179
+ w.lease.lease_expires_at = new Date(
180
+ Date.now() + (Number(sharedTtl) || 60) * 60000,
181
+ ).toISOString();
182
+ }
183
+ },
184
+ () =>
185
+ apiFetch("/api/shared-workspaces/renew", {
186
+ method: "POST",
187
+ body: JSON.stringify({
188
+ workspaceId: wsId,
189
+ owner: sharedOwner,
190
+ ttlMinutes: Number(sharedTtl) || undefined,
191
+ }),
192
+ }),
193
+ () => {
194
+ if (sharedWorkspaces) sharedWorkspaces.value = prev;
195
+ },
196
+ ).catch(() => {});
197
+ scheduleRefresh(120);
198
+ };
199
+
200
+ const handleSharedRelease = async (wsId) => {
201
+ haptic("medium");
202
+ const prev = cloneValue(sharedWorkspaces?.value);
203
+ await runOptimistic(
204
+ () => {
205
+ const w = (sharedWorkspaces?.value?.workspaces || []).find(
206
+ (x) => x.id === wsId,
207
+ );
208
+ if (w) {
209
+ w.availability = "available";
210
+ w.lease = null;
211
+ }
212
+ },
213
+ () =>
214
+ apiFetch("/api/shared-workspaces/release", {
215
+ method: "POST",
216
+ body: JSON.stringify({ workspaceId: wsId, owner: sharedOwner }),
217
+ }),
218
+ () => {
219
+ if (sharedWorkspaces) sharedWorkspaces.value = prev;
220
+ },
221
+ ).catch(() => {});
222
+ scheduleRefresh(120);
223
+ };
224
+
225
+ /* ── Export infrastructure report ── */
226
+ const handleExportReport = () => {
227
+ haptic("medium");
228
+ const now = new Date();
229
+ const activeCount = wStats.active ?? wts.filter((w) => w.status !== "stale" && w.status !== "error").length;
230
+ const staleCount = wStats.stale ?? wts.filter((w) => w.status === "stale").length;
231
+ const availCount = workspaces.filter((w) => w.availability === "available").length;
232
+ const leasedCount = workspaces.filter((w) => w.availability === "leased").length;
233
+
234
+ let report = "";
235
+ report += "VirtEngine Infrastructure Report\n";
236
+ report += `Generated: ${now.toISOString()}\n\n`;
237
+
238
+ report += "== Worktrees ==\n";
239
+ report += `Total: ${wts.length} | Active: ${activeCount} | Stale: ${staleCount}\n\n`;
240
+ for (const wt of wts) {
241
+ report += `- ${wt.branch || "(detached)"} (${wt.status || "active"}) — Age: ${ageString(wt.age)}, Path: ${wt.path || "—"}\n`;
242
+ }
243
+
244
+ report += "\n== Shared Workspaces ==\n";
245
+ report += `Total: ${workspaces.length} | Available: ${availCount} | Leased: ${leasedCount}\n\n`;
246
+ for (const ws of workspaces) {
247
+ const lease = ws.lease;
248
+ const owner = lease ? lease.owner || "—" : "—";
249
+ const expiry = lease ? new Date(lease.lease_expires_at).toISOString() : "—";
250
+ report += `- ${ws.name || ws.id}: ${ws.availability || "unknown"} — Owner: ${owner}, Expires: ${expiry}\n`;
251
+ }
252
+
253
+ report += "\n== Active Instances ==\n";
254
+ report += `Coordinator: ${coordinator?.instance_label || coordinator?.instance_id || "none"}\n`;
255
+ report += `Instances: ${instances.length}\n`;
256
+ for (const inst of instances) {
257
+ const since = inst.last_seen_at ? new Date(inst.last_seen_at).toISOString() : "unknown";
258
+ report += `- ${inst.instance_label || inst.instance_id} (${inst.workspace_role || "workspace"}) — Since: ${since}\n`;
259
+ }
260
+
261
+ const dateStr = now.toISOString().slice(0, 10);
262
+ downloadFile(report, `infra-report-${dateStr}.txt`, "text/plain");
263
+ showToast("Infrastructure report exported", "success");
264
+ };
265
+
266
+ /* ── Render ── */
267
+ return html`
268
+ <!-- ─── Infra header with export ─── -->
269
+ <div class="flex-between mb-md" style="padding:0 4px">
270
+ <span style="font-weight:600;font-size:15px">Infrastructure</span>
271
+ <button class="btn btn-secondary btn-sm" style="display:inline-flex;align-items:center;gap:4px" onClick=${handleExportReport}>
272
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
273
+ Export Report
274
+ </button>
275
+ </div>
276
+
277
+ <!-- ─── Worktrees ─── -->
278
+ <${Collapsible} title="Worktrees" defaultOpen=${true}>
279
+ <${Card}>
280
+ <div class="stats-grid mb-md">
281
+ <${StatCard} value=${wStats.total ?? wts.length} label="Total" />
282
+ <${StatCard}
283
+ value=${wStats.active ?? 0}
284
+ label="Active"
285
+ color="var(--color-done)"
286
+ />
287
+ <${StatCard}
288
+ value=${wStats.stale ?? 0}
289
+ label="Stale"
290
+ color="var(--color-inreview)"
291
+ />
292
+ </div>
293
+
294
+ <div class="input-row mb-md">
295
+ <input
296
+ class="input"
297
+ placeholder="Task key or branch"
298
+ value=${releaseInput}
299
+ onInput=${(e) => setReleaseInput(e.target.value)}
300
+ />
301
+ <button
302
+ class="btn btn-secondary btn-sm"
303
+ onClick=${handleReleaseInput}
304
+ >
305
+ Release
306
+ </button>
307
+ <button class="btn btn-danger btn-sm" onClick=${handlePrune}>
308
+ 🗑 Prune
309
+ </button>
310
+ </div>
311
+
312
+ ${wts.map(
313
+ (wt, idx) => html`
314
+ <div key=${wt.branch || wt.path || idx} class="task-card">
315
+ <div
316
+ class="task-card-header"
317
+ style="cursor:pointer"
318
+ onClick=${() => {
319
+ haptic();
320
+ setExpandedWt(expandedWt === idx ? null : idx);
321
+ }}
322
+ >
323
+ <div style="display:flex;align-items:center;gap:6px">
324
+ <${HealthDot} wt=${wt} />
325
+ <div>
326
+ <div class="task-card-title">
327
+ ${wt.branch || "(detached)"}
328
+ </div>
329
+ <div class="task-card-meta">${wt.path}</div>
330
+ </div>
331
+ </div>
332
+ <${Badge}
333
+ status=${wt.status || "active"}
334
+ text=${wt.status || "active"}
335
+ />
336
+ </div>
337
+ <div class="meta-text">
338
+ Age
339
+ ${ageString(wt.age)}${wt.taskKey
340
+ ? ` · ${wt.taskKey}`
341
+ : ""}${wt.owner ? ` · Owner ${wt.owner}` : ""}
342
+ </div>
343
+
344
+ <!-- Collapsible git status section -->
345
+ ${expandedWt === idx &&
346
+ html`
347
+ <div class="wt-detail mt-sm">
348
+ ${wt.gitStatus &&
349
+ html` <div class="log-box log-box-sm">${wt.gitStatus}</div> `}
350
+ ${wt.lastCommit &&
351
+ html`
352
+ <div class="meta-text mt-xs">
353
+ Last commit: ${truncate(wt.lastCommit, 80)}
354
+ </div>
355
+ `}
356
+ ${wt.filesChanged != null &&
357
+ html`
358
+ <div class="meta-text">
359
+ Files changed: ${wt.filesChanged}
360
+ </div>
361
+ `}
362
+ </div>
363
+ `}
364
+
365
+ <div class="btn-row mt-sm">
366
+ ${wt.taskKey &&
367
+ html`
368
+ <button
369
+ class="btn btn-ghost btn-sm"
370
+ onClick=${() => handleRelease(wt.taskKey, "")}
371
+ >
372
+ Release Key
373
+ </button>
374
+ `}
375
+ ${wt.branch &&
376
+ html`
377
+ <button
378
+ class="btn btn-ghost btn-sm"
379
+ onClick=${() => handleRelease("", wt.branch)}
380
+ >
381
+ Release Branch
382
+ </button>
383
+ `}
384
+ </div>
385
+ </div>
386
+ `,
387
+ )}
388
+ ${!wts.length &&
389
+ html`<${EmptyState} message="No worktrees tracked." />`}
390
+ <//>
391
+ <//>
392
+
393
+ <!-- ─── Shared Workspaces ─── -->
394
+ <${Collapsible} title="Shared Workspaces" defaultOpen=${true}>
395
+ <${Card}>
396
+ <div class="chip-group mb-sm">
397
+ ${Object.entries(availability).map(
398
+ ([k, v]) => html`<span key=${k} class="pill">${k}: ${v}</span>`,
399
+ )}
400
+ ${!Object.keys(availability).length &&
401
+ html`<span class="pill">No registry</span>`}
402
+ </div>
403
+
404
+ <div class="input-row mb-sm">
405
+ <input
406
+ class="input"
407
+ placeholder="Owner"
408
+ value=${sharedOwner}
409
+ onInput=${(e) => setSharedOwner(e.target.value)}
410
+ />
411
+ <input
412
+ class="input"
413
+ type="number"
414
+ min="30"
415
+ step="15"
416
+ placeholder="TTL (min)"
417
+ value=${sharedTtl}
418
+ onInput=${(e) => setSharedTtl(e.target.value)}
419
+ />
420
+ </div>
421
+ <input
422
+ class="input mb-md"
423
+ placeholder="Note (optional)"
424
+ value=${sharedNote}
425
+ onInput=${(e) => setSharedNote(e.target.value)}
426
+ />
427
+
428
+ ${workspaces.map((ws) => {
429
+ const lease = ws.lease;
430
+ const leaseInfo = lease
431
+ ? `Leased to ${lease.owner} until ${new Date(lease.lease_expires_at).toLocaleString()}`
432
+ : "Available";
433
+ return html`
434
+ <div key=${ws.id} class="task-card">
435
+ <div class="task-card-header">
436
+ <div>
437
+ <div class="task-card-title">${ws.name || ws.id}</div>
438
+ <div class="task-card-meta">
439
+ ${ws.provider || "provider"} · ${ws.region || "region?"}
440
+ </div>
441
+ </div>
442
+ <${Badge} status=${ws.availability} text=${ws.availability} />
443
+ </div>
444
+ <div class="meta-text">${leaseInfo}</div>
445
+ ${lease?.note &&
446
+ html`<div class="meta-text" style="font-style:italic">
447
+ ${lease.note}
448
+ </div>`}
449
+
450
+ <div class="btn-row mt-sm">
451
+ <button
452
+ class="btn btn-primary btn-sm"
453
+ onClick=${() => handleClaim(ws.id)}
454
+ >
455
+ 🔒 Claim
456
+ </button>
457
+ <button
458
+ class="btn btn-secondary btn-sm"
459
+ onClick=${() => handleRenew(ws.id)}
460
+ >
461
+ ↻ Renew
462
+ </button>
463
+ <button
464
+ class="btn btn-ghost btn-sm"
465
+ onClick=${() => handleSharedRelease(ws.id)}
466
+ >
467
+ 🔓 Release
468
+ </button>
469
+ </div>
470
+ </div>
471
+ `;
472
+ })}
473
+ ${!workspaces.length &&
474
+ html`<${EmptyState} message="No shared workspaces configured." />`}
475
+ <//>
476
+ <//>
477
+
478
+ <!-- ─── Presence ─── -->
479
+ <${Collapsible} title="Presence" defaultOpen=${true}>
480
+ <${Card}>
481
+ <!-- Coordinator info -->
482
+ <div class="task-card mb-md">
483
+ <div class="task-card-title">🎯 Coordinator</div>
484
+ <div class="meta-text">
485
+ ${coordinator?.instance_label || coordinator?.instance_id || "none"}
486
+ · Priority ${coordinator?.coordinator_priority ?? "—"}
487
+ </div>
488
+ ${coordinator?.last_seen_at &&
489
+ html`
490
+ <div class="meta-text">
491
+ Last seen: ${formatRelative(coordinator.last_seen_at)}
492
+ </div>
493
+ `}
494
+ </div>
495
+
496
+ <!-- Instance grid -->
497
+ ${instances.length
498
+ ? html`
499
+ <div class="stats-grid">
500
+ ${instances.map(
501
+ (inst, i) => html`
502
+ <div
503
+ key=${i}
504
+ class="stat-card"
505
+ style="text-align:left;padding:10px"
506
+ >
507
+ <div style="display:flex;align-items:center;gap:6px">
508
+ <span
509
+ class="health-dot"
510
+ style="background:${inst.status === "offline"
511
+ ? "var(--color-error)"
512
+ : "var(--color-done)"}"
513
+ ></span>
514
+ <span style="font-weight:600;font-size:13px">
515
+ ${inst.instance_label || inst.instance_id}
516
+ </span>
517
+ </div>
518
+ <div class="meta-text">
519
+ ${inst.workspace_role || "workspace"} ·
520
+ ${inst.host || "host"}
521
+ </div>
522
+ <div class="meta-text">
523
+ Last:
524
+ ${inst.last_seen_at
525
+ ? formatRelative(inst.last_seen_at)
526
+ : "unknown"}
527
+ </div>
528
+ </div>
529
+ `,
530
+ )}
531
+ </div>
532
+ `
533
+ : html`<${EmptyState} message="No active instances." />`}
534
+ <//>
535
+ <//>
536
+ `;
537
+ }