agenter-app 1.0.3 → 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 +14 -5
- package/cli.js +2 -2
- package/hook.js +7 -2
- package/lib/open-dashboard.js +4 -1
- package/lib/token.js +47 -0
- package/package.json +1 -1
- package/public/app.js +52 -9
- package/public/index.html +1 -11
- package/public/style.css +3 -0
- package/public/theme.js +9 -0
- package/scripts/install-shortcuts.ps1 +3 -2
- package/scripts/tray.ps1 +40 -0
- package/server.js +109 -16
package/Agenter.vbs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
' Double-click (or use the shortcut) to open Agenter without a console window.
|
|
2
|
-
' "Agenter.vbs server" only starts the background server (used at Windows login).
|
|
2
|
+
' "Agenter.vbs server" only starts the background server and tray icon (used at Windows login).
|
|
3
3
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
|
4
4
|
dir = fso.GetParentFolderName(WScript.ScriptFullName)
|
|
5
5
|
cmd = "node """ & dir & "\open.js"""
|
package/README.md
CHANGED
|
@@ -29,7 +29,12 @@ From a clone instead: `npm run install-hooks` and `npm run shortcuts`, then `npm
|
|
|
29
29
|
## Running it
|
|
30
30
|
|
|
31
31
|
You mostly don't have to: **the server starts itself** the first time any Claude session fires a hook,
|
|
32
|
-
and keeps running in the background
|
|
32
|
+
and keeps running in the background, with an icon in the Windows system tray. Closing the window leaves
|
|
33
|
+
it running there. Click the icon to open the dashboard.
|
|
34
|
+
|
|
35
|
+
**To stop it, right-click the tray icon and choose Quit Agenter.** That closes the windows and stops the
|
|
36
|
+
server. Anything waiting goes back to VS Code, and turns Agenter is running are stopped. Hooks won't restart
|
|
37
|
+
it; it stays off until you open Agenter again.
|
|
33
38
|
|
|
34
39
|
| Way | What it does |
|
|
35
40
|
|---|---|
|
|
@@ -37,9 +42,9 @@ and keeps running in the background. Just open the window when you want it:
|
|
|
37
42
|
| `agenter` | Same thing, from a terminal. |
|
|
38
43
|
| `agenter start` | Runs the server in the foreground with logs (handy for debugging). |
|
|
39
44
|
| `agenter doctor` | Checks the hooks, port, Claude Code and login, and says how to fix anything that's wrong. Start here if sessions don't show up. |
|
|
40
|
-
| `agenter shortcuts
|
|
45
|
+
| `agenter shortcuts` | Creates the shortcuts, and starts Agenter in the system tray when you log in to Windows. |
|
|
41
46
|
|
|
42
|
-
Set `AGENTER_AUTOSTART=0` to stop hooks from starting the server.
|
|
47
|
+
Set `AGENTER_AUTOSTART=0` to stop hooks from starting the server, or `AGENTER_TRAY=0` to hide the tray icon.
|
|
43
48
|
Dashboard settings are kept in `~/.agenter/settings.json`.
|
|
44
49
|
|
|
45
50
|
To remove everything: `agenter uninstall`, then `npm uninstall -g agenter-app`.
|
|
@@ -75,7 +80,8 @@ Each session has a **Drive from** switch in its header.
|
|
|
75
80
|
reply from Agenter the VS Code panel shows its spinner, and an idle session waits for VS Code.
|
|
76
81
|
- **Agenter**: Agenter runs the turns itself with the Claude Code that ships with the VS Code extension
|
|
77
82
|
(`claude -p --resume <session>`), in the same session. Nothing ever waits on VS Code, idle and closed
|
|
78
|
-
sessions can be continued, permission prompts and questions come to Agenter
|
|
83
|
+
sessions can be continued, permission prompts and questions come to Agenter and wait there for your answer
|
|
84
|
+
while a dashboard is open, and **Stop** ends a running turn and everything it started.
|
|
79
85
|
The VS Code panel doesn't show these turns until you reopen the session there (Claude panel → session
|
|
80
86
|
history), and don't type in VS Code's panel for that session at the same time.
|
|
81
87
|
|
|
@@ -146,7 +152,10 @@ forwards the event to the local server (`server.js`) and prints the server's ans
|
|
|
146
152
|
and nothing new gets held, and anything already waiting goes back to VS Code after 5 seconds.
|
|
147
153
|
- **Dashboard closed?** Same, after 15 seconds.
|
|
148
154
|
- **Interrupt in VS Code** while something is waiting? The dashboard item disappears.
|
|
149
|
-
- The server only listens on `127.0.0.1
|
|
155
|
+
- The server only listens on `127.0.0.1`, rejects requests from other websites, and requires the token in
|
|
156
|
+
`~/.agenter/token`. Hooks read it from there, and the Agenter shortcut and `agenter` sign the dashboard in,
|
|
157
|
+
so open it that way rather than from a bookmark on a new browser. The token keeps out other Windows users
|
|
158
|
+
and anything that can only make HTTP requests. A program running as you can still read the file.
|
|
150
159
|
|
|
151
160
|
## Plan usage
|
|
152
161
|
|
package/cli.js
CHANGED
|
@@ -86,7 +86,7 @@ Usage: agenter [command]
|
|
|
86
86
|
uninstall Remove the hooks and shortcuts
|
|
87
87
|
doctor Check hooks, port, Claude Code and login, and say how to fix what's wrong
|
|
88
88
|
start Run the server in the foreground with logs
|
|
89
|
-
shortcuts (Re)create the Windows shortcuts
|
|
89
|
+
shortcuts (Re)create the Windows shortcuts, and start Agenter in the tray at login
|
|
90
90
|
version Print the version
|
|
91
91
|
|
|
92
92
|
After installing, reload your VS Code windows so Claude sessions pick up the hooks.`;
|
|
@@ -117,7 +117,7 @@ After installing, reload your VS Code windows so Claude sessions pick up the hoo
|
|
|
117
117
|
break;
|
|
118
118
|
}
|
|
119
119
|
case 'shortcuts':
|
|
120
|
-
status = shortcuts(
|
|
120
|
+
status = shortcuts(['-Startup']);
|
|
121
121
|
break;
|
|
122
122
|
case 'version':
|
|
123
123
|
case '--version':
|
package/hook.js
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
// Claude Code hook bridge. Claude Code runs this for every hook event and pipes the
|
|
3
3
|
// event JSON on stdin. We forward it to the Agenter server and print whatever it
|
|
4
4
|
// answers. If the server isn't running we start it in the background (unless
|
|
5
|
-
// AGENTER_AUTOSTART=0) and exit silently so Claude behaves
|
|
5
|
+
// AGENTER_AUTOSTART=0, or you quit the app) and exit silently so Claude behaves
|
|
6
|
+
// normally this time.
|
|
6
7
|
'use strict';
|
|
8
|
+
const fs = require('node:fs');
|
|
7
9
|
const http = require('node:http');
|
|
10
|
+
const os = require('node:os');
|
|
8
11
|
const path = require('node:path');
|
|
9
12
|
const { spawn } = require('node:child_process');
|
|
13
|
+
const { readToken } = require('./lib/token');
|
|
10
14
|
|
|
11
15
|
const PORT = Number(process.env.AGENTER_PORT) || 4317;
|
|
12
16
|
|
|
13
17
|
function startServer() {
|
|
14
18
|
if (process.env.AGENTER_AUTOSTART === '0') return;
|
|
19
|
+
if (fs.existsSync(path.join(os.homedir(), '.agenter', 'stopped'))) return;
|
|
15
20
|
try {
|
|
16
21
|
spawn(process.execPath, [path.join(__dirname, 'server.js'), '--no-open'], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
17
22
|
} catch {}
|
|
@@ -27,7 +32,7 @@ process.stdin.on('end', () => {
|
|
|
27
32
|
port: PORT,
|
|
28
33
|
path: '/hook',
|
|
29
34
|
method: 'POST',
|
|
30
|
-
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(input) },
|
|
35
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(input), 'x-agenter-token': readToken() },
|
|
31
36
|
},
|
|
32
37
|
(res) => {
|
|
33
38
|
let body = '';
|
package/lib/open-dashboard.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { spawn, exec } = require('node:child_process');
|
|
7
|
+
const { getToken } = require('./token');
|
|
7
8
|
|
|
8
9
|
function findAppBrowser() {
|
|
9
10
|
if (process.platform !== 'win32') return null;
|
|
@@ -13,7 +14,9 @@ function findAppBrowser() {
|
|
|
13
14
|
return null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
module.exports = function openDashboard(
|
|
17
|
+
module.exports = function openDashboard(baseUrl) {
|
|
18
|
+
// In the fragment, so it never reaches a server or a log. The page keeps it from there.
|
|
19
|
+
const url = `${baseUrl}/#token=${getToken()}`;
|
|
17
20
|
const browser = findAppBrowser();
|
|
18
21
|
if (browser) {
|
|
19
22
|
const child = spawn(browser, [`--app=${url}`, '--window-size=1280,860'], { detached: true, stdio: 'ignore' });
|
package/lib/token.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The shared secret every caller of the local server must present, kept in
|
|
3
|
+
// ~/.agenter/token. server.js checks it, hook.js sends it for any agent, and the launchers
|
|
4
|
+
// hand it to the dashboard. It stays the same across restarts, so open dashboards and
|
|
5
|
+
// running sessions keep working.
|
|
6
|
+
//
|
|
7
|
+
// This keeps out other Windows users on the machine and anything that can only make
|
|
8
|
+
// HTTP requests to localhost. A program running as you can still read the file.
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const tokenFile = () => path.join(os.homedir(), '.agenter', 'token');
|
|
15
|
+
|
|
16
|
+
function readToken() {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(tokenFile(), 'utf8').trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Creates the token the first time. 'wx' so two processes starting together agree on one.
|
|
25
|
+
function getToken() {
|
|
26
|
+
const existing = readToken();
|
|
27
|
+
if (existing) return existing;
|
|
28
|
+
const file = tokenFile();
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
31
|
+
try {
|
|
32
|
+
fs.writeFileSync(file, token, { flag: 'wx', mode: 0o600 });
|
|
33
|
+
return token;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.code === 'EEXIST') return readToken();
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sameToken(given, expected) {
|
|
41
|
+
if (typeof given !== 'string' || !expected) return false;
|
|
42
|
+
const a = Buffer.from(given);
|
|
43
|
+
const b = Buffer.from(expected);
|
|
44
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { getToken, readToken, sameToken, tokenFile };
|
package/package.json
CHANGED
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",
|
|
@@ -1134,7 +1175,7 @@ function loadTranscript(sid) {
|
|
|
1134
1175
|
const have = state.transcript.sessionId === sid && state.transcript.messages ? state.transcript : null;
|
|
1135
1176
|
const lastSeq = () => have?.messages.at(-1)?.seq ?? 0;
|
|
1136
1177
|
const since = have ? `&epoch=${encodeURIComponent(have.epoch)}&after=${lastSeq()}` : '';
|
|
1137
|
-
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}
|
|
1178
|
+
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`, { headers: authHeaders });
|
|
1138
1179
|
if (!r.ok) return;
|
|
1139
1180
|
const { epoch, reset, messages } = await r.json();
|
|
1140
1181
|
if (sid !== state.selected) return;
|
|
@@ -1142,7 +1183,9 @@ function loadTranscript(sid) {
|
|
|
1142
1183
|
// A request that overlapped another may repeat messages; seq tells them apart.
|
|
1143
1184
|
const merged = reset || !have ? messages : [...have.messages, ...messages.filter((m) => m.seq > lastSeq())].slice(-KEEP_MESSAGES);
|
|
1144
1185
|
state.transcript = { sessionId: sid, epoch, messages: merged };
|
|
1186
|
+
clearEchoes(sid, messages);
|
|
1145
1187
|
renderConvo(same);
|
|
1188
|
+
renderLiveRow();
|
|
1146
1189
|
} catch {}
|
|
1147
1190
|
}, 150);
|
|
1148
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; }
|
|
@@ -555,3 +556,5 @@ dialog form { text-align: right; }
|
|
|
555
556
|
.splitter { display: none; }
|
|
556
557
|
.preview { order: -1; width: auto; max-width: none; min-width: 0; height: 48vh; border-bottom: 1px solid var(--border); }
|
|
557
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()
|
package/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const openDashboard = require('./lib/open-dashboard');
|
|
|
12
12
|
const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscript, lastAssistantText, transcriptInfo } = require('./lib/transcript');
|
|
13
13
|
const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
|
|
14
14
|
const { buildDiffs } = require('./lib/diff');
|
|
15
|
+
const { getToken, sameToken } = require('./lib/token');
|
|
15
16
|
|
|
16
17
|
const PORT = Number(process.env.AGENTER_PORT) || 4317;
|
|
17
18
|
const HOST = '127.0.0.1';
|
|
@@ -26,6 +27,8 @@ const MAX_ACTIVITY = 200;
|
|
|
26
27
|
const DISCONNECT_GRACE_MS = 15000;
|
|
27
28
|
// If you switch away from the dashboard (to VS Code, say) this long, same thing.
|
|
28
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');
|
|
29
32
|
const DISCOVER_INTERVAL_MS = 30000;
|
|
30
33
|
const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches changes in between
|
|
31
34
|
// Plan usage (5-hour session / weekly limits) comes from the same endpoint Claude
|
|
@@ -339,9 +342,12 @@ async function checkRollout(file) {
|
|
|
339
342
|
s.codexTurnOpen = tail.turnOpen;
|
|
340
343
|
s.status = codexStatus(s);
|
|
341
344
|
const last = tail.last;
|
|
345
|
+
// While working, only a running tool is current; the last reply may be from the previous turn.
|
|
342
346
|
s.lastAction =
|
|
343
|
-
s.status === 'working'
|
|
344
|
-
?
|
|
347
|
+
s.status === 'working'
|
|
348
|
+
? last?.kind === 'tool'
|
|
349
|
+
? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
|
|
350
|
+
: ''
|
|
345
351
|
: (lastAssistantText(file) || '').split('\n')[0].slice(0, 160);
|
|
346
352
|
return true;
|
|
347
353
|
} catch {
|
|
@@ -543,7 +549,9 @@ async function findApps(cwd) {
|
|
|
543
549
|
function addActivity(s, kind, text, extra = {}) {
|
|
544
550
|
s.activity.push({ t: Date.now(), kind, text: truncate(text, 4000), ...extra });
|
|
545
551
|
if (s.activity.length > MAX_ACTIVITY) s.activity.splice(0, s.activity.length - MAX_ACTIVITY);
|
|
546
|
-
|
|
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);
|
|
547
555
|
}
|
|
548
556
|
|
|
549
557
|
// Who drives a session: 'vscode' (Agenter only answers inside VS Code's turns) or
|
|
@@ -562,7 +570,8 @@ function hold(s, kind, data, payload, res) {
|
|
|
562
570
|
const item = { id: crypto.randomUUID(), sessionId: s.id, kind, createdAt: Date.now(), headless: runners.has(s.id), ...data };
|
|
563
571
|
const entry = { item, res, payload, timer: null };
|
|
564
572
|
const minutes = Number(settings.holdMinutes) || 0;
|
|
565
|
-
|
|
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) {
|
|
566
575
|
item.releaseAt = item.createdAt + minutes * 60000;
|
|
567
576
|
entry.timer = setTimeout(() => respond(item.id, 'pass', { auto: true }), minutes * 60000);
|
|
568
577
|
}
|
|
@@ -579,6 +588,17 @@ function hold(s, kind, data, payload, res) {
|
|
|
579
588
|
broadcast();
|
|
580
589
|
}
|
|
581
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
|
+
|
|
582
602
|
// action: allow | always | deny | answer | reply | pass
|
|
583
603
|
function respond(id, action, body = {}) {
|
|
584
604
|
const entry = pending.get(id);
|
|
@@ -652,6 +672,30 @@ function onAttentionChanged() {
|
|
|
652
672
|
}, clients.size ? BLUR_GRACE_MS : DISCONNECT_GRACE_MS);
|
|
653
673
|
}
|
|
654
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
|
+
|
|
655
699
|
// ---------- steering ----------
|
|
656
700
|
// Messages typed in the dashboard while Claude isn't waiting for a reply. Hooks only run
|
|
657
701
|
// at events, so they wait here and go out with the next one: after the current tool
|
|
@@ -729,6 +773,7 @@ function startRun(s, text, { fresh = false } = {}) {
|
|
|
729
773
|
child.on('error', (e) => (err = e.message));
|
|
730
774
|
child.on('close', (code) => {
|
|
731
775
|
runners.delete(s.id);
|
|
776
|
+
releaseRun(s);
|
|
732
777
|
if (code && !child.interrupted) addActivity(s, 'notice', `Claude stopped with an error${err.trim() ? `: ${err.trim()}` : ''}`);
|
|
733
778
|
if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
|
|
734
779
|
settleStatus(s, 'idle');
|
|
@@ -742,6 +787,12 @@ function startRun(s, text, { fresh = false } = {}) {
|
|
|
742
787
|
return true;
|
|
743
788
|
}
|
|
744
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
|
+
|
|
745
796
|
// ---------- steering Codex ----------
|
|
746
797
|
// Driven from the Codex app (default): `codex queue` hands the message to Codex, which runs
|
|
747
798
|
// it as the next turn of that session in the VS Code extension or the desktop app.
|
|
@@ -780,22 +831,37 @@ function findCodex() {
|
|
|
780
831
|
return null;
|
|
781
832
|
}
|
|
782
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
|
+
|
|
783
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
|
+
}
|
|
784
853
|
if (driveOf(s) === 'agenter') {
|
|
785
|
-
if (runners.has(s.id)) {
|
|
786
|
-
// `exec` can't take messages mid-turn; this runs as the next turn.
|
|
787
|
-
s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
|
|
788
|
-
broadcast();
|
|
789
|
-
return 'queued';
|
|
790
|
-
}
|
|
791
854
|
if (!(await startCodexRun(s, text))) return 'no-codex';
|
|
792
855
|
addActivity(s, 'prompt', text, { fromDashboard: true });
|
|
793
856
|
return 'started';
|
|
794
857
|
}
|
|
795
858
|
const bin = findCodex();
|
|
796
859
|
if (!bin) return 'no-codex';
|
|
860
|
+
const command = codexCommand(bin);
|
|
861
|
+
if (!command) return 'error';
|
|
862
|
+
const [file, ...prefix] = command;
|
|
797
863
|
return new Promise((resolve) => {
|
|
798
|
-
execFile(
|
|
864
|
+
execFile(file, [...prefix, 'queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000 }, (err) => {
|
|
799
865
|
if (err) return resolve('error');
|
|
800
866
|
addActivity(s, 'prompt', text, { fromDashboard: true });
|
|
801
867
|
resolve('codex-queued');
|
|
@@ -849,6 +915,7 @@ function startCodexRun(s, text, { fresh = false } = {}) {
|
|
|
849
915
|
// Never got going (Codex missing a login, bad folder…): report why to whoever asked.
|
|
850
916
|
if (runners.get(s.id) !== child) return resolve(fresh ? { error: failure || err.trim().split('\n').pop() || `Codex exited (${code}).` } : false);
|
|
851
917
|
runners.delete(s.id);
|
|
918
|
+
releaseRun(s);
|
|
852
919
|
s.codexTurnOpen = false;
|
|
853
920
|
if ((code || failure) && !child.interrupted) addActivity(s, 'notice', `Codex stopped with an error${failure || err.trim() ? `: ${failure || err.trim().split('\n').pop()}` : ''}`);
|
|
854
921
|
if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
|
|
@@ -994,11 +1061,29 @@ const json = (res, code, obj) => {
|
|
|
994
1061
|
const STATIC = {
|
|
995
1062
|
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
996
1063
|
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
1064
|
+
'/theme.js': ['theme.js', 'text/javascript; charset=utf-8'],
|
|
997
1065
|
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
998
1066
|
'/icon.png': ['icon.png', 'image/png'],
|
|
999
1067
|
'/favicon.ico': ['agenter.ico', 'image/x-icon'],
|
|
1000
1068
|
};
|
|
1001
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
|
+
|
|
1002
1087
|
const ALLOWED_HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
|
|
1003
1088
|
const ALLOWED_ORIGINS = new Set([...ALLOWED_HOSTS].map((h) => `http://${h}`));
|
|
1004
1089
|
|
|
@@ -1008,6 +1093,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1008
1093
|
return json(res, 403, { error: 'forbidden' });
|
|
1009
1094
|
}
|
|
1010
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' });
|
|
1011
1100
|
|
|
1012
1101
|
try {
|
|
1013
1102
|
if (req.method === 'POST' && url.pathname === '/hook') {
|
|
@@ -1016,7 +1105,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1016
1105
|
|
|
1017
1106
|
if (req.method === 'GET' && STATIC[url.pathname]) {
|
|
1018
1107
|
const [file, type] = STATIC[url.pathname];
|
|
1019
|
-
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 });
|
|
1020
1109
|
return res.end(fs.readFileSync(path.join(PUBLIC_DIR, file)));
|
|
1021
1110
|
}
|
|
1022
1111
|
|
|
@@ -1053,10 +1142,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1053
1142
|
}
|
|
1054
1143
|
|
|
1055
1144
|
if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
|
|
1056
|
-
// A custom header forces a CORS preflight, which we never approve.
|
|
1057
|
-
if (req.headers['x-agenter'] !== '1') return json(res, 403, { error: 'forbidden' });
|
|
1058
1145
|
const body = await readBody(req);
|
|
1059
1146
|
|
|
1147
|
+
if (url.pathname === '/api/quit') {
|
|
1148
|
+
stopServer();
|
|
1149
|
+
return json(res, 200, {});
|
|
1150
|
+
}
|
|
1060
1151
|
if (url.pathname === '/api/respond') {
|
|
1061
1152
|
const s = sessions.get(pending.get(body.id)?.item.sessionId);
|
|
1062
1153
|
const ok = respond(body.id, body.action, body);
|
|
@@ -1138,7 +1229,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1138
1229
|
const child = runners.get(body.sessionId);
|
|
1139
1230
|
if (child) {
|
|
1140
1231
|
child.interrupted = true;
|
|
1141
|
-
child
|
|
1232
|
+
killRun(child);
|
|
1142
1233
|
}
|
|
1143
1234
|
return json(res, child ? 200 : 404, {});
|
|
1144
1235
|
}
|
|
@@ -1195,6 +1286,8 @@ if (require.main === module) {
|
|
|
1195
1286
|
server.listen(PORT, HOST, () => {
|
|
1196
1287
|
const url = `http://localhost:${PORT}`;
|
|
1197
1288
|
console.log(`Agenter dashboard: ${url}`);
|
|
1289
|
+
fs.rmSync(STOPPED_FILE, { force: true });
|
|
1290
|
+
startTray();
|
|
1198
1291
|
startDiscovery();
|
|
1199
1292
|
startCodexDiscovery();
|
|
1200
1293
|
fetchUsage();
|
|
@@ -1207,5 +1300,5 @@ module.exports = {
|
|
|
1207
1300
|
sessions, pending, clients, runners, dismissed,
|
|
1208
1301
|
get settings() { return settings; },
|
|
1209
1302
|
handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
|
|
1210
|
-
findClaude, findCodex,
|
|
1303
|
+
findClaude, findCodex, codexCommand, releaseRun,
|
|
1211
1304
|
};
|