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.
@@ -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.2",
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
- const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-agenter': '1' }, body: JSON.stringify(body) });
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 = () => setConn(false);
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)}`).then((res) => res.json());
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
- let html = '';
756
- 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>`;
757
- else if (s.status === 'vscode') html = `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
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
- function renderToolBody(tool, input = {}) {
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; }
@@ -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 Agenter server silently when you log in to Windows.
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 (server starts at login)"
36
+ "Created $startupLink (Agenter starts in the tray at login)"
36
37
  }
@@ -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()