nothumanallowed 4.0.2 → 5.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.
@@ -3,16 +3,19 @@
3
3
  *
4
4
  * Runs as detached child process. Polls every 5 min (mail), 15 min (calendar).
5
5
  * Generates daily plan at configured time. Sends notifications.
6
+ *
7
+ * WebSocket server on port 3848 broadcasts real-time events to connected
8
+ * clients (nha ui dashboard, etc.) when new emails arrive or meetings approach.
6
9
  */
7
10
 
8
11
  import fs from 'fs';
9
12
  import path from 'path';
13
+ import http from 'http';
14
+ import crypto from 'crypto';
10
15
  import { spawn } from 'child_process';
11
16
  import { NHA_DIR } from '../constants.mjs';
12
17
  import { loadConfig } from '../config.mjs';
13
- import { loadTokens } from './token-store.mjs';
14
- import { getUnreadImportant } from './google-gmail.mjs';
15
- import { getUpcomingEvents } from './google-calendar.mjs';
18
+ import { hasMailProvider, getUnreadImportant, getUpcomingEvents } from './mail-router.mjs';
16
19
  import { notify } from './notification.mjs';
17
20
  import { callAgent } from './llm.mjs';
18
21
  import { runPlanningPipeline } from './ops-pipeline.mjs';
@@ -21,6 +24,7 @@ const DAEMON_DIR = path.join(NHA_DIR, 'ops', 'daemon');
21
24
  const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
22
25
  const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
23
26
  const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
27
+ const WS_PORT = 3848;
24
28
 
25
29
  // ── Daemon Control ─────────────────────────────────────────────────────────
26
30
 
@@ -103,6 +107,117 @@ export function getDaemonStatus() {
103
107
  return { running, pid, ...state };
104
108
  }
105
109
 
110
+ // ── WebSocket Server (RFC 6455, zero-dependency) ────────────────────────────
111
+
112
+ const wsClients = new Set();
113
+
114
+ /**
115
+ * Minimal WebSocket handshake and frame implementation.
116
+ * Implements just enough of RFC 6455 for text-frame broadcast.
117
+ */
118
+ function startWebSocketServer() {
119
+ const server = http.createServer((req, res) => {
120
+ if (req.url === '/health') {
121
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
122
+ res.end(JSON.stringify({ ok: true, clients: wsClients.size }));
123
+ return;
124
+ }
125
+ res.writeHead(404);
126
+ res.end();
127
+ });
128
+
129
+ server.on('upgrade', (req, socket, head) => {
130
+ const key = req.headers['sec-websocket-key'];
131
+ if (!key) { socket.destroy(); return; }
132
+
133
+ const acceptKey = crypto
134
+ .createHash('sha1')
135
+ .update(key + '258EAFA5-E914-47DA-95CA-5AB5DC86C11B')
136
+ .digest('base64');
137
+
138
+ socket.write(
139
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
140
+ 'Upgrade: websocket\r\n' +
141
+ 'Connection: Upgrade\r\n' +
142
+ `Sec-WebSocket-Accept: ${acceptKey}\r\n` +
143
+ '\r\n'
144
+ );
145
+
146
+ wsClients.add(socket);
147
+ log(`WebSocket client connected (${wsClients.size} total)`);
148
+
149
+ socket.on('data', (buf) => {
150
+ if (buf.length < 2) return;
151
+ const opcode = buf[0] & 0x0f;
152
+ if (opcode === 0x08) { wsClients.delete(socket); socket.end(); return; }
153
+ if (opcode === 0x09) {
154
+ const pong = Buffer.alloc(2);
155
+ pong[0] = 0x8a;
156
+ pong[1] = 0;
157
+ socket.write(pong);
158
+ }
159
+ });
160
+
161
+ socket.on('close', () => {
162
+ wsClients.delete(socket);
163
+ log(`WebSocket client disconnected (${wsClients.size} remaining)`);
164
+ });
165
+
166
+ socket.on('error', () => { wsClients.delete(socket); });
167
+ });
168
+
169
+ server.on('error', (err) => {
170
+ if (err.code === 'EADDRINUSE') {
171
+ log(`WebSocket port ${WS_PORT} already in use — skipping WS server`);
172
+ return;
173
+ }
174
+ log(`WebSocket server error: ${err.message}`);
175
+ });
176
+
177
+ server.listen(WS_PORT, '127.0.0.1', () => {
178
+ log(`WebSocket server listening on 127.0.0.1:${WS_PORT}`);
179
+ });
180
+
181
+ return server;
182
+ }
183
+
184
+ /**
185
+ * Encode a string into a WebSocket text frame (RFC 6455).
186
+ */
187
+ function encodeWSFrame(text) {
188
+ const data = Buffer.from(text, 'utf-8');
189
+ const len = data.length;
190
+ let header;
191
+ if (len < 126) {
192
+ header = Buffer.alloc(2);
193
+ header[0] = 0x81;
194
+ header[1] = len;
195
+ } else if (len < 65536) {
196
+ header = Buffer.alloc(4);
197
+ header[0] = 0x81;
198
+ header[1] = 126;
199
+ header.writeUInt16BE(len, 2);
200
+ } else {
201
+ header = Buffer.alloc(10);
202
+ header[0] = 0x81;
203
+ header[1] = 127;
204
+ header.writeBigUInt64BE(BigInt(len), 2);
205
+ }
206
+ return Buffer.concat([header, data]);
207
+ }
208
+
209
+ /**
210
+ * Broadcast a JSON message to all connected WebSocket clients.
211
+ */
212
+ function wsBroadcast(payload) {
213
+ const frame = encodeWSFrame(JSON.stringify(payload));
214
+ for (const socket of wsClients) {
215
+ try { socket.write(frame); } catch { wsClients.delete(socket); }
216
+ }
217
+ }
218
+
219
+ export { wsBroadcast, WS_PORT };
220
+
106
221
  // ── Daemon Loop (runs in background process) ───────────────────────────────
107
222
 
108
223
  function saveState(state) {
@@ -119,11 +234,15 @@ async function daemonLoop() {
119
234
  log('NHA PAO Daemon started');
120
235
  const config = loadConfig();
121
236
 
237
+ // Start WebSocket server for real-time push notifications
238
+ startWebSocketServer();
239
+
122
240
  const state = {
123
241
  startedAt: new Date().toISOString(),
124
242
  lastMailCheck: null,
125
243
  lastCalendarCheck: null,
126
244
  lastPlanGenerated: null,
245
+ wsPort: WS_PORT,
127
246
  errors: 0,
128
247
  };
129
248
  saveState(state);
@@ -141,8 +260,7 @@ async function daemonLoop() {
141
260
  // Main loop
142
261
  setInterval(async () => {
143
262
  const now = Date.now();
144
- const tokens = loadTokens();
145
- if (!tokens) return; // not authenticated
263
+ if (!hasMailProvider()) return; // not authenticated
146
264
 
147
265
  // ── Mail check ─────────────────────────────────────────
148
266
  if (now - lastMailCheck > MAIL_INTERVAL) {
@@ -154,6 +272,19 @@ async function daemonLoop() {
154
272
  for (const email of newEmails) {
155
273
  knownEmailIds.add(email.id);
156
274
 
275
+ // Broadcast new email event to WebSocket clients
276
+ wsBroadcast({
277
+ type: 'new_email',
278
+ timestamp: new Date().toISOString(),
279
+ data: {
280
+ id: email.id,
281
+ from: email.from,
282
+ subject: email.subject,
283
+ snippet: (email.snippet || '').slice(0, 120),
284
+ isImportant: email.isImportant,
285
+ },
286
+ });
287
+
157
288
  // SABER quick scan for suspicious emails
158
289
  if (email.urls.length > 0 || email.from.includes('paypal') || email.from.includes('bank') || email.subject.toLowerCase().includes('urgent')) {
159
290
  try {
@@ -162,6 +293,11 @@ async function daemonLoop() {
162
293
  );
163
294
  if (scanResult.toUpperCase().includes('FLAGGED')) {
164
295
  await notify('Security Alert', `Suspicious email from ${email.from}: ${email.subject}\n${scanResult}`, config);
296
+ wsBroadcast({
297
+ type: 'security_alert',
298
+ timestamp: new Date().toISOString(),
299
+ data: { from: email.from, subject: email.subject, reason: scanResult },
300
+ });
165
301
  }
166
302
  } catch { /* non-fatal */ }
167
303
  }
@@ -192,6 +328,19 @@ async function daemonLoop() {
192
328
  const minutesUntil = (eventStart - now) / 60000;
193
329
 
194
330
  if (minutesUntil > 0 && minutesUntil <= MEETING_ALERT) {
331
+ // Broadcast meeting alert to WebSocket clients
332
+ wsBroadcast({
333
+ type: 'meeting_alert',
334
+ timestamp: new Date().toISOString(),
335
+ data: {
336
+ summary: event.summary,
337
+ start: event.start,
338
+ minutesUntil: Math.round(minutesUntil),
339
+ location: event.location || null,
340
+ hangoutLink: event.hangoutLink || null,
341
+ },
342
+ });
343
+
195
344
  // Generate quick brief with HERALD
196
345
  try {
197
346
  const brief = await callAgent(config, 'herald',
@@ -226,6 +375,11 @@ async function daemonLoop() {
226
375
  state.lastPlanGenerated = new Date().toISOString();
227
376
  saveState(state);
228
377
  await notify('Daily Plan Ready', `Your plan for ${todayStr} is ready. Run "nha plan --show" to view.`, config);
378
+ wsBroadcast({
379
+ type: 'plan_ready',
380
+ timestamp: new Date().toISOString(),
381
+ data: { date: todayStr },
382
+ });
229
383
  log('Daily plan generated');
230
384
  } catch (err) {
231
385
  log(`Plan generation error: ${err.message}`);
@@ -15,10 +15,10 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import { callAgent, callLLM } from './llm.mjs';
18
- import { getUnreadImportant, getTodayEmails } from './google-gmail.mjs';
19
- import { getTodayEvents, getUpcomingEvents } from './google-calendar.mjs';
18
+ import { getUnreadImportant, getTodayEmails } from './mail-router.mjs';
19
+ import { getTodayEvents, getUpcomingEvents } from './mail-router.mjs';
20
20
  import { getTasks, bulkAddTasks } from './task-store.mjs';
21
- import { loadTokens } from './token-store.mjs';
21
+ import { hasMailProvider } from './mail-router.mjs';
22
22
  import { NHA_DIR } from '../constants.mjs';
23
23
  import { info, ok, fail, warn, D, C, G, Y, W, BOLD, NC } from '../ui.mjs';
24
24
 
@@ -47,8 +47,7 @@ export async function runPlanningPipeline(config, opts = {}) {
47
47
  }
48
48
 
49
49
  const startTime = Date.now();
50
- const tokens = loadTokens();
51
- const hasGoogle = !!tokens;
50
+ const hasMail = hasMailProvider();
52
51
 
53
52
  console.log(`\n ${BOLD}NHA Daily Plan — ${dateStr}${NC}`);
54
53
  console.log(` ${D}5 specialist agents analyzing your day${NC}\n`);
@@ -60,7 +59,7 @@ export async function runPlanningPipeline(config, opts = {}) {
60
59
  let events = [];
61
60
  const tasks = getTasks(dateStr);
62
61
 
63
- if (hasGoogle) {
62
+ if (hasMail) {
64
63
  try {
65
64
  [emails, events] = await Promise.all([
66
65
  getUnreadImportant(config, 30),
@@ -68,11 +67,11 @@ export async function runPlanningPipeline(config, opts = {}) {
68
67
  ]);
69
68
  ok(`${emails.length} emails, ${events.length} events, ${tasks.length} tasks`);
70
69
  } catch (err) {
71
- warn(`Google API error: ${err.message}`);
70
+ warn(`Mail API error: ${err.message}`);
72
71
  info('Continuing with tasks only...');
73
72
  }
74
73
  } else {
75
- warn('Google not connected. Using tasks only. Run "nha google auth" to connect.');
74
+ warn('No mail provider connected. Using tasks only. Run "nha google auth" or "nha microsoft auth" to connect.');
76
75
  }
77
76
 
78
77
  // Build context for agents
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Encrypted token storage — AES-256-GCM with machine-derived key.
3
- * Stores OAuth tokens at ~/.nha/ops/tokens.enc
3
+ * Supports multiple providers (google, microsoft) via separate encrypted files.
4
+ * Stores OAuth tokens at ~/.nha/ops/tokens-<provider>.enc
5
+ * Legacy single-file (~/.nha/ops/tokens.enc) is treated as 'google' for backward compat.
4
6
  * Zero dependencies — uses Node.js native crypto.
5
7
  */
6
8
 
@@ -11,7 +13,15 @@ import path from 'path';
11
13
  import { NHA_DIR } from '../constants.mjs';
12
14
 
13
15
  const OPS_DIR = path.join(NHA_DIR, 'ops');
14
- const TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
16
+ const LEGACY_TOKENS_FILE = path.join(OPS_DIR, 'tokens.enc');
17
+
18
+ /** Get tokens file path for a given provider */
19
+ function getTokensFile(provider) {
20
+ if (!provider || provider === 'google') {
21
+ return LEGACY_TOKENS_FILE; // backward compatible — Google uses the original file
22
+ }
23
+ return path.join(OPS_DIR, `tokens-${provider}.enc`);
24
+ }
15
25
 
16
26
  /** Derive encryption key from machine-specific fingerprint */
17
27
  function deriveKey() {
@@ -49,21 +59,25 @@ function decrypt(envelope) {
49
59
  /**
50
60
  * Save OAuth tokens (encrypted).
51
61
  * @param {object} tokens — { access_token, refresh_token, expires_at, scope, email }
62
+ * @param {string} provider — 'google' (default) or 'microsoft'
52
63
  */
53
- export function saveTokens(tokens) {
64
+ export function saveTokens(tokens, provider) {
54
65
  fs.mkdirSync(OPS_DIR, { recursive: true });
66
+ const file = getTokensFile(provider);
55
67
  const envelope = encrypt(tokens);
56
- fs.writeFileSync(TOKENS_FILE, JSON.stringify(envelope, null, 2), { mode: 0o600 });
68
+ fs.writeFileSync(file, JSON.stringify(envelope, null, 2), { mode: 0o600 });
57
69
  }
58
70
 
59
71
  /**
60
72
  * Load OAuth tokens (decrypted).
73
+ * @param {string} provider — 'google' (default) or 'microsoft'
61
74
  * @returns {object|null} tokens or null if not stored
62
75
  */
63
- export function loadTokens() {
64
- if (!fs.existsSync(TOKENS_FILE)) return null;
76
+ export function loadTokens(provider) {
77
+ const file = getTokensFile(provider);
78
+ if (!fs.existsSync(file)) return null;
65
79
  try {
66
- const envelope = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
80
+ const envelope = JSON.parse(fs.readFileSync(file, 'utf-8'));
67
81
  return decrypt(envelope);
68
82
  } catch {
69
83
  return null;
@@ -72,9 +86,11 @@ export function loadTokens() {
72
86
 
73
87
  /**
74
88
  * Delete stored tokens.
89
+ * @param {string} provider — 'google' (default) or 'microsoft'
75
90
  */
76
- export function deleteTokens() {
77
- if (fs.existsSync(TOKENS_FILE)) fs.rmSync(TOKENS_FILE);
91
+ export function deleteTokens(provider) {
92
+ const file = getTokensFile(provider);
93
+ if (fs.existsSync(file)) fs.rmSync(file);
78
94
  }
79
95
 
80
96
  /**
@@ -88,7 +104,7 @@ export function isExpired(tokens) {
88
104
  }
89
105
 
90
106
  /**
91
- * Refresh access token using refresh_token.
107
+ * Refresh access token using refresh_token (Google-specific).
92
108
  * @param {string} clientId
93
109
  * @param {string} clientSecret
94
110
  * @param {string} refreshToken
@@ -123,12 +139,12 @@ export async function refreshAccessToken(clientId, clientSecret, refreshToken) {
123
139
  }
124
140
 
125
141
  /**
126
- * Get a valid access token — refreshes automatically if expired.
142
+ * Get a valid Google access token — refreshes automatically if expired.
127
143
  * @param {object} config — NHA config with google.clientId etc.
128
144
  * @returns {Promise<string>} access_token
129
145
  */
130
146
  export async function getAccessToken(config) {
131
- let tokens = loadTokens();
147
+ let tokens = loadTokens('google');
132
148
  if (!tokens) throw new Error('Not authenticated. Run: nha google auth');
133
149
 
134
150
  if (isExpired(tokens)) {
@@ -137,9 +153,20 @@ export async function getAccessToken(config) {
137
153
  if (!clientId) throw new Error('Google client ID not configured');
138
154
 
139
155
  tokens = await refreshAccessToken(clientId, clientSecret, tokens.refresh_token);
140
- tokens.email = loadTokens()?.email; // preserve email
141
- saveTokens(tokens);
156
+ tokens.email = loadTokens('google')?.email; // preserve email
157
+ saveTokens(tokens, 'google');
142
158
  }
143
159
 
144
160
  return tokens.access_token;
145
161
  }
162
+
163
+ /**
164
+ * Check which providers are currently authenticated.
165
+ * @returns {{ google: boolean, microsoft: boolean }}
166
+ */
167
+ export function getAuthenticatedProviders() {
168
+ return {
169
+ google: loadTokens('google') !== null,
170
+ microsoft: loadTokens('microsoft') !== null,
171
+ };
172
+ }