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/Agenter.vbs +1 -1
- package/README.md +15 -5
- package/cli.js +11 -2
- package/hook.js +7 -2
- package/install.js +1 -12
- package/lib/diff.js +126 -0
- package/lib/doctor.js +151 -0
- package/lib/hook-events.js +14 -0
- package/lib/limits.js +25 -0
- package/lib/open-dashboard.js +4 -1
- package/lib/token.js +47 -0
- package/lib/transcript.js +238 -0
- package/package.json +2 -1
- package/public/app.js +69 -11
- package/public/index.html +1 -11
- package/public/style.css +9 -0
- package/public/theme.js +9 -0
- package/scripts/install-shortcuts.ps1 +3 -2
- package/scripts/tray.ps1 +40 -0
- package/server.js +137 -269
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'
|
|
572
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
+
};
|