nothumanallowed 13.2.55 → 13.2.58

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "13.2.55",
3
+ "version": "13.2.58",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@ import { loadConfig } from '../config.mjs';
18
18
  import { detectMailProvider, hasMailProvider, getProviderStatus } from '../services/mail-router.mjs';
19
19
  import { callLLM, callLLMStream, callAgent, parseAgentFile } from '../services/llm.mjs';
20
20
  import { getUnreadImportant, getMessage, listMessages, sendEmail, createDraft } from '../services/mail-router.mjs';
21
- import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, getEventsForDate } from '../services/mail-router.mjs';
21
+ import { getTodayEvents, getUpcomingEvents, createEvent, updateEvent, deleteEvent, getEventsForDate } from '../services/mail-router.mjs';
22
22
  import {
23
23
  getTasks,
24
24
  addTask,
@@ -1027,6 +1027,70 @@ export async function cmdUI(args) {
1027
1027
  return;
1028
1028
  }
1029
1029
 
1030
+ // POST /api/calendar — create event
1031
+ if (method === 'POST' && pathname === '/api/calendar') {
1032
+ try {
1033
+ const body = await parseBody(req);
1034
+ if (!body.summary) { sendJSON(res, 400, { error: 'summary required' }); logRequest(method, pathname, 400, Date.now() - start); return; }
1035
+ const calendarId = body.calendarId || 'primary';
1036
+ const event = { summary: body.summary };
1037
+ if (body.description) event.description = body.description;
1038
+ if (body.location) event.location = body.location;
1039
+ if (body.allDay && body.date) {
1040
+ event.start = { date: body.date };
1041
+ event.end = { date: body.date };
1042
+ } else {
1043
+ const startDT = body.start || (body.date ? body.date + 'T09:00:00' : new Date().toISOString());
1044
+ const endDT = body.end || (body.date ? body.date + 'T10:00:00' : new Date(Date.now() + 3600000).toISOString());
1045
+ event.start = { dateTime: startDT };
1046
+ event.end = { dateTime: endDT };
1047
+ }
1048
+ const created = await createEvent(config, event, calendarId);
1049
+ sendJSON(res, 201, { event: created });
1050
+ } catch (e) {
1051
+ sendJSON(res, 500, { error: e.message });
1052
+ }
1053
+ logRequest(method, pathname, 201, Date.now() - start);
1054
+ return;
1055
+ }
1056
+
1057
+ // PATCH /api/calendar/:calId/:eventId — update event
1058
+ const calPatchMatch = pathname.match(/^\/api\/calendar\/([^/]+)\/([^/]+)$/);
1059
+ if (method === 'PATCH' && calPatchMatch) {
1060
+ try {
1061
+ const calendarId = decodeURIComponent(calPatchMatch[1]);
1062
+ const eventId = decodeURIComponent(calPatchMatch[2]);
1063
+ const body = await parseBody(req);
1064
+ const patch = {};
1065
+ if (body.summary !== undefined) patch.summary = body.summary;
1066
+ if (body.description !== undefined) patch.description = body.description;
1067
+ if (body.location !== undefined) patch.location = body.location;
1068
+ if (body.start !== undefined) patch.start = { dateTime: body.start };
1069
+ if (body.end !== undefined) patch.end = { dateTime: body.end };
1070
+ const updated = await updateEvent(config, calendarId, eventId, patch);
1071
+ sendJSON(res, 200, { event: updated });
1072
+ } catch (e) {
1073
+ sendJSON(res, 500, { error: e.message });
1074
+ }
1075
+ logRequest(method, pathname, 200, Date.now() - start);
1076
+ return;
1077
+ }
1078
+
1079
+ // DELETE /api/calendar/:calId/:eventId — delete event
1080
+ const calDeleteMatch = pathname.match(/^\/api\/calendar\/([^/]+)\/([^/]+)$/);
1081
+ if (method === 'DELETE' && calDeleteMatch) {
1082
+ try {
1083
+ const calendarId = decodeURIComponent(calDeleteMatch[1]);
1084
+ const eventId = decodeURIComponent(calDeleteMatch[2]);
1085
+ await deleteEvent(config, calendarId, eventId);
1086
+ sendJSON(res, 200, { ok: true });
1087
+ } catch (e) {
1088
+ sendJSON(res, 500, { error: e.message });
1089
+ }
1090
+ logRequest(method, pathname, 200, Date.now() - start);
1091
+ return;
1092
+ }
1093
+
1030
1094
  // GET /api/tasks
1031
1095
  if (method === 'GET' && pathname === '/api/tasks') {
1032
1096
  const tasks = getTasks();
@@ -2390,11 +2454,26 @@ export async function cmdUI(args) {
2390
2454
  }
2391
2455
 
2392
2456
  // ── GitHub ───────────────────────────────────────────────────────
2457
+ if (method === 'GET' && pathname === '/api/github/repos') {
2458
+ try {
2459
+ const gh = await import('../services/github.mjs');
2460
+ const data = await gh.listUserRepos(config, 30);
2461
+ sendJSON(res, 200, data);
2462
+ } catch (e) {
2463
+ sendJSON(res, 200, { error: e.message, repos: [] });
2464
+ }
2465
+ logRequest(method, pathname, 200, Date.now() - start);
2466
+ return;
2467
+ }
2468
+
2393
2469
  if (method === 'GET' && pathname === '/api/github') {
2394
2470
  try {
2395
2471
  const gh = await import('../services/github.mjs');
2396
- const raw = await gh.listNotificationsRaw(config, 15);
2397
- sendJSON(res, 200, { notifications: raw });
2472
+ const [notifData, repoData] = await Promise.all([
2473
+ gh.listNotificationsRaw(config, 15),
2474
+ gh.listUserRepos(config, 30).catch(() => null),
2475
+ ]);
2476
+ sendJSON(res, 200, { notifications: notifData, user: repoData });
2398
2477
  } catch (e) {
2399
2478
  sendJSON(res, 200, { error: e.message, notifications: [] });
2400
2479
  }
@@ -2525,7 +2604,7 @@ export async function cmdUI(args) {
2525
2604
  const daysUntil = Math.ceil((thisYear - today) / 86400000);
2526
2605
  if (daysUntil <= 90) {
2527
2606
  const dateStr = thisYear.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
2528
- upcoming.push({ name: c.name, date: dateStr, daysUntil });
2607
+ upcoming.push({ name: c.name, date: dateStr, rawDate: c.birthday, daysUntil, contactId: c.resourceName });
2529
2608
  }
2530
2609
  }
2531
2610
  upcoming.sort((a, b) => a.daysUntil - b.daysUntil);
@@ -2537,6 +2616,42 @@ export async function cmdUI(args) {
2537
2616
  return;
2538
2617
  }
2539
2618
 
2619
+ // POST /api/birthdays — create or update birthday on a contact
2620
+ if (method === 'POST' && pathname === '/api/birthdays') {
2621
+ try {
2622
+ const body = await parseBody(req);
2623
+ const gc = await import('../services/google-contacts.mjs');
2624
+ if (body.contactId) {
2625
+ // Update existing contact's birthday
2626
+ await gc.updateContact(config, body.contactId, { birthday: body.date });
2627
+ } else {
2628
+ // Create new contact with just name + birthday
2629
+ const created = await gc.createContact(config, { name: body.name });
2630
+ await gc.updateContact(config, created.resourceName, { birthday: body.date });
2631
+ }
2632
+ sendJSON(res, 200, { ok: true });
2633
+ } catch (e) {
2634
+ sendJSON(res, 500, { error: e.message });
2635
+ }
2636
+ logRequest(method, pathname, 200, Date.now() - start);
2637
+ return;
2638
+ }
2639
+
2640
+ // POST /api/birthdays/delete — clear birthday from a contact
2641
+ if (method === 'POST' && pathname === '/api/birthdays/delete') {
2642
+ try {
2643
+ const body = await parseBody(req);
2644
+ const gc = await import('../services/google-contacts.mjs');
2645
+ // Clear birthday by setting it to empty (remove field)
2646
+ await gc.updateContact(config, body.contactId, { birthday: '' });
2647
+ sendJSON(res, 200, { ok: true });
2648
+ } catch (e) {
2649
+ sendJSON(res, 500, { error: e.message });
2650
+ }
2651
+ logRequest(method, pathname, 200, Date.now() - start);
2652
+ return;
2653
+ }
2654
+
2540
2655
  // ── Studio: plan workflow ────────────────────────────────────────
2541
2656
  if (pathname === '/api/studio/plan' && method === 'POST') {
2542
2657
  const body = await parseBody(req);
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '13.2.55';
8
+ export const VERSION = '13.2.58';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -166,6 +166,25 @@ export async function listPRsRaw(config, repo, state = 'open', maxResults = 15)
166
166
  /**
167
167
  * Mark all notifications as read.
168
168
  */
169
+ export async function listUserRepos(config, maxResults = 30) {
170
+ const data = await ghFetch(config, `/user/repos?sort=pushed&direction=desc&per_page=${maxResults}&affiliation=owner,collaborator`);
171
+ const user = await ghFetch(config, '/user');
172
+ return {
173
+ login: user.login,
174
+ name: user.name,
175
+ avatar: user.avatar_url,
176
+ repos: (Array.isArray(data) ? data : []).map(r => ({
177
+ full_name: r.full_name,
178
+ description: r.description || '',
179
+ language: r.language || '',
180
+ stars: r.stargazers_count || 0,
181
+ open_issues: r.open_issues_count || 0,
182
+ pushed: r.pushed_at ? r.pushed_at.slice(0, 10) : '',
183
+ private: r.private,
184
+ })),
185
+ };
186
+ }
187
+
169
188
  export async function markNotificationsRead(config) {
170
189
  await ghFetch(config, '/notifications', {
171
190
  method: 'PUT',
@@ -174,6 +174,12 @@ export async function updateEvent(config, calendarId, eventId, patch) {
174
174
  });
175
175
  }
176
176
 
177
+ export async function deleteEvent(config, calendarId, eventId) {
178
+ await calFetch(config, `/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, {
179
+ method: 'DELETE',
180
+ });
181
+ }
182
+
177
183
  // ── Event Parser ───────────────────────────────────────────────────────────
178
184
 
179
185
  function parseEvent(raw) {
@@ -281,6 +281,19 @@ export async function updateEvent(config, calendarId, eventId, patch) {
281
281
  return gc.updateEvent(config, calendarId, eventId, patch);
282
282
  }
283
283
 
284
+ export async function deleteEvent(config, calendarId, eventId) {
285
+ const provider = detectMailProvider(config);
286
+ if (!provider) throw new Error('No mail provider authenticated.');
287
+
288
+ if (provider === 'microsoft') {
289
+ const ms = await getMicrosoftCalendar();
290
+ return ms.deleteEvent(config, calendarId, eventId);
291
+ }
292
+
293
+ const gc = await getGoogleCalendar();
294
+ return gc.deleteEvent(config, calendarId, eventId);
295
+ }
296
+
284
297
  /**
285
298
  * List events for a date range.
286
299
  */
@@ -228,6 +228,13 @@ export async function updateEvent(config, calendarId, eventId, patch) {
228
228
  });
229
229
  }
230
230
 
231
+ export async function deleteEvent(config, calendarId, eventId) {
232
+ const calPath = calendarId === 'primary'
233
+ ? `/calendar/events/${eventId}`
234
+ : `/calendars/${calendarId}/events/${eventId}`;
235
+ await graphFetch(config, calPath, { method: 'DELETE' });
236
+ }
237
+
231
238
  // ── Event Parser ───────────────────────────────────────────────────────────
232
239
 
233
240
  /**
@@ -242,7 +242,7 @@ setInterval(updateClock,1000);updateClock();
242
242
 
243
243
  // ---- API ----
244
244
  function apiGet(p){return fetch(API+p).then(function(r){return r.ok?r.json():null}).catch(function(){return null})}
245
- function apiPost(p,b){return fetch(API+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})}
245
+ function apiPost(p,b,m){return fetch(API+p,{method:m||'POST',headers:{'Content-Type':'application/json'},body:b!=null?JSON.stringify(b):undefined}).then(function(r){if(!r.ok)return r.text().then(function(t){throw new Error(t||r.status)});return r.text().then(function(t){try{return JSON.parse(t)}catch(e){return null}})})}
246
246
  function apiPatch(p){return fetch(API+p,{method:'PATCH'}).then(function(r){return r.ok?r.json():null}).catch(function(){return null})}
247
247
 
248
248
  // ---- LOAD DATA ----
@@ -1184,75 +1184,180 @@ function openDayDetail(dateStr){
1184
1184
  var evts=calEventsCache[dateStr]||[];
1185
1185
  var dayLabel=new Date(dateStr+'T12:00:00').toLocaleDateString('en',{weekday:'long',month:'long',day:'numeric',year:'numeric'});
1186
1186
 
1187
- var h='<h2 style="color:var(--green);margin-bottom:4px">'+esc(dayLabel)+'</h2>';
1188
- h+='<div style="color:var(--dim);font-size:11px;margin-bottom:12px">'+dateStr+'</div>';
1189
-
1190
- if(evts.length===0){
1191
- h+='<div style="color:var(--dim);padding:20px;text-align:center">No events on this day</div>';
1192
- } else {
1193
- evts.forEach(function(x){
1194
- var timeStr=x.isAllDay?'All day':fmtTime(x.start)+' - '+fmtTime(x.end);
1195
- h+='<div style="border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:10px;background:var(--bg3)">';
1196
- h+='<div style="color:var(--amber);font-weight:700;font-size:13px;margin-bottom:4px">'+esc(timeStr)+'</div>';
1197
- h+='<div style="color:var(--bright);font-size:15px;font-weight:700;margin-bottom:6px">'+esc(x.summary)+'</div>';
1198
- if(x.location)h+='<div style="color:var(--cyan);font-size:12px;margin-bottom:4px">Location: '+esc(x.location)+'</div>';
1199
- if(x.organizer)h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Organizer: '+esc(x.organizer)+'</div>';
1200
- if(x.attendees&&x.attendees.length>0){
1201
- h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Attendees:</div>';
1202
- x.attendees.forEach(function(a){
1203
- var status=a.responseStatus==='accepted'?'var(--green)':a.responseStatus==='declined'?'var(--red)':'var(--dim)';
1204
- h+='<div style="font-size:11px;color:'+status+';padding-left:8px">'+esc(a.name||a.email)+' ('+esc(a.responseStatus)+')</div>';
1205
- });
1206
- }
1207
- if(x.description){
1208
- h+='<div style="border-top:1px solid var(--border);margin-top:8px;padding-top:8px;color:var(--text);font-size:12px;white-space:pre-wrap;word-wrap:break-word">'+esc(x.description)+'</div>';
1209
- }
1210
- if(x.hangoutLink){
1211
- h+='<div style="margin-top:8px"><a href="'+esc(x.hangoutLink)+'" target="_blank" style="color:var(--cyan);font-size:12px;font-weight:700">Join Video Call</a></div>';
1212
- }
1213
- if(x.htmlLink){
1214
- h+='<div style="margin-top:4px"><a href="'+esc(x.htmlLink)+'" target="_blank" style="color:var(--dim);font-size:10px">Open in Google Calendar</a></div>';
1215
- }
1216
- h+='</div>';
1217
- });
1187
+ function buildDayHtml(){
1188
+ var h='<h2 style="color:var(--green);margin-bottom:4px">'+esc(dayLabel)+'</h2>';
1189
+ h+='<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">';
1190
+ h+='<span style="color:var(--dim);font-size:11px">'+dateStr+'</span>';
1191
+ h+='<button onclick="openEventForm(null,'+JSON.stringify(dateStr)+')" style="margin-left:auto;background:var(--green3);color:var(--bg);padding:5px 12px;border-radius:var(--r);font-size:12px;font-weight:700">+ Add Event</button>';
1192
+ h+='</div>';
1193
+ if(evts.length===0){
1194
+ h+='<div style="color:var(--dim);padding:20px;text-align:center">No events on this day</div>';
1195
+ } else {
1196
+ evts.forEach(function(x,idx){
1197
+ var timeStr=x.isAllDay?'All day':fmtTime(x.start)+' - '+fmtTime(x.end);
1198
+ var calId=x.calendarId||'primary';
1199
+ h+='<div style="border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:10px;background:var(--bg3)">';
1200
+ h+='<div style="display:flex;align-items:flex-start;gap:6px;margin-bottom:4px">';
1201
+ h+='<div style="flex:1"><div style="color:var(--amber);font-weight:700;font-size:13px;margin-bottom:4px">'+esc(timeStr)+'</div>';
1202
+ h+='<div style="color:var(--bright);font-size:15px;font-weight:700;margin-bottom:6px">'+esc(x.summary)+'</div></div>';
1203
+ if(x.id){
1204
+ h+='<div style="display:flex;gap:4px;flex-shrink:0">';
1205
+ h+='<button onclick="openEventForm('+JSON.stringify({id:x.id,calId:calId,summary:x.summary,description:x.description||'',location:x.location||'',start:x.start,end:x.end,isAllDay:x.isAllDay})+','+JSON.stringify(dateStr)+')" style="background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:3px 8px;border-radius:4px;font-size:11px">Edit</button>';
1206
+ h+='<button onclick="deleteCalEvent('+JSON.stringify(calId)+','+JSON.stringify(x.id)+','+JSON.stringify(dateStr)+')" style="background:var(--bg2);border:1px solid var(--red);color:var(--red);padding:3px 8px;border-radius:4px;font-size:11px">Delete</button>';
1207
+ h+='</div>';
1208
+ }
1209
+ h+='</div>';
1210
+ if(x.location)h+='<div style="color:var(--cyan);font-size:12px;margin-bottom:4px">Location: '+esc(x.location)+'</div>';
1211
+ if(x.organizer)h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Organizer: '+esc(x.organizer)+'</div>';
1212
+ if(x.attendees&&x.attendees.length>0){
1213
+ h+='<div style="color:var(--dim);font-size:11px;margin-bottom:4px">Attendees:</div>';
1214
+ x.attendees.forEach(function(a){
1215
+ var status=a.responseStatus==='accepted'?'var(--green)':a.responseStatus==='declined'?'var(--red)':'var(--dim)';
1216
+ h+='<div style="font-size:11px;color:'+status+';padding-left:8px">'+esc(a.name||a.email)+' ('+esc(a.responseStatus)+')</div>';
1217
+ });
1218
+ }
1219
+ if(x.description){
1220
+ h+='<div style="border-top:1px solid var(--border);margin-top:8px;padding-top:8px;color:var(--text);font-size:12px;white-space:pre-wrap;word-wrap:break-word">'+esc(x.description)+'</div>';
1221
+ }
1222
+ if(x.hangoutLink){
1223
+ h+='<div style="margin-top:8px"><a href="'+esc(x.hangoutLink)+'" target="_blank" style="color:var(--cyan);font-size:12px;font-weight:700">Join Video Call</a></div>';
1224
+ }
1225
+ if(x.htmlLink){
1226
+ h+='<div style="margin-top:4px"><a href="'+esc(x.htmlLink)+'" target="_blank" style="color:var(--dim);font-size:10px">Open in Google Calendar</a></div>';
1227
+ }
1228
+ h+='</div>';
1229
+ });
1230
+ }
1231
+ return h;
1218
1232
  }
1219
1233
 
1220
- // Use the agent modal for day detail (read-only mode)
1221
1234
  selectedAgent=null;
1222
1235
  agentChatHistory=[];
1223
1236
  document.getElementById('modalName').textContent=dayLabel;
1224
1237
  document.getElementById('modalAgentDesc').textContent='';
1225
- // Show the day events in the messages area
1226
1238
  var msgEl=document.getElementById('agentMessages');
1227
- if(msgEl){msgEl.innerHTML='<div class="agent-chat__bubble agent-chat__bubble--agent md-body" style="width:100%;max-width:100%;box-sizing:border-box">'+h+'</div>';}
1228
- // Hide input footer in read-only mode
1239
+ if(msgEl){msgEl.innerHTML='<div id="dayDetailBody" class="agent-chat__bubble agent-chat__bubble--agent md-body" style="width:100%;max-width:100%;box-sizing:border-box">'+buildDayHtml()+'</div>';}
1229
1240
  var footer=document.querySelector('.agent-chat__footer');
1230
1241
  if(footer)footer.style.display='none';
1231
1242
  document.getElementById('agentModal').classList.add('modal-overlay--open');
1232
1243
  }
1233
1244
 
1245
+ function refreshDayDetail(dateStr){
1246
+ delete calEventsCache[dateStr];
1247
+ apiGet('/api/calendar?date='+dateStr).then(function(r){
1248
+ calEventsCache[dateStr]=(r&&r.events)||[];
1249
+ openDayDetail(dateStr);
1250
+ renderCalendar(document.getElementById('content'));
1251
+ });
1252
+ }
1253
+
1254
+ function deleteCalEvent(calId,eventId,dateStr){
1255
+ if(!confirm('Delete this event?'))return;
1256
+ apiPost('/api/calendar/'+encodeURIComponent(calId)+'/'+encodeURIComponent(eventId),null,'DELETE').then(function(){
1257
+ refreshDayDetail(dateStr);
1258
+ }).catch(function(e){alert('Error: '+e.message);});
1259
+ }
1260
+
1261
+ function openEventForm(evt,dateStr){
1262
+ var isEdit=evt&&evt.id;
1263
+ var defDate=dateStr||new Date().toISOString().split('T')[0];
1264
+ var defStart=evt&&evt.start?evt.start:defDate+'T09:00';
1265
+ var defEnd=evt&&evt.end?evt.end:defDate+'T10:00';
1266
+ if(defStart.length>16)defStart=defStart.slice(0,16);
1267
+ if(defEnd.length>16)defEnd=defEnd.slice(0,16);
1268
+
1269
+ var overlay=document.createElement('div');
1270
+ overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center';
1271
+ var card=document.createElement('div');
1272
+ card.style.cssText='background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:24px;width:420px;max-width:95vw;max-height:90vh;overflow-y:auto';
1273
+ card.innerHTML='<div style="font-size:16px;font-weight:700;color:var(--bright);margin-bottom:16px">'+(isEdit?'Edit Event':'New Event')+'</div>'+
1274
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Title *</label>'+
1275
+ '<input id="evtTitle" type="text" value="'+esc(evt&&evt.summary||'')+'" placeholder="Event title" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
1276
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Start</label>'+
1277
+ '<input id="evtStart" type="datetime-local" value="'+esc(defStart)+'" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
1278
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">End</label>'+
1279
+ '<input id="evtEnd" type="datetime-local" value="'+esc(defEnd)+'" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
1280
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Location</label>'+
1281
+ '<input id="evtLoc" type="text" value="'+esc(evt&&evt.location||'')+'" placeholder="Optional" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
1282
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Description</label>'+
1283
+ '<textarea id="evtDesc" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:16px;font-size:13px;height:70px;resize:vertical">'+esc(evt&&evt.description||'')+'</textarea>'+
1284
+ '<div style="display:flex;gap:8px;justify-content:flex-end">'+
1285
+ '<button id="evtCancelBtn" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 18px;border-radius:var(--r);font-size:13px">Cancel</button>'+
1286
+ '<button id="evtSaveBtn" style="background:var(--green3);color:var(--bg);padding:8px 18px;border-radius:var(--r);font-size:13px;font-weight:700">'+(isEdit?'Save Changes':'Create Event')+'</button>'+
1287
+ '</div><div id="evtErr" style="color:var(--red);font-size:12px;margin-top:8px"></div>';
1288
+ overlay.appendChild(card);
1289
+ document.body.appendChild(overlay);
1290
+ card.querySelector('#evtCancelBtn').onclick=function(){document.body.removeChild(overlay);};
1291
+ overlay.onclick=function(e){if(e.target===overlay)document.body.removeChild(overlay);};
1292
+ card.querySelector('#evtSaveBtn').onclick=function(){
1293
+ var title=card.querySelector('#evtTitle').value.trim();
1294
+ if(!title){card.querySelector('#evtErr').textContent='Title is required';return;}
1295
+ var startVal=card.querySelector('#evtStart').value;
1296
+ var endVal=card.querySelector('#evtEnd').value;
1297
+ var loc=card.querySelector('#evtLoc').value.trim();
1298
+ var desc=card.querySelector('#evtDesc').value.trim();
1299
+ var btn=card.querySelector('#evtSaveBtn');
1300
+ btn.textContent='Saving...';btn.disabled=true;
1301
+ var promise;
1302
+ if(isEdit){
1303
+ var calId=evt.calId||'primary';
1304
+ var patch={summary:title,start:startVal,end:endVal};
1305
+ if(loc)patch.location=loc;
1306
+ if(desc)patch.description=desc;
1307
+ promise=apiPost('/api/calendar/'+encodeURIComponent(calId)+'/'+encodeURIComponent(evt.id),patch,'PATCH');
1308
+ } else {
1309
+ promise=apiPost('/api/calendar',{summary:title,start:startVal,end:endVal,location:loc,description:desc,date:dateStr});
1310
+ }
1311
+ promise.then(function(){
1312
+ document.body.removeChild(overlay);
1313
+ refreshDayDetail(dateStr);
1314
+ }).catch(function(e){
1315
+ card.querySelector('#evtErr').textContent='Error: '+(e.message||'Unknown error');
1316
+ btn.textContent=isEdit?'Save Changes':'Create Event';btn.disabled=false;
1317
+ });
1318
+ };
1319
+ }
1320
+
1234
1321
  // ---- GITHUB ----
1235
1322
  var ghData=null;var ghRepo='';
1236
1323
  function renderGitHub(el){
1237
1324
  function renderGhData(r){
1238
- var h='<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap"><input type="text" id="ghRepo" placeholder="owner/repo" value="'+esc(ghRepo)+'" style="flex:1;min-width:180px;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)loadGhIssues()"><button onclick="loadGhIssues()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Issues</button><button onclick="loadGhPRs()" style="background:var(--cyan);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">PRs</button></div>';
1325
+ var user=r.user||null;
1326
+ // Header: user profile + repo input
1327
+ var userHtml='';
1328
+ if(user&&user.login){
1329
+ userHtml='<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;padding:10px 14px;background:var(--bg2);border-radius:var(--r);border:1px solid var(--border)">'+(user.avatar?'<img src="'+esc(user.avatar)+'" style="width:36px;height:36px;border-radius:50%;object-fit:cover" alt="">':'')+'<div><div style="font-weight:700;font-size:13px;color:var(--green)">@'+esc(user.login)+'</div>'+(user.name?'<div style="font-size:11px;color:var(--dim)">'+esc(user.name)+'</div>':'')+'</div></div>';
1330
+ }
1331
+ var h=userHtml+'<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap"><input type="text" id="ghRepo" placeholder="owner/repo" value="'+esc(ghRepo)+'" style="flex:1;min-width:180px;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)loadGhIssues()"><button onclick="loadGhIssues()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Issues</button><button onclick="loadGhPRs()" style="background:var(--cyan);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">PRs</button></div>';
1332
+ // My repos as clickable pills
1333
+ if(user&&user.repos&&user.repos.length>0){
1334
+ h+='<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">';
1335
+ user.repos.slice(0,12).forEach(function(repo){
1336
+ h+='<button onclick="ghRepo=\\x27'+esc(repo.full_name)+'\\x27;document.getElementById(\\x27ghRepo\\x27).value=\\x27'+esc(repo.full_name)+'\\x27;loadGhIssues()" style="background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:11px;color:var(--fg);cursor:pointer;white-space:nowrap" title="'+esc(repo.description)+(repo.open_issues?' | '+repo.open_issues+' open issues':'')+'">'+(repo.private?'&#128274; ':'')+esc(repo.full_name)+(repo.open_issues?'<span style="color:var(--amber);margin-left:4px">'+repo.open_issues+'</span>':'')+'</button>';
1337
+ });
1338
+ h+='</div>';
1339
+ }
1340
+ // Notifications
1239
1341
  var notifs=r.notifications||[];
1240
1342
  if(notifs.length>0){
1241
1343
  h+='<div style="display:flex;align-items:center;justify-content:space-between"><div class="section-title">Notifications ('+notifs.length+')</div><button onclick="ghMarkRead()" style="background:var(--bg3);color:var(--dim);border:1px solid var(--border);padding:4px 10px;border-radius:var(--r);font-size:10px;cursor:pointer">Mark all read</button></div>';
1242
1344
  notifs.forEach(function(n){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(n.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--cyan);font-size:11px">'+esc(n.repo)+'</span> <span style="color:var(--dim);font-size:10px">['+esc(n.type)+']</span><div style="font-size:13px;margin-top:2px">'+esc(n.title)+'</div><div style="font-size:10px;color:var(--dim)">'+esc(n.reason)+' &middot; '+esc(n.updated)+'</div></div>'});
1243
1345
  }
1346
+ // Issues
1244
1347
  if(r.issues&&r.issues.length>0){
1245
1348
  h+='<div class="section-title">Issues — '+esc(r.repo||ghRepo)+'</div>';
1246
1349
  r.issues.forEach(function(i){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(i.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--green);font-weight:700">#'+i.number+'</span> '+esc(i.title)+(i.assignee?' <span style="font-size:10px;color:var(--cyan)">&#8594; '+esc(i.assignee)+'</span>':'')+(i.labels?'<span style="font-size:9px;color:var(--amber);margin-left:6px">['+esc(i.labels)+']</span>':'')+'<div style="font-size:10px;color:var(--dim)">'+esc(i.updated)+'</div></div>'});
1247
1350
  }
1351
+ // PRs
1248
1352
  if(r.prs&&r.prs.length>0){
1249
1353
  h+='<div class="section-title">Pull Requests — '+esc(r.repo||ghRepo)+'</div>';
1250
1354
  r.prs.forEach(function(p){h+='<div class="card" style="padding:10px 14px;cursor:pointer" onclick="window.open(\\x27'+esc(p.url)+'\\x27,\\x27_blank\\x27)"><span style="color:var(--cyan);font-weight:700">#'+p.number+'</span> '+esc(p.title)+' <span style="font-size:10px;color:var(--dim)">by '+esc(p.author)+'</span>'+(p.draft?'<span style="font-size:9px;color:var(--amber)"> DRAFT</span>':'')+'<div style="font-size:10px;color:var(--dim)">'+esc(p.updated)+'</div></div>'});
1251
1355
  }
1252
- if(!notifs.length&&!(r.issues&&r.issues.length)&&!(r.prs&&r.prs.length)){h+='<div class="card" style="text-align:center;color:var(--dim);padding:20px">Enter a repo above (e.g. owner/repo) and click Issues or PRs.<br>Notifications load automatically.</div>'}
1356
+ if(!notifs.length&&!(r.issues&&r.issues.length)&&!(r.prs&&r.prs.length)){
1357
+ h+='<div class="card" style="text-align:center;color:var(--dim);padding:20px">Click a repo above or type owner/repo and click Issues or PRs.</div>';
1358
+ }
1253
1359
  el.innerHTML=h;
1254
1360
  }
1255
- // If ghData already loaded (e.g. after loadGhIssues/loadGhPRs), render directly without re-fetching
1256
1361
  if(ghData){renderGhData(ghData);return;}
1257
1362
  el.innerHTML='<div style="text-align:center;padding:40px"><div class="spinner"></div><div style="color:var(--dim)">Loading GitHub...</div></div>';
1258
1363
  apiGet('/api/github').then(function(r){
@@ -1267,7 +1372,12 @@ function ghMarkRead(){apiPost('/api/github/mark-read',{}).then(function(){if(ghD
1267
1372
 
1268
1373
  // ---- NOTION ----
1269
1374
  function renderNotion(el){
1270
- el.innerHTML='<div style="display:flex;gap:8px;margin-bottom:16px"><input type="text" id="notionQuery" placeholder="Search Notion pages..." style="flex:1;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)searchNotion()"><button onclick="searchNotion()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Search</button></div><div id="notionResults"><div class="card" style="text-align:center;color:var(--dim);padding:20px">Search your Notion workspace. Requires: nha config set notion-token YOUR_TOKEN</div></div>';
1375
+ apiGet('/api/notion/search?q=').then(function(r){
1376
+ var banner=r&&r.error?setupBanner('Notion','nha config set notion-token YOUR_INTEGRATION_TOKEN')+'<div style="color:var(--dim);font-size:12px;padding:8px 0">Get an Integration Token from notion.so/my-integrations → New integration → Internal → copy Secret</div>':'';
1377
+ el.innerHTML=banner+'<div style="display:flex;gap:8px;margin-bottom:16px"><input type="text" id="notionQuery" placeholder="Search Notion pages..." style="flex:1;font-size:13px;padding:10px 14px" onkeydown="if(event.key===\\x27Enter\\x27)searchNotion()"><button onclick="searchNotion()" style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px">Search</button></div><div id="notionResults"></div>';
1378
+ }).catch(function(){
1379
+ el.innerHTML=setupBanner('Notion','nha config set notion-token YOUR_INTEGRATION_TOKEN')+'<div style="display:flex;gap:8px;margin-bottom:16px"><input type="text" id="notionQuery" placeholder="Search Notion pages..." style="flex:1;font-size:13px;padding:10px 14px"><button style="background:var(--green3);color:var(--bg);padding:8px 16px;border-radius:var(--r);font-weight:700;font-size:12px" disabled>Search</button></div>';
1380
+ });
1271
1381
  }
1272
1382
  function searchNotion(){
1273
1383
  var q=document.getElementById('notionQuery');if(!q||!q.value.trim())return;
@@ -1289,11 +1399,12 @@ function loadNotionPage(id){
1289
1399
  }
1290
1400
 
1291
1401
  // ---- SLACK ----
1402
+ function setupBanner(service,cmd){return '<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg2);border:1px solid var(--border);border-left:3px solid var(--amber);border-radius:var(--r);margin-bottom:14px;font-size:12px"><span style="font-size:20px">&#128274;</span><div><div style="color:var(--fg);font-weight:600;margin-bottom:2px">'+esc(service)+' not configured</div><div style="color:var(--dim);font-family:var(--mono);font-size:11px">'+esc(cmd)+'</div></div></div>';}
1292
1403
  var slackData=null;
1293
1404
  function renderSlack(el){
1294
1405
  el.innerHTML='<div style="text-align:center;padding:40px"><div class="spinner"></div><div style="color:var(--dim)">Loading Slack channels...</div></div>';
1295
1406
  apiGet('/api/slack/channels').then(function(r){
1296
- if(r&&r.error){el.innerHTML='<div class="card" style="text-align:center;padding:30px"><div style="color:var(--dim);margin-bottom:8px">'+esc(r.error)+'</div><div style="font-size:11px;color:var(--dim)">Run: nha config set slack-token xoxb-YOUR_TOKEN</div></div>';return}
1407
+ if(r&&r.error){el.innerHTML=setupBanner('Slack','nha config set slack-token xoxb-YOUR_TOKEN')+'<div style="color:var(--dim);font-size:12px;padding:8px 0">Get a Bot Token from api.slack.com/apps → OAuth &amp; Permissions → Bot Token Scopes: channels:read, channels:history, users:read</div>';return}
1297
1408
  slackData=r;
1298
1409
  var channels=r.channels||[];
1299
1410
  var h='<div class="section-title">Channels ('+channels.length+')</div>';
@@ -1322,17 +1433,75 @@ function renderBirthdays(el){
1322
1433
  apiGet('/api/birthdays').then(function(r){
1323
1434
  if(r&&r.error){el.innerHTML='<div class="card" style="text-align:center;padding:30px"><div style="color:var(--dim)">'+esc(r.error)+'</div></div>';return}
1324
1435
  var bdays=r.birthdays||[];
1325
- if(bdays.length===0){el.innerHTML='<div class="card" style="text-align:center;padding:30px;color:var(--dim)">No upcoming birthdays found. Make sure your Google Contacts have birthday info.</div>';return}
1326
- var h='<div class="section-title">Upcoming Birthdays</div>';
1327
- bdays.forEach(function(b){
1328
- var isToday=b.daysUntil===0;
1329
- var label=isToday?'<span style="color:var(--red);font-weight:700">TODAY!</span>':b.daysUntil===1?'<span style="color:var(--amber)">Tomorrow</span>':'<span style="color:var(--dim)">in '+b.daysUntil+' days</span>';
1330
- h+='<div class="card" style="padding:12px 14px'+(isToday?';border-color:var(--red)':'')+'"><span style="font-size:16px">&#127874;</span> <span style="font-weight:700">'+esc(b.name)+'</span> - '+esc(b.date)+' '+label+'</div>';
1331
- });
1436
+ var h='<div style="display:flex;align-items:center;margin-bottom:12px">';
1437
+ h+='<div class="section-title" style="margin:0;flex:1">Upcoming Birthdays</div>';
1438
+ h+='<button onclick="openBirthdayForm(null)" style="background:var(--green3);color:var(--bg);padding:5px 14px;border-radius:var(--r);font-size:12px;font-weight:700">+ Add Birthday</button>';
1439
+ h+='</div>';
1440
+ if(bdays.length===0){
1441
+ h+='<div class="card" style="text-align:center;padding:30px;color:var(--dim)">No upcoming birthdays found.<br><span style="font-size:11px">Add one above, or add birthdays to your Google Contacts.</span></div>';
1442
+ } else {
1443
+ bdays.forEach(function(b){
1444
+ var isToday=b.daysUntil===0;
1445
+ var label=isToday?'<span style="color:var(--red);font-weight:700">TODAY!</span>':b.daysUntil===1?'<span style="color:var(--amber)">Tomorrow</span>':'<span style="color:var(--dim)">in '+b.daysUntil+' days</span>';
1446
+ h+='<div class="card" style="padding:10px 14px;display:flex;align-items:center;gap:8px'+(isToday?';border-color:var(--red)':'')+'"><span style="font-size:18px">&#127874;</span><div style="flex:1"><div style="font-weight:700;font-size:13px">'+esc(b.name)+'</div><div style="font-size:11px;color:var(--dim)">'+esc(b.date)+'</div></div><div style="margin-right:8px">'+label+'</div>';
1447
+ if(b.contactId){
1448
+ h+='<button onclick="openBirthdayForm('+JSON.stringify({contactId:b.contactId,name:b.name,date:b.date})+')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 8px;border-radius:4px;font-size:11px">Edit</button>';
1449
+ h+='<button onclick="deleteBirthday('+JSON.stringify(b.contactId)+','+JSON.stringify(b.name)+')" style="background:var(--bg3);border:1px solid var(--red);color:var(--red);padding:3px 8px;border-radius:4px;font-size:11px;margin-left:4px">Delete</button>';
1450
+ }
1451
+ h+='</div>';
1452
+ });
1453
+ }
1332
1454
  el.innerHTML=h;
1333
1455
  });
1334
1456
  }
1335
1457
 
1458
+ function openBirthdayForm(b){
1459
+ var isEdit=b&&b.contactId;
1460
+ var overlay=document.createElement('div');
1461
+ overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center';
1462
+ var card=document.createElement('div');
1463
+ card.style.cssText='background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:24px;width:360px;max-width:95vw';
1464
+ card.innerHTML='<div style="font-size:16px;font-weight:700;color:var(--bright);margin-bottom:16px">'+(isEdit?'Edit Birthday':'Add Birthday')+'</div>'+
1465
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Name *</label>'+
1466
+ '<input id="bdayName" type="text" value="'+esc(b&&b.name||'')+'" placeholder="Contact name" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:12px;font-size:13px">'+
1467
+ '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">Birthday (MM-DD or YYYY-MM-DD)</label>'+
1468
+ '<input id="bdayDate" type="text" value="'+esc(b&&b.date||'')+'" placeholder="e.g. 03-15 or 1990-03-15" style="width:100%;box-sizing:border-box;padding:8px 10px;margin-bottom:16px;font-size:13px">'+
1469
+ '<div style="font-size:11px;color:var(--dim);margin-bottom:16px">Birthday will be saved as a Google Calendar event on the specified date.</div>'+
1470
+ '<div style="display:flex;gap:8px;justify-content:flex-end">'+
1471
+ '<button id="bdayCancelBtn" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 18px;border-radius:var(--r);font-size:13px">Cancel</button>'+
1472
+ '<button id="bdaySaveBtn" style="background:var(--green3);color:var(--bg);padding:8px 18px;border-radius:var(--r);font-size:13px;font-weight:700">'+(isEdit?'Save':'Add')+'</button>'+
1473
+ '</div><div id="bdayErr" style="color:var(--red);font-size:12px;margin-top:8px"></div>';
1474
+ overlay.appendChild(card);
1475
+ document.body.appendChild(overlay);
1476
+ card.querySelector('#bdayCancelBtn').onclick=function(){document.body.removeChild(overlay);};
1477
+ overlay.onclick=function(e){if(e.target===overlay)document.body.removeChild(overlay);};
1478
+ card.querySelector('#bdaySaveBtn').onclick=function(){
1479
+ var name=card.querySelector('#bdayName').value.trim();
1480
+ var date=card.querySelector('#bdayDate').value.trim();
1481
+ if(!name){card.querySelector('#bdayErr').textContent='Name is required';return;}
1482
+ if(!date){card.querySelector('#bdayErr').textContent='Date is required';return;}
1483
+ var btn=card.querySelector('#bdaySaveBtn');
1484
+ btn.textContent='Saving...';btn.disabled=true;
1485
+ // Parse date into a full date for the calendar event
1486
+ var fullDate=date;
1487
+ if(/^\d{2}-\d{2}$/.test(date))fullDate=new Date().getFullYear()+'-'+date;
1488
+ apiPost('/api/birthdays',{name:name,date:fullDate,contactId:isEdit?b.contactId:null,edit:isEdit}).then(function(){
1489
+ document.body.removeChild(overlay);
1490
+ renderBirthdays(document.getElementById('content'));
1491
+ }).catch(function(e){
1492
+ card.querySelector('#bdayErr').textContent='Error: '+(e.message||'Unknown error');
1493
+ btn.textContent=isEdit?'Save':'Add';btn.disabled=false;
1494
+ });
1495
+ };
1496
+ }
1497
+
1498
+ function deleteBirthday(contactId,name){
1499
+ if(!confirm('Remove birthday for '+name+'?'))return;
1500
+ apiPost('/api/birthdays/delete',{contactId:contactId}).then(function(){
1501
+ renderBirthdays(document.getElementById('content'));
1502
+ }).catch(function(e){alert('Error: '+e.message);});
1503
+ }
1504
+
1336
1505
  // ---- AGENTS ----
1337
1506
  var AGENT_DESCRIPTIONS = {
1338
1507
  saber:'Security audits, OWASP Top 10, threat modeling, pentest planning',
@@ -2624,15 +2793,16 @@ function showToast(type, title, body, durationMs) {
2624
2793
  var ws = null;
2625
2794
  var wsReconnectTimer = null;
2626
2795
  var wsRetryCount = 0;
2627
- var wsMaxRetries = 1;
2796
+ var wsRetryDelay = 2000; // start at 2s, exponential backoff up to 30s
2628
2797
  function connectWebSocket() {
2629
- if (wsRetryCount >= wsMaxRetries) return; // Stop trying after 3 failures
2798
+ if (wsReconnectTimer) return; // already scheduled
2630
2799
  try {
2631
2800
  ws = new WebSocket('ws://' + window.location.host);
2632
2801
  } catch(e) { return; }
2633
2802
 
2634
2803
  ws.onopen = function() {
2635
2804
  wsRetryCount = 0;
2805
+ wsRetryDelay = 2000;
2636
2806
  var indicator = document.getElementById('wsIndicator');
2637
2807
  if (indicator) { indicator.style.color = 'var(--green)'; indicator.title = 'Live updates: connected'; }
2638
2808
  };
@@ -2646,15 +2816,15 @@ function connectWebSocket() {
2646
2816
 
2647
2817
  ws.onclose = function() {
2648
2818
  var indicator = document.getElementById('wsIndicator');
2649
- if (indicator) { indicator.style.color = 'var(--dim)'; indicator.title = 'Live updates: disconnected'; }
2819
+ if (indicator) { indicator.style.color = 'var(--dim)'; indicator.title = 'Live updates: reconnecting...'; }
2650
2820
  ws = null;
2651
2821
  wsRetryCount++;
2652
- if (wsRetryCount < wsMaxRetries && !wsReconnectTimer) {
2653
- wsReconnectTimer = setTimeout(function() {
2654
- wsReconnectTimer = null;
2655
- connectWebSocket();
2656
- }, 3000); // 3s between retries
2657
- }
2822
+ var delay = Math.min(wsRetryDelay * Math.pow(1.5, Math.min(wsRetryCount - 1, 6)), 30000);
2823
+ wsRetryDelay = delay;
2824
+ wsReconnectTimer = setTimeout(function() {
2825
+ wsReconnectTimer = null;
2826
+ connectWebSocket();
2827
+ }, delay);
2658
2828
  };
2659
2829
 
2660
2830
  ws.onerror = function() {