agenter-app 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Agenter.vbs +1 -1
- package/README.md +15 -5
- package/cli.js +11 -2
- package/hook.js +7 -2
- package/install.js +1 -12
- package/lib/diff.js +126 -0
- package/lib/doctor.js +151 -0
- package/lib/hook-events.js +14 -0
- package/lib/limits.js +25 -0
- package/lib/open-dashboard.js +4 -1
- package/lib/token.js +47 -0
- package/lib/transcript.js +238 -0
- package/package.json +2 -1
- package/public/app.js +69 -11
- package/public/index.html +1 -11
- package/public/style.css +9 -0
- package/public/theme.js +9 -0
- package/scripts/install-shortcuts.ps1 +3 -2
- package/scripts/tray.ps1 +40 -0
- package/server.js +137 -269
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 };
|
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 };
|