agenter-app 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Agenter.vbs 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,16 +29,22 @@ 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
  |---|---|
36
41
  | **Agenter shortcut** (Desktop / Start Menu / taskbar) | Opens the dashboard in its own app window and starts the server if needed. No console. |
37
42
  | `agenter` | Same thing, from a terminal. |
38
43
  | `agenter start` | Runs the server in the foreground with logs (handy for debugging). |
39
- | `agenter shortcuts --startup` | Also starts the server silently when you log in to Windows. |
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. |
45
+ | `agenter shortcuts` | Creates the shortcuts, and starts Agenter in the system tray when you log in to Windows. |
40
46
 
41
- 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.
42
48
  Dashboard settings are kept in `~/.agenter/settings.json`.
43
49
 
44
50
  To remove everything: `agenter uninstall`, then `npm uninstall -g agenter-app`.
@@ -74,7 +80,8 @@ Each session has a **Drive from** switch in its header.
74
80
  reply from Agenter the VS Code panel shows its spinner, and an idle session waits for VS Code.
75
81
  - **Agenter**: Agenter runs the turns itself with the Claude Code that ships with the VS Code extension
76
82
  (`claude -p --resume <session>`), in the same session. Nothing ever waits on VS Code, idle and closed
77
- 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.
78
85
  The VS Code panel doesn't show these turns until you reopen the session there (Claude panel → session
79
86
  history), and don't type in VS Code's panel for that session at the same time.
80
87
 
@@ -145,7 +152,10 @@ forwards the event to the local server (`server.js`) and prints the server's ans
145
152
  and nothing new gets held, and anything already waiting goes back to VS Code after 5 seconds.
146
153
  - **Dashboard closed?** Same, after 15 seconds.
147
154
  - **Interrupt in VS Code** while something is waiting? The dashboard item disappears.
148
- - 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.
149
159
 
150
160
  ## Plan usage
151
161
 
package/cli.js CHANGED
@@ -84,8 +84,9 @@ Usage: agenter [command]
84
84
  install Add Agenter's hooks to ~/.claude/settings.json (backed up first)
85
85
  and, on Windows, Desktop and Start Menu shortcuts
86
86
  uninstall Remove the hooks and shortcuts
87
+ doctor Check hooks, port, Claude Code and login, and say how to fix what's wrong
87
88
  start Run the server in the foreground with logs
88
- 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
89
90
  version Print the version
90
91
 
91
92
  After installing, reload your VS Code windows so Claude sessions pick up the hooks.`;
@@ -107,8 +108,16 @@ After installing, reload your VS Code windows so Claude sessions pick up the hoo
107
108
  case 'start':
108
109
  status = node('server.js', rest);
109
110
  break;
111
+ case 'doctor': {
112
+ const { runChecks, format } = require('./lib/doctor');
113
+ const { findClaude, findCodex } = require('./server');
114
+ const results = await runChecks({ findClaude, findCodex });
115
+ console.log(format(results));
116
+ status = results.some((r) => r.status === 'fail') ? 1 : 0;
117
+ break;
118
+ }
110
119
  case 'shortcuts':
111
- status = shortcuts(rest.includes('--startup') ? ['-Startup'] : []);
120
+ status = shortcuts(['-Startup']);
112
121
  break;
113
122
  case 'version':
114
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 = '';
package/install.js CHANGED
@@ -10,18 +10,7 @@ const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
10
10
  const HOOK_SCRIPT = path.join(__dirname, 'hook.js').replace(/\\/g, '/');
11
11
  const COMMAND = `node "${HOOK_SCRIPT}"`;
12
12
 
13
- // timeout is in seconds. Events that can wait for you get an hour.
14
- const EVENTS = {
15
- SessionStart: { timeout: 10 },
16
- SessionEnd: { timeout: 10 },
17
- UserPromptSubmit: { timeout: 10 },
18
- PreToolUse: { matcher: '*', timeout: 3600 },
19
- PostToolUse: { matcher: '*', timeout: 10 },
20
- PermissionRequest: { matcher: '*', timeout: 3600 },
21
- Notification: { timeout: 10 },
22
- Stop: { timeout: 3600 },
23
- SubagentStop: { timeout: 10 },
24
- };
13
+ const EVENTS = require('./lib/hook-events');
25
14
 
26
15
  const uninstall = process.argv.includes('--uninstall');
27
16
 
package/lib/diff.js ADDED
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+ // Line diffs for the file changes Claude asks permission for (Edit, MultiEdit, Write).
3
+ // Built on the server so the dashboard only has to draw rows, and so it can be tested.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const CONTEXT = 3; // unchanged lines kept around each change
8
+ const MAX_ROWS = 300; // rows sent per file; the rest is summarised
9
+ const MAX_FILE_BYTES = 512 * 1024; // bigger files aren't read to diff a Write against
10
+ const MAX_CELLS = 1_500_000; // LCS table size; beyond this fall back to "all replaced"
11
+
12
+ const lines = (text) => {
13
+ text = String(text ?? '').replace(/\r\n/g, '\n');
14
+ return text === '' ? [] : text.split('\n');
15
+ };
16
+
17
+ // Ops: { type: 'ctx' | 'del' | 'add', text }. Common start and end are peeled off first,
18
+ // so the usual small edit in a big block costs almost nothing.
19
+ function lineDiff(oldText, newText) {
20
+ const a = lines(oldText);
21
+ const b = lines(newText);
22
+ let head = 0;
23
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
24
+ let tail = 0;
25
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
26
+
27
+ const ops = [];
28
+ for (let i = 0; i < head; i++) ops.push({ type: 'ctx', text: a[i] });
29
+ const am = a.slice(head, a.length - tail);
30
+ const bm = b.slice(head, b.length - tail);
31
+ middle(am, bm, ops);
32
+ for (let i = a.length - tail; i < a.length; i++) ops.push({ type: 'ctx', text: a[i] });
33
+ return ops;
34
+ }
35
+
36
+ function middle(a, b, ops) {
37
+ const n = a.length;
38
+ const m = b.length;
39
+ if (!n || !m || (n + 1) * (m + 1) > MAX_CELLS) {
40
+ for (const text of a) ops.push({ type: 'del', text });
41
+ for (const text of b) ops.push({ type: 'add', text });
42
+ return;
43
+ }
44
+ // dp[i][j] = length of the longest common subsequence of a[i..] and b[j..]
45
+ const w = m + 1;
46
+ const dp = new Uint32Array((n + 1) * w);
47
+ for (let i = n - 1; i >= 0; i--) {
48
+ for (let j = m - 1; j >= 0; j--) {
49
+ dp[i * w + j] = a[i] === b[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
50
+ }
51
+ }
52
+ let i = 0;
53
+ let j = 0;
54
+ while (i < n && j < m) {
55
+ if (a[i] === b[j]) ops.push({ type: 'ctx', text: a[i++] }), j++;
56
+ else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) ops.push({ type: 'del', text: a[i++] });
57
+ else ops.push({ type: 'add', text: b[j++] });
58
+ }
59
+ while (i < n) ops.push({ type: 'del', text: a[i++] });
60
+ while (j < m) ops.push({ type: 'add', text: b[j++] });
61
+ }
62
+
63
+ // Keeps changes plus `context` lines around them; long unchanged runs become one
64
+ // { type: 'skip', count } row. Rows past `maxRows` become { type: 'more', count }.
65
+ function compact(ops, context = CONTEXT, maxRows = MAX_ROWS) {
66
+ const keep = new Array(ops.length).fill(false);
67
+ ops.forEach((op, i) => {
68
+ if (op.type === 'ctx') return;
69
+ for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) keep[k] = true;
70
+ });
71
+ const rows = [];
72
+ let skipped = 0;
73
+ const flush = () => {
74
+ if (skipped) rows.push({ type: 'skip', count: skipped });
75
+ skipped = 0;
76
+ };
77
+ ops.forEach((op, i) => {
78
+ if (keep[i]) (flush(), rows.push(op));
79
+ else skipped++;
80
+ });
81
+ flush();
82
+ if (rows.length <= maxRows) return rows;
83
+ const omitted = rows.slice(maxRows).reduce((n, r) => n + (r.type === 'skip' ? r.count : 1), 0);
84
+ return [...rows.slice(0, maxRows), { type: 'more', count: omitted }];
85
+ }
86
+
87
+ function summarize(file, oldText, newText, extra = {}) {
88
+ const ops = lineDiff(oldText, newText);
89
+ return {
90
+ file,
91
+ ...extra,
92
+ added: ops.filter((o) => o.type === 'add').length,
93
+ removed: ops.filter((o) => o.type === 'del').length,
94
+ rows: compact(ops),
95
+ };
96
+ }
97
+
98
+ // The file as it is now, or null when it's missing, too big, or not text.
99
+ function readExisting(file) {
100
+ try {
101
+ if (fs.statSync(file).size > MAX_FILE_BYTES) return undefined;
102
+ const text = fs.readFileSync(file, 'utf8');
103
+ return text.includes('\0') ? undefined : text;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // What a permission prompt for this tool would change: an array with one entry per file
110
+ // change, or null for tools that don't edit files (or when it can't be worked out).
111
+ function buildDiffs(tool, input, { cwd = '', read = readExisting } = {}) {
112
+ try {
113
+ if (!input || typeof input !== 'object') return null;
114
+ const file = input.file_path;
115
+ if (tool === 'Edit') return [summarize(file, input.old_string, input.new_string)];
116
+ if (tool === 'MultiEdit' && Array.isArray(input.edits)) return input.edits.map((e) => summarize(file, e.old_string, e.new_string));
117
+ if (tool === 'Write' && typeof file === 'string') {
118
+ const existing = read(path.resolve(cwd || '.', file));
119
+ if (existing === undefined) return null; // there, but we can't show it
120
+ return [summarize(file, existing ?? '', input.content, { isNew: existing === null })];
121
+ }
122
+ } catch {}
123
+ return null;
124
+ }
125
+
126
+ module.exports = { lineDiff, compact, buildDiffs, CONTEXT, MAX_ROWS };
package/lib/doctor.js ADDED
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ // `agenter doctor`: checks the things that silently stop Agenter from working.
3
+ // Every check reads what it needs from ctx, so tests can point it at a temp home folder.
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const http = require('node:http');
8
+ const EVENTS = require('./hook-events');
9
+
10
+ const ok = (name, detail) => ({ name, status: 'ok', detail });
11
+ const warn = (name, detail, fix) => ({ name, status: 'warn', detail, fix });
12
+ const fail = (name, detail, fix) => ({ name, status: 'fail', detail, fix });
13
+
14
+ const readJson = (file) => {
15
+ try {
16
+ return { value: JSON.parse(fs.readFileSync(file, 'utf8')) };
17
+ } catch (error) {
18
+ return { error: error.code === 'ENOENT' ? 'missing' : error.message };
19
+ }
20
+ };
21
+
22
+ // Is there an executable called `name` (with the usual Windows extensions) on PATH?
23
+ function onPath(name, env, platform) {
24
+ const exts = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
25
+ for (const dir of (env.PATH || env.Path || '').split(path.delimiter)) {
26
+ for (const ext of exts) if (dir && fs.existsSync(path.join(dir, name + ext))) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ function checkNode(ctx) {
32
+ const major = Number(String(ctx.nodeVersion).replace(/^v/, '').split('.')[0]);
33
+ return major >= 18 ? ok('Node.js', `v${String(ctx.nodeVersion).replace(/^v/, '')}`) : fail('Node.js', `${ctx.nodeVersion} is too old (Agenter needs 18+)`, 'Install a current Node.js LTS.');
34
+ }
35
+
36
+ function checkHooks(ctx) {
37
+ const name = 'Claude hooks';
38
+ const file = path.join(ctx.home, '.claude', 'settings.json');
39
+ const { value, error } = readJson(file);
40
+ if (error === 'missing') return fail(name, `${file} doesn't exist yet`, 'Run: agenter install');
41
+ if (error) return fail(name, `${file} isn't valid JSON (${error})`, 'Fix the file, then run: agenter install');
42
+
43
+ // Hooks of ours, by event, with the script path each one runs.
44
+ const ours = {};
45
+ const scripts = new Set();
46
+ for (const [event, groups] of Object.entries(value.hooks || {})) {
47
+ for (const h of (Array.isArray(groups) ? groups : []).flatMap((g) => g?.hooks || [])) {
48
+ const script = typeof h?.command === 'string' && h.command.match(/"([^"]*[\\/]hook\.js)"/)?.[1];
49
+ if (!script || !(script.replace(/\\/g, '/') === ctx.hookScript || /agenter/i.test(script))) continue;
50
+ (ours[event] ||= []).push(script);
51
+ scripts.add(script);
52
+ }
53
+ }
54
+ if (!scripts.size) return fail(name, "Agenter's hooks aren't in ~/.claude/settings.json, so no sessions will show up", 'Run: agenter install');
55
+
56
+ const stale = [...scripts].filter((s) => !fs.existsSync(s));
57
+ if (stale.length) return fail(name, `a hook points at a file that no longer exists: ${stale[0]}`, 'Run: agenter install (it replaces the old hooks)');
58
+ const elsewhere = [...scripts].filter((s) => s.replace(/\\/g, '/') !== ctx.hookScript);
59
+ if (elsewhere.length) return warn(name, `hooks point at another copy of Agenter: ${elsewhere[0]}`, 'Run: agenter install to point them at this one');
60
+ const missing = Object.keys(EVENTS).filter((e) => !ours[e]);
61
+ if (missing.length) return warn(name, `no hook for: ${missing.join(', ')}`, 'Run: agenter install');
62
+ if (!onPath('node', ctx.env, ctx.platform)) return fail(name, 'the hooks run `node`, but node isn\'t on your PATH', 'Add Node.js to PATH, then reload VS Code.');
63
+ return ok(name, `${Object.keys(EVENTS).length} events installed`);
64
+ }
65
+
66
+ function checkClaude(ctx) {
67
+ const bin = ctx.findClaude();
68
+ return bin
69
+ ? ok('Claude Code', bin)
70
+ : warn('Claude Code', "couldn't find it, so \"Drive from Agenter\" and New session won't work (watching and approving still do)", 'Install the Claude Code VS Code extension, or set AGENTER_CLAUDE to the path of claude.exe.');
71
+ }
72
+
73
+ function checkLogin(ctx) {
74
+ const name = 'Plan usage login';
75
+ if (ctx.env.AGENTER_USAGE === '0') return ok(name, 'turned off (AGENTER_USAGE=0)');
76
+ const { value, error } = readJson(path.join(ctx.home, '.claude', '.credentials.json'));
77
+ if (error === 'missing') return warn(name, 'no Claude subscription login found, so the usage bars stay empty', 'Sign in with Claude Code using a Claude subscription.');
78
+ if (error) return warn(name, `couldn't read the saved login (${error})`);
79
+ const oauth = value.claudeAiOauth;
80
+ if (!oauth?.accessToken) return warn(name, 'saved login has no subscription token (API-key logins have no plan usage)');
81
+ if (oauth.expiresAt && oauth.expiresAt < ctx.now) return warn(name, 'saved login has expired; it renews the next time Claude runs');
82
+ return ok(name, oauth.subscriptionType ? `${oauth.subscriptionType} plan` : 'signed in');
83
+ }
84
+
85
+ function checkSettings(ctx) {
86
+ const name = 'Agenter settings';
87
+ const file = path.join(ctx.home, '.agenter', 'settings.json');
88
+ const { error } = readJson(file);
89
+ if (error === 'missing') return ok(name, 'none saved yet (defaults)');
90
+ return error ? warn(name, `${file} isn't valid JSON, so your settings are ignored (${error})`, 'Fix or delete the file.') : ok(name, file);
91
+ }
92
+
93
+ async function checkServer(ctx) {
94
+ const name = `Server (port ${ctx.port})`;
95
+ const state = await ctx.probeServer(ctx.port);
96
+ if (state === 'agenter') return ok(name, 'running');
97
+ if (state === 'other') return fail(name, `something else is using port ${ctx.port}`, 'Close it, or set AGENTER_PORT to another port (for both Agenter and your terminal).');
98
+ return ok(name, 'not running (it starts itself on the first Claude event, or run: agenter)');
99
+ }
100
+
101
+ function checkCodex(ctx) {
102
+ const bin = ctx.findCodex();
103
+ return bin ? ok('Codex', bin) : ok('Codex', 'not found (only needed to steer Codex sessions)');
104
+ }
105
+
106
+ // Asks whatever is listening on the port whether it's Agenter.
107
+ function probeServer(port) {
108
+ return new Promise((resolve) => {
109
+ const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 1500 }, (res) => {
110
+ let body = '';
111
+ res.setEncoding('utf8');
112
+ res.on('data', (c) => body.length < 4096 && (body += c));
113
+ res.on('end', () => resolve(/agenter/i.test(body) ? 'agenter' : 'other'));
114
+ });
115
+ req.on('timeout', () => (req.destroy(), resolve('other')));
116
+ req.on('error', (e) => resolve(e.code === 'ECONNREFUSED' ? 'none' : 'other'));
117
+ });
118
+ }
119
+
120
+ async function runChecks(overrides = {}) {
121
+ const ctx = {
122
+ home: os.homedir(),
123
+ env: process.env,
124
+ platform: process.platform,
125
+ nodeVersion: process.version,
126
+ now: Date.now(),
127
+ port: Number(process.env.AGENTER_PORT) || 4317,
128
+ hookScript: path.join(__dirname, '..', 'hook.js').replace(/\\/g, '/'),
129
+ findClaude: () => null,
130
+ findCodex: () => null,
131
+ probeServer,
132
+ ...overrides,
133
+ };
134
+ return [checkNode(ctx), checkHooks(ctx), await checkServer(ctx), checkClaude(ctx), checkLogin(ctx), checkSettings(ctx), checkCodex(ctx)];
135
+ }
136
+
137
+ const MARK = { ok: '✓', warn: '!', fail: '✗' };
138
+
139
+ function format(results) {
140
+ const lines = [];
141
+ for (const r of results) {
142
+ lines.push(`${MARK[r.status]} ${r.name}: ${r.detail}`);
143
+ if (r.fix && r.status !== 'ok') lines.push(` → ${r.fix}`);
144
+ }
145
+ const bad = results.filter((r) => r.status === 'fail').length;
146
+ const odd = results.filter((r) => r.status === 'warn').length;
147
+ lines.push('', bad ? `${bad} problem${bad === 1 ? '' : 's'} found.` : odd ? 'Working, with some things to look at.' : 'All good.');
148
+ return lines.join('\n');
149
+ }
150
+
151
+ module.exports = { runChecks, format, checkHooks, checkLogin, checkSettings, checkServer, checkNode, onPath, probeServer };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ // The Claude Code hook events Agenter installs, shared by install.js and `agenter doctor`.
3
+ // timeout is in seconds. Events that can wait for you get an hour.
4
+ module.exports = {
5
+ SessionStart: { timeout: 10 },
6
+ SessionEnd: { timeout: 10 },
7
+ UserPromptSubmit: { timeout: 10 },
8
+ PreToolUse: { matcher: '*', timeout: 3600 },
9
+ PostToolUse: { matcher: '*', timeout: 10 },
10
+ PermissionRequest: { matcher: '*', timeout: 3600 },
11
+ Notification: { timeout: 10 },
12
+ Stop: { timeout: 3600 },
13
+ SubagentStop: { timeout: 10 },
14
+ };
package/lib/limits.js ADDED
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ // Turning plan-usage responses (Anthropic and Codex) into the limits the dashboard shows.
3
+
4
+ const LIMIT_LABELS = { session: '5-hour session', weekly_all: 'Weekly · all models', weekly_opus: 'Weekly · Opus', weekly_sonnet: 'Weekly · Sonnet' };
5
+
6
+ function normalizeLimits(j) {
7
+ if (Array.isArray(j.limits) && j.limits.length) {
8
+ return j.limits
9
+ .filter((l) => typeof l.percent === 'number')
10
+ .map((l) => ({ kind: l.kind, label: LIMIT_LABELS[l.kind] || String(l.kind).replace(/_/g, ' '), percent: l.percent, severity: l.severity || null, resetsAt: l.resets_at || null }));
11
+ }
12
+ // Older response shape.
13
+ const pick = (key, kind) => j[key] && typeof j[key].utilization === 'number' && { kind, label: LIMIT_LABELS[kind], percent: j[key].utilization, severity: null, resetsAt: j[key].resets_at || null };
14
+ return [pick('five_hour', 'session'), pick('seven_day', 'weekly_all'), pick('seven_day_opus', 'weekly_opus'), pick('seven_day_sonnet', 'weekly_sonnet')].filter(Boolean);
15
+ }
16
+
17
+ function codexWindowLabel(minutes) {
18
+ if (minutes === 300) return '5-hour session';
19
+ if (minutes === 10080) return 'Weekly';
20
+ if (minutes % 1440 === 0) return `${minutes / 1440}-day`;
21
+ if (minutes % 60 === 0) return `${minutes / 60}-hour`;
22
+ return `${minutes}-minute`;
23
+ }
24
+
25
+ module.exports = { LIMIT_LABELS, normalizeLimits, codexWindowLabel };
@@ -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 };