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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Reading Claude Code transcripts and Codex rollouts: pure parsing plus the incremental file reader.
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const truncate = (s, n) => {
|
|
7
|
+
s = String(s ?? '');
|
|
8
|
+
return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function describeTool(name, input = {}) {
|
|
12
|
+
if (!input || typeof input !== 'object') return '';
|
|
13
|
+
if (input.command) return input.command;
|
|
14
|
+
if (name === 'AskUserQuestion' && Array.isArray(input.questions)) return input.questions.map((q) => q.question).join(' / ');
|
|
15
|
+
if (input.file_path) return input.file_path;
|
|
16
|
+
if (input.notebook_path) return input.notebook_path;
|
|
17
|
+
if (input.pattern) return input.pattern + (input.path ? ` in ${input.path}` : '');
|
|
18
|
+
if (input.url) return input.url;
|
|
19
|
+
if (input.query) return input.query;
|
|
20
|
+
if (input.description) return input.description;
|
|
21
|
+
if (input.prompt) return input.prompt;
|
|
22
|
+
return JSON.stringify(input);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readTail(file, maxBytes) {
|
|
26
|
+
const fd = fs.openSync(file, 'r');
|
|
27
|
+
try {
|
|
28
|
+
const size = fs.fstatSync(fd).size;
|
|
29
|
+
const start = Math.max(0, size - maxBytes);
|
|
30
|
+
const buf = Buffer.alloc(size - start);
|
|
31
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
32
|
+
let text = buf.toString('utf8');
|
|
33
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
|
|
34
|
+
return text;
|
|
35
|
+
} finally {
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const resultText = (content) =>
|
|
41
|
+
typeof content === 'string'
|
|
42
|
+
? content
|
|
43
|
+
: Array.isArray(content)
|
|
44
|
+
? content.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`)).join('\n')
|
|
45
|
+
: '';
|
|
46
|
+
|
|
47
|
+
const isNoise = (text) => /^\s*<(system-reminder|ide_|command-|local-command)/.test(text);
|
|
48
|
+
|
|
49
|
+
// Appends the messages in these transcript lines to out (metadata and noise skipped).
|
|
50
|
+
// Reads both Claude Code transcripts and Codex rollouts.
|
|
51
|
+
function parseLines(text, out) {
|
|
52
|
+
for (const line of text.split('\n')) {
|
|
53
|
+
if (!line.trim()) continue;
|
|
54
|
+
let e;
|
|
55
|
+
try {
|
|
56
|
+
e = JSON.parse(line);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (e.type === 'response_item') {
|
|
61
|
+
parseCodexItem(e, out);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if ((e.type !== 'user' && e.type !== 'assistant') || e.isMeta || !e.message) continue;
|
|
65
|
+
const ts = e.timestamp;
|
|
66
|
+
const content = e.message.content;
|
|
67
|
+
if (typeof content === 'string') {
|
|
68
|
+
if (!isNoise(content)) out.push({ role: e.type, kind: 'text', text: content, ts });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(content)) continue;
|
|
72
|
+
for (const b of content) {
|
|
73
|
+
if (b.type === 'text' && b.text?.trim() && !isNoise(b.text)) out.push({ role: e.type, kind: 'text', text: b.text, ts });
|
|
74
|
+
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 });
|
|
75
|
+
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 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Codex's exec tool takes a small script; the shell command inside it is what's worth showing.
|
|
82
|
+
function describeCodexCall(name, input) {
|
|
83
|
+
const raw = String(input ?? '');
|
|
84
|
+
if (name === 'wait') return 'Waiting for the running command';
|
|
85
|
+
const m = raw.match(/\bcmd\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/);
|
|
86
|
+
if (m) return m[2].replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c);
|
|
87
|
+
// Other built-in tools called from the script: name them, plus the search query if any.
|
|
88
|
+
const call = raw.match(/\btools\.(\w+)\(/)?.[1];
|
|
89
|
+
if (call === 'write_stdin') return 'Sending input to the running command';
|
|
90
|
+
if (call) {
|
|
91
|
+
const q = raw.match(/\bq\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/)?.[2];
|
|
92
|
+
return call.replace(/__/g, ' ') + (q ? `: ${q}` : '');
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const args = JSON.parse(raw);
|
|
96
|
+
if (args && typeof args === 'object') return describeTool(name, args);
|
|
97
|
+
} catch {}
|
|
98
|
+
return raw.split('\n')[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const codexOutput = (o) => (typeof o === 'string' ? o : Array.isArray(o) ? o.map((c) => c.text ?? `[${c.type}]`).join('\n') : '');
|
|
102
|
+
|
|
103
|
+
function parseCodexItem(e, out) {
|
|
104
|
+
const p = e.payload || {};
|
|
105
|
+
const ts = e.timestamp;
|
|
106
|
+
if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
|
|
107
|
+
// Only what you typed: Codex also sends context (environment, open pages) as user messages.
|
|
108
|
+
const kinds = p.internal_chat_message_metadata_passthrough?.content_item_kinds;
|
|
109
|
+
(p.content || []).forEach((c, i) => {
|
|
110
|
+
const text = c.text?.trim();
|
|
111
|
+
if (!text) return;
|
|
112
|
+
if (p.role === 'user' && (kinds ? kinds[i] !== 'user.text' : text.startsWith('<'))) return;
|
|
113
|
+
// The VS Code extension wraps what you typed in IDE context (open tabs etc.).
|
|
114
|
+
const typed = p.role === 'user' ? c.text.match(/^#+ My request:\s*\n([\s\S]*)$/m)?.[1] : null;
|
|
115
|
+
out.push({ role: p.role, kind: 'text', text: typed?.trim() || c.text, ts });
|
|
116
|
+
});
|
|
117
|
+
} else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
|
|
118
|
+
out.push({ role: 'assistant', kind: 'tool', id: p.call_id, name: p.name, text: describeCodexCall(p.name, p.arguments ?? p.input), ts });
|
|
119
|
+
} else if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
|
|
120
|
+
out.push({ role: 'tool', kind: 'result', toolUseId: p.call_id, text: codexOutput(p.output), isError: false, ts });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const clipMessage = (m) => ({ ...m, text: truncate(m.text, m.kind === 'result' ? 4000 : 20000) });
|
|
125
|
+
|
|
126
|
+
function parseTranscript(file, limit = 120) {
|
|
127
|
+
if (!file || !fs.existsSync(file)) return [];
|
|
128
|
+
return parseLines(readTail(file, TRANSCRIPT_TAIL), []).slice(-limit).map(clipMessage);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The conversation open on the dashboard is read incrementally: each request parses
|
|
132
|
+
// only the bytes appended since the last one. Messages get increasing seq numbers so
|
|
133
|
+
// the dashboard can ask for just the new ones; epoch changes if the file is replaced.
|
|
134
|
+
const TRANSCRIPT_TAIL = 2 * 1024 * 1024;
|
|
135
|
+
const TRANSCRIPT_KEEP = 400;
|
|
136
|
+
const TRANSCRIPT_FILES = 8;
|
|
137
|
+
const transcriptCache = new Map(); // file -> { epoch, offset, rest, dropFirstLine, seq, messages }
|
|
138
|
+
|
|
139
|
+
function readTranscript(file) {
|
|
140
|
+
let size;
|
|
141
|
+
try {
|
|
142
|
+
size = fs.statSync(file).size;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let c = transcriptCache.get(file);
|
|
147
|
+
if (!c || size < c.offset) {
|
|
148
|
+
const offset = Math.max(0, size - TRANSCRIPT_TAIL);
|
|
149
|
+
c = { epoch: crypto.randomUUID(), offset, rest: Buffer.alloc(0), dropFirstLine: offset > 0, seq: 0, messages: [] };
|
|
150
|
+
}
|
|
151
|
+
transcriptCache.delete(file); // most recently used goes last
|
|
152
|
+
transcriptCache.set(file, c);
|
|
153
|
+
if (transcriptCache.size > TRANSCRIPT_FILES) transcriptCache.delete(transcriptCache.keys().next().value);
|
|
154
|
+
if (size === c.offset) return c;
|
|
155
|
+
|
|
156
|
+
const fd = fs.openSync(file, 'r');
|
|
157
|
+
let buf;
|
|
158
|
+
try {
|
|
159
|
+
buf = Buffer.alloc(size - c.offset);
|
|
160
|
+
fs.readSync(fd, buf, 0, buf.length, c.offset);
|
|
161
|
+
} finally {
|
|
162
|
+
fs.closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
c.offset = size;
|
|
165
|
+
buf = Buffer.concat([c.rest, buf]);
|
|
166
|
+
let start = 0;
|
|
167
|
+
if (c.dropFirstLine) {
|
|
168
|
+
start = buf.indexOf(10) + 1; // we started mid-file: skip the partial first line
|
|
169
|
+
if (start) c.dropFirstLine = false;
|
|
170
|
+
}
|
|
171
|
+
// Only complete lines. Splitting on the newline byte never cuts a UTF-8 character.
|
|
172
|
+
const end = Math.max(start, buf.lastIndexOf(10) + 1);
|
|
173
|
+
c.rest = Buffer.from(buf.subarray(end));
|
|
174
|
+
for (const m of parseLines(buf.toString('utf8', start, end), [])) c.messages.push({ ...clipMessage(m), seq: ++c.seq });
|
|
175
|
+
if (c.messages.length > TRANSCRIPT_KEEP) c.messages.splice(0, c.messages.length - TRANSCRIPT_KEEP);
|
|
176
|
+
return c;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function lastAssistantText(file) {
|
|
180
|
+
const msgs = parseTranscript(file, 400);
|
|
181
|
+
for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant' && msgs[i].kind === 'text') return msgs[i].text;
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readHead(file, maxBytes) {
|
|
186
|
+
const fd = fs.openSync(file, 'r');
|
|
187
|
+
try {
|
|
188
|
+
const buf = Buffer.alloc(Math.min(maxBytes, fs.fstatSync(fd).size));
|
|
189
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
190
|
+
return buf.toString('utf8');
|
|
191
|
+
} finally {
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const firstText = (content) =>
|
|
197
|
+
typeof content === 'string' ? content : Array.isArray(content) ? content.find((b) => b.type === 'text' && !isNoise(b.text || ''))?.text || '' : '';
|
|
198
|
+
|
|
199
|
+
// cwd and a title (Claude's summary, else the first prompt) from the start of a transcript.
|
|
200
|
+
function transcriptInfo(file) {
|
|
201
|
+
let cwd = '';
|
|
202
|
+
let prompt = '';
|
|
203
|
+
let summary = '';
|
|
204
|
+
for (const line of readHead(file, 256 * 1024).split('\n')) {
|
|
205
|
+
let e;
|
|
206
|
+
try {
|
|
207
|
+
e = JSON.parse(line);
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!cwd && e.cwd) cwd = e.cwd;
|
|
212
|
+
if (e.type === 'summary' && e.summary && !summary) summary = e.summary;
|
|
213
|
+
if (!prompt && e.type === 'user' && !e.isMeta) {
|
|
214
|
+
const t = firstText(e.message?.content);
|
|
215
|
+
if (t && !isNoise(t)) prompt = t;
|
|
216
|
+
}
|
|
217
|
+
if (cwd && prompt) break;
|
|
218
|
+
}
|
|
219
|
+
return { cwd, title: (summary || prompt).split('\n')[0].slice(0, 140) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
truncate,
|
|
224
|
+
describeTool,
|
|
225
|
+
describeCodexCall,
|
|
226
|
+
readTail,
|
|
227
|
+
readHead,
|
|
228
|
+
parseLines,
|
|
229
|
+
parseCodexItem,
|
|
230
|
+
parseTranscript,
|
|
231
|
+
readTranscript,
|
|
232
|
+
lastAssistantText,
|
|
233
|
+
transcriptInfo,
|
|
234
|
+
isNoise,
|
|
235
|
+
TRANSCRIPT_TAIL,
|
|
236
|
+
TRANSCRIPT_KEEP,
|
|
237
|
+
TRANSCRIPT_FILES,
|
|
238
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agenter-app",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "One local dashboard to watch, approve and steer every Claude Code and Codex session on your machine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts": {
|
|
39
39
|
"start": "node server.js",
|
|
40
40
|
"open": "node open.js",
|
|
41
|
+
"test": "node --test",
|
|
41
42
|
"shortcuts": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-shortcuts.ps1",
|
|
42
43
|
"shortcuts:startup": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-shortcuts.ps1 -Startup",
|
|
43
44
|
"install-hooks": "node install.js",
|
package/public/app.js
CHANGED
|
@@ -44,7 +44,25 @@ function ago(t) {
|
|
|
44
44
|
return `${Math.floor(s / 86400)}d`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// The launchers open the dashboard with #token=…; it's kept here, out of the address bar.
|
|
48
|
+
const TOKEN = (() => {
|
|
49
|
+
const key = 'agenter.token';
|
|
50
|
+
const m = location.hash.match(/^#token=([0-9a-f]+)$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(key, m[1]);
|
|
55
|
+
} catch {}
|
|
56
|
+
return m[1];
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return localStorage.getItem(key) || '';
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const authHeaders = { 'x-agenter-token': TOKEN };
|
|
65
|
+
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...authHeaders }, body: JSON.stringify(body) });
|
|
48
66
|
|
|
49
67
|
const STATUS_LABEL = { working: 'Working', waiting: 'Needs you', vscode: 'Waiting in VS Code', idle: 'Idle', dormant: 'Not connected', ended: 'Ended' };
|
|
50
68
|
const isLive = (s) => s.status !== 'dormant' && s.status !== 'ended';
|
|
@@ -190,10 +208,18 @@ window.addEventListener('blur', () => setTimeout(reportFocus));
|
|
|
190
208
|
document.addEventListener('visibilitychange', () => reportFocus());
|
|
191
209
|
|
|
192
210
|
function connect() {
|
|
193
|
-
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}`);
|
|
211
|
+
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}&token=${encodeURIComponent(TOKEN)}`);
|
|
194
212
|
es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
|
|
213
|
+
es.addEventListener('quit', () => window.close());
|
|
195
214
|
es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
|
|
196
|
-
es.onerror = () =>
|
|
215
|
+
es.onerror = async () => {
|
|
216
|
+
setConn(false);
|
|
217
|
+
// EventSource doesn't say why it failed, and it gives up for good on a refused request.
|
|
218
|
+
const refused = await post('/api/focus', {}).then((r) => r.status === 401, () => false);
|
|
219
|
+
if (!refused) return;
|
|
220
|
+
es.close();
|
|
221
|
+
if (!$('#authLost')) document.body.insertAdjacentHTML('afterbegin', '<div class="auth-lost" id="authLost" role="alert">This window isn\'t signed in to Agenter. Open it from the Agenter shortcut, or run <code>agenter</code>.</div>');
|
|
222
|
+
};
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
function setConn(ok) {
|
|
@@ -632,7 +658,7 @@ async function scanApps() {
|
|
|
632
658
|
loadFrame();
|
|
633
659
|
let r = null;
|
|
634
660
|
try {
|
|
635
|
-
r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}
|
|
661
|
+
r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}`, { headers: authHeaders }).then((res) => res.json());
|
|
636
662
|
} catch {}
|
|
637
663
|
if (cwd !== preview.cwd) return; // switched project meanwhile; that one has its own scan
|
|
638
664
|
preview.scanning = false;
|
|
@@ -748,13 +774,23 @@ const sortPending = (list) => [...list].sort((a, b) => a.createdAt - b.createdAt
|
|
|
748
774
|
|
|
749
775
|
// ---------- session view ----------
|
|
750
776
|
|
|
777
|
+
// Messages you sent that the agent's transcript hasn't recorded yet, by session.
|
|
778
|
+
const sentEchoes = new Map();
|
|
779
|
+
|
|
780
|
+
function clearEchoes(sid, messages) {
|
|
781
|
+
const left = (sentEchoes.get(sid) || []).filter((text) => !messages.some((m) => m.role === 'user' && m.text.trim() === text));
|
|
782
|
+
if (left.length) sentEchoes.set(sid, left);
|
|
783
|
+
else sentEchoes.delete(sid);
|
|
784
|
+
}
|
|
785
|
+
|
|
751
786
|
function renderLiveRow() {
|
|
752
787
|
const s = sessionById(state.selected);
|
|
753
788
|
const el = $('#liveRow');
|
|
754
789
|
if (!s || !el) return;
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
790
|
+
if (!s.running) sentEchoes.delete(s.id); // The turn is over, so the transcript has the last word.
|
|
791
|
+
let html = (sentEchoes.get(s.id) || []).map((text) => `<div class="live-echo"><div class="msg-user">${esc(text)}</div></div>`).join('');
|
|
792
|
+
if (s.status === 'working') html += `<div class="live-row"><span class="dots"><i></i><i></i><i></i></span><span class="targ">${esc(s.lastAction || 'Working…')}</span></div>`;
|
|
793
|
+
else if (s.status === 'vscode') html += `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
|
|
758
794
|
if (el.innerHTML !== html) {
|
|
759
795
|
const stick = nearBottom();
|
|
760
796
|
el.innerHTML = html;
|
|
@@ -793,7 +829,7 @@ function renderSteer(s) {
|
|
|
793
829
|
.join('');
|
|
794
830
|
let when;
|
|
795
831
|
if (isCodex(s)) {
|
|
796
|
-
if (agenter && busy) when = 'Codex gets this as its next turn, when the current one ends.';
|
|
832
|
+
if (s.running || (agenter && busy)) when = 'Codex gets this as its next turn, when the current one ends.';
|
|
797
833
|
else if (agenter) when = 'Runs here in Agenter, sandboxed to the project folder (no network).';
|
|
798
834
|
else when = `Queued in Codex: it runs as the next turn when this session is open in Codex (VS Code or the desktop app). Or <button class="link" type="button" data-drive="agenter">drive from Agenter</button>.`;
|
|
799
835
|
} else if (busy) when = 'Claude gets this after its current step.';
|
|
@@ -827,6 +863,11 @@ async function steer(card) {
|
|
|
827
863
|
return toast("Couldn't send that");
|
|
828
864
|
}
|
|
829
865
|
const { result } = await r.json();
|
|
866
|
+
// A turn Agenter just started: show your message now, ahead of the transcript.
|
|
867
|
+
if (result === 'started') {
|
|
868
|
+
sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), message]);
|
|
869
|
+
renderLiveRow();
|
|
870
|
+
}
|
|
830
871
|
const FAILED = {
|
|
831
872
|
'no-claude': "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe",
|
|
832
873
|
'no-codex': "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe",
|
|
@@ -994,8 +1035,23 @@ function keyAct(action) {
|
|
|
994
1035
|
act(card, action);
|
|
995
1036
|
}
|
|
996
1037
|
|
|
997
|
-
|
|
1038
|
+
// One file's changes, as built by the server (lib/diff.js): unchanged lines around each change.
|
|
1039
|
+
function renderDiff(d) {
|
|
1040
|
+
const sign = { ctx: ' ', del: '−', add: '+' };
|
|
1041
|
+
const rows = d.rows
|
|
1042
|
+
.map((r) => {
|
|
1043
|
+
if (r.type === 'skip') return `<div class="skip">⋯ ${r.count} unchanged line${r.count === 1 ? '' : 's'}</div>`;
|
|
1044
|
+
if (r.type === 'more') return `<div class="skip">⋯ ${r.count} more lines not shown</div>`;
|
|
1045
|
+
return `<div class="${r.type}">${sign[r.type]} ${esc(r.text)}</div>`;
|
|
1046
|
+
})
|
|
1047
|
+
.join('');
|
|
1048
|
+
const stats = `<span class="add-n">+${d.added}</span> <span class="del-n">−${d.removed}</span>`;
|
|
1049
|
+
return `<div class="diff-head"><span class="path mono">${esc(d.file)}</span>${d.isNew ? '<span class="pill plain">New file</span>' : ''}<span class="diff-stats">${stats}</span></div><div class="diff mono">${rows || '<div class="skip">No changes</div>'}</div>`;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function renderToolBody(tool, input = {}, diffs = null) {
|
|
998
1053
|
input = input || {};
|
|
1054
|
+
if (diffs?.length) return diffs.map(renderDiff).join('');
|
|
999
1055
|
if (['Bash', 'PowerShell'].includes(tool) || typeof input.command === 'string') {
|
|
1000
1056
|
return `${input.description ? `<div class="desc">${esc(input.description)}</div>` : ''}<pre class="cmd shell">${esc(input.command)}</pre>`;
|
|
1001
1057
|
}
|
|
@@ -1029,7 +1085,7 @@ function renderCard(p, showSession) {
|
|
|
1029
1085
|
const risky = isDestructive(p);
|
|
1030
1086
|
return `<div class="card kind-permission ${risky ? 'risky' : ''}" data-id="${p.id}">${sessionBar}<div class="card-body">
|
|
1031
1087
|
${head(toolIcon(p.tool), `Allow <span class="mono">${esc(toolName(p.tool))}</span>?`, 'Claude is asking for permission')}
|
|
1032
|
-
<div class="tool-body">${renderToolBody(p.tool, p.input)}</div>
|
|
1088
|
+
<div class="tool-body">${renderToolBody(p.tool, p.input, p.diffs)}</div>
|
|
1033
1089
|
${risky ? `<div class="risk-note">${icon('shield')}<span>This looks destructive, so the <kbd>A</kbd> shortcut is off. Read it, then click Allow if it's fine.</span></div>` : ''}
|
|
1034
1090
|
<div class="actions">
|
|
1035
1091
|
<button class="btn primary" data-act="allow">${icon('check')}Allow${risky ? '' : ' <kbd>A</kbd>'}</button>
|
|
@@ -1119,7 +1175,7 @@ function loadTranscript(sid) {
|
|
|
1119
1175
|
const have = state.transcript.sessionId === sid && state.transcript.messages ? state.transcript : null;
|
|
1120
1176
|
const lastSeq = () => have?.messages.at(-1)?.seq ?? 0;
|
|
1121
1177
|
const since = have ? `&epoch=${encodeURIComponent(have.epoch)}&after=${lastSeq()}` : '';
|
|
1122
|
-
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}
|
|
1178
|
+
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`, { headers: authHeaders });
|
|
1123
1179
|
if (!r.ok) return;
|
|
1124
1180
|
const { epoch, reset, messages } = await r.json();
|
|
1125
1181
|
if (sid !== state.selected) return;
|
|
@@ -1127,7 +1183,9 @@ function loadTranscript(sid) {
|
|
|
1127
1183
|
// A request that overlapped another may repeat messages; seq tells them apart.
|
|
1128
1184
|
const merged = reset || !have ? messages : [...have.messages, ...messages.filter((m) => m.seq > lastSeq())].slice(-KEEP_MESSAGES);
|
|
1129
1185
|
state.transcript = { sessionId: sid, epoch, messages: merged };
|
|
1186
|
+
clearEchoes(sid, messages);
|
|
1130
1187
|
renderConvo(same);
|
|
1188
|
+
renderLiveRow();
|
|
1131
1189
|
} catch {}
|
|
1132
1190
|
}, 150);
|
|
1133
1191
|
}
|
package/public/index.html
CHANGED
|
@@ -11,17 +11,7 @@
|
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
12
12
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap">
|
|
13
13
|
<link rel="stylesheet" href="/style.css">
|
|
14
|
-
<script>
|
|
15
|
-
// Applied before first paint so there's no flash of the wrong theme.
|
|
16
|
-
(() => {
|
|
17
|
-
const read = (k) => { try { return JSON.parse(localStorage.getItem('agenter.' + k)); } catch { return null; } };
|
|
18
|
-
const theme = read('theme') || 'system';
|
|
19
|
-
const dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
20
|
-
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
21
|
-
const accent = read('accent');
|
|
22
|
-
if (accent && accent !== 'sage') document.documentElement.dataset.accent = accent;
|
|
23
|
-
})();
|
|
24
|
-
</script>
|
|
14
|
+
<script src="/theme.js"></script>
|
|
25
15
|
</head>
|
|
26
16
|
<body>
|
|
27
17
|
<div class="app" id="app">
|
package/public/style.css
CHANGED
|
@@ -363,6 +363,7 @@ dialog.new-dialog { max-width: 520px; }
|
|
|
363
363
|
.convo { display: flex; flex-direction: column; gap: 10px; }
|
|
364
364
|
.msg-user { align-self: flex-end; max-width: 78%; background: var(--accent-soft); border: 1px solid transparent; padding: 9px 14px; border-radius: 14px 14px 4px 14px; white-space: pre-wrap; word-break: break-word; }
|
|
365
365
|
.msg-user.from-dash { border-color: var(--accent-ink); }
|
|
366
|
+
.live-echo { display: flex; justify-content: flex-end; margin-top: 12px; }
|
|
366
367
|
.msg-claude { display: grid; grid-template-columns: 26px 1fr; gap: 10px; align-items: start; }
|
|
367
368
|
.claude-mark { width: 26px; height: 26px; border-radius: 8px; background: var(--panel); border: 1px solid var(--border); display: grid; place-items: center; color: var(--accent-ink); }
|
|
368
369
|
.claude-mark .ic { width: 14px; height: 14px; }
|
|
@@ -457,6 +458,12 @@ dialog.new-dialog { max-width: 520px; }
|
|
|
457
458
|
.diff div { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
|
|
458
459
|
.diff .del { background: var(--del-bg); }
|
|
459
460
|
.diff .add { background: var(--add-bg); }
|
|
461
|
+
.diff .skip { color: var(--muted); font-size: 11.5px; padding-top: 2px; padding-bottom: 2px; background: var(--panel-2); user-select: none; }
|
|
462
|
+
.diff-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
463
|
+
.diff-head .path { margin-bottom: 0; min-width: 0; }
|
|
464
|
+
.diff-stats { margin-left: auto; flex: none; font-size: 12px; font-weight: 600; }
|
|
465
|
+
.diff-stats .add-n { color: var(--ok, #2e7d32); }
|
|
466
|
+
.diff-stats .del-n { color: var(--danger); }
|
|
460
467
|
|
|
461
468
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
462
469
|
.actions .grow { flex: 1; min-width: 180px; }
|
|
@@ -549,3 +556,5 @@ dialog form { text-align: right; }
|
|
|
549
556
|
.splitter { display: none; }
|
|
550
557
|
.preview { order: -1; width: auto; max-width: none; min-width: 0; height: 48vh; border-bottom: 1px solid var(--border); }
|
|
551
558
|
}
|
|
559
|
+
|
|
560
|
+
.auth-lost { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 1000; max-width: calc(100vw - 32px); padding: 10px 14px; border-radius: 10px; background: #5c2a26; color: #fff; font-size: 13px; }
|
package/public/theme.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Applied before first paint so there's no flash of the wrong theme.
|
|
2
|
+
(() => {
|
|
3
|
+
const read = (k) => { try { return JSON.parse(localStorage.getItem('agenter.' + k)); } catch { return null; } };
|
|
4
|
+
const theme = read('theme') || 'system';
|
|
5
|
+
const dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
6
|
+
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
7
|
+
const accent = read('accent');
|
|
8
|
+
if (accent && accent !== 'sage') document.documentElement.dataset.accent = accent;
|
|
9
|
+
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Creates "Agenter" shortcuts on the Desktop and in the Start Menu (pin it to the taskbar from there).
|
|
2
|
-
# -Startup also starts the
|
|
2
|
+
# -Startup also starts Agenter in the system tray when you log in to Windows.
|
|
3
3
|
param([switch]$Startup, [switch]$Remove)
|
|
4
4
|
|
|
5
5
|
$root = Split-Path -Parent $PSScriptRoot
|
|
@@ -31,6 +31,7 @@ if ($Startup) {
|
|
|
31
31
|
$lnk.TargetPath = "$env:SystemRoot\System32\wscript.exe"
|
|
32
32
|
$lnk.Arguments = "`"$root\Agenter.vbs`" server"
|
|
33
33
|
$lnk.WorkingDirectory = $root
|
|
34
|
+
$lnk.IconLocation = "$root\public\agenter.ico,0"
|
|
34
35
|
$lnk.Save()
|
|
35
|
-
"Created $startupLink (
|
|
36
|
+
"Created $startupLink (Agenter starts in the tray at login)"
|
|
36
37
|
}
|
package/scripts/tray.ps1
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# The Agenter icon in the Windows system tray. server.js starts this, and it lives exactly as
|
|
2
|
+
# long as the server does. Open shows the dashboard; Quit stops the server, and with it this.
|
|
3
|
+
param([int]$ServerPid, [string]$Node, [int]$Port)
|
|
4
|
+
|
|
5
|
+
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
6
|
+
|
|
7
|
+
$root = Split-Path -Parent $PSScriptRoot
|
|
8
|
+
|
|
9
|
+
$open = {
|
|
10
|
+
Start-Process -FilePath $Node -ArgumentList "`"$root\open.js`"" -WindowStyle Hidden
|
|
11
|
+
}
|
|
12
|
+
$quit = {
|
|
13
|
+
$token = (Get-Content (Join-Path $env:USERPROFILE '.agenter\token') -Raw).Trim()
|
|
14
|
+
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$Port/api/quit" -Headers @{ 'x-agenter-token' = $token } -ContentType 'application/json' -Body '{}' | Out-Null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
18
|
+
$menu.Items.Add('Open Agenter').add_Click($open)
|
|
19
|
+
$menu.Items.Add('Quit Agenter').add_Click($quit)
|
|
20
|
+
|
|
21
|
+
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
22
|
+
$tray.Icon = New-Object System.Drawing.Icon (Join-Path $root 'public\agenter.ico')
|
|
23
|
+
$tray.Text = 'Agenter'
|
|
24
|
+
$tray.ContextMenuStrip = $menu
|
|
25
|
+
$tray.add_MouseClick({ if ($_.Button -eq 'Left') { & $open } })
|
|
26
|
+
$tray.Visible = $true
|
|
27
|
+
|
|
28
|
+
# However the server ended, don't leave a dead icon behind.
|
|
29
|
+
$watch = New-Object System.Windows.Forms.Timer
|
|
30
|
+
$watch.Interval = 1000
|
|
31
|
+
$watch.add_Tick({
|
|
32
|
+
if (-not (Get-Process -Id $ServerPid -ErrorAction SilentlyContinue)) {
|
|
33
|
+
$tray.Visible = $false
|
|
34
|
+
[System.Windows.Forms.Application]::Exit()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
$watch.Start()
|
|
38
|
+
|
|
39
|
+
[System.Windows.Forms.Application]::Run()
|
|
40
|
+
$tray.Dispose()
|