@bill10/agent-007 0.6.2

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
Binary file
@@ -0,0 +1,245 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
6
+ <title>Agent 007</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M4 4 L16 16 L28 4 Z' fill='%23d4a847' opacity='0.25' stroke='%23d4a847' stroke-width='3' stroke-linejoin='round'/%3E%3Cline x1='16' y1='16' x2='16' y2='26' stroke='%23d4a847' stroke-width='2.5'/%3E%3Cline x1='10' y1='26' x2='22' y2='26' stroke='%23d4a847' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='22' cy='6' r='3' fill='%23d4a847'/%3E%3C/svg%3E">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
9
+ <link rel="stylesheet" href="style.css">
10
+ </head>
11
+ <body data-view="office">
12
+ <span class="topbar-status" id="status-bar" style="display:none"></span>
13
+
14
+ <!-- Spawn form (hidden by default) -->
15
+ <div class="spawn-form" id="spawn-form" style="display:none">
16
+ <div class="spawn-form-inner">
17
+ <div class="spawn-presets" id="spawn-presets"></div>
18
+ <div class="spawn-form-main">
19
+ <label class="spawn-repo-label">Repository
20
+ <div class="spawn-repo-wrapper">
21
+ <input type="text" id="spawn-repo" placeholder="/path/to/repo" spellcheck="false" autocomplete="off"><button type="button" class="spawn-browse-btn" id="btn-browse-repo" title="Browse directories">&#128194;</button>
22
+ <div class="repo-dropdown" id="repo-dropdown" style="display:none"></div>
23
+ </div>
24
+ </label>
25
+ <div class="spawn-form-actions">
26
+ <button id="btn-spawn-start">Start</button>
27
+ <button id="btn-spawn-cancel" class="secondary">Cancel</button>
28
+ </div>
29
+ </div>
30
+ <div class="spawn-form-error" id="spawn-form-error" style="display:none"></div>
31
+ <div class="spawn-advanced-toggle" id="spawn-advanced-toggle">Advanced &#x25B6;</div>
32
+ <div class="spawn-advanced" id="spawn-advanced" style="display:none">
33
+ <label>Command<input type="text" id="spawn-command" value="claude" spellcheck="false"></label>
34
+ <label>Agent / worktree name<input type="text" id="spawn-name" placeholder="auto" spellcheck="false"></label>
35
+ <label>Branch name<input type="text" id="spawn-branch" placeholder="auto" spellcheck="false"></label>
36
+ <label>Start from<input type="text" id="spawn-start-point" placeholder="origin/main (default)" spellcheck="false" title="Branch, tag or commit the new branch starts from. Blank uses the repo's base branch on the remote, freshly fetched."></label>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Directory browser modal -->
42
+ <div class="dir-browser-modal" id="dir-browser-modal" style="display:none">
43
+ <div class="dir-browser-inner">
44
+ <div class="dir-browser-header">
45
+ <span class="dir-browser-title">Select Repository</span>
46
+ <button class="dir-browser-close" id="btn-dir-browser-close">&times;</button>
47
+ </div>
48
+ <div class="dir-browser-path" id="dir-browser-path"></div>
49
+ <div class="dir-browser-list" id="dir-browser-list"></div>
50
+ <div class="dir-browser-actions">
51
+ <button id="btn-dir-browser-select" disabled>Select This Directory</button>
52
+ <button id="btn-dir-browser-cancel" class="secondary">Cancel</button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Main layout -->
58
+ <div class="main">
59
+ <!-- Left panel: File Explorer -->
60
+ <div class="explorer" id="explorer-panel">
61
+ <div class="panel-header">
62
+ <span class="topbar-title">Agent<svg class="app-logo" width="16" height="16" viewBox="0 0 32 32"><path d="M4 4 L16 16 L28 4 Z" fill="#d4a847" opacity="0.2" stroke="#d4a847" stroke-width="2" stroke-linejoin="round"/><line x1="16" y1="16" x2="16" y2="26" stroke="#d4a847" stroke-width="2"/><line x1="10" y1="26" x2="22" y2="26" stroke="#d4a847" stroke-width="2.5" stroke-linecap="round"/><circle cx="22" cy="6" r="2.5" fill="#d4a847"/></svg>007</span>
63
+ </div>
64
+ <div class="explorer-header">
65
+ <span class="explorer-label">REPOS</span>
66
+ <span class="explorer-header-actions">
67
+ <button class="explorer-icon-btn" id="btn-add-repo" title="Add repository"><svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 1v10M1 6h10" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
68
+ <button class="explorer-icon-btn" id="btn-refresh-tree" title="Refresh file trees"><svg width="12" height="12" viewBox="0 0 12 12"><path d="M10 4.5A4 4 0 1 0 9 9" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M10 1.5v3h-3" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
69
+ <button class="explorer-icon-btn" id="btn-toggle-explorer" title="Toggle explorer (Cmd+E)"><svg width="12" height="12" viewBox="0 0 12 12"><path d="M8 1L3 6l5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
70
+ </span>
71
+ </div>
72
+ <div class="add-repo-bar" id="add-repo-bar" style="display:none">
73
+ <input type="text" id="add-repo-input" placeholder="/path/to/repo" spellcheck="false" autocomplete="off">
74
+ <button class="add-repo-browse" id="add-repo-browse" title="Browse">
75
+ <svg width="12" height="12" viewBox="0 0 12 12"><path d="M1 3h4l1-1h5v8H1z" stroke="currentColor" stroke-width="1" fill="none"/></svg>
76
+ </button>
77
+ </div>
78
+ <div class="explorer-content" id="explorer-content" role="tree"></div>
79
+ </div>
80
+
81
+ <!-- Explorer reopen button (visible when collapsed) -->
82
+ <button class="explorer-reopen-btn" id="btn-reopen-explorer" title="Show file explorer (Cmd+E)" style="display:none"><svg width="12" height="12" viewBox="0 0 12 12"><path d="M4 1l5 5-5 5" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button>
83
+
84
+ <!-- Divider: Explorer | Office -->
85
+ <div class="divider" id="divider-explorer"></div>
86
+
87
+ <!-- Middle panel: Pixel Office / Diff Viewer -->
88
+ <div class="office" id="office-panel">
89
+ <div class="panel-header office-header">
90
+ <button class="topbar-btn" id="btn-new-agent">+ Agent</button>
91
+ <button class="topbar-btn" id="btn-new-job-shortcut" title="Post a job on the board">+ Job</button>
92
+ </div>
93
+ <canvas id="office-canvas"></canvas>
94
+ <div class="office-empty" id="office-empty">
95
+ <p>No agents yet</p>
96
+ <p class="dim">Click "+ Agent" to get started</p>
97
+ </div>
98
+ <div class="diff-viewer" id="diff-viewer" style="display:none">
99
+ <div class="diff-viewer-header">
100
+ <span class="diff-viewer-path" id="diff-viewer-path"></span>
101
+ <button class="diff-viewer-close" id="diff-viewer-close" title="Back to office">&times;</button>
102
+ </div>
103
+ <pre class="diff-viewer-content" id="diff-viewer-content"></pre>
104
+ </div>
105
+ </div>
106
+
107
+ <!-- Divider: Office | Terminal -->
108
+ <div class="divider" id="divider"></div>
109
+
110
+ <!-- Right panel: Terminal -->
111
+ <div class="terminal-panel" id="terminal-panel">
112
+ <div class="panel-header terminal-header">
113
+ <span class="topbar-agent-info" id="topbar-agent-info"></span>
114
+ <button class="icon-btn" id="theme-toggle" title="Toggle dark/light mode" aria-label="Toggle dark/light mode">
115
+ <svg id="theme-icon-moon" width="14" height="14" viewBox="0 0 14 14" style="display:none"><path d="M12 8.5A5.5 5.5 0 1 1 5.5 2 4 4 0 0 0 12 8.5z" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
116
+ <svg id="theme-icon-sun" width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="3" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M7 1v2M7 11v2M1 7h2M11 7h2M2.8 2.8l1.4 1.4M9.8 9.8l1.4 1.4M11.2 2.8l-1.4 1.4M4.2 9.8l-1.4 1.4" stroke="currentColor" stroke-width="1.2"/></svg>
117
+ </button>
118
+ </div>
119
+ <div class="terminal-tabs" id="terminal-tabs"></div>
120
+ <div class="terminal-viewport" id="terminal-viewport">
121
+ <div class="terminal-empty" id="terminal-empty">
122
+ <p>No agent selected</p>
123
+ </div>
124
+
125
+ <!-- Job board (shares the viewport with the terminals) -->
126
+ <div class="job-board" id="job-board" style="display:none">
127
+ <div class="job-board-toolbar">
128
+ <button class="job-new-btn" id="btn-new-job">+ New Job</button>
129
+ <span class="job-dispatcher">
130
+ <button class="job-run-toggle" id="btn-dispatcher-toggle">Start</button>
131
+ <button class="job-run-now" id="btn-dispatch-now" title="Scan the board now instead of waiting for the next interval">Run now</button>
132
+ <span class="job-dispatcher-status" id="job-dispatcher-status"></span>
133
+ </span>
134
+ <button class="job-finished-btn" id="btn-finished-jobs" aria-pressed="false" title="Jobs whose pull request merged or was closed, or that were marked done. They leave the board but are kept here.">View finished jobs</button>
135
+ <label class="job-cap-label" title="Maximum agents the board will run at once per repository">
136
+ max/repo
137
+ <input type="number" id="job-max-per-repo" min="1" max="10" step="1">
138
+ </label>
139
+ <label class="job-cap-label job-perm-label" title="How much a dispatched agent may do without asking. Auto lets Claude Code's classifier decide and escalate the rest to you; it needs a supported model, and where it is unavailable the agent quietly starts in manual and waits for a permission nobody is there to give. A card can override this for itself.">
140
+ permissions
141
+ <select id="job-permission-mode">
142
+ <option value="auto">auto — classifier decides</option>
143
+ <option value="acceptEdits">acceptEdits — edits, ask for the rest</option>
144
+ <option value="bypassPermissions">bypassPermissions — never asks</option>
145
+ <option value="manual">manual — ask for everything</option>
146
+ <option value="dontAsk">dontAsk</option>
147
+ <option value="plan">plan — read-only</option>
148
+ </select>
149
+ </label>
150
+ </div>
151
+
152
+ <div class="job-form" id="job-form" style="display:none">
153
+ <label>Title<input type="text" id="job-title" placeholder="What needs doing" spellcheck="false" maxlength="200"></label>
154
+ <label>Repository
155
+ <select id="job-repo"></select>
156
+ </label>
157
+ <div class="job-form-row">
158
+ <label>Type
159
+ <select id="job-type">
160
+ <option value="one-time">One-time</option>
161
+ <option value="scheduled">Scheduled</option>
162
+ </select>
163
+ </label>
164
+ <label title="Which CLI the board spawns for this job.">Agent
165
+ <select id="job-agent">
166
+ <option value="claude">Claude Code</option>
167
+ <option value="codex">Codex</option>
168
+ </select>
169
+ </label>
170
+ <label title="How much this job's agent may do without asking. Leave it on the board default unless this one job needs something different. A Codex card offers auto (Codex's own default) and bypassPermissions (--dangerously-bypass-approvals-and-sandbox); a board set to another mode maps onto Codex's nearest flag: plan is read-only, manual asks before every command, dontAsk never asks.">Permissions
171
+ <select id="job-permission-mode-field">
172
+ <option value="">Board default</option>
173
+ <option value="auto" data-codex-label="auto — Codex default, no flag">auto — classifier decides</option>
174
+ <option value="acceptEdits" data-claude-only>acceptEdits — edits, ask for the rest</option>
175
+ <option value="bypassPermissions" data-codex-label="bypassPermissions — no sandbox, never asks">bypassPermissions — never asks</option>
176
+ <option value="manual" data-claude-only>manual — ask for everything</option>
177
+ <option value="dontAsk" data-claude-only>dontAsk</option>
178
+ <option value="plan" data-claude-only>plan — read-only</option>
179
+ </select>
180
+ </label>
181
+ <label id="job-pr-field" title="Whether the work ends in a pull request. Choose Not required for research, investigations or chores that change no code: the agent reports a summary instead. On a schedule this is what each run gets, and it starts at Not required.">Pull request
182
+ <select id="job-requires-pr">
183
+ <option value="yes">Required</option>
184
+ <option value="no">Not required</option>
185
+ </select>
186
+ </label>
187
+ <label class="job-form-schedule" id="job-schedule-field" style="display:none">Schedule
188
+ <input type="text" id="job-schedule" placeholder="0 9 * * 1-5" spellcheck="false" autocomplete="off" maxlength="120">
189
+ <span class="job-form-hint" id="job-schedule-hint">min hour day month weekday, or @daily · server local time</span>
190
+ </label>
191
+ </div>
192
+ <label>Details<textarea id="job-detail" rows="5" placeholder="Everything the agent needs to know. It runs unattended, so be explicit about scope and acceptance criteria."></textarea></label>
193
+ <div class="job-form-attach">
194
+ <button type="button" id="btn-job-attach" class="secondary" title="Attach screenshots or files. The agent is told where to find them.">Attach files</button>
195
+ <span class="job-form-hint">or paste a screenshot into Details · 10MB each</span>
196
+ <input type="file" id="job-attach-input" multiple style="display:none">
197
+ <div class="job-form-attachments" id="job-attachments"></div>
198
+ </div>
199
+ <div class="job-form-actions">
200
+ <button id="btn-job-save">Post job</button>
201
+ <button id="btn-job-cancel" class="secondary">Cancel</button>
202
+ </div>
203
+ <div class="job-form-error" id="job-form-error" style="display:none"></div>
204
+ </div>
205
+
206
+ <div class="job-columns" id="job-columns"></div>
207
+
208
+ <!-- Finished jobs — shown instead of the columns, never beside them -->
209
+ <div class="job-finished" id="job-finished" style="display:none">
210
+ <div class="job-finished-header">
211
+ <span class="job-finished-label">Finished</span>
212
+ <span class="job-finished-count" id="job-finished-count"></span>
213
+ </div>
214
+ <div class="job-cards" id="job-finished-cards"></div>
215
+ </div>
216
+ </div>
217
+ <div class="voice-indicator" id="voice-indicator" aria-hidden="true" style="display:none">
218
+ <span class="voice-indicator-dot"></span><span class="voice-indicator-text"></span>
219
+ </div>
220
+ <span class="sr-only" id="voice-status" role="status" aria-live="polite"></span>
221
+ <button class="icon-btn voice-fab" id="btn-voice" title="Voice input (Cmd+D)" aria-label="Voice input" aria-pressed="false">
222
+ <svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><rect x="5" y="1.5" width="4" height="6.5" rx="2"/><path d="M2.8 6.5a4.2 4.2 0 0 0 8.4 0"/><line x1="7" y1="10.7" x2="7" y2="12.5"/></svg>
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Phone-width bottom nav: picks the one panel shown (see style.css) -->
229
+ <nav class="mobile-nav" id="mobile-nav" aria-label="Panels">
230
+ <button data-view="files">Files</button>
231
+ <button data-view="office" aria-current="true">Office</button>
232
+ <button data-view="terminal">Terminal</button>
233
+ </nav>
234
+
235
+ <!-- Reconnecting banner -->
236
+ <div class="reconnecting" id="reconnecting" style="display:none">
237
+ Reconnecting...
238
+ </div>
239
+
240
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
241
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
242
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
243
+ <script type="module" src="app.js"></script>
244
+ </body>
245
+ </html>
@@ -0,0 +1,83 @@
1
+ // Client-side identity & login (phase 1)
2
+ const TOKEN_KEY = 'agent007-token';
3
+
4
+ // Mirrors WS_UNAUTHORIZED in server/auth.js — keep in sync.
5
+ export const WS_UNAUTHORIZED = 4401;
6
+ export const DEFAULT_LOGIN_MESSAGE = 'Enter your access token to continue.';
7
+
8
+ export const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) =>
9
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
10
+ export const safeColor = (c) => /^#[0-9a-fA-F]{3,8}$/.test(String(c)) ? c : 'var(--accent)';
11
+
12
+ // On load, pull ?token= out of the URL, persist it, and strip it from the
13
+ // address bar so it doesn't linger in history or get shared accidentally.
14
+ export function captureTokenFromUrl() {
15
+ try {
16
+ const url = new URL(location.href);
17
+ if (url.searchParams.get('token')) {
18
+ localStorage.setItem(TOKEN_KEY, url.searchParams.get('token'));
19
+ url.searchParams.delete('token');
20
+ history.replaceState(null, '', url.pathname + url.search + url.hash);
21
+ }
22
+ } catch {}
23
+ }
24
+
25
+ export function getToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
26
+ export function setToken(t) { localStorage.setItem(TOKEN_KEY, t); }
27
+ export function clearToken() { localStorage.removeItem(TOKEN_KEY); }
28
+
29
+ // Spread into fetch() options so /api calls carry the bearer token.
30
+ export function authHeaders() {
31
+ const t = getToken();
32
+ return t ? { Authorization: `Bearer ${t}` } : {};
33
+ }
34
+
35
+ // Full-screen login overlay. Submitting stores the token and reloads so every
36
+ // connection (WS + fetch) picks it up cleanly.
37
+ export function showLogin(message = DEFAULT_LOGIN_MESSAGE) {
38
+ const existing = document.getElementById('login-overlay');
39
+ if (existing) {
40
+ if (message) existing.querySelector('#login-message').textContent = message;
41
+ return;
42
+ }
43
+ const overlay = document.createElement('div');
44
+ overlay.id = 'login-overlay';
45
+ overlay.className = 'login-overlay';
46
+ overlay.innerHTML = `
47
+ <form class="login-box" id="login-form">
48
+ <div class="login-title">Agent 007</div>
49
+ <div class="login-subtitle">Enter your access token</div>
50
+ <input type="password" id="login-token" class="login-input" placeholder="Paste token" autocomplete="off" spellcheck="false">
51
+ <button type="submit" class="login-submit">Enter</button>
52
+ <div class="login-message" id="login-message">${message || ''}</div>
53
+ </form>`;
54
+ document.body.appendChild(overlay);
55
+ overlay.querySelector('#login-form').onsubmit = (e) => {
56
+ e.preventDefault();
57
+ const val = overlay.querySelector('#login-token').value.trim();
58
+ if (!val) return;
59
+ setToken(val);
60
+ location.reload();
61
+ };
62
+ overlay.querySelector('#login-token').focus();
63
+ }
64
+
65
+ // Minimal presence pill: who's online right now (Phase 3 grows this into the
66
+ // office). Hidden when auth is off or nobody is present.
67
+ export function renderPresence(users, selfId) {
68
+ let el = document.getElementById('presence');
69
+ if (!el) {
70
+ el = document.createElement('div');
71
+ el.id = 'presence';
72
+ el.className = 'presence';
73
+ document.body.appendChild(el);
74
+ }
75
+ if (!users || users.length === 0) { el.style.display = 'none'; return; }
76
+ el.style.display = 'flex';
77
+ el.innerHTML = users.map(u => {
78
+ const initials = (u.displayName || '?').trim().slice(0, 2).toUpperCase();
79
+ const isSelf = u.id === selfId;
80
+ const title = escapeHtml(u.displayName) + (isSelf ? ' (you)' : '');
81
+ return `<span class="presence-dot${isSelf ? ' presence-self' : ''}" style="background:${safeColor(u.color)}" title="${title}">${escapeHtml(initials)}</span>`;
82
+ }).join('');
83
+ }