agent-relay-server 0.1.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.
@@ -0,0 +1,501 @@
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">
6
+ <title>Agent Relay</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
11
+ --text: #e6edf3; --text-dim: #7d8590; --accent: #58a6ff;
12
+ --green: #3fb950; --yellow: #d29922; --red: #f85149; --blue: #58a6ff;
13
+ }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
15
+ .container { max-width: 1200px; margin: 0 auto; padding: 16px; overflow-x: hidden; }
16
+
17
+ header { display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px; flex-wrap: wrap; gap: 8px; }
18
+ header h1 { font-size: 20px; font-weight: 600; }
19
+ .stats { display: flex; gap: 12px; font-size: 13px; color: var(--text-dim); flex-wrap: wrap; }
20
+ .stats .num { color: var(--accent); font-weight: 600; font-size: 15px; }
21
+
22
+ .grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }
23
+ @media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
24
+
25
+ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
26
+ .panel-head { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); display: flex; justify-content: space-between; align-items: center; }
27
+ .panel-body { padding: 8px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; }
28
+
29
+ .agent { padding: 10px 12px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; transition: background 0.15s; }
30
+ .agent:hover { background: #1c2128; }
31
+ .agent.selected { background: #1c2128; border-left: 3px solid var(--accent); }
32
+ .agent-name { font-weight: 600; font-size: 14px; }
33
+ .agent-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
34
+ .agent-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
35
+ .tag { background: #1c2128; border: 1px solid var(--border); border-radius: 12px; padding: 1px 8px; font-size: 11px; color: var(--text-dim); }
36
+
37
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
38
+ .dot.online { background: var(--green); }
39
+ .dot.idle { background: var(--yellow); }
40
+ .dot.busy { background: var(--red); }
41
+ .dot.offline { background: var(--text-dim); }
42
+
43
+ .msg { padding: 10px 14px; border-radius: 6px; margin-bottom: 6px; background: #1c2128; position: relative; overflow: hidden; }
44
+ .msg.thread-child { border-left: 2px solid var(--border); margin-left: 16px; }
45
+ .msg-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4px; flex-wrap: wrap; gap: 4px; }
46
+ .msg-from { font-weight: 600; font-size: 13px; color: var(--accent); overflow: hidden; text-overflow: ellipsis; }
47
+ .msg-to { font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; }
48
+ .msg-time { font-size: 11px; color: var(--text-dim); }
49
+ .msg-subject { font-size: 13px; font-weight: 600; margin-bottom: 2px; }
50
+ .msg-body { font-size: 13px; white-space: pre-wrap; word-break: break-word; }
51
+ .msg-channel { font-size: 11px; color: var(--yellow); margin-top: 4px; }
52
+ .msg-footer { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
53
+ .msg-thread-info { font-size: 11px; color: var(--text-dim); }
54
+ .msg-reply-btn, .msg-claim-btn, .msg-delete-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-dim); font-size: 11px; padding: 1px 8px; cursor: pointer; }
55
+ .msg-reply-btn:hover { color: var(--accent); border-color: var(--accent); }
56
+ .msg-claim-btn:hover { color: var(--green); border-color: var(--green); }
57
+ .msg-delete-btn:hover { color: var(--red); border-color: var(--red); }
58
+ .agent-actions { float: right; display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
59
+ .agent:hover .agent-actions { opacity: 1; }
60
+ .agent-actions button { background: none; border: none; color: var(--text-dim); font-size: 13px; cursor: pointer; padding: 2px 4px; }
61
+ .agent-actions button.delete:hover { color: var(--red); }
62
+ .agent-actions button.rename:hover { color: var(--accent); }
63
+ .agent-label { color: var(--yellow); font-weight: 600; }
64
+ .msg-claimed { font-size: 11px; color: var(--green); }
65
+ .msg-claimable { font-size: 11px; color: var(--yellow); }
66
+ .msg.claimed { border-left: 2px solid var(--green); }
67
+ .msg-thread-link { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: underline; }
68
+ .reply-banner { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: var(--bg); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); }
69
+ .reply-banner .cancel { cursor: pointer; color: var(--red); margin-left: auto; }
70
+
71
+ .thread-overlay { position: fixed; top: 0; right: 0; width: 450px; max-width: 100vw; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); z-index: 100; display: flex; flex-direction: column; box-shadow: -4px 0 24px rgba(0,0,0,0.4); }
72
+ .thread-overlay .panel-head { flex-shrink: 0; }
73
+ .thread-overlay .panel-body { flex: 1; overflow-y: auto; }
74
+ .thread-close { cursor: pointer; color: var(--text-dim); font-size: 18px; }
75
+ .thread-close:hover { color: var(--text); }
76
+
77
+ .compose { padding: 16px; border-top: 1px solid var(--border); }
78
+ .compose-row { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
79
+ .compose input, .compose textarea, .compose select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 10px; font-size: 13px; font-family: inherit; }
80
+ .compose input, .compose select { flex: 1; min-width: 0; }
81
+ .compose textarea { width: 100%; min-height: 60px; resize: vertical; }
82
+ .compose button { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 6px 16px; font-size: 13px; cursor: pointer; font-weight: 600; }
83
+ .compose button:hover { opacity: 0.9; }
84
+
85
+ .empty { text-align: center; padding: 40px 16px; color: var(--text-dim); font-size: 14px; }
86
+ .filter-row { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
87
+ .filter-row select, .filter-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 4px 8px; font-size: 12px; flex: 1; min-width: 0; }
88
+
89
+ @media (max-width: 500px) {
90
+ .container { padding: 8px; }
91
+ header { margin-bottom: 12px; }
92
+ .grid { gap: 12px; }
93
+ .compose-row { flex-direction: column; }
94
+ .compose-row input, .compose-row select { width: 100%; }
95
+ .msg-footer { flex-wrap: wrap; }
96
+ .agent-meta { word-break: break-all; }
97
+ .stats { gap: 8px; }
98
+ }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <div class="container">
103
+ <header>
104
+ <h1>Agent Relay</h1>
105
+ <div class="stats">
106
+ <span>Agents: <span class="num" id="stat-agents">-</span></span>
107
+ <span>Online: <span class="num" id="stat-online">-</span></span>
108
+ <span>Messages (24h): <span class="num" id="stat-msgs24">-</span></span>
109
+ <span>Total: <span class="num" id="stat-msgs">-</span></span>
110
+ </div>
111
+ </header>
112
+
113
+ <div class="grid">
114
+ <div class="panel">
115
+ <div class="panel-head">
116
+ Agents
117
+ <label style="font-size:11px;font-weight:400;text-transform:none;">
118
+ <input type="checkbox" id="show-offline" onchange="renderAgents(); updateComposeDropdowns();"> Show offline
119
+ </label>
120
+ </div>
121
+ <div class="panel-body" id="agent-list">
122
+ <div class="empty">No agents registered</div>
123
+ </div>
124
+ </div>
125
+
126
+ <div class="panel" style="display:flex;flex-direction:column;">
127
+ <div class="panel-head">
128
+ Messages
129
+ <label style="font-size:11px;font-weight:400;text-transform:none;">
130
+ <input type="checkbox" id="auto-refresh" checked> Auto-refresh
131
+ </label>
132
+ </div>
133
+ <div class="filter-row">
134
+ <select id="filter-agent"><option value="">All agents</option></select>
135
+ <input id="filter-channel" placeholder="Channel filter..." />
136
+ </div>
137
+ <div class="panel-body" id="msg-list" style="flex:1;">
138
+ <div class="empty">No messages</div>
139
+ </div>
140
+ <div id="reply-banner" class="reply-banner" style="display:none;">
141
+ Replying to <strong id="reply-to-label"></strong>
142
+ <span class="cancel" onclick="cancelReply()">&times; Cancel</span>
143
+ </div>
144
+ <div class="compose">
145
+ <div class="compose-row">
146
+ <select id="c-from"><option value="">From...</option></select>
147
+ <select id="c-to"><option value="">To...</option></select>
148
+ </div>
149
+ <div class="compose-row">
150
+ <input id="c-channel" placeholder="Channel (optional)" />
151
+ <input id="c-subject" placeholder="Subject (optional)" />
152
+ </div>
153
+ <textarea id="c-body" placeholder="Message body..."></textarea>
154
+ <div style="margin-top:8px;display:flex;justify-content:space-between;align-items:center;">
155
+ <label style="font-size:11px;color:var(--text-dim);"><input type="checkbox" id="c-claimable"> Claimable</label>
156
+ <button onclick="doSend()">Send</button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <div id="thread-overlay" class="thread-overlay" style="display:none;">
164
+ <div class="panel-head">
165
+ Thread
166
+ <span class="thread-close" onclick="closeThread()">&times;</span>
167
+ </div>
168
+ <div class="panel-body" id="thread-list"></div>
169
+ </div>
170
+
171
+ <script>
172
+ const API = window.location.origin + '/api';
173
+ let agents = [];
174
+ let agentsById = {};
175
+ let messages = [];
176
+ let selectedAgent = null;
177
+ let replyTo = null; // {id, from, body} of message being replied to
178
+
179
+ // Display helpers — prefer label, fallback to name, fallback to id
180
+ function displayName(a) { return a?.label || a?.name || a?.id || '?'; }
181
+ function displayById(id) {
182
+ if (!id) return '';
183
+ if (id.startsWith('tag:')) return '#' + id.slice(4);
184
+ if (id.startsWith('cap:')) return '⚡' + id.slice(4);
185
+ if (id.startsWith('label:')) return '★' + id.slice(6);
186
+ if (id === 'broadcast') return '📢 broadcast';
187
+ const a = agentsById[id];
188
+ return a ? displayName(a) : id;
189
+ }
190
+
191
+ async function fetchStats() {
192
+ try {
193
+ const r = await fetch(`${API}/stats`);
194
+ const s = await r.json();
195
+ document.getElementById('stat-agents').textContent = s.agents;
196
+ document.getElementById('stat-online').textContent = s.online;
197
+ document.getElementById('stat-msgs24').textContent = s.messagesLast24h;
198
+ document.getElementById('stat-msgs').textContent = s.messages;
199
+ } catch {}
200
+ }
201
+
202
+ async function fetchAgents() {
203
+ try {
204
+ const r = await fetch(`${API}/agents`);
205
+ agents = await r.json();
206
+ agentsById = Object.fromEntries(agents.map(a => [a.id, a]));
207
+ renderAgents();
208
+ updateAgentFilter();
209
+ } catch {}
210
+ }
211
+
212
+ async function fetchMessages() {
213
+ try {
214
+ const params = new URLSearchParams();
215
+ const agentId = document.getElementById('filter-agent').value;
216
+ const channel = document.getElementById('filter-channel').value;
217
+ if (agentId) params.set('for', agentId);
218
+ if (channel) params.set('channel', channel);
219
+ params.set('limit', '100');
220
+ const r = await fetch(`${API}/messages?${params}`);
221
+ messages = await r.json();
222
+ renderMessages();
223
+ } catch {}
224
+ }
225
+
226
+ function renderAgents() {
227
+ const el = document.getElementById('agent-list');
228
+ const showOffline = document.getElementById('show-offline').checked;
229
+ const visible = showOffline ? agents : agents.filter(a => a.status !== 'offline');
230
+ if (!visible.length) { el.innerHTML = '<div class="empty">' + (agents.length ? 'All agents offline' : 'No agents registered') + '</div>'; return; }
231
+ el.innerHTML = visible.map(a => {
232
+ const deletable = a.status === 'offline' && a.id !== 'user';
233
+ const renameable = a.id !== 'user';
234
+ const actions = `
235
+ <span class="agent-actions">
236
+ ${renameable ? `<button class="rename" onclick="event.stopPropagation(); doRenameAgent('${esc(a.id)}')" title="Rename (set label)">✎</button>` : ''}
237
+ ${deletable ? `<button class="delete" onclick="event.stopPropagation(); doDeleteAgent('${esc(a.id)}')" title="Delete agent">&times;</button>` : ''}
238
+ </span>`;
239
+ const nameHtml = a.label
240
+ ? `<span class="agent-label">${esc(a.label)}</span> <span style="color:var(--text-dim);font-size:11px;">(${esc(a.name)})</span>`
241
+ : esc(a.name);
242
+ return `
243
+ <div class="agent ${selectedAgent === a.id ? 'selected' : ''}" onclick="selectAgent('${esc(a.id)}')">
244
+ ${actions}
245
+ <div class="agent-name"><span class="dot ${a.status}"></span>${nameHtml}</div>
246
+ <div class="agent-meta">${esc(a.id)} ${a.machine ? '@ ' + esc(a.machine) : ''} ${a.rig ? '(' + esc(a.rig) + ')' : ''} &middot; ${new Date(a.createdAt).toLocaleTimeString()}</div>
247
+ ${a.tags.length ? `<div class="agent-tags">${a.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : ''}
248
+ </div>`;
249
+ }).join('');
250
+ }
251
+
252
+ async function doRenameAgent(id) {
253
+ const current = agentsById[id]?.label || '';
254
+ const next = prompt(`Label for "${id}" (blank to clear):`, current);
255
+ if (next === null) return;
256
+ const r = await fetch(`${API}/agents/${encodeURIComponent(id)}/label`, {
257
+ method: 'PATCH',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ label: next.trim() || null }),
260
+ });
261
+ if (!r.ok) {
262
+ const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
263
+ alert(`Rename failed: ${err.error || 'unknown error'}`);
264
+ return;
265
+ }
266
+ refresh();
267
+ }
268
+
269
+ async function doDeleteAgent(id) {
270
+ if (!confirm(`Delete agent "${id}"? This also releases any tasks they've claimed.`)) return;
271
+ const r = await fetch(`${API}/agents/${encodeURIComponent(id)}`, { method: 'DELETE' });
272
+ if (!r.ok) {
273
+ const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
274
+ alert(`Delete failed: ${err.error || 'unknown error'}`);
275
+ return;
276
+ }
277
+ if (selectedAgent === id) selectedAgent = null;
278
+ refresh();
279
+ }
280
+
281
+ function threadCounts() {
282
+ const counts = {};
283
+ for (const m of messages) {
284
+ if (m.threadId) counts[m.threadId] = (counts[m.threadId] || 0) + 1;
285
+ }
286
+ return counts;
287
+ }
288
+
289
+ function renderMessages() {
290
+ const el = document.getElementById('msg-list');
291
+ if (!messages.length) { el.innerHTML = '<div class="empty">No messages</div>'; return; }
292
+ const tc = threadCounts();
293
+ // Group by thread, sort threads newest-first, messages within thread chronologically
294
+ const threads = new Map();
295
+ for (const m of messages) {
296
+ const tid = m.threadId || m.id;
297
+ if (!threads.has(tid)) threads.set(tid, []);
298
+ threads.get(tid).push(m);
299
+ }
300
+ for (const msgs of threads.values()) msgs.sort((a, b) => a.id - b.id);
301
+ const sorted = [...threads.values()]
302
+ .sort((a, b) => b[b.length - 1].id - a[a.length - 1].id)
303
+ .flat();
304
+ el.innerHTML = sorted.map(m => {
305
+ const replies = (tc[m.threadId] || 1) - 1;
306
+ const isReply = m.replyTo != null;
307
+ const claimInfo = m.claimable
308
+ ? (m.claimedBy
309
+ ? `<span class="msg-claimed">claimed by ${esc(m.claimedBy)}</span>`
310
+ : `<span class="msg-claimable">claimable</span>`)
311
+ : '';
312
+ const claimBtn = m.claimable && !m.claimedBy
313
+ ? `<button class="msg-claim-btn" onclick="doClaim(${m.id})">Claim</button>`
314
+ : '';
315
+ return `
316
+ <div class="msg${isReply ? ' thread-child' : ''}${m.claimedBy ? ' claimed' : ''}" data-id="${m.id}">
317
+ <div class="msg-head">
318
+ <span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">&rarr; ${esc(displayById(m.to))}</span></span>
319
+ <span class="msg-time">#${m.id} &middot; ${new Date(m.createdAt).toLocaleString()}</span>
320
+ </div>
321
+ ${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
322
+ <div class="msg-body">${esc(m.body)}</div>
323
+ ${m.channel ? `<div class="msg-channel">#${esc(m.channel)}</div>` : ''}
324
+ <div class="msg-footer">
325
+ <button class="msg-reply-btn" onclick="startReply(${m.id}, '${esc(m.from)}')">Reply</button>
326
+ ${claimBtn}
327
+ <button class="msg-delete-btn" onclick="doDeleteMessage(${m.id})">Delete</button>
328
+ ${claimInfo}
329
+ ${!isReply && replies > 0 ? `<span class="msg-thread-link" onclick="openThread(${m.threadId})">${replies} repl${replies === 1 ? 'y' : 'ies'}</span>` : ''}
330
+ ${isReply ? `<span class="msg-thread-info">reply to #${m.replyTo}</span>` : ''}
331
+ </div>
332
+ </div>`;
333
+ }).join('');
334
+ }
335
+
336
+ function updateAgentFilter() {
337
+ const sel = document.getElementById('filter-agent');
338
+ const current = sel.value;
339
+ sel.innerHTML = '<option value="">All agents</option>' +
340
+ agents.map(a => `<option value="${esc(a.id)}" ${a.id === current ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
341
+ updateComposeDropdowns();
342
+ }
343
+
344
+ function updateComposeDropdowns() {
345
+ const fromSel = document.getElementById('c-from');
346
+ const toSel = document.getElementById('c-to');
347
+ const fromVal = fromSel.value;
348
+ const toVal = toSel.value;
349
+
350
+ const showOffline = document.getElementById('show-offline').checked;
351
+ const visible = showOffline ? agents : agents.filter(a => a.status !== 'offline');
352
+
353
+ // Tags/caps/labels follow the same filter so we don't suggest routing to empty groups
354
+ const tags = [...new Set(visible.flatMap(a => a.tags))].sort();
355
+ const caps = [...new Set(visible.flatMap(a => a.capabilities))].sort();
356
+ const labels = [...new Set(visible.map(a => a.label).filter(Boolean))].sort();
357
+
358
+ fromSel.innerHTML = '<option value="">From...</option>' +
359
+ visible.map(a => `<option value="${esc(a.id)}" ${a.id === fromVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
360
+
361
+ toSel.innerHTML = '<option value="">To...</option>' +
362
+ '<option value="broadcast">📢 Broadcast (all)</option>' +
363
+ labels.map(l => `<option value="label:${esc(l)}" ${('label:'+l) === toVal ? 'selected' : ''}>★ ${esc(l)}</option>`).join('') +
364
+ caps.map(c => `<option value="cap:${esc(c)}" ${('cap:'+c) === toVal ? 'selected' : ''}>⚡ cap: ${esc(c)}</option>`).join('') +
365
+ tags.map(t => `<option value="tag:${esc(t)}" ${('tag:'+t) === toVal ? 'selected' : ''}>#${esc(t)}</option>`).join('') +
366
+ visible.map(a => `<option value="${esc(a.id)}" ${a.id === toVal ? 'selected' : ''}>${esc(displayName(a))} [${esc(a.id.slice(-6))}]</option>`).join('');
367
+
368
+ // Restore selections even if the selected agent is now filtered out
369
+ if (fromVal && !fromSel.querySelector(`option[value="${CSS.escape(fromVal)}"]`)) {
370
+ fromSel.insertAdjacentHTML('beforeend', `<option value="${esc(fromVal)}" selected>${esc(fromVal)} (offline)</option>`);
371
+ }
372
+ if (toVal && !toSel.querySelector(`option[value="${CSS.escape(toVal)}"]`)) {
373
+ toSel.insertAdjacentHTML('beforeend', `<option value="${esc(toVal)}" selected>${esc(toVal)} (offline)</option>`);
374
+ }
375
+ if (fromVal) fromSel.value = fromVal;
376
+ if (toVal) toSel.value = toVal;
377
+ }
378
+
379
+ function selectAgent(id) {
380
+ selectedAgent = selectedAgent === id ? null : id;
381
+ document.getElementById('filter-agent').value = selectedAgent || '';
382
+ renderAgents();
383
+ fetchMessages();
384
+ }
385
+
386
+ async function doSend() {
387
+ const from = document.getElementById('c-from').value.trim();
388
+ const to = document.getElementById('c-to').value.trim();
389
+ const body = document.getElementById('c-body').value.trim();
390
+ const channel = document.getElementById('c-channel').value.trim() || undefined;
391
+ const subject = document.getElementById('c-subject').value.trim() || undefined;
392
+ if (!from || !to || !body) return alert('from, to, and body required');
393
+ const claimable = document.getElementById('c-claimable').checked;
394
+ const payload = { from, to, body, channel, subject };
395
+ if (replyTo) payload.replyTo = replyTo.id;
396
+ if (claimable) payload.claimable = true;
397
+ const r = await fetch(`${API}/messages`, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify(payload),
401
+ });
402
+ if (!r.ok) {
403
+ const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
404
+ alert(`Send failed: ${err.error || 'unknown error'}`);
405
+ return;
406
+ }
407
+ document.getElementById('c-body').value = '';
408
+ document.getElementById('c-subject').value = '';
409
+ cancelReply();
410
+ fetchMessages();
411
+ }
412
+
413
+ function startReply(msgId, fromAgent) {
414
+ const msg = messages.find(m => m.id === msgId);
415
+ replyTo = { id: msgId, from: fromAgent };
416
+ document.getElementById('reply-banner').style.display = 'flex';
417
+ document.getElementById('reply-to-label').textContent = `#${msgId} from ${fromAgent}`;
418
+ // Auto-set "to" as the original sender
419
+ const toSel = document.getElementById('c-to');
420
+ toSel.value = fromAgent;
421
+ document.getElementById('c-body').focus();
422
+ }
423
+
424
+ function cancelReply() {
425
+ replyTo = null;
426
+ document.getElementById('reply-banner').style.display = 'none';
427
+ }
428
+
429
+ async function openThread(threadId) {
430
+ try {
431
+ const r = await fetch(`${API}/messages/${threadId}/thread`);
432
+ if (!r.ok) {
433
+ const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
434
+ alert(`Thread load failed: ${err.error || 'unknown error'}`);
435
+ return;
436
+ }
437
+ const thread = await r.json();
438
+ const el = document.getElementById('thread-list');
439
+ el.innerHTML = thread.map((m, i) => `
440
+ <div class="msg${i > 0 ? ' thread-child' : ''}" style="margin: 8px;">
441
+ <div class="msg-head">
442
+ <span><span class="msg-from" title="${esc(m.from)}">${esc(displayById(m.from))}</span> <span class="msg-to" title="${esc(m.to)}">&rarr; ${esc(displayById(m.to))}</span></span>
443
+ <span class="msg-time">#${m.id} &middot; ${new Date(m.createdAt).toLocaleString()}</span>
444
+ </div>
445
+ ${m.subject ? `<div class="msg-subject">${esc(m.subject)}</div>` : ''}
446
+ <div class="msg-body">${esc(m.body)}</div>
447
+ </div>
448
+ `).join('');
449
+ document.getElementById('thread-overlay').style.display = 'flex';
450
+ } catch (e) {
451
+ alert(`Thread load failed: ${e.message || e}`);
452
+ }
453
+ }
454
+
455
+ function closeThread() {
456
+ document.getElementById('thread-overlay').style.display = 'none';
457
+ }
458
+
459
+ async function doDeleteMessage(msgId) {
460
+ if (!confirm(`Delete message #${msgId}?`)) return;
461
+ const r = await fetch(`${API}/messages/${msgId}`, { method: 'DELETE' });
462
+ if (!r.ok) {
463
+ const err = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
464
+ alert(`Delete failed: ${err.error || 'unknown error'}`);
465
+ return;
466
+ }
467
+ fetchMessages();
468
+ fetchStats();
469
+ }
470
+
471
+ async function doClaim(msgId) {
472
+ const from = document.getElementById('c-from').value.trim();
473
+ if (!from) return alert('Select "From" agent to claim as');
474
+ const r = await fetch(`${API}/messages/${msgId}/claim`, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: JSON.stringify({ agentId: from }),
478
+ });
479
+ const result = await r.json();
480
+ if (!result.ok) alert(result.error || 'Claim failed');
481
+ fetchMessages();
482
+ }
483
+
484
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
485
+
486
+ // Poll
487
+ function refresh() {
488
+ fetchStats();
489
+ fetchAgents();
490
+ fetchMessages();
491
+ }
492
+ refresh();
493
+ setInterval(() => {
494
+ if (document.getElementById('auto-refresh').checked) refresh();
495
+ }, 3000);
496
+
497
+ document.getElementById('filter-agent').addEventListener('change', fetchMessages);
498
+ document.getElementById('filter-channel').addEventListener('input', fetchMessages);
499
+ </script>
500
+ </body>
501
+ </html>
package/src/config.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Shared runtime constants. Import from here rather than redefining.
2
+
3
+ export const STALE_TTL_MS = 600_000; // 10min without heartbeat → offline
4
+ export const REAP_INTERVAL_MS = 60_000; // reaper cadence
5
+ export const DAY_MS = 86_400_000;
6
+
7
+ // Max body size for any POST/PATCH request (64 KiB).
8
+ export const MAX_BODY_BYTES = 64 * 1024;
9
+
10
+ // Default capabilities for session-start hook when AGENT_RELAY_CAPS is unset.
11
+ export const DEFAULT_CAPABILITIES = ["chat"];