agenter-app 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -9,6 +9,10 @@ const os = require('node:os');
9
9
  const crypto = require('node:crypto');
10
10
  const { spawn, execFile } = require('node:child_process');
11
11
  const openDashboard = require('./lib/open-dashboard');
12
+ const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscript, lastAssistantText, transcriptInfo } = require('./lib/transcript');
13
+ const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
14
+ const { buildDiffs } = require('./lib/diff');
15
+ const { getToken, sameToken } = require('./lib/token');
12
16
 
13
17
  const PORT = Number(process.env.AGENTER_PORT) || 4317;
14
18
  const HOST = '127.0.0.1';
@@ -23,6 +27,8 @@ const MAX_ACTIVITY = 200;
23
27
  const DISCONNECT_GRACE_MS = 15000;
24
28
  // If you switch away from the dashboard (to VS Code, say) this long, same thing.
25
29
  const BLUR_GRACE_MS = 5000;
30
+ // While this exists hook.js won't restart the server, so an app you quit stays quit.
31
+ const STOPPED_FILE = path.join(SETTINGS_DIR, 'stopped');
26
32
  const DISCOVER_INTERVAL_MS = 30000;
27
33
  const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches changes in between
28
34
  // Plan usage (5-hour session / weekly limits) comes from the same endpoint Claude
@@ -49,224 +55,6 @@ const saveSettings = () => {
49
55
  fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
50
56
  };
51
57
 
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
58
  // ---------- sessions ----------
271
59
 
272
60
  const newSession = (id) => ({ id, agent: 'claude', cwd: '', project: '', title: '', transcriptPath: '', status: 'idle', hold: null, startedAt: Date.now(), lastEventAt: 0, lastAction: '', permissionMode: '', activity: [], queue: [] });
@@ -392,19 +180,6 @@ function startDiscovery() {
392
180
  // ---------- plan usage ----------
393
181
 
394
182
  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
183
  // The usage endpoint is rate-limited per account (Claude Code's /usage calls it too).
409
184
  // After a 429 we wait before asking again, longer each time, instead of keeping the
410
185
  // limit tripped by retrying every couple of minutes.
@@ -567,9 +342,12 @@ async function checkRollout(file) {
567
342
  s.codexTurnOpen = tail.turnOpen;
568
343
  s.status = codexStatus(s);
569
344
  const last = tail.last;
345
+ // While working, only a running tool is current; the last reply may be from the previous turn.
570
346
  s.lastAction =
571
- s.status === 'working' && last?.kind === 'tool'
572
- ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
347
+ s.status === 'working'
348
+ ? last?.kind === 'tool'
349
+ ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
350
+ : ''
573
351
  : (lastAssistantText(file) || '').split('\n')[0].slice(0, 160);
574
352
  return true;
575
353
  } catch {
@@ -666,14 +444,6 @@ function startCodexDiscovery() {
666
444
  // Codex records your plan limits with every turn (the same numbers its /status shows).
667
445
  let codexUsage = null; // { updatedAt, plan, limits, credits }
668
446
 
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
447
  function noteCodexLimits(rl, at) {
678
448
  if (codexUsage && at <= codexUsage.updatedAt) return;
679
449
  const limits = [];
@@ -779,7 +549,9 @@ async function findApps(cwd) {
779
549
  function addActivity(s, kind, text, extra = {}) {
780
550
  s.activity.push({ t: Date.now(), kind, text: truncate(text, 4000), ...extra });
781
551
  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);
552
+ // lastAction is what the agent is doing. A prompt is what you said, so it starts blank
553
+ // (the dashboard shows "Working…") until the agent reports something.
554
+ s.lastAction = kind === 'prompt' ? '' : (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
783
555
  }
784
556
 
785
557
  // Who drives a session: 'vscode' (Agenter only answers inside VS Code's turns) or
@@ -798,7 +570,8 @@ function hold(s, kind, data, payload, res) {
798
570
  const item = { id: crypto.randomUUID(), sessionId: s.id, kind, createdAt: Date.now(), headless: runners.has(s.id), ...data };
799
571
  const entry = { item, res, payload, timer: null };
800
572
  const minutes = Number(settings.holdMinutes) || 0;
801
- if (minutes > 0) {
573
+ // A turn Agenter runs has no VS Code to go back to, so it waits for your answer.
574
+ if (minutes > 0 && !item.headless) {
802
575
  item.releaseAt = item.createdAt + minutes * 60000;
803
576
  entry.timer = setTimeout(() => respond(item.id, 'pass', { auto: true }), minutes * 60000);
804
577
  }
@@ -815,6 +588,17 @@ function hold(s, kind, data, payload, res) {
815
588
  broadcast();
816
589
  }
817
590
 
591
+ // A turn Agenter ran is over, so anything it was waiting on can't be answered any more.
592
+ function releaseRun(s) {
593
+ for (const [id, e] of [...pending]) {
594
+ if (e.item.sessionId !== s.id || !e.item.headless) continue;
595
+ clearTimeout(e.timer);
596
+ pending.delete(id);
597
+ e.res.writeHead(200, { 'content-type': 'application/json' });
598
+ e.res.end('{}');
599
+ }
600
+ }
601
+
818
602
  // action: allow | always | deny | answer | reply | pass
819
603
  function respond(id, action, body = {}) {
820
604
  const entry = pending.get(id);
@@ -888,6 +672,30 @@ function onAttentionChanged() {
888
672
  }, clients.size ? BLUR_GRACE_MS : DISCONNECT_GRACE_MS);
889
673
  }
890
674
 
675
+ // Quit, from the tray. Open windows close themselves on the 'quit' event.
676
+ function stopServer() {
677
+ for (const c of clients.keys()) c.write('event: quit\ndata: {}\n\n');
678
+ for (const id of [...pending.keys()]) respond(id, 'pass');
679
+ for (const child of runners.values()) {
680
+ child.interrupted = true;
681
+ killRun(child);
682
+ }
683
+ fs.mkdirSync(SETTINGS_DIR, { recursive: true });
684
+ fs.writeFileSync(STOPPED_FILE, '');
685
+ // Long enough for the hooks to receive those answers.
686
+ setTimeout(() => process.exit(0), 300);
687
+ }
688
+
689
+ // The tray icon. It watches this process, so it goes away when the server does.
690
+ function startTray() {
691
+ if (process.platform !== 'win32' || process.env.AGENTER_TRAY === '0') return;
692
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-WindowStyle', 'Hidden', '-File', path.join(__dirname, 'scripts', 'tray.ps1'), '-ServerPid', String(process.pid), '-Node', process.execPath, '-Port', String(PORT)];
693
+ // Not detached: PowerShell exits at once without a console.
694
+ const child = spawn('powershell.exe', args, { stdio: 'ignore', windowsHide: true });
695
+ child.on('error', () => {});
696
+ child.unref();
697
+ }
698
+
891
699
  // ---------- steering ----------
892
700
  // Messages typed in the dashboard while Claude isn't waiting for a reply. Hooks only run
893
701
  // at events, so they wait here and go out with the next one: after the current tool
@@ -965,6 +773,7 @@ function startRun(s, text, { fresh = false } = {}) {
965
773
  child.on('error', (e) => (err = e.message));
966
774
  child.on('close', (code) => {
967
775
  runners.delete(s.id);
776
+ releaseRun(s);
968
777
  if (code && !child.interrupted) addActivity(s, 'notice', `Claude stopped with an error${err.trim() ? `: ${err.trim()}` : ''}`);
969
778
  if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
970
779
  settleStatus(s, 'idle');
@@ -978,6 +787,12 @@ function startRun(s, text, { fresh = false } = {}) {
978
787
  return true;
979
788
  }
980
789
 
790
+ // Ends a turn along with what it started: tools, and hooks still waiting on the dashboard.
791
+ function killRun(child) {
792
+ if (process.platform !== 'win32') return child.kill();
793
+ execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, (err) => err && child.kill());
794
+ }
795
+
981
796
  // ---------- steering Codex ----------
982
797
  // Driven from the Codex app (default): `codex queue` hands the message to Codex, which runs
983
798
  // it as the next turn of that session in the VS Code extension or the desktop app.
@@ -1016,22 +831,37 @@ function findCodex() {
1016
831
  return null;
1017
832
  }
1018
833
 
834
+ // Windows only runs a .cmd through a shell, which would treat your message as commands.
835
+ // npm's codex.cmd just starts a script with Node, so start that script directly.
836
+ function codexCommand(bin) {
837
+ if (!bin.endsWith('.cmd')) return [bin];
838
+ try {
839
+ const script = fs.readFileSync(bin, 'utf8').match(/"%~?dp0%?[\\/]([^"]+\.js)"/i)?.[1];
840
+ if (script) return [process.execPath, path.join(path.dirname(bin), script)];
841
+ } catch {}
842
+ return null;
843
+ }
844
+
1019
845
  async function steerCodex(s, text) {
846
+ // A turn Agenter is running keeps the session here, even if the switch moved since it started.
847
+ if (runners.has(s.id)) {
848
+ // `exec` can't take messages mid-turn; this runs as the next turn.
849
+ s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
850
+ broadcast();
851
+ return 'queued';
852
+ }
1020
853
  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
854
  if (!(await startCodexRun(s, text))) return 'no-codex';
1028
855
  addActivity(s, 'prompt', text, { fromDashboard: true });
1029
856
  return 'started';
1030
857
  }
1031
858
  const bin = findCodex();
1032
859
  if (!bin) return 'no-codex';
860
+ const command = codexCommand(bin);
861
+ if (!command) return 'error';
862
+ const [file, ...prefix] = command;
1033
863
  return new Promise((resolve) => {
1034
- execFile(bin, ['queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000, shell: bin.endsWith('.cmd') }, (err) => {
864
+ execFile(file, [...prefix, 'queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000 }, (err) => {
1035
865
  if (err) return resolve('error');
1036
866
  addActivity(s, 'prompt', text, { fromDashboard: true });
1037
867
  resolve('codex-queued');
@@ -1085,6 +915,7 @@ function startCodexRun(s, text, { fresh = false } = {}) {
1085
915
  // Never got going (Codex missing a login, bad folder…): report why to whoever asked.
1086
916
  if (runners.get(s.id) !== child) return resolve(fresh ? { error: failure || err.trim().split('\n').pop() || `Codex exited (${code}).` } : false);
1087
917
  runners.delete(s.id);
918
+ releaseRun(s);
1088
919
  s.codexTurnOpen = false;
1089
920
  if ((code || failure) && !child.interrupted) addActivity(s, 'notice', `Codex stopped with an error${failure || err.trim() ? `: ${failure || err.trim().split('\n').pop()}` : ''}`);
1090
921
  if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
@@ -1146,7 +977,8 @@ function handleHook(p, res) {
1146
977
  case 'PermissionRequest':
1147
978
  if (watching && p.tool_name !== 'AskUserQuestion') {
1148
979
  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);
980
+ const diffs = buildDiffs(p.tool_name, p.tool_input, { cwd: p.cwd || s.cwd });
981
+ return hold(s, 'permission', { tool: p.tool_name, input: p.tool_input, canAlways, ...(diffs && { diffs }) }, p, res);
1150
982
  }
1151
983
  s.status = 'vscode';
1152
984
  break;
@@ -1229,11 +1061,29 @@ const json = (res, code, obj) => {
1229
1061
  const STATIC = {
1230
1062
  '/': ['index.html', 'text/html; charset=utf-8'],
1231
1063
  '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
1064
+ '/theme.js': ['theme.js', 'text/javascript; charset=utf-8'],
1232
1065
  '/style.css': ['style.css', 'text/css; charset=utf-8'],
1233
1066
  '/icon.png': ['icon.png', 'image/png'],
1234
1067
  '/favicon.ico': ['agenter.ico', 'image/x-icon'],
1235
1068
  };
1236
1069
 
1070
+ // frame-src stays open for http(s): Preview shows whatever app address you type.
1071
+ const CSP = [
1072
+ "default-src 'none'",
1073
+ "script-src 'self'",
1074
+ "style-src-elem 'self' https://fonts.googleapis.com",
1075
+ "style-src-attr 'unsafe-inline'",
1076
+ 'font-src https://fonts.gstatic.com',
1077
+ "img-src 'self' data:",
1078
+ "connect-src 'self'",
1079
+ 'frame-src http: https:',
1080
+ "base-uri 'none'",
1081
+ "form-action 'none'",
1082
+ "frame-ancestors 'none'",
1083
+ ].join('; ');
1084
+
1085
+ const TOKEN = require.main === module ? getToken() : '';
1086
+
1237
1087
  const ALLOWED_HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
1238
1088
  const ALLOWED_ORIGINS = new Set([...ALLOWED_HOSTS].map((h) => `http://${h}`));
1239
1089
 
@@ -1243,6 +1093,10 @@ const server = http.createServer(async (req, res) => {
1243
1093
  return json(res, 403, { error: 'forbidden' });
1244
1094
  }
1245
1095
  const url = new URL(req.url, `http://${req.headers.host}`);
1096
+ // The page itself holds no secrets. Everything else needs the token; a custom header also
1097
+ // forces a CORS preflight, which we never approve. EventSource can't set headers.
1098
+ const given = req.headers['x-agenter-token'] ?? (url.pathname === '/events' ? url.searchParams.get('token') : null);
1099
+ if (!(req.method === 'GET' && STATIC[url.pathname]) && !sameToken(given, TOKEN)) return json(res, 401, { error: 'unauthorized' });
1246
1100
 
1247
1101
  try {
1248
1102
  if (req.method === 'POST' && url.pathname === '/hook') {
@@ -1251,7 +1105,7 @@ const server = http.createServer(async (req, res) => {
1251
1105
 
1252
1106
  if (req.method === 'GET' && STATIC[url.pathname]) {
1253
1107
  const [file, type] = STATIC[url.pathname];
1254
- res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
1108
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store', 'content-security-policy': CSP });
1255
1109
  return res.end(fs.readFileSync(path.join(PUBLIC_DIR, file)));
1256
1110
  }
1257
1111
 
@@ -1288,10 +1142,12 @@ const server = http.createServer(async (req, res) => {
1288
1142
  }
1289
1143
 
1290
1144
  if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
1291
- // A custom header forces a CORS preflight, which we never approve.
1292
- if (req.headers['x-agenter'] !== '1') return json(res, 403, { error: 'forbidden' });
1293
1145
  const body = await readBody(req);
1294
1146
 
1147
+ if (url.pathname === '/api/quit') {
1148
+ stopServer();
1149
+ return json(res, 200, {});
1150
+ }
1295
1151
  if (url.pathname === '/api/respond') {
1296
1152
  const s = sessions.get(pending.get(body.id)?.item.sessionId);
1297
1153
  const ok = respond(body.id, body.action, body);
@@ -1373,7 +1229,7 @@ const server = http.createServer(async (req, res) => {
1373
1229
  const child = runners.get(body.sessionId);
1374
1230
  if (child) {
1375
1231
  child.interrupted = true;
1376
- child.kill();
1232
+ killRun(child);
1377
1233
  }
1378
1234
  return json(res, child ? 200 : 404, {});
1379
1235
  }
@@ -1420,17 +1276,29 @@ const server = http.createServer(async (req, res) => {
1420
1276
  });
1421
1277
 
1422
1278
  server.requestTimeout = 0; // hooks can legitimately wait a long time for you
1423
- server.on('error', (err) => {
1424
- if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1425
- else console.error(err);
1426
- process.exit(1);
1427
- });
1428
- server.listen(PORT, HOST, () => {
1429
- const url = `http://localhost:${PORT}`;
1430
- console.log(`Agenter dashboard: ${url}`);
1431
- startDiscovery();
1432
- startCodexDiscovery();
1433
- fetchUsage();
1434
- setInterval(fetchUsage, USAGE_INTERVAL_MS);
1435
- if (!process.argv.includes('--no-open')) openDashboard(url);
1436
- });
1279
+ // Only listen when run directly; the tests load this file to drive the hook and steering logic.
1280
+ if (require.main === module) {
1281
+ server.on('error', (err) => {
1282
+ if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1283
+ else console.error(err);
1284
+ process.exit(1);
1285
+ });
1286
+ server.listen(PORT, HOST, () => {
1287
+ const url = `http://localhost:${PORT}`;
1288
+ console.log(`Agenter dashboard: ${url}`);
1289
+ fs.rmSync(STOPPED_FILE, { force: true });
1290
+ startTray();
1291
+ startDiscovery();
1292
+ startCodexDiscovery();
1293
+ fetchUsage();
1294
+ setInterval(fetchUsage, USAGE_INTERVAL_MS);
1295
+ if (!process.argv.includes('--no-open')) openDashboard(url);
1296
+ });
1297
+ }
1298
+
1299
+ module.exports = {
1300
+ sessions, pending, clients, runners, dismissed,
1301
+ get settings() { return settings; },
1302
+ handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
1303
+ findClaude, findCodex, codexCommand, releaseRun,
1304
+ };