agentopia 1.0.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 (140) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/dist/app.d.ts +10 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +121 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +19 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.d.ts +5 -0
  11. package/dist/db/database.d.ts.map +1 -0
  12. package/dist/db/database.js +39 -0
  13. package/dist/db/database.js.map +1 -0
  14. package/dist/db/schema.d.ts +3 -0
  15. package/dist/db/schema.d.ts.map +1 -0
  16. package/dist/db/schema.js +621 -0
  17. package/dist/db/schema.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +49 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/logger.d.ts +4 -0
  23. package/dist/logger.d.ts.map +1 -0
  24. package/dist/logger.js +9 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/middleware/auth.d.ts +13 -0
  27. package/dist/middleware/auth.d.ts.map +1 -0
  28. package/dist/middleware/auth.js +733 -0
  29. package/dist/middleware/auth.js.map +1 -0
  30. package/dist/routes/agents.d.ts +3 -0
  31. package/dist/routes/agents.d.ts.map +1 -0
  32. package/dist/routes/agents.js +1058 -0
  33. package/dist/routes/agents.js.map +1 -0
  34. package/dist/routes/issues.d.ts +4 -0
  35. package/dist/routes/issues.d.ts.map +1 -0
  36. package/dist/routes/issues.js +946 -0
  37. package/dist/routes/issues.js.map +1 -0
  38. package/dist/routes/knowledge.d.ts +3 -0
  39. package/dist/routes/knowledge.d.ts.map +1 -0
  40. package/dist/routes/knowledge.js +117 -0
  41. package/dist/routes/knowledge.js.map +1 -0
  42. package/dist/routes/memories.d.ts +3 -0
  43. package/dist/routes/memories.d.ts.map +1 -0
  44. package/dist/routes/memories.js +115 -0
  45. package/dist/routes/memories.js.map +1 -0
  46. package/dist/routes/messages.d.ts +3 -0
  47. package/dist/routes/messages.d.ts.map +1 -0
  48. package/dist/routes/messages.js +130 -0
  49. package/dist/routes/messages.js.map +1 -0
  50. package/dist/routes/projects.d.ts +3 -0
  51. package/dist/routes/projects.d.ts.map +1 -0
  52. package/dist/routes/projects.js +754 -0
  53. package/dist/routes/projects.js.map +1 -0
  54. package/dist/routes/templates.d.ts +3 -0
  55. package/dist/routes/templates.d.ts.map +1 -0
  56. package/dist/routes/templates.js +117 -0
  57. package/dist/routes/templates.js.map +1 -0
  58. package/dist/routes/ui.d.ts +3 -0
  59. package/dist/routes/ui.d.ts.map +1 -0
  60. package/dist/routes/ui.js +38 -0
  61. package/dist/routes/ui.js.map +1 -0
  62. package/dist/services/agent-hierarchy.d.ts +14 -0
  63. package/dist/services/agent-hierarchy.d.ts.map +1 -0
  64. package/dist/services/agent-hierarchy.js +58 -0
  65. package/dist/services/agent-hierarchy.js.map +1 -0
  66. package/dist/services/agent-issue-batch.d.ts +17 -0
  67. package/dist/services/agent-issue-batch.d.ts.map +1 -0
  68. package/dist/services/agent-issue-batch.js +57 -0
  69. package/dist/services/agent-issue-batch.js.map +1 -0
  70. package/dist/services/controller.d.ts +4 -0
  71. package/dist/services/controller.d.ts.map +1 -0
  72. package/dist/services/controller.js +237 -0
  73. package/dist/services/controller.js.map +1 -0
  74. package/dist/services/langgraph-runner.d.ts +33 -0
  75. package/dist/services/langgraph-runner.d.ts.map +1 -0
  76. package/dist/services/langgraph-runner.js +478 -0
  77. package/dist/services/langgraph-runner.js.map +1 -0
  78. package/dist/services/orchestrator.d.ts +9 -0
  79. package/dist/services/orchestrator.d.ts.map +1 -0
  80. package/dist/services/orchestrator.js +116 -0
  81. package/dist/services/orchestrator.js.map +1 -0
  82. package/dist/services/pre-controller.d.ts +7 -0
  83. package/dist/services/pre-controller.d.ts.map +1 -0
  84. package/dist/services/pre-controller.js +101 -0
  85. package/dist/services/pre-controller.js.map +1 -0
  86. package/dist/services/process-manager.d.ts +67 -0
  87. package/dist/services/process-manager.d.ts.map +1 -0
  88. package/dist/services/process-manager.js +938 -0
  89. package/dist/services/process-manager.js.map +1 -0
  90. package/dist/services/project-permissions.d.ts +84 -0
  91. package/dist/services/project-permissions.d.ts.map +1 -0
  92. package/dist/services/project-permissions.js +129 -0
  93. package/dist/services/project-permissions.js.map +1 -0
  94. package/dist/services/scheduler.d.ts +6 -0
  95. package/dist/services/scheduler.d.ts.map +1 -0
  96. package/dist/services/scheduler.js +300 -0
  97. package/dist/services/scheduler.js.map +1 -0
  98. package/dist/services/system-prompt.d.ts +3 -0
  99. package/dist/services/system-prompt.d.ts.map +1 -0
  100. package/dist/services/system-prompt.js +285 -0
  101. package/dist/services/system-prompt.js.map +1 -0
  102. package/dist/services/terminal.d.ts +18 -0
  103. package/dist/services/terminal.d.ts.map +1 -0
  104. package/dist/services/terminal.js +222 -0
  105. package/dist/services/terminal.js.map +1 -0
  106. package/dist/services/websocket.d.ts +15 -0
  107. package/dist/services/websocket.d.ts.map +1 -0
  108. package/dist/services/websocket.js +204 -0
  109. package/dist/services/websocket.js.map +1 -0
  110. package/dist/types.d.ts +108 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +3 -0
  113. package/dist/types.js.map +1 -0
  114. package/env.ini +18 -0
  115. package/package.json +38 -0
  116. package/project_id +0 -0
  117. package/public/admin-users.html +188 -0
  118. package/public/agent.html +199 -0
  119. package/public/css/issues.css +275 -0
  120. package/public/css/style.css +1299 -0
  121. package/public/index.html +166 -0
  122. package/public/issue.html +76 -0
  123. package/public/js/agent.js +19 -0
  124. package/public/js/common.js +735 -0
  125. package/public/js/dashboard.js +772 -0
  126. package/public/js/files-panel.js +703 -0
  127. package/public/js/interactive-terminal.js +201 -0
  128. package/public/js/issue-renderer.js +559 -0
  129. package/public/js/issue.js +57 -0
  130. package/public/js/project.js +2425 -0
  131. package/public/js/terminal.js +564 -0
  132. package/public/project.html +430 -0
  133. package/public/terminal.html +67 -0
  134. package/public/vendor/marked.js +74 -0
  135. package/public/vendor/xterm-addon-fit.js +2 -0
  136. package/public/vendor/xterm.css +209 -0
  137. package/public/vendor/xterm.js +2 -0
  138. package/send_message_and_update_issue.js +65 -0
  139. package/tsconfig.json +19 -0
  140. package/update_round2_and_create_round3.js +284 -0
@@ -0,0 +1,735 @@
1
+ // ─── User Menu ───
2
+
3
+ let _currentUser = null;
4
+
5
+ async function initUserMenu() {
6
+ try {
7
+ const res = await fetch('/api/auth/me', { cache: 'no-cache' });
8
+ if (!res.ok) {
9
+ console.warn('[Argus] initUserMenu: /api/auth/me returned', res.status, '— avatar will not show');
10
+ return;
11
+ }
12
+ _currentUser = await res.json();
13
+ window.dispatchEvent(new CustomEvent('argus:user-ready', { detail: _currentUser }));
14
+ } catch (e) {
15
+ console.warn('[Argus] initUserMenu: fetch failed —', e.message || e);
16
+ return;
17
+ }
18
+
19
+ // Append user menu to .header-right if it exists, otherwise to header
20
+ const headerRight = document.querySelector('.header-right') || document.querySelector('header');
21
+ if (!headerRight) return;
22
+
23
+ const menu = document.createElement('div');
24
+ menu.className = 'user-menu';
25
+ const initials = (_currentUser.display_name || _currentUser.username || '?').charAt(0).toUpperCase();
26
+ menu.innerHTML = `
27
+ <button class="user-menu-btn" title="${esc(_currentUser.display_name || _currentUser.username)}">${esc(initials)}</button>
28
+ <div class="user-menu-dropdown" id="user-menu-dropdown">
29
+ <div class="user-menu-info">
30
+ <div class="name">${esc(_currentUser.display_name || _currentUser.username)}</div>
31
+ <div class="role">${esc(_currentUser.role)}</div>
32
+ </div>
33
+ ${_currentUser.role === 'admin' ? '<a href="/admin/users">User Management</a>' : ''}
34
+ <a href="/change-password">Change Password</a>
35
+ <div class="divider"></div>
36
+ <button class="menu-item" onclick="doLogout()">Logout</button>
37
+ </div>
38
+ `;
39
+ headerRight.appendChild(menu);
40
+
41
+ const btn = menu.querySelector('.user-menu-btn');
42
+ const dropdown = menu.querySelector('.user-menu-dropdown');
43
+ btn.addEventListener('click', (e) => {
44
+ e.stopPropagation();
45
+ dropdown.classList.toggle('open');
46
+ });
47
+ document.addEventListener('click', () => dropdown.classList.remove('open'));
48
+ }
49
+
50
+ async function doLogout() {
51
+ await fetch('/api/auth/logout', { method: 'POST' });
52
+ window.location.href = '/login';
53
+ }
54
+
55
+ document.addEventListener('DOMContentLoaded', initUserMenu);
56
+
57
+ // ─── Shared utility functions ───
58
+
59
+ function esc(s) {
60
+ const d = document.createElement('div');
61
+ d.textContent = s || '';
62
+ return d.innerHTML;
63
+ }
64
+
65
+ function apiHeaders() {
66
+ return { 'Content-Type': 'application/json' };
67
+ }
68
+
69
+ async function withLoading(btn, asyncFn) {
70
+ if (!btn || btn.disabled) return;
71
+ const originalText = btn.textContent;
72
+ btn.disabled = true;
73
+ btn.textContent = originalText + '…';
74
+ try {
75
+ await asyncFn();
76
+ } finally {
77
+ btn.disabled = false;
78
+ btn.textContent = originalText;
79
+ }
80
+ }
81
+
82
+ function parseServerDate(dateStr) {
83
+ if (!dateStr) return null;
84
+ const value = String(dateStr).trim();
85
+ if (!value) return null;
86
+ const normalized = /(?:Z|[+-]\d{2}:\d{2})$/.test(value) ? value : value.replace(' ', 'T') + 'Z';
87
+ const date = new Date(normalized);
88
+ return Number.isNaN(date.getTime()) ? null : date;
89
+ }
90
+
91
+ function formatLocalDateTime(dateStr) {
92
+ const date = parseServerDate(dateStr);
93
+ if (!date) return '-';
94
+ return date.toLocaleString([], {
95
+ year: 'numeric',
96
+ month: '2-digit',
97
+ day: '2-digit',
98
+ hour: '2-digit',
99
+ minute: '2-digit',
100
+ second: '2-digit',
101
+ hour12: false,
102
+ });
103
+ }
104
+
105
+ function formatLocalTime(dateStr) {
106
+ const date = parseServerDate(dateStr);
107
+ if (!date) return '-';
108
+ return date.toLocaleTimeString([], {
109
+ hour: '2-digit',
110
+ minute: '2-digit',
111
+ second: '2-digit',
112
+ hour12: false,
113
+ });
114
+ }
115
+
116
+ function timeAgo(dateStr) {
117
+ if (!dateStr) return '-';
118
+ const now = Date.now();
119
+ const thenDate = parseServerDate(dateStr);
120
+ if (!thenDate) return '-';
121
+ const then = thenDate.getTime();
122
+ const diff = now - then;
123
+ if (diff < 0) return 'just now';
124
+ const mins = Math.floor(diff / 60000);
125
+ if (mins < 1) return 'just now';
126
+ if (mins < 60) return mins + 'm ago';
127
+ const hours = Math.floor(mins / 60);
128
+ if (hours < 24) return hours + 'h ago';
129
+ const days = Math.floor(hours / 24);
130
+ return days + 'd ago';
131
+ }
132
+
133
+ function priorityBadge(p) {
134
+ if (p >= 10) return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(220,50,47,0.15);color:var(--error)">USER</span>';
135
+ if (p >= 5) return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(181,137,0,0.15);color:var(--warning)">CTRL</span>';
136
+ return '<span style="font-size:10px;padding:1px 6px;border-radius:8px;background:rgba(88,110,117,0.15);color:var(--text-secondary)">AGENT</span>';
137
+ }
138
+
139
+ // nameOf resolves agent IDs to names. Uses the global `agentsData` array if available.
140
+ function nameOf(id) {
141
+ if (id === 'user') return 'User';
142
+ if (id === 'all') return 'All';
143
+ if (typeof agentsData !== 'undefined') {
144
+ const a = agentsData.find(x => x.id === id);
145
+ if (a) return a.name;
146
+ }
147
+ return (id || '').slice(0, 8);
148
+ }
149
+
150
+ // ─── Loading & Error helpers ───
151
+
152
+ function renderLoading(text, small) {
153
+ var cls = 'loading-spinner' + (small ? ' small' : '');
154
+ return '<div class="' + cls + '"><div class="spinner"></div>' + esc(text || 'Loading...') + '</div>';
155
+ }
156
+
157
+ function renderError(err, onRetryId) {
158
+ var msg = 'Failed to load';
159
+ if (err) {
160
+ if (typeof err === 'string') msg = err;
161
+ else if (err.status === 0 || err.message === 'Failed to fetch') msg = 'Network error, please check your connection';
162
+ else if (err.status >= 500) msg = 'Server error, please try again later';
163
+ else if (err.status >= 400) msg = 'Request failed (resource not found or no permission)';
164
+ else if (err.message) msg = err.message;
165
+ }
166
+ var retryHtml = onRetryId ? '<button class="retry-btn" onclick="' + onRetryId + '">Retry</button>' : '';
167
+ return '<div class="error-retry"><div class="error-msg">' + esc(msg) + '</div>' + retryHtml + '</div>';
168
+ }
169
+
170
+ function renderCollapsibleText(text, options) {
171
+ const value = text == null ? '' : String(text);
172
+ const opts = options || {};
173
+ const previewChars = Number.isFinite(opts.previewChars) ? opts.previewChars : 120;
174
+ const className = opts.className ? ' ' + opts.className : '';
175
+ const styleAttr = opts.style ? ` style="${opts.style}"` : '';
176
+ const expandLabel = opts.expandLabel || 'Expand';
177
+ const collapseLabel = opts.collapseLabel || 'Collapse';
178
+ const contentHtml = `<span class="collapsible-text__content">${esc(value)}</span>`;
179
+ const needsCollapse = value.length > previewChars || /[\r\n]/.test(value);
180
+
181
+ if (!needsCollapse) {
182
+ return `<span class="collapsible-text${className}"${styleAttr}>${contentHtml}</span>`;
183
+ }
184
+
185
+ return `<button type="button" class="collapsible-text is-collapsible${className}" data-collapsible-text data-expanded="false" data-expand-label="${esc(expandLabel)}" data-collapse-label="${esc(collapseLabel)}" aria-expanded="false" title="Click to expand"${styleAttr}>${contentHtml}<span class="collapsible-text__hint" aria-hidden="true">${esc(expandLabel)}</span></button>`;
186
+ }
187
+
188
+ document.addEventListener('click', function(e) {
189
+ const trigger = e.target.closest('[data-collapsible-text]');
190
+ if (!trigger) return;
191
+ const expanded = trigger.getAttribute('data-expanded') === 'true';
192
+ const nextExpanded = !expanded;
193
+ trigger.setAttribute('data-expanded', nextExpanded ? 'true' : 'false');
194
+ trigger.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
195
+ trigger.title = nextExpanded ? 'Click to collapse' : 'Click to expand';
196
+ const hint = trigger.querySelector('.collapsible-text__hint');
197
+ if (hint) {
198
+ hint.textContent = trigger.getAttribute(nextExpanded ? 'data-collapse-label' : 'data-expand-label') || (nextExpanded ? 'Collapse' : 'Expand');
199
+ }
200
+ });
201
+
202
+ // Toast notifications
203
+ function showToast(message, type) {
204
+ type = type || 'info';
205
+ let container = document.getElementById('toast-container');
206
+ if (!container) {
207
+ container = document.createElement('div');
208
+ container.id = 'toast-container';
209
+ container.className = 'toast-container';
210
+ document.body.appendChild(container);
211
+ }
212
+ const toast = document.createElement('div');
213
+ toast.className = 'toast toast-' + type;
214
+ toast.textContent = message;
215
+ toast.onclick = function() { toast.remove(); };
216
+ container.appendChild(toast);
217
+ setTimeout(function() { toast.remove(); }, 3000);
218
+ }
219
+
220
+ function showConfirm(message) {
221
+ return new Promise(resolve => {
222
+ let overlay = document.getElementById('confirm-overlay');
223
+ if (!overlay) {
224
+ overlay = document.createElement('div');
225
+ overlay.id = 'confirm-overlay';
226
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999';
227
+ document.body.appendChild(overlay);
228
+ }
229
+ overlay.style.display = 'flex';
230
+ overlay.innerHTML = `<div style="background:var(--bg-secondary,#1e1e2e);border:1px solid var(--border,#333);border-radius:8px;padding:20px;max-width:400px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.3)">
231
+ <div style="margin-bottom:16px;font-size:14px;line-height:1.5">${esc(message)}</div>
232
+ <div style="display:flex;gap:8px;justify-content:flex-end">
233
+ <button class="btn btn-sm" id="confirm-cancel" style="padding:6px 16px">Cancel</button>
234
+ <button class="btn btn-sm" id="confirm-ok" style="padding:6px 16px;background:var(--accent);color:#fff">OK</button>
235
+ </div>
236
+ </div>`;
237
+ const close = val => { overlay.style.display = 'none'; resolve(val); };
238
+ document.getElementById('confirm-ok').onclick = () => close(true);
239
+ document.getElementById('confirm-cancel').onclick = () => close(false);
240
+ overlay.onclick = e => { if (e.target === overlay) close(false); };
241
+ });
242
+ }
243
+
244
+ // ─── Keyboard shortcuts ───
245
+
246
+ // ESC to close modals
247
+ document.addEventListener('keydown', function(e) {
248
+ if (e.key === 'Escape') {
249
+ // Close the topmost open modal-overlay
250
+ const modals = document.querySelectorAll('.modal-overlay.active');
251
+ if (modals.length > 0) {
252
+ modals[modals.length - 1].classList.remove('active');
253
+ e.preventDefault();
254
+ return;
255
+ }
256
+ // Close drawer if open
257
+ const drawer = document.getElementById('drawer');
258
+ if (drawer && drawer.classList.contains('open')) {
259
+ closeDrawer();
260
+ e.preventDefault();
261
+ }
262
+ }
263
+ });
264
+
265
+ // Click overlay background to close modal
266
+ document.addEventListener('click', function(e) {
267
+ if (e.target.classList.contains('modal-overlay') && e.target.classList.contains('active')) {
268
+ e.target.classList.remove('active');
269
+ }
270
+ });
271
+
272
+ // Ctrl+Enter to submit forms
273
+ document.addEventListener('keydown', function(e) {
274
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
275
+ const el = e.target;
276
+ // Check if inside a modal — find the submit button
277
+ const modal = el.closest('.modal-overlay.active');
278
+ if (modal) {
279
+ const submitBtn = modal.querySelector('.btn-primary');
280
+ if (submitBtn) { submitBtn.click(); e.preventDefault(); return; }
281
+ }
282
+ // Comment input on issue page
283
+ if (el.id === 'ir-comment-input') {
284
+ const submitBtn = document.querySelector('button[onclick="IssueRenderer.addComment()"]');
285
+ if (submitBtn) { submitBtn.click(); e.preventDefault(); }
286
+ }
287
+ }
288
+ });
289
+
290
+ // ─── Avatars — GitHub-style identicon based on name hash ───
291
+ const AVATAR_COLORS = ['#e06c75','#98c379','#e5c07b','#61afef','#c678dd','#56b6c2','#be5046','#d19a66','#7ec8e3','#b5bd68','#cc6666','#8abeb7','#f0c674','#81a2be','#b294bb'];
292
+
293
+ function hashCode(s) {
294
+ let h = 0;
295
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
296
+ return Math.abs(h);
297
+ }
298
+
299
+ function avatarSvg(name, size) {
300
+ size = size || 28;
301
+ if (name === 'user' || name === 'User') {
302
+ // User: fixed person silhouette
303
+ return `<svg width="${size}" height="${size}" viewBox="0 0 5 5"><rect width="5" height="5" rx="0.5" fill="#268bd2"/><circle cx="2.5" cy="1.8" r="0.9" fill="rgba(255,255,255,0.9)"/><ellipse cx="2.5" cy="4.2" rx="1.5" ry="1.2" fill="rgba(255,255,255,0.9)"/></svg>`;
304
+ }
305
+ if (name === 'all' || name === 'All') {
306
+ return `<svg width="${size}" height="${size}" viewBox="0 0 5 5"><rect width="5" height="5" rx="0.5" fill="#859900"/><circle cx="1.5" cy="1.8" r="0.7" fill="rgba(255,255,255,0.85)"/><circle cx="3.5" cy="1.8" r="0.7" fill="rgba(255,255,255,0.85)"/><ellipse cx="2.5" cy="4" rx="2" ry="1" fill="rgba(255,255,255,0.85)"/></svg>`;
307
+ }
308
+ // GitHub-style 5x5 symmetric identicon
309
+ const h = hashCode(name || '?');
310
+ const color = AVATAR_COLORS[h % AVATAR_COLORS.length];
311
+ // Generate 15 bits for the left half + center column of 5x5 grid (mirrored)
312
+ let bits = h;
313
+ let cells = '';
314
+ for (let y = 0; y < 5; y++) {
315
+ for (let x = 0; x < 3; x++) {
316
+ if ((bits >> (y * 3 + x)) & 1) {
317
+ cells += `<rect x="${x}" y="${y}" width="1" height="1" fill="${color}"/>`;
318
+ if (x < 2) cells += `<rect x="${4-x}" y="${y}" width="1" height="1" fill="${color}"/>`;
319
+ }
320
+ }
321
+ }
322
+ return `<svg width="${size}" height="${size}" viewBox="-0.5 -0.5 6 6"><rect x="-0.5" y="-0.5" width="6" height="6" rx="0.8" fill="var(--selected-bg, #eee)"/>${cells}</svg>`;
323
+ }
324
+
325
+ // Themes
326
+ const themes = {
327
+ 'github-dark': { bg:'#0d1117', fg:'#e6edf3', headerBg:'#161b22', drawerBg:'#161b22', border:'#30363d', textSecondary:'#8b949e', accent:'#58a6ff', success:'#3fb950', warning:'#d29922', error:'#f85149', selectedBg:'#21262d' },
328
+ 'dracula': { bg:'#282a36', fg:'#f8f8f2', headerBg:'#21222c', drawerBg:'#21222c', border:'#44475a', textSecondary:'#6272a4', accent:'#8be9fd', success:'#50fa7b', warning:'#f1fa8c', error:'#ff5555', selectedBg:'#282a36' },
329
+ 'nord-dark': { bg:'#2e3440', fg:'#d8dee9', headerBg:'#3b4252', drawerBg:'#3b4252', border:'#4c566a', textSecondary:'#81a1c1', accent:'#88c0d0', success:'#a3be8c', warning:'#ebcb8b', error:'#bf616a', selectedBg:'#2e3440' },
330
+ 'nord-light': { bg:'#ECEFF4', fg:'#2E3440', headerBg:'#E5E9F0', drawerBg:'#E5E9F0', border:'#D8DEE9', textSecondary:'#4C566A', accent:'#5E81AC', success:'#A3BE8C', warning:'#EBCB8B', error:'#BF616A', selectedBg:'#D8DEE9' },
331
+ 'monokai': { bg:'#272822', fg:'#f8f8f2', headerBg:'#1e1f1c', drawerBg:'#1e1f1c', border:'#3e3d32', textSecondary:'#75715e', accent:'#66d9ef', success:'#a6e22e', warning:'#e6db74', error:'#f92672', selectedBg:'#272822' },
332
+ 'solarized-dark': { bg:'#002b36', fg:'#839496', headerBg:'#073642', drawerBg:'#073642', border:'#586e75', textSecondary:'#657b83', accent:'#268bd2', success:'#859900', warning:'#b58900', error:'#dc322f', selectedBg:'#002b36' },
333
+ 'solarized-light': { bg:'#fdf6e3', fg:'#073642', headerBg:'#eee8d5', drawerBg:'#eee8d5', border:'#c9bba3', textSecondary:'#586e75', accent:'#268bd2', success:'#859900', warning:'#b58900', error:'#dc322f', selectedBg:'#e8dcc8' },
334
+ };
335
+
336
+ function applyTheme(name) {
337
+ // Backward compat: 'nord' was renamed to 'nord-dark'
338
+ if (name === 'nord') { name = 'nord-dark'; localStorage.setItem('argus-theme', name); }
339
+ const t = themes[name] || themes['github-dark'];
340
+ const r = document.documentElement;
341
+ r.style.setProperty('--bg', t.bg);
342
+ r.style.setProperty('--fg', t.fg);
343
+ r.style.setProperty('--header-bg', t.headerBg);
344
+ r.style.setProperty('--drawer-bg', t.drawerBg);
345
+ r.style.setProperty('--border', t.border);
346
+ r.style.setProperty('--text-secondary', t.textSecondary);
347
+ r.style.setProperty('--accent', t.accent);
348
+ r.style.setProperty('--success', t.success);
349
+ r.style.setProperty('--warning', t.warning);
350
+ r.style.setProperty('--error', t.error);
351
+ r.style.setProperty('--selected-bg', t.selectedBg);
352
+ }
353
+
354
+ function changeTheme(name) {
355
+ localStorage.setItem('argus-theme', name);
356
+ applyTheme(name);
357
+ }
358
+
359
+ // Drawer
360
+ function toggleDrawer() {
361
+ const drawer = document.getElementById('drawer');
362
+ const overlay = document.getElementById('overlay');
363
+ const isOpen = drawer.classList.contains('open');
364
+ if (isOpen) { closeDrawer(); } else { openDrawer(); }
365
+ }
366
+
367
+ function openDrawer() {
368
+ document.getElementById('drawer').classList.add('open');
369
+ document.getElementById('overlay').classList.add('open');
370
+ }
371
+
372
+ function closeDrawer() {
373
+ document.getElementById('drawer').classList.remove('open');
374
+ document.getElementById('overlay').classList.remove('open');
375
+ }
376
+
377
+ // Override fetch — on 401 API response, redirect to /login
378
+ const _originalFetch = window.fetch;
379
+ window.fetch = function(input, init) {
380
+ return _originalFetch.call(this, input, init).then(function(resp) {
381
+ if (resp.status === 401) {
382
+ var url = typeof input === 'string' ? input : (input && input.url ? input.url : '');
383
+ // Don't redirect for auth endpoints (login/setup/logout)
384
+ if (url.indexOf('/api/auth') === -1) {
385
+ window.location.href = '/login';
386
+ }
387
+ }
388
+ return resp;
389
+ });
390
+ };
391
+
392
+ // Logout
393
+ async function logout() {
394
+ await fetch('/api/auth/logout', { method: 'POST' });
395
+ window.location.href = '/login';
396
+ }
397
+
398
+ // Request notification permission
399
+ if ('Notification' in window && Notification.permission === 'default') {
400
+ Notification.requestPermission();
401
+ }
402
+
403
+ // ─── Notification Sound ───
404
+
405
+ // Web Audio API notification sound (short "ding")
406
+ let _notifAudioCtx = null;
407
+ let _notifLastPlayTime = 0;
408
+
409
+ // Unlock AudioContext on first user interaction (browser autoplay policy)
410
+ // Listen on multiple event types to maximize chances of unlocking
411
+ function _unlockAudioCtx() {
412
+ if (!_notifAudioCtx) {
413
+ _notifAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
414
+ }
415
+ if (_notifAudioCtx.state === 'suspended') {
416
+ _notifAudioCtx.resume();
417
+ }
418
+ }
419
+ ['click', 'keydown', 'touchstart', 'mousedown'].forEach(function(evt) {
420
+ document.addEventListener(evt, _unlockAudioCtx, { once: false, passive: true });
421
+ });
422
+
423
+ function _playDingSound(ctx) {
424
+ var t = ctx.currentTime;
425
+ var osc1 = ctx.createOscillator();
426
+ var osc2 = ctx.createOscillator();
427
+ var gain = ctx.createGain();
428
+
429
+ osc1.type = 'sine';
430
+ osc1.frequency.setValueAtTime(880, t); // A5
431
+ osc2.type = 'sine';
432
+ osc2.frequency.setValueAtTime(1175, t + 0.1); // D6
433
+
434
+ gain.gain.setValueAtTime(0.3, t);
435
+ gain.gain.exponentialRampToValueAtTime(0.01, t + 0.4);
436
+
437
+ osc1.connect(gain);
438
+ osc2.connect(gain);
439
+ gain.connect(ctx.destination);
440
+
441
+ osc1.start(t);
442
+ osc1.stop(t + 0.15);
443
+ osc2.start(t + 0.1);
444
+ osc2.stop(t + 0.4);
445
+ }
446
+
447
+ function playNotificationSound() {
448
+ // Check setting
449
+ if (localStorage.getItem('argus-notification-sound') === 'off') return;
450
+
451
+ // Throttle: no more than once per 5 seconds
452
+ var now = Date.now();
453
+ if (now - _notifLastPlayTime < 5000) return;
454
+ _notifLastPlayTime = now;
455
+
456
+ // Create AudioContext if needed
457
+ if (!_notifAudioCtx) {
458
+ _notifAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
459
+ }
460
+
461
+ var ctx = _notifAudioCtx;
462
+
463
+ // If suspended, resume first then play after resume completes
464
+ if (ctx.state === 'suspended') {
465
+ ctx.resume().then(function() {
466
+ if (ctx.state === 'running') {
467
+ _playDingSound(ctx);
468
+ }
469
+ });
470
+ } else if (ctx.state === 'running') {
471
+ _playDingSound(ctx);
472
+ }
473
+ }
474
+
475
+ function toggleNotificationSound() {
476
+ const current = localStorage.getItem('argus-notification-sound') !== 'off';
477
+ const newVal = current ? 'off' : 'on';
478
+ localStorage.setItem('argus-notification-sound', newVal);
479
+ // Update all toggles on the page
480
+ document.querySelectorAll('.notif-sound-toggle').forEach(function(el) {
481
+ el.classList.toggle('on', newVal === 'on');
482
+ });
483
+ }
484
+
485
+ // Init notification sound toggles on page load
486
+ document.addEventListener('DOMContentLoaded', function() {
487
+ const isOn = localStorage.getItem('argus-notification-sound') !== 'off';
488
+ document.querySelectorAll('.notif-sound-toggle').forEach(function(el) {
489
+ el.classList.toggle('on', isOn);
490
+ });
491
+ });
492
+
493
+ // Init theme
494
+ (function() {
495
+ const saved = localStorage.getItem('argus-theme') || 'solarized-light';
496
+ applyTheme(saved);
497
+ const sel = document.getElementById('theme-select');
498
+ if (sel) sel.value = saved;
499
+ })();
500
+
501
+ // ─── Project-level WebSocket for real-time updates ───
502
+
503
+ /**
504
+ * Connect to a project's event stream. Returns an object with .close() and .on(type, cb).
505
+ * Reconnects automatically on disconnect.
506
+ * Event types: agent_status, issue_created, issue_updated, comment_added
507
+ */
508
+ function connectProjectEvents(projectId) {
509
+ const listeners = {};
510
+ let ws = null;
511
+ let closed = false;
512
+ let retryDelay = 1000;
513
+
514
+ function on(type, cb) {
515
+ if (!listeners[type]) listeners[type] = [];
516
+ listeners[type].push(cb);
517
+ }
518
+
519
+ function emit(type, data) {
520
+ (listeners[type] || []).forEach(cb => { try { cb(data); } catch(e) { console.error('WS listener error:', e); } });
521
+ (listeners['*'] || []).forEach(cb => { try { cb({ type, ...data }); } catch(e) {} });
522
+ }
523
+
524
+ function updateWsIndicator(state) {
525
+ const el = document.getElementById('ws-status-indicator');
526
+ if (!el) return;
527
+ const colors = { connected: '#3fb950', connecting: '#d29922', disconnected: '#8b949e' };
528
+ const labels = { connected: 'Live updates connected', connecting: 'Connecting...', disconnected: 'Live updates disconnected' };
529
+ el.innerHTML = `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${colors[state] || colors.disconnected};margin-right:4px"></span><span style="font-size:11px;color:var(--text-secondary)">${labels[state] || ''}</span>`;
530
+ el.title = labels[state] || '';
531
+ }
532
+
533
+ function connect() {
534
+ if (closed) return;
535
+ updateWsIndicator('connecting');
536
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
537
+ ws = new WebSocket(`${proto}//${location.host}/ws/projects/${projectId}/events`);
538
+
539
+ ws.onopen = function() { retryDelay = 1000; updateWsIndicator('connected'); };
540
+
541
+ ws.onmessage = function(e) {
542
+ try {
543
+ const msg = JSON.parse(e.data);
544
+ if (msg.type) emit(msg.type, msg.data || msg);
545
+ } catch {}
546
+ };
547
+
548
+ ws.onclose = function() {
549
+ updateWsIndicator('disconnected');
550
+ if (!closed) {
551
+ setTimeout(connect, retryDelay);
552
+ retryDelay = Math.min(retryDelay * 1.5, 15000);
553
+ }
554
+ };
555
+
556
+ ws.onerror = function() { /* onclose will fire */ };
557
+ }
558
+
559
+ connect();
560
+
561
+ return {
562
+ on: on,
563
+ close: function() { closed = true; if (ws) ws.close(); },
564
+ };
565
+ }
566
+
567
+ // ─── @mention autocomplete for textareas ───
568
+ // Usage: setupMentionAutocomplete(textareaElement, agentsArray)
569
+ // agentsArray: [{name, role, ...}, ...]
570
+ function setupMentionAutocomplete(textarea, agents) {
571
+ if (!textarea || textarea._mentionSetup) return;
572
+ textarea._mentionSetup = true;
573
+
574
+ let dropdown = null;
575
+ let mentionStart = -1;
576
+ let selectedIdx = 0;
577
+ let blurTimeout = null;
578
+
579
+ function removeDropdown() {
580
+ if (dropdown) { dropdown.remove(); dropdown = null; }
581
+ selectedIdx = 0;
582
+ }
583
+
584
+ function cancelMention() {
585
+ removeDropdown();
586
+ mentionStart = -1;
587
+ }
588
+
589
+ function getCaretPixelPos() {
590
+ // Use a mirror element to measure caret position within textarea
591
+ const mirror = document.createElement('div');
592
+ const style = getComputedStyle(textarea);
593
+ ['font', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing',
594
+ 'padding', 'paddingTop', 'paddingLeft', 'paddingRight', 'paddingBottom',
595
+ 'border', 'borderWidth', 'boxSizing', 'whiteSpace', 'wordWrap', 'wordBreak', 'overflowWrap'
596
+ ].forEach(p => { mirror.style[p] = style[p]; });
597
+ mirror.style.position = 'absolute';
598
+ mirror.style.visibility = 'hidden';
599
+ mirror.style.whiteSpace = 'pre-wrap';
600
+ mirror.style.wordWrap = 'break-word';
601
+ mirror.style.overflow = 'hidden';
602
+ mirror.style.width = style.width;
603
+ mirror.style.height = 'auto';
604
+
605
+ const textBefore = textarea.value.substring(0, textarea.selectionStart);
606
+ const textNode = document.createTextNode(textBefore);
607
+ const span = document.createElement('span');
608
+ span.textContent = '\u200b'; // zero-width space as caret marker
609
+ mirror.appendChild(textNode);
610
+ mirror.appendChild(span);
611
+ document.body.appendChild(mirror);
612
+
613
+ const textareaRect = textarea.getBoundingClientRect();
614
+ const mirrorRect = mirror.getBoundingClientRect();
615
+ const spanRect = span.getBoundingClientRect();
616
+
617
+ const top = textareaRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop;
618
+ const left = textareaRect.left + (spanRect.left - mirrorRect.left) - textarea.scrollLeft;
619
+ mirror.remove();
620
+ return { top, left };
621
+ }
622
+
623
+ function showDropdown(items) {
624
+ removeDropdown();
625
+ if (items.length === 0) return;
626
+ dropdown = document.createElement('div');
627
+ dropdown.className = 'mention-dropdown';
628
+ dropdown.style.cssText = 'position:fixed;z-index:300;background:var(--header-bg);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-height:200px;overflow-y:auto;min-width:200px;';
629
+
630
+ const coords = getCaretPixelPos();
631
+ dropdown.style.top = (coords.top + 22) + 'px';
632
+ dropdown.style.left = coords.left + 'px';
633
+
634
+ items.forEach((agent, i) => {
635
+ const item = document.createElement('div');
636
+ item.style.cssText = 'padding:6px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;';
637
+ if (i === selectedIdx) item.style.background = 'var(--selected-bg)';
638
+ const roleText = (agent.role || '').slice(0, 30);
639
+ item.innerHTML = `${avatarSvg(agent.name, 18)}<span><strong>${esc(agent.name)}</strong> <span style="color:var(--text-secondary);font-size:11px">${esc(roleText)}</span></span>`;
640
+ item.onmouseenter = () => { selectedIdx = i; updateSelection(); };
641
+ item.onmousedown = (e) => { e.preventDefault(); selectItem(agent.name); };
642
+ dropdown.appendChild(item);
643
+ });
644
+ document.body.appendChild(dropdown);
645
+ }
646
+
647
+ function updateSelection() {
648
+ if (!dropdown) return;
649
+ const children = dropdown.children;
650
+ for (let i = 0; i < children.length; i++) {
651
+ children[i].style.background = i === selectedIdx ? 'var(--selected-bg)' : '';
652
+ }
653
+ // Scroll selected into view
654
+ if (children[selectedIdx]) children[selectedIdx].scrollIntoView({ block: 'nearest' });
655
+ }
656
+
657
+ function selectItem(name) {
658
+ // Cancel any pending blur timeout to prevent race condition
659
+ if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
660
+
661
+ // Recalculate mention position from current textarea state for robustness.
662
+ // mentionStart may be stale if a blur/cancelMention fired between input and selection.
663
+ const cursorPos = textarea.selectionStart;
664
+ const textBefore = textarea.value.substring(0, cursorPos);
665
+ const reMatch = textBefore.match(/(?:^|[\s])@([\w-]*)$/);
666
+ const atPos = reMatch
667
+ ? textBefore.length - reMatch[0].length + (reMatch[0].startsWith('@') ? 0 : 1)
668
+ : mentionStart;
669
+
670
+ if (atPos < 0 || atPos > textarea.value.length) {
671
+ removeDropdown();
672
+ return;
673
+ }
674
+
675
+ const before = textarea.value.substring(0, atPos);
676
+ const after = textarea.value.substring(cursorPos);
677
+ textarea.value = before + '@' + name + ' ' + after;
678
+ const newPos = atPos + name.length + 2;
679
+ textarea.setSelectionRange(newPos, newPos);
680
+ textarea.focus();
681
+ mentionStart = -1;
682
+ removeDropdown();
683
+ }
684
+
685
+ function getFilteredAgents(query) {
686
+ if (!query) return agents.slice();
687
+ const q = query.toLowerCase();
688
+ return agents.filter(a => a.name.toLowerCase().includes(q));
689
+ }
690
+
691
+ textarea.addEventListener('input', () => {
692
+ if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
693
+ const pos = textarea.selectionStart;
694
+ const text = textarea.value.substring(0, pos);
695
+ const match = text.match(/(?:^|[\s])@([\w-]*)$/);
696
+ if (match) {
697
+ selectedIdx = 0;
698
+ showDropdown(getFilteredAgents(match[1]));
699
+ mentionStart = text.length - match[0].length + (match[0].startsWith('@') ? 0 : 1);
700
+ } else {
701
+ cancelMention();
702
+ }
703
+ });
704
+
705
+ textarea.addEventListener('keydown', (e) => {
706
+ if (!dropdown) return;
707
+ const items = dropdown.children;
708
+ if (items.length === 0) return;
709
+ if (e.key === 'ArrowDown') {
710
+ e.preventDefault();
711
+ selectedIdx = (selectedIdx + 1) % items.length;
712
+ updateSelection();
713
+ } else if (e.key === 'ArrowUp') {
714
+ e.preventDefault();
715
+ selectedIdx = (selectedIdx - 1 + items.length) % items.length;
716
+ updateSelection();
717
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
718
+ e.preventDefault();
719
+ // Recalculate query from current textarea state (don't rely on mentionStart alone)
720
+ const curPos = textarea.selectionStart;
721
+ const txtBefore = textarea.value.substring(0, curPos);
722
+ const km = txtBefore.match(/(?:^|[\s])@([\w-]*)$/);
723
+ const query = km ? km[1] : textarea.value.substring(mentionStart + 1, curPos);
724
+ const filtered = getFilteredAgents(query);
725
+ if (filtered[selectedIdx]) selectItem(filtered[selectedIdx].name);
726
+ } else if (e.key === 'Escape') {
727
+ e.preventDefault();
728
+ cancelMention();
729
+ }
730
+ });
731
+
732
+ textarea.addEventListener('blur', () => {
733
+ blurTimeout = setTimeout(cancelMention, 200);
734
+ });
735
+ }