nothumanallowed 9.5.2 → 9.6.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.
package/src/config.mjs CHANGED
@@ -283,8 +283,6 @@ export function setConfigValue(key, value) {
283
283
  'my-role': 'profile.role',
284
284
  'role': 'profile.role',
285
285
  'profile-notes': 'profile.notes',
286
- 'language': 'language',
287
- 'lang': 'language',
288
286
  };
289
287
 
290
288
  const resolved = aliases[key] || key;
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 = '9.5.2';
8
+ export const VERSION = '9.6.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -11,9 +11,8 @@ import os from 'os';
11
11
  import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
12
12
  import { info, ok, fail, warn } from '../ui.mjs';
13
13
 
14
- // NHA published OAuth client — same credentials as the mobile app
15
- const DEFAULT_CLIENT_ID = '740354841334-o0l6rhkn900jtjtfcls3bkr2n9f1l3tt.apps.googleusercontent.com';
16
- const DEFAULT_CLIENT_SECRET = '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';
14
+ // NHA published OAuth client (Desktop app type client_id is not a secret)
15
+ const DEFAULT_CLIENT_ID = ''; // Will be set when Google Cloud project is verified
17
16
  const SCOPES = [
18
17
  'https://www.googleapis.com/auth/gmail.modify',
19
18
  'https://www.googleapis.com/auth/gmail.send',
@@ -80,7 +79,7 @@ function waitForCallback(state, port) {
80
79
  return;
81
80
  }
82
81
 
83
- if (!code || !returnedState?.startsWith(state.split('|')[0])) {
82
+ if (!code || returnedState !== state) {
84
83
  res.writeHead(400, { 'Content-Type': 'text/html' });
85
84
  res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
86
85
  server.close();
@@ -160,7 +159,20 @@ async function getUserEmail(accessToken) {
160
159
  */
161
160
  export async function runAuthFlow(config) {
162
161
  const clientId = config.google?.clientId || DEFAULT_CLIENT_ID;
163
- const clientSecret = config.google?.clientSecret || DEFAULT_CLIENT_SECRET;
162
+ const clientSecret = config.google?.clientSecret || '';
163
+
164
+ if (!clientId) {
165
+ fail('Google OAuth client ID not configured.');
166
+ info('Get credentials from Google Cloud Console:');
167
+ info(' 1. Go to https://console.cloud.google.com/apis/credentials');
168
+ info(' 2. Create an OAuth 2.0 Client ID (Desktop app type)');
169
+ info(' 3. Enable Gmail API and Calendar API');
170
+ info(' 4. Run:');
171
+ info(' nha config set google-client-id YOUR_CLIENT_ID');
172
+ info(' nha config set google-client-secret YOUR_CLIENT_SECRET');
173
+ info(' 5. Run: nha google auth');
174
+ return false;
175
+ }
164
176
 
165
177
  // Find available port
166
178
  let port = 0;
@@ -180,16 +192,13 @@ export async function runAuthFlow(config) {
180
192
  return false;
181
193
  }
182
194
 
183
- // Use our server as OAuth redirect (same as mobile app)
184
- // The callback page extracts the code and redirects to localhost
185
- const serverRedirectUri = 'https://nothumanallowed.com/auth/callback';
186
- const localCallbackUrl = `http://127.0.0.1:${port}/callback`;
195
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
187
196
  const { verifier, challenge } = generatePKCE();
188
- const state = crypto.randomBytes(32).toString('hex') + `|localhost:${port}`;
197
+ const state = crypto.randomBytes(32).toString('hex');
189
198
 
190
199
  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
191
200
  authUrl.searchParams.set('client_id', clientId);
192
- authUrl.searchParams.set('redirect_uri', serverRedirectUri);
201
+ authUrl.searchParams.set('redirect_uri', redirectUri);
193
202
  authUrl.searchParams.set('response_type', 'code');
194
203
  authUrl.searchParams.set('scope', SCOPES);
195
204
  authUrl.searchParams.set('state', state);
@@ -206,7 +215,7 @@ export async function runAuthFlow(config) {
206
215
  const { code } = await waitForCallback(state, port);
207
216
  info('Authorization code received. Exchanging for tokens...');
208
217
 
209
- const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, serverRedirectUri);
218
+ const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri);
210
219
  const email = await getUserEmail(tokenData.access_token);
211
220
 
212
221
  const tokens = {
@@ -275,144 +275,6 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
275
275
  return callFn(apiKey, model, systemPrompt, userMessage, false);
276
276
  }
277
277
 
278
- /**
279
- * Call an LLM provider with streaming enabled.
280
- * Calls onToken(chunk) for each token, returns full text at the end.
281
- * @returns {Promise<string>} The full LLM response text.
282
- */
283
- export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
284
- const provider = opts.provider || config.llm.provider || 'anthropic';
285
- const model = opts.model || config.llm.model || null;
286
- const apiKey = getApiKey(config, provider);
287
- if (!apiKey) throw new Error(`No API key for ${provider}`);
288
-
289
- const callFn = getProviderCall(provider);
290
- if (!callFn) throw new Error(`Unknown provider: ${provider}`);
291
-
292
- // Gemini and Cohere don't support streaming — fall back to non-streaming
293
- if (provider === 'gemini' || provider === 'cohere') {
294
- const text = await callFn(apiKey, model, systemPrompt, userMessage, false);
295
- if (onToken) onToken(text);
296
- return text;
297
- }
298
-
299
- const format = provider === 'anthropic' ? 'anthropic' : 'openai';
300
- const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
301
- const url = getProviderUrl(provider, model, apiKey);
302
- const headers = getProviderHeaders(provider, apiKey);
303
-
304
- const res = await fetch(url, {
305
- method: 'POST',
306
- headers,
307
- body: JSON.stringify(body),
308
- });
309
- if (!res.ok) {
310
- const err = await res.text();
311
- throw new Error(`${provider} ${res.status}: ${err}`);
312
- }
313
-
314
- return streamSSEWithCallback(res, format, onToken);
315
- }
316
-
317
- /** Build request body for a provider */
318
- function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
319
- if (provider === 'anthropic') {
320
- return {
321
- model: model || 'claude-sonnet-4-20250514',
322
- max_tokens: 8192,
323
- system: systemPrompt,
324
- messages: [{ role: 'user', content: userMessage }],
325
- stream,
326
- };
327
- }
328
- // OpenAI-compatible format (OpenAI, DeepSeek, Grok, Mistral)
329
- const modelDefaults = {
330
- openai: 'gpt-4o',
331
- deepseek: 'deepseek-chat',
332
- grok: 'grok-3-latest',
333
- mistral: 'mistral-large-latest',
334
- };
335
- return {
336
- model: model || modelDefaults[provider] || 'gpt-4o',
337
- max_tokens: 8192,
338
- messages: [
339
- { role: 'system', content: systemPrompt },
340
- { role: 'user', content: userMessage },
341
- ],
342
- stream,
343
- };
344
- }
345
-
346
- /** Get provider API URL */
347
- function getProviderUrl(provider, model, apiKey) {
348
- const urls = {
349
- anthropic: 'https://api.anthropic.com/v1/messages',
350
- openai: 'https://api.openai.com/v1/chat/completions',
351
- deepseek: 'https://api.deepseek.com/v1/chat/completions',
352
- grok: 'https://api.x.ai/v1/chat/completions',
353
- mistral: 'https://api.mistral.ai/v1/chat/completions',
354
- };
355
- return urls[provider] || urls.openai;
356
- }
357
-
358
- /** Get provider request headers */
359
- function getProviderHeaders(provider, apiKey) {
360
- if (provider === 'anthropic') {
361
- return {
362
- 'Content-Type': 'application/json',
363
- 'x-api-key': apiKey,
364
- 'anthropic-version': '2023-06-01',
365
- };
366
- }
367
- return {
368
- 'Content-Type': 'application/json',
369
- 'Authorization': `Bearer ${apiKey}`,
370
- };
371
- }
372
-
373
- /** SSE stream parser with onToken callback (does NOT write to stdout directly) */
374
- async function streamSSEWithCallback(res, format, onToken) {
375
- const reader = res.body.getReader();
376
- const decoder = new TextDecoder();
377
- let buffer = '';
378
- let fullText = '';
379
-
380
- while (true) {
381
- const { done, value } = await reader.read();
382
- if (done) break;
383
-
384
- buffer += decoder.decode(value, { stream: true });
385
- const lines = buffer.split('\n');
386
- buffer = lines.pop() || '';
387
-
388
- for (const line of lines) {
389
- if (!line.startsWith('data: ')) continue;
390
- const data = line.slice(6).trim();
391
- if (data === '[DONE]') continue;
392
-
393
- try {
394
- const json = JSON.parse(data);
395
- let chunk = '';
396
-
397
- if (format === 'anthropic') {
398
- if (json.type === 'content_block_delta') {
399
- chunk = json.delta?.text || '';
400
- }
401
- } else {
402
- chunk = json.choices?.[0]?.delta?.content || '';
403
- }
404
-
405
- if (chunk) {
406
- fullText += chunk;
407
- if (onToken) onToken(chunk);
408
- }
409
- } catch {}
410
- }
411
- }
412
-
413
- return fullText;
414
- }
415
-
416
278
  /**
417
279
  * Call an agent by name — loads the agent file, calls LLM, returns response.
418
280
  * No streaming. Used by PAO pipeline for batch agent calls.
@@ -36,8 +36,237 @@ const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
36
36
  const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
37
37
  const BRIEFS_DIR = path.join(NHA_DIR, 'ops', 'briefs');
38
38
  const INSIGHTS_DIR = path.join(NHA_DIR, 'ops', 'insights');
39
+ const CRON_FILE = path.join(DAEMON_DIR, 'cron.json');
40
+ const HEARTBEAT_FILE = path.join(DAEMON_DIR, 'heartbeat.json');
39
41
  const WS_PORT = 3848;
40
42
 
43
+ // ── Cron / Heartbeat Persistence ────────────────────────────────────────────
44
+
45
+ function loadCronJobs() {
46
+ if (!fs.existsSync(CRON_FILE)) return [];
47
+ try { return JSON.parse(fs.readFileSync(CRON_FILE, 'utf-8')); } catch { return []; }
48
+ }
49
+
50
+ function saveCronJobs(jobs) {
51
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
52
+ fs.writeFileSync(CRON_FILE, JSON.stringify(jobs, null, 2), { mode: 0o600 });
53
+ }
54
+
55
+ function loadHeartbeats() {
56
+ if (!fs.existsSync(HEARTBEAT_FILE)) return [];
57
+ try { return JSON.parse(fs.readFileSync(HEARTBEAT_FILE, 'utf-8')); } catch { return []; }
58
+ }
59
+
60
+ function saveHeartbeats(beats) {
61
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
62
+ fs.writeFileSync(HEARTBEAT_FILE, JSON.stringify(beats, null, 2), { mode: 0o600 });
63
+ }
64
+
65
+ /**
66
+ * Parse a human-readable schedule into a cron-like spec.
67
+ * Supports:
68
+ * "every 5m" / "every 2h" / "every 30min"
69
+ * "every monday 9am" / "every friday 17:00"
70
+ * "every day 8:30" / "daily 9am"
71
+ * "every hour"
72
+ * "at 14:00" (once daily)
73
+ *
74
+ * Returns { type: 'interval'|'daily'|'weekly', intervalMs?, hour?, minute?, dayOfWeek? }
75
+ */
76
+ function parseSchedule(schedule) {
77
+ const s = schedule.toLowerCase().trim();
78
+
79
+ // Interval: "every 5m", "every 2h", "every 30min", "every 1 hour"
80
+ const intervalMatch = s.match(/every\s+(\d+)\s*(m(?:in(?:utes?)?)?|h(?:ours?)?|s(?:ec(?:onds?)?)?)/);
81
+ if (intervalMatch) {
82
+ const num = parseInt(intervalMatch[1]);
83
+ const unit = intervalMatch[2][0]; // m, h, or s
84
+ const ms = unit === 'h' ? num * 3_600_000 : unit === 'm' ? num * 60_000 : num * 1_000;
85
+ return { type: 'interval', intervalMs: Math.max(ms, 60_000) }; // min 1 minute
86
+ }
87
+
88
+ // "every hour"
89
+ if (s === 'every hour' || s === 'hourly') {
90
+ return { type: 'interval', intervalMs: 3_600_000 };
91
+ }
92
+
93
+ // Weekly: "every monday 9am", "every friday 17:00"
94
+ const weeklyMatch = s.match(/every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
95
+ if (weeklyMatch) {
96
+ const days = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
97
+ let hour = parseInt(weeklyMatch[2]);
98
+ const minute = parseInt(weeklyMatch[3] || '0');
99
+ const ampm = weeklyMatch[4];
100
+ if (ampm === 'pm' && hour < 12) hour += 12;
101
+ if (ampm === 'am' && hour === 12) hour = 0;
102
+ return { type: 'weekly', dayOfWeek: days[weeklyMatch[1]], hour, minute };
103
+ }
104
+
105
+ // Daily: "every day 8:30", "daily 9am", "at 14:00"
106
+ const dailyMatch = s.match(/(?:every\s*day|daily|at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
107
+ if (dailyMatch) {
108
+ let hour = parseInt(dailyMatch[1]);
109
+ const minute = parseInt(dailyMatch[2] || '0');
110
+ const ampm = dailyMatch[3];
111
+ if (ampm === 'pm' && hour < 12) hour += 12;
112
+ if (ampm === 'am' && hour === 12) hour = 0;
113
+ return { type: 'daily', hour, minute };
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Check if a cron job should run now (within the current minute).
121
+ */
122
+ function shouldRunNow(spec, lastRun) {
123
+ const now = new Date();
124
+
125
+ if (spec.type === 'interval') {
126
+ if (!lastRun) return true;
127
+ return (now.getTime() - lastRun) >= spec.intervalMs;
128
+ }
129
+
130
+ if (spec.type === 'daily') {
131
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
132
+ const targetMinutes = spec.hour * 60 + spec.minute;
133
+ // Run if we're in the right minute and haven't run today
134
+ if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
135
+ if (lastRun) {
136
+ const lastRunDate = new Date(lastRun);
137
+ if (lastRunDate.toDateString() === now.toDateString()) return false;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ if (spec.type === 'weekly') {
143
+ if (now.getDay() !== spec.dayOfWeek) return false;
144
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
145
+ const targetMinutes = spec.hour * 60 + spec.minute;
146
+ if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
147
+ if (lastRun) {
148
+ const lastRunDate = new Date(lastRun);
149
+ const daysDiff = (now.getTime() - lastRunDate.getTime()) / 86_400_000;
150
+ if (daysDiff < 1) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * Add a cron job. Returns the job object.
160
+ */
161
+ export function addCronJob(schedule, prompt, options = {}) {
162
+ const spec = parseSchedule(schedule);
163
+ if (!spec) return { ok: false, error: `Cannot parse schedule: "${schedule}"` };
164
+
165
+ const jobs = loadCronJobs();
166
+ const job = {
167
+ id: crypto.randomUUID(),
168
+ schedule,
169
+ spec,
170
+ prompt,
171
+ agent: options.agent || null, // null = auto-route
172
+ notify: options.notify !== false, // default: send notification
173
+ enabled: true,
174
+ createdAt: new Date().toISOString(),
175
+ lastRun: null,
176
+ lastResult: null,
177
+ runCount: 0,
178
+ };
179
+ jobs.push(job);
180
+ saveCronJobs(jobs);
181
+
182
+ // Notify daemon if running (it will pick up changes on next tick)
183
+ if (isRunning()) {
184
+ wsBroadcast({ type: 'cron_added', data: { id: job.id, schedule, prompt } });
185
+ }
186
+
187
+ return { ok: true, job };
188
+ }
189
+
190
+ /**
191
+ * Remove a cron job by ID or index.
192
+ */
193
+ export function removeCronJob(idOrIndex) {
194
+ const jobs = loadCronJobs();
195
+ let removed;
196
+ if (typeof idOrIndex === 'number' || /^\d+$/.test(idOrIndex)) {
197
+ const idx = parseInt(idOrIndex) - 1; // 1-based
198
+ if (idx < 0 || idx >= jobs.length) return { ok: false, error: 'Invalid job index' };
199
+ removed = jobs.splice(idx, 1)[0];
200
+ } else {
201
+ const idx = jobs.findIndex(j => j.id === idOrIndex);
202
+ if (idx === -1) return { ok: false, error: 'Job not found' };
203
+ removed = jobs.splice(idx, 1)[0];
204
+ }
205
+ saveCronJobs(jobs);
206
+ return { ok: true, removed };
207
+ }
208
+
209
+ /**
210
+ * List all cron jobs.
211
+ */
212
+ export function listCronJobs() {
213
+ return loadCronJobs();
214
+ }
215
+
216
+ /**
217
+ * Add a heartbeat (recurring prompt at fixed interval).
218
+ * Convenience wrapper over cron with interval spec.
219
+ */
220
+ export function addHeartbeat(intervalStr, prompt, options = {}) {
221
+ return addCronJob(`every ${intervalStr}`, prompt, options);
222
+ }
223
+
224
+ /**
225
+ * Execute cron jobs that are due. Called from daemon loop.
226
+ */
227
+ async function executeDueCronJobs(config) {
228
+ const jobs = loadCronJobs();
229
+ let changed = false;
230
+
231
+ for (const job of jobs) {
232
+ if (!job.enabled) continue;
233
+ if (!shouldRunNow(job.spec, job.lastRun)) continue;
234
+
235
+ log(`[Cron] Executing: "${job.prompt}" (schedule: ${job.schedule})`);
236
+
237
+ try {
238
+ const agent = job.agent || 'conductor';
239
+ const result = await callAgent(config, agent, job.prompt);
240
+ job.lastRun = Date.now();
241
+ job.lastResult = result.slice(0, 500); // cap stored result
242
+ job.runCount++;
243
+ changed = true;
244
+
245
+ log(`[Cron] Completed: "${job.prompt}" → ${result.slice(0, 100)}...`);
246
+
247
+ // Broadcast result to connected clients
248
+ wsBroadcast({
249
+ type: 'cron_result',
250
+ timestamp: new Date().toISOString(),
251
+ data: { id: job.id, schedule: job.schedule, prompt: job.prompt, result: result.slice(0, 500) },
252
+ });
253
+
254
+ // Send notification if enabled
255
+ if (job.notify) {
256
+ await notify('Scheduled Task', `${job.prompt}\n\n${result.slice(0, 200)}`, config);
257
+ }
258
+ } catch (err) {
259
+ log(`[Cron] Error executing "${job.prompt}": ${err.message}`);
260
+ job.lastRun = Date.now();
261
+ job.lastResult = `ERROR: ${err.message}`;
262
+ job.runCount++;
263
+ changed = true;
264
+ }
265
+ }
266
+
267
+ if (changed) saveCronJobs(jobs);
268
+ }
269
+
41
270
  // ── Daemon Control ─────────────────────────────────────────────────────────
42
271
 
43
272
  /**
@@ -837,6 +1066,13 @@ async function daemonLoop() {
837
1066
  }
838
1067
  }
839
1068
 
1069
+ // ── User-defined Cron Jobs ────────────────────────────
1070
+ try {
1071
+ await executeDueCronJobs(config);
1072
+ } catch (err) {
1073
+ log(`[Cron] Execution error: ${err.message}`);
1074
+ }
1075
+
840
1076
  // Reset daily flags at midnight
841
1077
  if (currentTime === '00:00') {
842
1078
  todayPlanDone = false;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Screen Capture + Vision — captures desktop screenshots for LLM analysis.
3
+ *
4
+ * macOS: screencapture -x (native, silent)
5
+ * Linux: import -window root (ImageMagick) or gnome-screenshot or scrot
6
+ * Windows (WSL): PowerShell screenshot via clip
7
+ *
8
+ * Returns base64 PNG for multimodal LLM input.
9
+ * Zero npm dependencies.
10
+ */
11
+
12
+ import { execSync } from 'child_process';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import os from 'os';
16
+
17
+ const SCREENSHOT_DIR = path.join(os.tmpdir(), 'nha-screenshots');
18
+
19
+ /**
20
+ * Capture a screenshot of the desktop.
21
+ * @param {object} options
22
+ * @param {number} [options.monitor=1] - Monitor number (multi-monitor)
23
+ * @param {boolean} [options.base64=true] - Return base64 instead of file path
24
+ * @returns {{ ok: boolean, path?: string, base64?: string, error?: string, width?: number, height?: number }}
25
+ */
26
+ export function captureScreen(options = {}) {
27
+ const { monitor = 1, base64: returnBase64 = true } = options;
28
+ const platform = os.platform();
29
+
30
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
31
+ const filename = `screen-${Date.now()}.png`;
32
+ const filepath = path.join(SCREENSHOT_DIR, filename);
33
+
34
+ try {
35
+ if (platform === 'darwin') {
36
+ // macOS: native screencapture, -x = no sound
37
+ const monitorFlag = monitor > 1 ? `-D ${monitor}` : '';
38
+ execSync(`screencapture -x ${monitorFlag} "${filepath}"`, { timeout: 10_000 });
39
+ } else if (platform === 'linux') {
40
+ // Linux: try multiple tools in order of preference
41
+ const tools = [
42
+ { cmd: `gnome-screenshot -f "${filepath}"`, check: 'which gnome-screenshot' },
43
+ { cmd: `scrot "${filepath}"`, check: 'which scrot' },
44
+ { cmd: `import -window root "${filepath}"`, check: 'which import' },
45
+ { cmd: `grim "${filepath}"`, check: 'which grim' }, // Wayland
46
+ ];
47
+
48
+ let captured = false;
49
+ for (const tool of tools) {
50
+ try {
51
+ execSync(tool.check, { stdio: 'ignore' });
52
+ execSync(tool.cmd, { timeout: 10_000 });
53
+ captured = true;
54
+ break;
55
+ } catch { continue; }
56
+ }
57
+
58
+ if (!captured) {
59
+ return { ok: false, error: 'No screenshot tool found. Install: sudo apt install scrot' };
60
+ }
61
+ } else if (platform === 'win32') {
62
+ // Windows: PowerShell screenshot
63
+ const psScript = `
64
+ Add-Type -AssemblyName System.Windows.Forms
65
+ $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
66
+ $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height)
67
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
68
+ $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size)
69
+ $bitmap.Save('${filepath.replace(/\\/g, '\\\\')}')
70
+ $graphics.Dispose()
71
+ $bitmap.Dispose()
72
+ `.trim();
73
+ execSync(`powershell -Command "${psScript}"`, { timeout: 15_000 });
74
+ } else {
75
+ return { ok: false, error: `Unsupported platform: ${platform}` };
76
+ }
77
+
78
+ if (!fs.existsSync(filepath)) {
79
+ return { ok: false, error: 'Screenshot file not created' };
80
+ }
81
+
82
+ const stats = fs.statSync(filepath);
83
+ if (stats.size < 100) {
84
+ fs.rmSync(filepath, { force: true });
85
+ return { ok: false, error: 'Screenshot file is empty' };
86
+ }
87
+
88
+ const result = { ok: true, path: filepath };
89
+
90
+ if (returnBase64) {
91
+ const buf = fs.readFileSync(filepath);
92
+ // Resize if too large (>2MB) to save tokens
93
+ if (buf.length > 2_000_000) {
94
+ try {
95
+ // Try to resize with sips (macOS) or convert (Linux)
96
+ const resized = path.join(SCREENSHOT_DIR, `resized-${filename}`);
97
+ if (platform === 'darwin') {
98
+ execSync(`sips --resampleWidth 1280 "${filepath}" --out "${resized}"`, { stdio: 'ignore', timeout: 10_000 });
99
+ } else {
100
+ execSync(`convert "${filepath}" -resize 1280x "${resized}"`, { stdio: 'ignore', timeout: 10_000 });
101
+ }
102
+ if (fs.existsSync(resized)) {
103
+ result.base64 = fs.readFileSync(resized).toString('base64');
104
+ fs.rmSync(resized, { force: true });
105
+ } else {
106
+ result.base64 = buf.toString('base64');
107
+ }
108
+ } catch {
109
+ result.base64 = buf.toString('base64');
110
+ }
111
+ } else {
112
+ result.base64 = buf.toString('base64');
113
+ }
114
+ }
115
+
116
+ // Clean up old screenshots (keep last 5)
117
+ cleanupOldScreenshots();
118
+
119
+ return result;
120
+ } catch (err) {
121
+ return { ok: false, error: `Screenshot failed: ${err.message}` };
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Clean up old screenshot files, keeping only the 5 most recent.
127
+ */
128
+ function cleanupOldScreenshots() {
129
+ try {
130
+ const files = fs.readdirSync(SCREENSHOT_DIR)
131
+ .filter(f => f.startsWith('screen-') && f.endsWith('.png'))
132
+ .map(f => ({ name: f, path: path.join(SCREENSHOT_DIR, f), mtime: fs.statSync(path.join(SCREENSHOT_DIR, f)).mtimeMs }))
133
+ .sort((a, b) => b.mtime - a.mtime);
134
+
135
+ // Keep only last 5
136
+ for (const file of files.slice(5)) {
137
+ fs.rmSync(file.path, { force: true });
138
+ }
139
+ } catch { /* non-fatal */ }
140
+ }
141
+
142
+ /**
143
+ * Check if screen capture is available on this platform.
144
+ */
145
+ export function isScreenCaptureAvailable() {
146
+ const platform = os.platform();
147
+ if (platform === 'darwin') return true; // screencapture always available
148
+ if (platform === 'win32') return true; // PowerShell always available
149
+ if (platform === 'linux') {
150
+ const tools = ['gnome-screenshot', 'scrot', 'import', 'grim'];
151
+ for (const tool of tools) {
152
+ try {
153
+ execSync(`which ${tool}`, { stdio: 'ignore' });
154
+ return true;
155
+ } catch { continue; }
156
+ }
157
+ return false;
158
+ }
159
+ return false;
160
+ }