agenter-app 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,1434 @@
1
+ #!/usr/bin/env node
2
+ // Agenter server: receives Claude Code hook events from hook.js, keeps track of every
3
+ // session, and lets the dashboard answer permission prompts, questions and replies.
4
+ 'use strict';
5
+ const http = require('node:http');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+ const crypto = require('node:crypto');
10
+ const { spawn, execFile } = require('node:child_process');
11
+ const openDashboard = require('./lib/open-dashboard');
12
+
13
+ const PORT = Number(process.env.AGENTER_PORT) || 4317;
14
+ const HOST = '127.0.0.1';
15
+ const PUBLIC_DIR = path.join(__dirname, 'public');
16
+ // Kept in the home folder, not next to the code, so updating a global npm install doesn't wipe them.
17
+ const SETTINGS_DIR = path.join(os.homedir(), '.agenter');
18
+ const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
19
+ const OLD_SETTINGS_FILE = path.join(__dirname, 'agenter-settings.json');
20
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
21
+ const MAX_ACTIVITY = 200;
22
+ // If the dashboard is closed this long, anything waiting goes back to VS Code.
23
+ const DISCONNECT_GRACE_MS = 15000;
24
+ // If you switch away from the dashboard (to VS Code, say) this long, same thing.
25
+ const BLUR_GRACE_MS = 5000;
26
+ const DISCOVER_INTERVAL_MS = 30000;
27
+ const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches changes in between
28
+ // Plan usage (5-hour session / weekly limits) comes from the same endpoint Claude
29
+ // Code's /usage uses. It's undocumented, so treat it as best effort. AGENTER_USAGE=0 disables it.
30
+ const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
31
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
32
+ const USAGE_INTERVAL_MS = 2 * 60 * 1000;
33
+
34
+ const sessions = new Map(); // session_id -> session
35
+ const pending = new Map(); // pending id -> { item, res, payload }
36
+ const clients = new Map(); // SSE response -> { id, focused }
37
+ const dismissed = new Set(); // session ids the user cleared; discovery won't bring them back
38
+
39
+ // holdMinutes: anything waiting on the dashboard goes back to VS Code after this
40
+ // long (0 = wait until answered), so a session never spins forever unnoticed.
41
+ let settings = { holdOnStop: true, holdMinutes: 3 };
42
+ try {
43
+ // Older versions kept settings next to server.js; carry them over the first time.
44
+ const file = fs.existsSync(SETTINGS_FILE) ? SETTINGS_FILE : OLD_SETTINGS_FILE;
45
+ settings = { ...settings, ...JSON.parse(fs.readFileSync(file, 'utf8')) };
46
+ } catch {}
47
+ const saveSettings = () => {
48
+ fs.mkdirSync(SETTINGS_DIR, { recursive: true });
49
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
50
+ };
51
+
52
+ // ---------- helpers ----------
53
+
54
+ const truncate = (s, n) => {
55
+ s = String(s ?? '');
56
+ return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s;
57
+ };
58
+
59
+ function describeTool(name, input = {}) {
60
+ if (!input || typeof input !== 'object') return '';
61
+ if (input.command) return input.command;
62
+ if (name === 'AskUserQuestion' && Array.isArray(input.questions)) return input.questions.map((q) => q.question).join(' / ');
63
+ if (input.file_path) return input.file_path;
64
+ if (input.notebook_path) return input.notebook_path;
65
+ if (input.pattern) return input.pattern + (input.path ? ` in ${input.path}` : '');
66
+ if (input.url) return input.url;
67
+ if (input.query) return input.query;
68
+ if (input.description) return input.description;
69
+ if (input.prompt) return input.prompt;
70
+ return JSON.stringify(input);
71
+ }
72
+
73
+ function readTail(file, maxBytes) {
74
+ const fd = fs.openSync(file, 'r');
75
+ try {
76
+ const size = fs.fstatSync(fd).size;
77
+ const start = Math.max(0, size - maxBytes);
78
+ const buf = Buffer.alloc(size - start);
79
+ fs.readSync(fd, buf, 0, buf.length, start);
80
+ let text = buf.toString('utf8');
81
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
82
+ return text;
83
+ } finally {
84
+ fs.closeSync(fd);
85
+ }
86
+ }
87
+
88
+ const resultText = (content) =>
89
+ typeof content === 'string'
90
+ ? content
91
+ : Array.isArray(content)
92
+ ? content.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`)).join('\n')
93
+ : '';
94
+
95
+ const isNoise = (text) => /^\s*<(system-reminder|ide_|command-|local-command)/.test(text);
96
+
97
+ // Appends the messages in these transcript lines to out (metadata and noise skipped).
98
+ // Reads both Claude Code transcripts and Codex rollouts.
99
+ function parseLines(text, out) {
100
+ for (const line of text.split('\n')) {
101
+ if (!line.trim()) continue;
102
+ let e;
103
+ try {
104
+ e = JSON.parse(line);
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (e.type === 'response_item') {
109
+ parseCodexItem(e, out);
110
+ continue;
111
+ }
112
+ if ((e.type !== 'user' && e.type !== 'assistant') || e.isMeta || !e.message) continue;
113
+ const ts = e.timestamp;
114
+ const content = e.message.content;
115
+ if (typeof content === 'string') {
116
+ if (!isNoise(content)) out.push({ role: e.type, kind: 'text', text: content, ts });
117
+ continue;
118
+ }
119
+ if (!Array.isArray(content)) continue;
120
+ for (const b of content) {
121
+ if (b.type === 'text' && b.text?.trim() && !isNoise(b.text)) out.push({ role: e.type, kind: 'text', text: b.text, ts });
122
+ else if (b.type === 'tool_use') out.push({ role: 'assistant', kind: 'tool', id: b.id, name: b.name, text: describeTool(b.name, b.input), ts });
123
+ else if (b.type === 'tool_result') out.push({ role: 'tool', kind: 'result', toolUseId: b.tool_use_id, text: resultText(b.content), isError: !!b.is_error, ts });
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // Codex's exec tool takes a small script; the shell command inside it is what's worth showing.
130
+ function describeCodexCall(name, input) {
131
+ const raw = String(input ?? '');
132
+ if (name === 'wait') return 'Waiting for the running command';
133
+ const m = raw.match(/\bcmd\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/);
134
+ if (m) return m[2].replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c);
135
+ // Other built-in tools called from the script: name them, plus the search query if any.
136
+ const call = raw.match(/\btools\.(\w+)\(/)?.[1];
137
+ if (call === 'write_stdin') return 'Sending input to the running command';
138
+ if (call) {
139
+ const q = raw.match(/\bq\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/)?.[2];
140
+ return call.replace(/__/g, ' ') + (q ? `: ${q}` : '');
141
+ }
142
+ try {
143
+ const args = JSON.parse(raw);
144
+ if (args && typeof args === 'object') return describeTool(name, args);
145
+ } catch {}
146
+ return raw.split('\n')[0];
147
+ }
148
+
149
+ const codexOutput = (o) => (typeof o === 'string' ? o : Array.isArray(o) ? o.map((c) => c.text ?? `[${c.type}]`).join('\n') : '');
150
+
151
+ function parseCodexItem(e, out) {
152
+ const p = e.payload || {};
153
+ const ts = e.timestamp;
154
+ if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
155
+ // Only what you typed: Codex also sends context (environment, open pages) as user messages.
156
+ const kinds = p.internal_chat_message_metadata_passthrough?.content_item_kinds;
157
+ (p.content || []).forEach((c, i) => {
158
+ const text = c.text?.trim();
159
+ if (!text) return;
160
+ if (p.role === 'user' && (kinds ? kinds[i] !== 'user.text' : text.startsWith('<'))) return;
161
+ // The VS Code extension wraps what you typed in IDE context (open tabs etc.).
162
+ const typed = p.role === 'user' ? c.text.match(/^#+ My request:\s*\n([\s\S]*)$/m)?.[1] : null;
163
+ out.push({ role: p.role, kind: 'text', text: typed?.trim() || c.text, ts });
164
+ });
165
+ } else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
166
+ out.push({ role: 'assistant', kind: 'tool', id: p.call_id, name: p.name, text: describeCodexCall(p.name, p.arguments ?? p.input), ts });
167
+ } else if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
168
+ out.push({ role: 'tool', kind: 'result', toolUseId: p.call_id, text: codexOutput(p.output), isError: false, ts });
169
+ }
170
+ }
171
+
172
+ const clipMessage = (m) => ({ ...m, text: truncate(m.text, m.kind === 'result' ? 4000 : 20000) });
173
+
174
+ function parseTranscript(file, limit = 120) {
175
+ if (!file || !fs.existsSync(file)) return [];
176
+ return parseLines(readTail(file, TRANSCRIPT_TAIL), []).slice(-limit).map(clipMessage);
177
+ }
178
+
179
+ // The conversation open on the dashboard is read incrementally: each request parses
180
+ // only the bytes appended since the last one. Messages get increasing seq numbers so
181
+ // the dashboard can ask for just the new ones; epoch changes if the file is replaced.
182
+ const TRANSCRIPT_TAIL = 2 * 1024 * 1024;
183
+ const TRANSCRIPT_KEEP = 400;
184
+ const TRANSCRIPT_FILES = 8;
185
+ const transcriptCache = new Map(); // file -> { epoch, offset, rest, dropFirstLine, seq, messages }
186
+
187
+ function readTranscript(file) {
188
+ let size;
189
+ try {
190
+ size = fs.statSync(file).size;
191
+ } catch {
192
+ return null;
193
+ }
194
+ let c = transcriptCache.get(file);
195
+ if (!c || size < c.offset) {
196
+ const offset = Math.max(0, size - TRANSCRIPT_TAIL);
197
+ c = { epoch: crypto.randomUUID(), offset, rest: Buffer.alloc(0), dropFirstLine: offset > 0, seq: 0, messages: [] };
198
+ }
199
+ transcriptCache.delete(file); // most recently used goes last
200
+ transcriptCache.set(file, c);
201
+ if (transcriptCache.size > TRANSCRIPT_FILES) transcriptCache.delete(transcriptCache.keys().next().value);
202
+ if (size === c.offset) return c;
203
+
204
+ const fd = fs.openSync(file, 'r');
205
+ let buf;
206
+ try {
207
+ buf = Buffer.alloc(size - c.offset);
208
+ fs.readSync(fd, buf, 0, buf.length, c.offset);
209
+ } finally {
210
+ fs.closeSync(fd);
211
+ }
212
+ c.offset = size;
213
+ buf = Buffer.concat([c.rest, buf]);
214
+ let start = 0;
215
+ if (c.dropFirstLine) {
216
+ start = buf.indexOf(10) + 1; // we started mid-file: skip the partial first line
217
+ if (start) c.dropFirstLine = false;
218
+ }
219
+ // Only complete lines. Splitting on the newline byte never cuts a UTF-8 character.
220
+ const end = Math.max(start, buf.lastIndexOf(10) + 1);
221
+ c.rest = Buffer.from(buf.subarray(end));
222
+ for (const m of parseLines(buf.toString('utf8', start, end), [])) c.messages.push({ ...clipMessage(m), seq: ++c.seq });
223
+ if (c.messages.length > TRANSCRIPT_KEEP) c.messages.splice(0, c.messages.length - TRANSCRIPT_KEEP);
224
+ return c;
225
+ }
226
+
227
+ function lastAssistantText(file) {
228
+ const msgs = parseTranscript(file, 400);
229
+ for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant' && msgs[i].kind === 'text') return msgs[i].text;
230
+ return '';
231
+ }
232
+
233
+ function readHead(file, maxBytes) {
234
+ const fd = fs.openSync(file, 'r');
235
+ try {
236
+ const buf = Buffer.alloc(Math.min(maxBytes, fs.fstatSync(fd).size));
237
+ fs.readSync(fd, buf, 0, buf.length, 0);
238
+ return buf.toString('utf8');
239
+ } finally {
240
+ fs.closeSync(fd);
241
+ }
242
+ }
243
+
244
+ const firstText = (content) =>
245
+ typeof content === 'string' ? content : Array.isArray(content) ? content.find((b) => b.type === 'text' && !isNoise(b.text || ''))?.text || '' : '';
246
+
247
+ // cwd and a title (Claude's summary, else the first prompt) from the start of a transcript.
248
+ function transcriptInfo(file) {
249
+ let cwd = '';
250
+ let prompt = '';
251
+ let summary = '';
252
+ for (const line of readHead(file, 256 * 1024).split('\n')) {
253
+ let e;
254
+ try {
255
+ e = JSON.parse(line);
256
+ } catch {
257
+ continue;
258
+ }
259
+ if (!cwd && e.cwd) cwd = e.cwd;
260
+ if (e.type === 'summary' && e.summary && !summary) summary = e.summary;
261
+ if (!prompt && e.type === 'user' && !e.isMeta) {
262
+ const t = firstText(e.message?.content);
263
+ if (t && !isNoise(t)) prompt = t;
264
+ }
265
+ if (cwd && prompt) break;
266
+ }
267
+ return { cwd, title: (summary || prompt).split('\n')[0].slice(0, 140) };
268
+ }
269
+
270
+ // ---------- sessions ----------
271
+
272
+ const newSession = (id) => ({ id, agent: 'claude', cwd: '', project: '', title: '', transcriptPath: '', status: 'idle', hold: null, startedAt: Date.now(), lastEventAt: 0, lastAction: '', permissionMode: '', activity: [], queue: [] });
273
+
274
+ function getOrCreateSession(id) {
275
+ let s = sessions.get(id);
276
+ if (!s) {
277
+ s = newSession(id);
278
+ sessions.set(id, s);
279
+ }
280
+ return s;
281
+ }
282
+
283
+ function setCwd(s, cwd) {
284
+ if (!cwd || s.cwd) return;
285
+ s.cwd = cwd;
286
+ s.project = path.basename(cwd);
287
+ }
288
+
289
+ function touchSession(p) {
290
+ const id = p.session_id || 'unknown';
291
+ dismissed.delete(id);
292
+ const s = getOrCreateSession(id);
293
+ setCwd(s, p.cwd);
294
+ if (p.transcript_path) s.transcriptPath = p.transcript_path;
295
+ if (p.permission_mode) s.permissionMode = p.permission_mode;
296
+ if (s.status === 'dormant' || s.status === 'ended') s.status = 'idle';
297
+ s.lastEventAt = Date.now();
298
+ return s;
299
+ }
300
+
301
+ // Sessions you ran today that aren't talking to us (closed, or started before the
302
+ // server/hooks were up) are found from their transcripts in ~/.claude/projects.
303
+ // A watcher on ~/.claude/projects tells us which transcripts changed, so normally only
304
+ // those are looked at. A full scan (async, so hooks are never kept waiting) runs at
305
+ // startup and now and then as a safety net, or every 30s if watching isn't possible.
306
+ const scanned = new Map(); // transcript file -> mtime when last read
307
+ const isSessionTranscript = (f) => f.endsWith('.jsonl') && !path.basename(f).startsWith('agent-');
308
+
309
+ // Returns true if it added or updated a session.
310
+ async function checkTranscript(file) {
311
+ const id = path.basename(file, '.jsonl');
312
+ let mtime;
313
+ try {
314
+ mtime = (await fs.promises.stat(file)).mtimeMs;
315
+ } catch {
316
+ return false;
317
+ }
318
+ const since = new Date().setHours(0, 0, 0, 0);
319
+ if (mtime < since || dismissed.has(id) || scanned.get(file) === mtime) return false;
320
+ scanned.set(file, mtime);
321
+ try {
322
+ let s = sessions.get(id);
323
+ if (!s || !s.title || !s.cwd) {
324
+ const info = transcriptInfo(file);
325
+ if (!s && !info.title) return false; // opened but never used
326
+ // Agenter can continue a session it drives at any time, so it isn't "not connected".
327
+ s = s || Object.assign(getOrCreateSession(id), { status: driveOf({ id }) === 'agenter' ? 'idle' : 'dormant', startedAt: mtime });
328
+ setCwd(s, info.cwd);
329
+ if (!s.title) s.title = info.title;
330
+ }
331
+ if (!s.transcriptPath) s.transcriptPath = file;
332
+ if (s.status === 'dormant' || (driveOf(s) === 'agenter' && s.status === 'idle' && !runners.has(s.id))) {
333
+ s.lastEventAt = mtime;
334
+ s.lastAction = lastAssistantText(file).split('\n')[0].slice(0, 160);
335
+ }
336
+ return true;
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
342
+ let scanning = false;
343
+ async function discoverSessions() {
344
+ if (scanning) return;
345
+ scanning = true;
346
+ let changed = false;
347
+ try {
348
+ const dirs = (await fs.promises.readdir(PROJECTS_DIR, { withFileTypes: true })).filter((d) => d.isDirectory());
349
+ for (const d of dirs) {
350
+ const dir = path.join(PROJECTS_DIR, d.name);
351
+ let files = [];
352
+ try {
353
+ files = (await fs.promises.readdir(dir)).filter(isSessionTranscript);
354
+ } catch {
355
+ continue;
356
+ }
357
+ const results = await Promise.all(files.map((f) => checkTranscript(path.join(dir, f))));
358
+ changed = results.some(Boolean) || changed;
359
+ }
360
+ } catch {
361
+ } finally {
362
+ scanning = false;
363
+ }
364
+ if (changed) broadcast();
365
+ }
366
+
367
+ const changedFiles = new Set();
368
+ let changedTimer = null;
369
+ function onProjectsChange(_, name) {
370
+ // Only <project>/<session>.jsonl; subagent transcripts live deeper.
371
+ if (!name || name.split(/[\\/]/).length !== 2 || !isSessionTranscript(name)) return;
372
+ changedFiles.add(path.join(PROJECTS_DIR, name));
373
+ if (changedTimer) return;
374
+ changedTimer = setTimeout(async () => {
375
+ changedTimer = null;
376
+ const files = [...changedFiles];
377
+ changedFiles.clear();
378
+ if ((await Promise.all(files.map(checkTranscript))).some(Boolean)) broadcast();
379
+ }, 1000);
380
+ }
381
+
382
+ function startDiscovery() {
383
+ let watching = false;
384
+ try {
385
+ fs.watch(PROJECTS_DIR, { recursive: true }, onProjectsChange).on('error', () => {});
386
+ watching = true;
387
+ } catch {} // no projects folder yet, or recursive watching isn't supported here
388
+ discoverSessions();
389
+ setInterval(discoverSessions, watching ? DISCOVER_FULL_SCAN_MS : DISCOVER_INTERVAL_MS);
390
+ }
391
+
392
+ // ---------- plan usage ----------
393
+
394
+ let usage = null; // { fetchedAt, plan, limits: [...], error }
395
+ const LIMIT_LABELS = { session: '5-hour session', weekly_all: 'Weekly · all models', weekly_opus: 'Weekly · Opus', weekly_sonnet: 'Weekly · Sonnet' };
396
+
397
+ function normalizeLimits(j) {
398
+ if (Array.isArray(j.limits) && j.limits.length) {
399
+ return j.limits
400
+ .filter((l) => typeof l.percent === 'number')
401
+ .map((l) => ({ kind: l.kind, label: LIMIT_LABELS[l.kind] || String(l.kind).replace(/_/g, ' '), percent: l.percent, severity: l.severity || null, resetsAt: l.resets_at || null }));
402
+ }
403
+ // Older response shape.
404
+ const pick = (key, kind) => j[key] && typeof j[key].utilization === 'number' && { kind, label: LIMIT_LABELS[kind], percent: j[key].utilization, severity: null, resetsAt: j[key].resets_at || null };
405
+ return [pick('five_hour', 'session'), pick('seven_day', 'weekly_all'), pick('seven_day_opus', 'weekly_opus'), pick('seven_day_sonnet', 'weekly_sonnet')].filter(Boolean);
406
+ }
407
+
408
+ // The usage endpoint is rate-limited per account (Claude Code's /usage calls it too).
409
+ // After a 429 we wait before asking again, longer each time, instead of keeping the
410
+ // limit tripped by retrying every couple of minutes.
411
+ let usageBackoffUntil = 0;
412
+ let usageBackoffMs = 0;
413
+
414
+ let usageInFlight = false;
415
+ async function fetchUsage({ force = false } = {}) {
416
+ if (process.env.AGENTER_USAGE === '0' || usageInFlight) return;
417
+ if (!force && Date.now() < usageBackoffUntil) return;
418
+ usageInFlight = true;
419
+ const keep = usage?.limits || [];
420
+ const fail = (error) => (usage = { ...usage, limits: keep, fetchedAt: Date.now(), error });
421
+ try {
422
+ let oauth;
423
+ try {
424
+ oauth = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8')).claudeAiOauth;
425
+ } catch {}
426
+ if (!oauth?.accessToken) return fail('Not signed in to Claude Code with a Claude subscription.');
427
+ // Never refresh the token ourselves (that would rotate it under Claude Code).
428
+ if (oauth.expiresAt && oauth.expiresAt < Date.now()) return fail('Login token expired. It renews the next time Claude runs.');
429
+ const r = await fetch(USAGE_URL, {
430
+ headers: { authorization: `Bearer ${oauth.accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' },
431
+ signal: AbortSignal.timeout(10000),
432
+ });
433
+ if (r.status === 401 || r.status === 403) return fail('Login token was rejected. It renews the next time Claude runs.');
434
+ if (r.status === 429) {
435
+ const retryAfter = Number(r.headers.get('retry-after')) * 1000;
436
+ usageBackoffMs = Math.min(Math.max(usageBackoffMs * 2, 5 * 60000), 30 * 60000);
437
+ usageBackoffUntil = Date.now() + Math.max(retryAfter || 0, usageBackoffMs);
438
+ const at = new Date(usageBackoffUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
439
+ return fail(`Anthropic is limiting how often usage can be checked. Trying again at ${at}.`);
440
+ }
441
+ if (!r.ok) return fail(`Usage service returned ${r.status}.`);
442
+ usageBackoffMs = 0;
443
+ usageBackoffUntil = 0;
444
+ usage = { fetchedAt: Date.now(), plan: oauth.subscriptionType || '', limits: normalizeLimits(await r.json()), error: null };
445
+ } catch {
446
+ fail("Couldn't reach Anthropic to check usage.");
447
+ } finally {
448
+ usageInFlight = false;
449
+ broadcast();
450
+ }
451
+ }
452
+
453
+ // ---------- Codex ----------
454
+ // Codex sessions and plan usage, read from Codex's own session logs
455
+ // (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl). Read-only: nothing is sent anywhere,
456
+ // and Codex's settings aren't touched. AGENTER_CODEX=0 turns it off.
457
+
458
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
459
+ const CODEX_SESSIONS = path.join(CODEX_HOME, 'sessions');
460
+ const CODEX_ENABLED = process.env.AGENTER_CODEX !== '0';
461
+ // A turn whose log hasn't moved in this long was most likely cut off (Codex closed mid-turn).
462
+ const CODEX_STALE_TURN_MS = 10 * 60 * 1000;
463
+ // A Codex session written to this recently counts as live.
464
+ const CODEX_LIVE_MS = 30 * 60 * 1000;
465
+
466
+ const isRollout = (f) => /^rollout-.*\.jsonl$/.test(path.basename(f));
467
+
468
+ let codexIndex = { mtime: 0, names: new Map() };
469
+ function codexThreadName(id) {
470
+ const file = path.join(CODEX_HOME, 'session_index.jsonl');
471
+ try {
472
+ const mtime = fs.statSync(file).mtimeMs;
473
+ if (mtime !== codexIndex.mtime) {
474
+ const names = new Map();
475
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
476
+ try {
477
+ const e = JSON.parse(line);
478
+ if (e.id && e.thread_name) names.set(e.id, e.thread_name);
479
+ } catch {}
480
+ }
481
+ codexIndex = { mtime, names };
482
+ }
483
+ } catch {}
484
+ return codexIndex.names.get(id) || '';
485
+ }
486
+
487
+ // Session id, folder and first prompt from the start of a rollout. Subagent threads
488
+ // (Codex's own reviewers and helpers) are skipped: they aren't conversations you had.
489
+ function codexInfo(file) {
490
+ let meta = null;
491
+ let prompt = '';
492
+ for (const line of readHead(file, 256 * 1024).split('\n')) {
493
+ let e;
494
+ try {
495
+ e = JSON.parse(line);
496
+ } catch {
497
+ continue;
498
+ }
499
+ if (e.type === 'session_meta') meta = e.payload;
500
+ else if (e.type === 'response_item' && !prompt) {
501
+ const msgs = [];
502
+ parseCodexItem(e, msgs);
503
+ prompt = msgs.find((m) => m.role === 'user')?.text || '';
504
+ }
505
+ if (meta && prompt) break;
506
+ }
507
+ if (!meta?.id || meta.source?.subagent || (meta.thread_source && meta.thread_source !== 'user')) return null;
508
+ return { id: meta.id, cwd: meta.cwd || '', title: (codexThreadName(meta.id) || prompt).split('\n')[0].slice(0, 140) };
509
+ }
510
+
511
+ // From the end of a rollout: whether a turn is in progress, what it's doing, and the
512
+ // latest plan limits Codex recorded.
513
+ function codexTail(file) {
514
+ let turnOpen = false;
515
+ let rateLimits = null;
516
+ let rateAt = 0;
517
+ const msgs = [];
518
+ for (const line of readTail(file, 512 * 1024).split('\n')) {
519
+ let e;
520
+ try {
521
+ e = JSON.parse(line);
522
+ } catch {
523
+ continue;
524
+ }
525
+ const t = e.payload?.type;
526
+ if (e.type === 'event_msg') {
527
+ if (t === 'task_started') turnOpen = true;
528
+ else if (t === 'task_complete' || t === 'turn_aborted') turnOpen = false;
529
+ else if (t === 'token_count' && e.payload.rate_limits) {
530
+ rateLimits = e.payload.rate_limits;
531
+ rateAt = Date.parse(e.timestamp) || 0;
532
+ }
533
+ } else if (e.type === 'response_item') parseCodexItem(e, msgs);
534
+ }
535
+ return { turnOpen, rateLimits, rateAt, last: msgs.at(-1) };
536
+ }
537
+
538
+ function codexStatus(s, now = Date.now()) {
539
+ if (runners.has(s.id) || (s.codexTurnOpen && now - s.lastEventAt < CODEX_STALE_TURN_MS)) return 'working';
540
+ // Agenter can continue a session it drives at any time.
541
+ if (driveOf(s) === 'agenter') return 'idle';
542
+ return now - s.lastEventAt < CODEX_LIVE_MS ? 'idle' : 'dormant';
543
+ }
544
+
545
+ // Returns true if it added or updated a session.
546
+ async function checkRollout(file) {
547
+ let mtime;
548
+ try {
549
+ mtime = (await fs.promises.stat(file)).mtimeMs;
550
+ } catch {
551
+ return false;
552
+ }
553
+ const since = new Date().setHours(0, 0, 0, 0);
554
+ if (scanned.get(file) === mtime) return false;
555
+ scanned.set(file, mtime);
556
+ try {
557
+ const tail = codexTail(file);
558
+ if (tail.rateLimits) noteCodexLimits(tail.rateLimits, tail.rateAt);
559
+ if (mtime < since) return false;
560
+ const info = codexInfo(file);
561
+ if (!info || dismissed.has(info.id) || (!sessions.has(info.id) && !info.title)) return false;
562
+ const s = sessions.get(info.id) || Object.assign(getOrCreateSession(info.id), { agent: 'codex', startedAt: mtime });
563
+ setCwd(s, info.cwd);
564
+ if (info.title) s.title = info.title;
565
+ s.transcriptPath = file;
566
+ s.lastEventAt = mtime;
567
+ s.codexTurnOpen = tail.turnOpen;
568
+ s.status = codexStatus(s);
569
+ const last = tail.last;
570
+ s.lastAction =
571
+ s.status === 'working' && last?.kind === 'tool'
572
+ ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
573
+ : (lastAssistantText(file) || '').split('\n')[0].slice(0, 160);
574
+ return true;
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+
580
+ // Rollouts live in per-day folders. Yesterday's too: a session started before midnight
581
+ // and still going today keeps writing to yesterday's folder.
582
+ function codexDayDirs() {
583
+ const day = (d) => path.join(CODEX_SESSIONS, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'));
584
+ const today = new Date();
585
+ return [day(today), day(new Date(today.getTime() - 86400000))];
586
+ }
587
+
588
+ let codexScanning = false;
589
+ async function discoverCodex() {
590
+ if (!CODEX_ENABLED || codexScanning) return;
591
+ codexScanning = true;
592
+ let changed = false;
593
+ try {
594
+ for (const dir of codexDayDirs()) {
595
+ let files = [];
596
+ try {
597
+ files = (await fs.promises.readdir(dir)).filter(isRollout);
598
+ } catch {
599
+ continue;
600
+ }
601
+ changed = (await Promise.all(files.map((f) => checkRollout(path.join(dir, f))))).some(Boolean) || changed;
602
+ }
603
+ // Plan usage even if you haven't used Codex today: the newest rollout anywhere has it.
604
+ if (!codexUsage) {
605
+ const newest = await newestRollout();
606
+ if (newest && !scanned.has(newest)) changed = (await checkRollout(newest)) || changed;
607
+ }
608
+ // Live/working wears off with time even when nothing is written.
609
+ for (const s of sessions.values()) {
610
+ if (s.agent !== 'codex') continue;
611
+ const status = codexStatus(s);
612
+ if (status !== s.status) {
613
+ s.status = status;
614
+ changed = true;
615
+ }
616
+ }
617
+ } finally {
618
+ codexScanning = false;
619
+ }
620
+ if (changed) broadcast();
621
+ }
622
+
623
+ async function newestRollout() {
624
+ // Walk year/month/day folders newest first; the first rollout found is the newest.
625
+ const sortedDirs = async (dir) =>
626
+ (await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []))
627
+ .filter((d) => d.isDirectory())
628
+ .map((d) => d.name)
629
+ .sort()
630
+ .reverse();
631
+ for (const y of await sortedDirs(CODEX_SESSIONS))
632
+ for (const m of await sortedDirs(path.join(CODEX_SESSIONS, y)))
633
+ for (const d of await sortedDirs(path.join(CODEX_SESSIONS, y, m))) {
634
+ const dir = path.join(CODEX_SESSIONS, y, m, d);
635
+ const files = (await fs.promises.readdir(dir).catch(() => [])).filter(isRollout);
636
+ if (!files.length) continue;
637
+ const stats = await Promise.all(files.map(async (f) => ({ f: path.join(dir, f), m: (await fs.promises.stat(path.join(dir, f))).mtimeMs })));
638
+ return stats.sort((a, b) => b.m - a.m)[0].f;
639
+ }
640
+ return null;
641
+ }
642
+
643
+ const codexChanged = new Set();
644
+ let codexTimer = null;
645
+ function onCodexChange(_, name) {
646
+ if (!name || !isRollout(name)) return;
647
+ codexChanged.add(path.join(CODEX_SESSIONS, name));
648
+ if (codexTimer) return;
649
+ codexTimer = setTimeout(async () => {
650
+ codexTimer = null;
651
+ const files = [...codexChanged];
652
+ codexChanged.clear();
653
+ if ((await Promise.all(files.map(checkRollout))).some(Boolean)) broadcast();
654
+ }, 1000);
655
+ }
656
+
657
+ function startCodexDiscovery() {
658
+ if (!CODEX_ENABLED || !fs.existsSync(CODEX_SESSIONS)) return;
659
+ try {
660
+ fs.watch(CODEX_SESSIONS, { recursive: true }, onCodexChange).on('error', () => {});
661
+ } catch {}
662
+ discoverCodex();
663
+ setInterval(discoverCodex, DISCOVER_INTERVAL_MS);
664
+ }
665
+
666
+ // Codex records your plan limits with every turn (the same numbers its /status shows).
667
+ let codexUsage = null; // { updatedAt, plan, limits, credits }
668
+
669
+ function codexWindowLabel(minutes) {
670
+ if (minutes === 300) return '5-hour session';
671
+ if (minutes === 10080) return 'Weekly';
672
+ if (minutes % 1440 === 0) return `${minutes / 1440}-day`;
673
+ if (minutes % 60 === 0) return `${minutes / 60}-hour`;
674
+ return `${minutes}-minute`;
675
+ }
676
+
677
+ function noteCodexLimits(rl, at) {
678
+ if (codexUsage && at <= codexUsage.updatedAt) return;
679
+ const limits = [];
680
+ for (const [kind, w] of [['session', rl.primary], ['weekly_all', rl.secondary]]) {
681
+ if (!w || typeof w.used_percent !== 'number') continue;
682
+ const resetsAt = w.resets_at ? new Date(w.resets_at * 1000).toISOString() : null;
683
+ // A window that has reset since Codex last wrote this starts over at 0.
684
+ const reset = resetsAt && Date.parse(resetsAt) < Date.now();
685
+ limits.push({
686
+ kind,
687
+ label: (rl.limit_name ? `${rl.limit_name} · ` : '') + codexWindowLabel(w.window_minutes),
688
+ percent: reset ? 0 : w.used_percent,
689
+ severity: rl.rate_limit_reached_type ? 'critical' : null,
690
+ resetsAt: reset ? null : resetsAt,
691
+ });
692
+ }
693
+ codexUsage = { updatedAt: at, plan: rl.plan_type || '', limits, credits: rl.credits || null };
694
+ }
695
+
696
+ function openInVSCode(cwd) {
697
+ if (!cwd || /["&|<>^%]/.test(cwd) || !fs.existsSync(cwd)) return false;
698
+ const child =
699
+ process.platform === 'win32'
700
+ ? spawn('cmd.exe', ['/c', 'code', cwd], { detached: true, stdio: 'ignore', windowsHide: true })
701
+ : spawn('code', [cwd], { detached: true, stdio: 'ignore' });
702
+ child.on('error', () => {});
703
+ child.unref();
704
+ return true;
705
+ }
706
+
707
+ // ---------- app preview ----------
708
+ // Finds web servers started from a session's project folder (npm run dev and the like)
709
+ // so the dashboard can show the app next to the conversation. Windows only for now;
710
+ // elsewhere you type the address yourself.
711
+
712
+ const PS_LISTENERS =
713
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress; "<<split>>"; ' +
714
+ 'Get-NetTCPConnection -State Listen | Select-Object LocalAddress,LocalPort,OwningProcess | ConvertTo-Json -Compress';
715
+ // Editors and Claude itself are often started with the project path; their ports aren't the app.
716
+ const NOT_APPS = new Set(['code.exe', 'code - insiders.exe', 'cursor.exe', 'claude.exe', 'explorer.exe']);
717
+
718
+ function listListeners() {
719
+ return new Promise((resolve) => {
720
+ if (process.platform !== 'win32') return resolve(null);
721
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_LISTENERS], { windowsHide: true, timeout: 20000, maxBuffer: 64 * 1024 * 1024 }, (err, out) => {
722
+ if (err) return resolve(null);
723
+ try {
724
+ const [procs, listen] = out.split('<<split>>').map((part) => [].concat(JSON.parse(part.trim() || '[]')));
725
+ resolve({ procs, listen });
726
+ } catch {
727
+ resolve(null);
728
+ }
729
+ });
730
+ });
731
+ }
732
+
733
+ const exeOf = (cmd) => path.basename((cmd.startsWith('"') ? cmd.slice(1).split('"')[0] : cmd.split(' ')[0]) || '').toLowerCase();
734
+ // "…\node_modules\.bin\..\vite\bin\vite.js dev" -> "vite"
735
+ const appLabel = (cmd) => cmd.match(/node_modules[\\/](?:\.bin[\\/]\.\.[\\/])?((?:@[^\\/]+[\\/])?[^\\/"\s]+)/i)?.[1].replace('\\', '/') || exeOf(cmd).replace(/\.exe$/, '');
736
+
737
+ function probe(host, port) {
738
+ return new Promise((resolve) => {
739
+ const req = http.get({ host, port, path: '/', timeout: 1500 }, (res) => {
740
+ res.resume();
741
+ const xfo = String(res.headers['x-frame-options'] || '');
742
+ const csp = String(res.headers['content-security-policy'] || '');
743
+ resolve({
744
+ html: /html/i.test(res.headers['content-type'] || ''),
745
+ embeddable: !xfo && !/frame-ancestors/i.test(csp),
746
+ });
747
+ });
748
+ req.on('timeout', () => req.destroy());
749
+ req.on('error', () => resolve(null));
750
+ });
751
+ }
752
+
753
+ async function findApps(cwd) {
754
+ const data = cwd && (await listListeners());
755
+ if (!data) return [];
756
+ const root = cwd.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
757
+ const inProject = (cmd) => {
758
+ const c = cmd.toLowerCase().replace(/\//g, '\\');
759
+ const i = c.indexOf(root);
760
+ return i >= 0 && !/[\w.-]/.test(c[i + root.length] || '');
761
+ };
762
+ const owners = new Map(); // pid -> command line
763
+ for (const p of data.procs) {
764
+ const cmd = String(p.CommandLine || '');
765
+ if (p.ProcessId !== process.pid && !NOT_APPS.has(exeOf(cmd)) && inProject(cmd)) owners.set(p.ProcessId, cmd);
766
+ }
767
+ const seen = new Set();
768
+ const found = [];
769
+ for (const l of data.listen) {
770
+ if (!owners.has(l.OwningProcess) || l.LocalPort === PORT || seen.has(l.LocalPort)) continue;
771
+ seen.add(l.LocalPort);
772
+ const host = l.LocalAddress === '::1' ? '::1' : l.LocalAddress === '::' || l.LocalAddress === '0.0.0.0' ? '127.0.0.1' : l.LocalAddress;
773
+ found.push(probe(host, l.LocalPort).then((r) => r && { url: `http://localhost:${l.LocalPort}`, port: l.LocalPort, label: appLabel(owners.get(l.OwningProcess)), ...r }));
774
+ }
775
+ // Pages first (an API server answers too, but isn't much to look at), then by port.
776
+ return (await Promise.all(found)).filter(Boolean).sort((a, b) => b.html - a.html || a.port - b.port);
777
+ }
778
+
779
+ function addActivity(s, kind, text, extra = {}) {
780
+ s.activity.push({ t: Date.now(), kind, text: truncate(text, 4000), ...extra });
781
+ if (s.activity.length > MAX_ACTIVITY) s.activity.splice(0, s.activity.length - MAX_ACTIVITY);
782
+ s.lastAction = (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
783
+ }
784
+
785
+ // Who drives a session: 'vscode' (Agenter only answers inside VS Code's turns) or
786
+ // 'agenter' (Agenter runs turns itself, so nothing ever waits on VS Code).
787
+ const driveOf = (s) => (settings.drive?.[s.id] === 'agenter' ? 'agenter' : 'vscode');
788
+ const effectiveHold = (s) => driveOf(s) === 'vscode' && (s.hold === null ? !!settings.holdOnStop : s.hold);
789
+ const hasPending = (s) => [...pending.values()].some((e) => e.item.sessionId === s.id);
790
+
791
+ function settleStatus(s, status) {
792
+ s.status = hasPending(s) ? 'waiting' : status;
793
+ }
794
+
795
+ // ---------- waiting on the dashboard ----------
796
+
797
+ function hold(s, kind, data, payload, res) {
798
+ const item = { id: crypto.randomUUID(), sessionId: s.id, kind, createdAt: Date.now(), headless: runners.has(s.id), ...data };
799
+ const entry = { item, res, payload, timer: null };
800
+ const minutes = Number(settings.holdMinutes) || 0;
801
+ if (minutes > 0) {
802
+ item.releaseAt = item.createdAt + minutes * 60000;
803
+ entry.timer = setTimeout(() => respond(item.id, 'pass', { auto: true }), minutes * 60000);
804
+ }
805
+ pending.set(item.id, entry);
806
+ s.status = 'waiting';
807
+ // Claude gave up on the hook (interrupted, timed out, session closed).
808
+ res.on('close', () => {
809
+ if (!pending.has(item.id)) return;
810
+ clearTimeout(entry.timer);
811
+ pending.delete(item.id);
812
+ settleStatus(s, kind === 'stop' ? 'idle' : 'working');
813
+ broadcast();
814
+ });
815
+ broadcast();
816
+ }
817
+
818
+ // action: allow | always | deny | answer | reply | pass
819
+ function respond(id, action, body = {}) {
820
+ const entry = pending.get(id);
821
+ if (!entry) return false;
822
+ const { item, res, payload } = entry;
823
+ clearTimeout(entry.timer);
824
+ const s = sessions.get(item.sessionId);
825
+ let out = {};
826
+ let status = 'working';
827
+ if (body.auto) addActivity(s, 'notice', `No answer after ${settings.holdMinutes} min, handed back to VS Code`);
828
+
829
+ if (item.kind === 'permission') {
830
+ if (action === 'allow' || action === 'always') {
831
+ const decision = { behavior: 'allow' };
832
+ if (action === 'always' && Array.isArray(payload.permission_suggestions)) decision.updatedPermissions = payload.permission_suggestions;
833
+ out = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } };
834
+ addActivity(s, 'decision', `${action === 'always' ? 'Always allowed' : 'Allowed'} ${item.tool}`);
835
+ } else if (action === 'deny') {
836
+ const message = body.message?.trim() || 'The user denied this from the Agenter dashboard.';
837
+ out = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny', message } } };
838
+ addActivity(s, 'decision', `Denied ${item.tool}${body.message?.trim() ? `: ${body.message.trim()}` : ''}`);
839
+ } else {
840
+ status = 'vscode';
841
+ addActivity(s, 'decision', `Sent ${item.tool} prompt back to VS Code`);
842
+ }
843
+ } else if (item.kind === 'question') {
844
+ if (action === 'answer' && body.answers && typeof body.answers === 'object') {
845
+ out = {
846
+ hookSpecificOutput: {
847
+ hookEventName: 'PreToolUse',
848
+ permissionDecision: 'allow',
849
+ permissionDecisionReason: 'Answered from the Agenter dashboard',
850
+ updatedInput: { ...payload.tool_input, answers: body.answers },
851
+ },
852
+ };
853
+ addActivity(s, 'decision', `Answered: ${Object.values(body.answers).join(' | ')}`);
854
+ } else {
855
+ status = 'vscode';
856
+ addActivity(s, 'decision', 'Sent question back to VS Code');
857
+ }
858
+ } else if (item.kind === 'stop') {
859
+ const message = body.message?.trim();
860
+ if (action === 'reply' && message) {
861
+ out = { decision: 'block', reason: message };
862
+ addActivity(s, 'prompt', message, { fromDashboard: true });
863
+ } else {
864
+ status = 'idle';
865
+ addActivity(s, 'stop', 'Finished turn');
866
+ }
867
+ }
868
+
869
+ pending.delete(id);
870
+ res.writeHead(200, { 'content-type': 'application/json' });
871
+ res.end(JSON.stringify(out));
872
+ if (s) settleStatus(s, status);
873
+ broadcast();
874
+ return true;
875
+ }
876
+
877
+ // Sessions are only held while a dashboard window has focus. If you're in VS Code,
878
+ // Claude finishes (or asks) there as usual instead of spinning on the dashboard.
879
+ const inUse = () => [...clients.values()].some((c) => c.focused);
880
+
881
+ let releaseTimer = null;
882
+ function onAttentionChanged() {
883
+ clearTimeout(releaseTimer);
884
+ if (inUse()) return;
885
+ releaseTimer = setTimeout(() => {
886
+ // A turn Agenter runs has no VS Code to fall back to, so it keeps waiting while a dashboard is open.
887
+ for (const [id, e] of [...pending]) if (!clients.size || !e.item.headless) respond(id, 'pass');
888
+ }, clients.size ? BLUR_GRACE_MS : DISCONNECT_GRACE_MS);
889
+ }
890
+
891
+ // ---------- steering ----------
892
+ // Messages typed in the dashboard while Claude isn't waiting for a reply. Hooks only run
893
+ // at events, so they wait here and go out with the next one: after the current tool
894
+ // call, when the turn ends (Claude carries on), or with your next prompt if it's idle.
895
+
896
+ function takeQueue(s, when) {
897
+ if (!s.queue.length) return '';
898
+ const texts = s.queue.map((m) => m.text);
899
+ s.queue = [];
900
+ for (const t of texts) addActivity(s, 'prompt', t, { fromDashboard: true });
901
+ const body = texts.join('\n\n');
902
+ return when === 'stop' ? body : `The user sent this from the Agenter dashboard ${when}. Follow it from here on:\n\n${body}`;
903
+ }
904
+
905
+ function steer(s, text) {
906
+ if (s.agent === 'codex') return steerCodex(s, text);
907
+ const stop = [...pending.values()].find((e) => e.item.sessionId === s.id && e.item.kind === 'stop');
908
+ if (stop) return respond(stop.item.id, 'reply', { message: text }), 'sent';
909
+ const busy = runners.has(s.id) || s.status === 'working' || s.status === 'waiting' || s.status === 'vscode';
910
+ if (driveOf(s) === 'agenter' && !busy) {
911
+ if (!startRun(s, text)) return 'no-claude';
912
+ addActivity(s, 'prompt', text, { fromDashboard: true });
913
+ return 'started';
914
+ }
915
+ s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
916
+ broadcast();
917
+ return 'queued';
918
+ }
919
+
920
+ // ---------- turns Agenter runs itself ----------
921
+ // "Drive from Agenter" continues the session with the Claude Code CLI that ships with the
922
+ // VS Code extension (`claude -p --resume <id>`). Its hooks report back here like any
923
+ // session, so permission prompts and questions show up on the dashboard.
924
+
925
+ const runners = new Map(); // session id -> claude process
926
+
927
+ function findClaude() {
928
+ if (process.env.AGENTER_CLAUDE) return process.env.AGENTER_CLAUDE;
929
+ const exe = process.platform === 'win32' ? 'claude.exe' : 'claude';
930
+ const found = [];
931
+ for (const editor of ['.vscode', '.vscode-insiders', '.cursor']) {
932
+ const dir = path.join(os.homedir(), editor, 'extensions');
933
+ let names = [];
934
+ try {
935
+ names = fs.readdirSync(dir).filter((n) => n.startsWith('anthropic.claude-code-'));
936
+ } catch {}
937
+ for (const n of names) {
938
+ const bin = path.join(dir, n, 'resources', 'native-binary', exe);
939
+ try {
940
+ found.push({ bin, mtime: fs.statSync(bin).mtimeMs });
941
+ } catch {}
942
+ }
943
+ }
944
+ if (found.length) return found.sort((a, b) => b.mtime - a.mtime)[0].bin;
945
+ // Installed on PATH (npm or the native installer)?
946
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
947
+ for (const name of process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']) {
948
+ if (dir && fs.existsSync(path.join(dir, name))) return path.join(dir, name);
949
+ }
950
+ }
951
+ return null;
952
+ }
953
+
954
+ // fresh: the session doesn't exist yet (started from Agenter), so create it under this id.
955
+ function startRun(s, text, { fresh = false } = {}) {
956
+ const bin = s.cwd && fs.existsSync(s.cwd) && findClaude();
957
+ if (!bin) return false;
958
+ const args = ['-p', fresh ? '--session-id' : '--resume', s.id];
959
+ if (s.permissionMode === 'acceptEdits') args.push('--permission-mode', 'acceptEdits');
960
+ const child = spawn(bin, args, { cwd: s.cwd, windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'], shell: bin.endsWith('.cmd') });
961
+ runners.set(s.id, child);
962
+ s.status = 'working';
963
+ let err = '';
964
+ child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
965
+ child.on('error', (e) => (err = e.message));
966
+ child.on('close', (code) => {
967
+ runners.delete(s.id);
968
+ if (code && !child.interrupted) addActivity(s, 'notice', `Claude stopped with an error${err.trim() ? `: ${err.trim()}` : ''}`);
969
+ if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
970
+ settleStatus(s, 'idle');
971
+ // Something sent after the last point Claude could pick it up: run it next.
972
+ const next = !child.interrupted && takeQueue(s, 'stop');
973
+ if (next) startRun(s, next);
974
+ broadcast();
975
+ });
976
+ child.stdin.end(text);
977
+ broadcast();
978
+ return true;
979
+ }
980
+
981
+ // ---------- steering Codex ----------
982
+ // Driven from the Codex app (default): `codex queue` hands the message to Codex, which runs
983
+ // it as the next turn of that session in the VS Code extension or the desktop app.
984
+ // Driven from Agenter: `codex exec resume` runs the turn here, sandboxed to the project
985
+ // (workspace-write, no network) since nobody is there to approve anything more.
986
+
987
+ function findCodex() {
988
+ if (process.env.AGENTER_CODEX_BIN) return process.env.AGENTER_CODEX_BIN;
989
+ const exe = process.platform === 'win32' ? 'codex.exe' : 'codex';
990
+ const found = [];
991
+ for (const editor of ['.vscode', '.vscode-insiders', '.cursor']) {
992
+ const dir = path.join(os.homedir(), editor, 'extensions');
993
+ let names = [];
994
+ try {
995
+ names = fs.readdirSync(dir).filter((n) => n.startsWith('openai.chatgpt-'));
996
+ } catch {}
997
+ for (const n of names) {
998
+ let platforms = [];
999
+ try {
1000
+ platforms = fs.readdirSync(path.join(dir, n, 'bin'));
1001
+ } catch {}
1002
+ for (const p of platforms) {
1003
+ const bin = path.join(dir, n, 'bin', p, exe);
1004
+ try {
1005
+ found.push({ bin, mtime: fs.statSync(bin).mtimeMs });
1006
+ } catch {}
1007
+ }
1008
+ }
1009
+ }
1010
+ if (found.length) return found.sort((a, b) => b.mtime - a.mtime)[0].bin;
1011
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
1012
+ for (const name of process.platform === 'win32' ? ['codex.exe', 'codex.cmd'] : ['codex']) {
1013
+ if (dir && fs.existsSync(path.join(dir, name))) return path.join(dir, name);
1014
+ }
1015
+ }
1016
+ return null;
1017
+ }
1018
+
1019
+ async function steerCodex(s, text) {
1020
+ if (driveOf(s) === 'agenter') {
1021
+ if (runners.has(s.id)) {
1022
+ // `exec` can't take messages mid-turn; this runs as the next turn.
1023
+ s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
1024
+ broadcast();
1025
+ return 'queued';
1026
+ }
1027
+ if (!(await startCodexRun(s, text))) return 'no-codex';
1028
+ addActivity(s, 'prompt', text, { fromDashboard: true });
1029
+ return 'started';
1030
+ }
1031
+ const bin = findCodex();
1032
+ if (!bin) return 'no-codex';
1033
+ return new Promise((resolve) => {
1034
+ execFile(bin, ['queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000, shell: bin.endsWith('.cmd') }, (err) => {
1035
+ if (err) return resolve('error');
1036
+ addActivity(s, 'prompt', text, { fromDashboard: true });
1037
+ resolve('codex-queued');
1038
+ });
1039
+ });
1040
+ }
1041
+
1042
+ // Runs one Codex turn. fresh: start a new session (no id yet); resolves with the session
1043
+ // once Codex has named it. Otherwise resolves true/false for whether it started.
1044
+ function startCodexRun(s, text, { fresh = false } = {}) {
1045
+ const bin = s.cwd && fs.existsSync(s.cwd) && findCodex();
1046
+ if (!bin) return Promise.resolve(fresh ? null : false);
1047
+ const common = ['--json', '--skip-git-repo-check'];
1048
+ const args = fresh ? ['exec', ...common, '--sandbox', 'workspace-write', '-'] : ['exec', 'resume', ...common, '-c', 'sandbox_mode=workspace-write', s.id, '-'];
1049
+ const child = spawn(bin, args, { cwd: s.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], shell: bin.endsWith('.cmd') });
1050
+ let err = '';
1051
+ let failure = '';
1052
+ let buf = '';
1053
+ return new Promise((resolve) => {
1054
+ const begin = () => {
1055
+ runners.set(s.id, child);
1056
+ s.status = 'working';
1057
+ s.codexTurnOpen = true;
1058
+ s.lastEventAt = Date.now();
1059
+ broadcast();
1060
+ resolve(fresh ? s : true);
1061
+ };
1062
+ if (!fresh) begin();
1063
+ child.stdout.on('data', (d) => {
1064
+ buf += d;
1065
+ let i;
1066
+ while ((i = buf.indexOf('\n')) >= 0) {
1067
+ const line = buf.slice(0, i);
1068
+ buf = buf.slice(i + 1);
1069
+ let e;
1070
+ try {
1071
+ e = JSON.parse(line);
1072
+ } catch {
1073
+ continue;
1074
+ }
1075
+ if (e.type === 'thread.started' && fresh && !runners.has(e.thread_id)) {
1076
+ s.id = e.thread_id;
1077
+ sessions.set(s.id, s);
1078
+ begin();
1079
+ } else if (e.type === 'error' || e.type === 'turn.failed') failure = e.message || e.error?.message || failure;
1080
+ }
1081
+ });
1082
+ child.stderr.on('data', (d) => (err = (err + d).slice(-2000)));
1083
+ child.on('error', (e) => (err = e.message));
1084
+ child.on('close', (code) => {
1085
+ // Never got going (Codex missing a login, bad folder…): report why to whoever asked.
1086
+ if (runners.get(s.id) !== child) return resolve(fresh ? { error: failure || err.trim().split('\n').pop() || `Codex exited (${code}).` } : false);
1087
+ runners.delete(s.id);
1088
+ s.codexTurnOpen = false;
1089
+ if ((code || failure) && !child.interrupted) addActivity(s, 'notice', `Codex stopped with an error${failure || err.trim() ? `: ${failure || err.trim().split('\n').pop()}` : ''}`);
1090
+ if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
1091
+ s.status = 'idle';
1092
+ s.lastAction = (s.transcriptPath && lastAssistantText(s.transcriptPath).split('\n')[0].slice(0, 160)) || s.lastAction;
1093
+ const next = !child.interrupted && takeQueue(s, 'stop');
1094
+ if (next) startCodexRun(s, next);
1095
+ broadcast();
1096
+ });
1097
+ child.stdin.end(text);
1098
+ });
1099
+ }
1100
+
1101
+ // ---------- hook events ----------
1102
+
1103
+ function handleHook(p, res) {
1104
+ const ev = p.hook_event_name;
1105
+ const s = touchSession(p);
1106
+ // A turn Agenter started has nowhere else to ask, so it always asks here.
1107
+ const watching = inUse() || runners.has(s.id);
1108
+ let out = {};
1109
+ let steering;
1110
+
1111
+ switch (ev) {
1112
+ case 'SessionStart':
1113
+ s.status = 'idle';
1114
+ addActivity(s, 'session', `Session ${p.source || 'started'}`);
1115
+ break;
1116
+ case 'SessionEnd':
1117
+ for (const [id, e] of pending) if (e.item.sessionId === s.id) respond(id, 'pass');
1118
+ // Each turn Agenter runs ends its process; the session itself carries on.
1119
+ if (driveOf(s) === 'agenter') {
1120
+ s.status = 'idle';
1121
+ s.lastAction = lastAssistantText(s.transcriptPath).split('\n')[0].slice(0, 160) || s.lastAction;
1122
+ } else {
1123
+ s.status = 'ended';
1124
+ s.queue = [];
1125
+ addActivity(s, 'session', `Session ended${p.reason ? ` (${p.reason})` : ''}`);
1126
+ }
1127
+ break;
1128
+ case 'UserPromptSubmit':
1129
+ s.status = 'working';
1130
+ if (!s.title && p.prompt) s.title = p.prompt.split('\n')[0].slice(0, 140);
1131
+ addActivity(s, 'prompt', p.prompt || '');
1132
+ if ((steering = takeQueue(s, 'while you were idle'))) out = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: steering } };
1133
+ break;
1134
+ case 'PreToolUse':
1135
+ if (p.tool_name === 'AskUserQuestion' && watching && Array.isArray(p.tool_input?.questions)) {
1136
+ addActivity(s, 'tool', describeTool(p.tool_name, p.tool_input), { tool: p.tool_name });
1137
+ return hold(s, 'question', { tool: p.tool_name, questions: p.tool_input.questions }, p, res);
1138
+ }
1139
+ s.status = 'working';
1140
+ addActivity(s, 'tool', describeTool(p.tool_name, p.tool_input), { tool: p.tool_name });
1141
+ break;
1142
+ case 'PostToolUse':
1143
+ settleStatus(s, 'working');
1144
+ if ((steering = takeQueue(s, 'while you were working'))) out = { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: steering } };
1145
+ break;
1146
+ case 'PermissionRequest':
1147
+ if (watching && p.tool_name !== 'AskUserQuestion') {
1148
+ const canAlways = Array.isArray(p.permission_suggestions) && p.permission_suggestions.length > 0;
1149
+ return hold(s, 'permission', { tool: p.tool_name, input: p.tool_input, canAlways }, p, res);
1150
+ }
1151
+ s.status = 'vscode';
1152
+ break;
1153
+ case 'Notification':
1154
+ if (p.notification_type === 'permission_prompt' && hasPending(s)) break; // already on the dashboard
1155
+ addActivity(s, 'notice', p.message || '');
1156
+ if (p.notification_type === 'idle_prompt') settleStatus(s, 'idle');
1157
+ break;
1158
+ case 'Stop':
1159
+ if ((steering = takeQueue(s, 'stop'))) {
1160
+ out = { decision: 'block', reason: steering };
1161
+ break;
1162
+ }
1163
+ if (watching && effectiveHold(s)) {
1164
+ return hold(s, 'stop', { message: truncate(p.last_assistant_message || lastAssistantText(s.transcriptPath), 20000) }, p, res);
1165
+ }
1166
+ settleStatus(s, 'idle');
1167
+ addActivity(s, 'stop', 'Finished turn');
1168
+ break;
1169
+ case 'SubagentStop':
1170
+ addActivity(s, 'notice', 'Subagent finished');
1171
+ break;
1172
+ }
1173
+ broadcast();
1174
+ res.writeHead(200, { 'content-type': 'application/json' });
1175
+ res.end(JSON.stringify(out));
1176
+ }
1177
+
1178
+ // ---------- dashboard plumbing ----------
1179
+
1180
+ function snapshot() {
1181
+ return {
1182
+ settings,
1183
+ usage,
1184
+ codexUsage,
1185
+ // activity stays server-side (it feeds lastAction); the dashboard reads the transcript instead.
1186
+ sessions: [...sessions.values()].map(({ activity, ...s }) => ({ ...s, holdOverride: s.hold, hold: effectiveHold(s), drive: driveOf(s), running: runners.has(s.id) })),
1187
+ pending: [...pending.values()].map((e) => e.item),
1188
+ };
1189
+ }
1190
+
1191
+ let broadcastQueued = false;
1192
+ function broadcast() {
1193
+ if (broadcastQueued) return;
1194
+ broadcastQueued = true;
1195
+ setTimeout(() => {
1196
+ broadcastQueued = false;
1197
+ const msg = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`;
1198
+ for (const c of clients.keys()) c.write(msg);
1199
+ }, 30);
1200
+ }
1201
+
1202
+ function readBody(req, limit = 32 * 1024 * 1024) {
1203
+ return new Promise((resolve, reject) => {
1204
+ let size = 0;
1205
+ const chunks = [];
1206
+ req.on('data', (c) => {
1207
+ size += c.length;
1208
+ if (size > limit) {
1209
+ reject(new Error('body too large'));
1210
+ req.destroy();
1211
+ } else chunks.push(c);
1212
+ });
1213
+ req.on('end', () => {
1214
+ try {
1215
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8').replace(/^/, '') || '{}'));
1216
+ } catch (err) {
1217
+ reject(err);
1218
+ }
1219
+ });
1220
+ req.on('error', reject);
1221
+ });
1222
+ }
1223
+
1224
+ const json = (res, code, obj) => {
1225
+ res.writeHead(code, { 'content-type': 'application/json' });
1226
+ res.end(JSON.stringify(obj));
1227
+ };
1228
+
1229
+ const STATIC = {
1230
+ '/': ['index.html', 'text/html; charset=utf-8'],
1231
+ '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
1232
+ '/style.css': ['style.css', 'text/css; charset=utf-8'],
1233
+ };
1234
+
1235
+ const ALLOWED_HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
1236
+ const ALLOWED_ORIGINS = new Set([...ALLOWED_HOSTS].map((h) => `http://${h}`));
1237
+
1238
+ const server = http.createServer(async (req, res) => {
1239
+ // Only this machine, and never another website (DNS rebinding / cross-site POSTs).
1240
+ if (!ALLOWED_HOSTS.has(req.headers.host) || (req.headers.origin && !ALLOWED_ORIGINS.has(req.headers.origin))) {
1241
+ return json(res, 403, { error: 'forbidden' });
1242
+ }
1243
+ const url = new URL(req.url, `http://${req.headers.host}`);
1244
+
1245
+ try {
1246
+ if (req.method === 'POST' && url.pathname === '/hook') {
1247
+ return handleHook(await readBody(req), res);
1248
+ }
1249
+
1250
+ if (req.method === 'GET' && STATIC[url.pathname]) {
1251
+ const [file, type] = STATIC[url.pathname];
1252
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
1253
+ return res.end(fs.readFileSync(path.join(PUBLIC_DIR, file)));
1254
+ }
1255
+
1256
+ if (req.method === 'GET' && url.pathname === '/events') {
1257
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
1258
+ res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
1259
+ clients.set(res, { id: url.searchParams.get('client') || '', focused: url.searchParams.get('focused') === '1' });
1260
+ onAttentionChanged();
1261
+ if (!usage || Date.now() - usage.fetchedAt > 60000) fetchUsage();
1262
+ const ping = setInterval(() => res.write(': ping\n\n'), 20000);
1263
+ req.on('close', () => {
1264
+ clearInterval(ping);
1265
+ clients.delete(res);
1266
+ onAttentionChanged();
1267
+ });
1268
+ return;
1269
+ }
1270
+
1271
+ if (req.method === 'GET' && url.pathname === '/api/transcript') {
1272
+ const s = sessions.get(url.searchParams.get('session'));
1273
+ if (!s) return json(res, 404, { error: 'unknown session' });
1274
+ const c = s.transcriptPath && readTranscript(s.transcriptPath);
1275
+ if (!c) return json(res, 200, { epoch: '', reset: true, messages: [] });
1276
+ // Same epoch: only messages after the dashboard's last one. Otherwise start over.
1277
+ const reset = url.searchParams.get('epoch') !== c.epoch;
1278
+ const after = Number(url.searchParams.get('after')) || 0;
1279
+ return json(res, 200, { epoch: c.epoch, reset, messages: reset ? c.messages.slice(-120) : c.messages.filter((m) => m.seq > after) });
1280
+ }
1281
+
1282
+ if (req.method === 'GET' && url.pathname === '/api/preview/apps') {
1283
+ const s = sessions.get(url.searchParams.get('session'));
1284
+ if (!s) return json(res, 404, { error: 'unknown session' });
1285
+ return json(res, 200, { apps: await findApps(s.cwd), detects: process.platform === 'win32' });
1286
+ }
1287
+
1288
+ if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
1289
+ // A custom header forces a CORS preflight, which we never approve.
1290
+ if (req.headers['x-agenter'] !== '1') return json(res, 403, { error: 'forbidden' });
1291
+ const body = await readBody(req);
1292
+
1293
+ if (url.pathname === '/api/respond') {
1294
+ const s = sessions.get(pending.get(body.id)?.item.sessionId);
1295
+ const ok = respond(body.id, body.action, body);
1296
+ // Handing something back to VS Code: bring up that project's window so it's right there.
1297
+ if (ok && body.action === 'pass') openInVSCode(s?.cwd);
1298
+ return json(res, ok ? 200 : 404, {});
1299
+ }
1300
+ if (url.pathname === '/api/focus') {
1301
+ for (const c of clients.values()) if (c.id === body.client) c.focused = !!body.focused;
1302
+ onAttentionChanged();
1303
+ return json(res, 200, {});
1304
+ }
1305
+ if (url.pathname === '/api/settings') {
1306
+ if (typeof body.holdOnStop === 'boolean') settings.holdOnStop = body.holdOnStop;
1307
+ if (Number.isFinite(body.holdMinutes) && body.holdMinutes >= 0) settings.holdMinutes = body.holdMinutes;
1308
+ saveSettings();
1309
+ broadcast();
1310
+ return json(res, 200, settings);
1311
+ }
1312
+ if (url.pathname === '/api/session/steer') {
1313
+ const s = sessions.get(body.sessionId);
1314
+ const text = String(body.message || '').trim();
1315
+ // A closed Claude session can only be continued by Agenter; Codex queues for its app either way.
1316
+ if (!s || !text || (s.agent !== 'codex' && driveOf(s) === 'vscode' && (s.status === 'dormant' || s.status === 'ended'))) return json(res, 404, {});
1317
+ return json(res, 200, { result: await steer(s, text) });
1318
+ }
1319
+ if (url.pathname === '/api/session/new') {
1320
+ const cwd = String(body.cwd || '').trim().replace(/^"(.*)"$/, '$1');
1321
+ const text = String(body.message || '').trim();
1322
+ const codex = body.agent === 'codex';
1323
+ if (!text) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1324
+ let isDir = false;
1325
+ try {
1326
+ isDir = path.isAbsolute(cwd) && fs.statSync(cwd).isDirectory();
1327
+ } catch {}
1328
+ if (!isDir) return json(res, 400, { error: "That folder doesn't exist. Use the full path, like C:\\Users\\you\\project." });
1329
+ if (codex) {
1330
+ if (!findCodex()) return json(res, 400, { error: "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe." });
1331
+ // Codex names the session itself, so it's registered once Codex has started it.
1332
+ const draft = Object.assign(newSession(''), { agent: 'codex', title: text.split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1333
+ setCwd(draft, path.resolve(cwd));
1334
+ const started = await Promise.race([startCodexRun(draft, text, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1335
+ if (!started?.id) return json(res, 500, { error: `Codex couldn't start: ${started?.error || 'unknown error'}` });
1336
+ settings.drive = { ...settings.drive, [started.id]: 'agenter' };
1337
+ saveSettings();
1338
+ addActivity(started, 'prompt', text, { fromDashboard: true });
1339
+ broadcast();
1340
+ return json(res, 200, { sessionId: started.id });
1341
+ }
1342
+ if (!findClaude()) return json(res, 400, { error: "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe." });
1343
+ const s = getOrCreateSession(crypto.randomUUID());
1344
+ setCwd(s, path.resolve(cwd));
1345
+ s.title = text.split('\n')[0].slice(0, 140);
1346
+ s.lastEventAt = Date.now();
1347
+ settings.drive = { ...settings.drive, [s.id]: 'agenter' };
1348
+ saveSettings();
1349
+ if (!startRun(s, text, { fresh: true })) {
1350
+ sessions.delete(s.id);
1351
+ return json(res, 500, { error: "Couldn't start Claude." });
1352
+ }
1353
+ addActivity(s, 'prompt', text, { fromDashboard: true });
1354
+ return json(res, 200, { sessionId: s.id });
1355
+ }
1356
+ if (url.pathname === '/api/session/drive') {
1357
+ const s = sessions.get(body.sessionId);
1358
+ if (!s) return json(res, 404, {});
1359
+ settings.drive = { ...settings.drive };
1360
+ if (body.drive === 'agenter') {
1361
+ settings.drive[s.id] = 'agenter';
1362
+ // Let go of a finished turn VS Code is spinning on; from now on Agenter runs the turns.
1363
+ for (const [id, e] of [...pending]) if (e.item.sessionId === s.id && e.item.kind === 'stop') respond(id, 'pass');
1364
+ } else delete settings.drive[s.id];
1365
+ saveSettings();
1366
+ if (s.agent === 'codex') s.status = codexStatus(s);
1367
+ broadcast();
1368
+ return json(res, 200, { cli: !!(s.agent === 'codex' ? findCodex() : findClaude()) });
1369
+ }
1370
+ if (url.pathname === '/api/session/interrupt') {
1371
+ const child = runners.get(body.sessionId);
1372
+ if (child) {
1373
+ child.interrupted = true;
1374
+ child.kill();
1375
+ }
1376
+ return json(res, child ? 200 : 404, {});
1377
+ }
1378
+ if (url.pathname === '/api/session/unqueue') {
1379
+ const s = sessions.get(body.sessionId);
1380
+ if (s) s.queue = s.queue.filter((m) => m.id !== body.id);
1381
+ broadcast();
1382
+ return json(res, 200, {});
1383
+ }
1384
+ if (url.pathname === '/api/session/hold') {
1385
+ const s = sessions.get(body.sessionId);
1386
+ if (!s) return json(res, 404, {});
1387
+ s.hold = typeof body.hold === 'boolean' ? body.hold : null;
1388
+ broadcast();
1389
+ return json(res, 200, {});
1390
+ }
1391
+ if (url.pathname === '/api/session/remove') {
1392
+ for (const id of body.sessionIds || []) {
1393
+ const s = sessions.get(id);
1394
+ if (s && !hasPending(s) && !runners.has(id)) {
1395
+ sessions.delete(id);
1396
+ dismissed.add(id);
1397
+ if (settings.drive?.[id]) delete settings.drive[id];
1398
+ }
1399
+ }
1400
+ saveSettings();
1401
+ broadcast();
1402
+ return json(res, 200, {});
1403
+ }
1404
+ if (url.pathname === '/api/usage/refresh') {
1405
+ await fetchUsage({ force: true }); // you asked: try even while backing off
1406
+ return json(res, 200, usage || {});
1407
+ }
1408
+ if (url.pathname === '/api/session/open') {
1409
+ const s = sessions.get(body.sessionId);
1410
+ return json(res, s && openInVSCode(s.cwd) ? 200 : 400, {});
1411
+ }
1412
+ }
1413
+
1414
+ json(res, 404, { error: 'not found' });
1415
+ } catch (err) {
1416
+ if (!res.headersSent) json(res, 400, { error: err.message });
1417
+ }
1418
+ });
1419
+
1420
+ server.requestTimeout = 0; // hooks can legitimately wait a long time for you
1421
+ server.on('error', (err) => {
1422
+ if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1423
+ else console.error(err);
1424
+ process.exit(1);
1425
+ });
1426
+ server.listen(PORT, HOST, () => {
1427
+ const url = `http://localhost:${PORT}`;
1428
+ console.log(`Agenter dashboard: ${url}`);
1429
+ startDiscovery();
1430
+ startCodexDiscovery();
1431
+ fetchUsage();
1432
+ setInterval(fetchUsage, USAGE_INTERVAL_MS);
1433
+ if (!process.argv.includes('--no-open')) openDashboard(url);
1434
+ });