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 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. Just open the window when you want it:
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 --startup` | Also starts the server silently when you log in to Windows. |
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, and **Stop** ends a running turn.
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` and rejects requests from other websites.
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; --startup also starts the server at login
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(rest.includes('--startup') ? ['-Startup'] : []);
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 normally this time.
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 = '';
@@ -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(url) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenter-app",
3
- "version": "1.0.3",
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",
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",
@@ -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; }
@@ -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()
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' && last?.kind === 'tool'
344
- ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
347
+ s.status === 'working'
348
+ ? last?.kind === 'tool'
349
+ ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
350
+ : ''
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
- s.lastAction = (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
552
+ // lastAction is what the agent is doing. A prompt is what you said, so it starts blank
553
+ // (the dashboard shows "Working…") until the agent reports something.
554
+ s.lastAction = kind === 'prompt' ? '' : (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
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
- if (minutes > 0) {
573
+ // A turn Agenter runs has no VS Code to go back to, so it waits for your answer.
574
+ if (minutes > 0 && !item.headless) {
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(bin, ['queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000, shell: bin.endsWith('.cmd') }, (err) => {
864
+ execFile(file, [...prefix, 'queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000 }, (err) => {
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.kill();
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
  };