agenter-app 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -19,6 +19,8 @@ agenter install # adds hooks to ~/.claude/settings.json (a backup is made) + D
19
19
 
20
20
  Then **reload each VS Code window** (or start new Claude sessions). Hooks are read when a session starts.
21
21
 
22
+ Skipped `agenter install`? Running `agenter` notices the hooks are missing and offers to set them up.
23
+
22
24
  Install it globally (`-g`), not with `npx`: the hooks point at the installed files, so they need to stay put.
23
25
  If you move Node (a new Node version manager install, say), run `agenter install` again; it replaces the old hooks.
24
26
 
@@ -34,6 +36,7 @@ and keeps running in the background. Just open the window when you want it:
34
36
  | **Agenter shortcut** (Desktop / Start Menu / taskbar) | Opens the dashboard in its own app window and starts the server if needed. No console. |
35
37
  | `agenter` | Same thing, from a terminal. |
36
38
  | `agenter start` | Runs the server in the foreground with logs (handy for debugging). |
39
+ | `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. |
37
40
  | `agenter shortcuts --startup` | Also starts the server silently when you log in to Windows. |
38
41
 
39
42
  Set `AGENTER_AUTOSTART=0` to stop hooks from starting the server.
package/cli.js CHANGED
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // The `agenter` command (npm i -g agenter-app). Each subcommand runs one of the existing scripts.
3
3
  'use strict';
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
4
6
  const path = require('node:path');
7
+ const readline = require('node:readline/promises');
5
8
  const { spawnSync } = require('node:child_process');
6
9
  const { version } = require('./package.json');
7
10
 
11
+ const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
12
+ // The same path install.js puts in the hook commands.
13
+ const HOOK_SCRIPT = path.join(__dirname, 'hook.js').replace(/\\/g, '/');
14
+ const PROMPT_FILE = path.join(os.homedir(), '.agenter', 'cli.json');
15
+
8
16
  const [cmd = 'open', ...rest] = process.argv.slice(2);
9
17
  const isWindows = process.platform === 'win32';
10
18
 
@@ -20,52 +28,111 @@ const shortcuts = (args = []) => {
20
28
  return spawnSync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, ...args], { stdio: 'inherit' }).status ?? 1;
21
29
  };
22
30
 
31
+ const install = (args = []) => {
32
+ const status = node('install.js', args);
33
+ return status === 0 ? shortcuts() : status;
34
+ };
35
+
36
+ // 'ok' when this copy's hooks are installed, 'other' when only an older copy's are, else 'missing'.
37
+ const hookState = () => {
38
+ let commands = [];
39
+ try {
40
+ const { hooks = {} } = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8'));
41
+ commands = Object.values(hooks).flat().flatMap((g) => g?.hooks || []).map((h) => h?.command).filter((c) => typeof c === 'string');
42
+ } catch {}
43
+ if (commands.some((c) => c.includes(HOOK_SCRIPT))) return 'ok';
44
+ return commands.some((c) => /agenter[^"]*\/hook\.js"$/i.test(c)) ? 'other' : 'missing';
45
+ };
46
+
47
+ // Setup is never run without asking. A "no" is remembered until Agenter moves to a new path.
48
+ async function offerSetup() {
49
+ const state = hookState();
50
+ if (state === 'ok') return;
51
+ try {
52
+ if (JSON.parse(fs.readFileSync(PROMPT_FILE, 'utf8')).declinedSetup === HOOK_SCRIPT) return;
53
+ } catch {}
54
+
55
+ const [why, ask] =
56
+ state === 'other'
57
+ ? ['Your Claude hooks point at another copy of Agenter (an older install, or Node moved).', 'Point them at this one?']
58
+ : ["Agenter's hooks aren't set up yet, so your Claude sessions won't show up.", 'Set them up now?'];
59
+ if (!process.stdin.isTTY) {
60
+ console.log(`${why}\nRun: agenter install`);
61
+ return;
62
+ }
63
+
64
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
65
+ rl.on('SIGINT', () => process.exit(130));
66
+ const answer = (await rl.question(`${why}\n${ask} (Y/n) `)).trim().toLowerCase();
67
+ rl.close();
68
+
69
+ if (['', 'y', 'yes'].includes(answer)) {
70
+ install();
71
+ return;
72
+ }
73
+ fs.mkdirSync(path.dirname(PROMPT_FILE), { recursive: true });
74
+ fs.writeFileSync(PROMPT_FILE, JSON.stringify({ declinedSetup: HOOK_SCRIPT }, null, 2));
75
+ console.log("OK. Run 'agenter install' whenever you want them.");
76
+ }
77
+
23
78
  const help = `Agenter ${version}: one inbox for every Claude Code and Codex session.
24
79
 
25
80
  Usage: agenter [command]
26
81
 
27
- (none), open Open the dashboard (starts the server if needed)
82
+ (none), open Open the dashboard (starts the server if needed); offers setup
83
+ if the hooks aren't installed yet
28
84
  install Add Agenter's hooks to ~/.claude/settings.json (backed up first)
29
85
  and, on Windows, Desktop and Start Menu shortcuts
30
86
  uninstall Remove the hooks and shortcuts
87
+ doctor Check hooks, port, Claude Code and login, and say how to fix what's wrong
31
88
  start Run the server in the foreground with logs
32
89
  shortcuts (Re)create the Windows shortcuts; --startup also starts the server at login
33
90
  version Print the version
34
91
 
35
92
  After installing, reload your VS Code windows so Claude sessions pick up the hooks.`;
36
93
 
37
- let status = 0;
38
- switch (cmd) {
39
- case 'open':
40
- status = node('open.js', rest);
41
- break;
42
- case 'install':
43
- status = node('install.js', rest);
44
- if (status === 0) status = shortcuts();
45
- break;
46
- case 'uninstall':
47
- status = node('install.js', ['--uninstall']);
48
- shortcuts(['-Remove']);
49
- break;
50
- case 'start':
51
- status = node('server.js', rest);
52
- break;
53
- case 'shortcuts':
54
- status = shortcuts(rest.includes('--startup') ? ['-Startup'] : []);
55
- break;
56
- case 'version':
57
- case '--version':
58
- case '-v':
59
- console.log(version);
60
- break;
61
- case 'help':
62
- case '--help':
63
- case '-h':
64
- console.log(help);
65
- break;
66
- default:
67
- console.error(`Unknown command: ${cmd}\n`);
68
- console.log(help);
69
- status = 1;
70
- }
71
- process.exit(status);
94
+ (async () => {
95
+ let status = 0;
96
+ switch (cmd) {
97
+ case 'open':
98
+ await offerSetup();
99
+ status = node('open.js', rest);
100
+ break;
101
+ case 'install':
102
+ status = install(rest);
103
+ break;
104
+ case 'uninstall':
105
+ status = node('install.js', ['--uninstall']);
106
+ shortcuts(['-Remove']);
107
+ break;
108
+ case 'start':
109
+ status = node('server.js', rest);
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
+ }
119
+ case 'shortcuts':
120
+ status = shortcuts(rest.includes('--startup') ? ['-Startup'] : []);
121
+ break;
122
+ case 'version':
123
+ case '--version':
124
+ case '-v':
125
+ console.log(version);
126
+ break;
127
+ case 'help':
128
+ case '--help':
129
+ case '-h':
130
+ console.log(help);
131
+ break;
132
+ default:
133
+ console.error(`Unknown command: ${cmd}\n`);
134
+ console.log(help);
135
+ status = 1;
136
+ }
137
+ process.exit(status);
138
+ })();
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 };
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+ // Reading Claude Code transcripts and Codex rollouts: pure parsing plus the incremental file reader.
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+
6
+ const truncate = (s, n) => {
7
+ s = String(s ?? '');
8
+ return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s;
9
+ };
10
+
11
+ function describeTool(name, input = {}) {
12
+ if (!input || typeof input !== 'object') return '';
13
+ if (input.command) return input.command;
14
+ if (name === 'AskUserQuestion' && Array.isArray(input.questions)) return input.questions.map((q) => q.question).join(' / ');
15
+ if (input.file_path) return input.file_path;
16
+ if (input.notebook_path) return input.notebook_path;
17
+ if (input.pattern) return input.pattern + (input.path ? ` in ${input.path}` : '');
18
+ if (input.url) return input.url;
19
+ if (input.query) return input.query;
20
+ if (input.description) return input.description;
21
+ if (input.prompt) return input.prompt;
22
+ return JSON.stringify(input);
23
+ }
24
+
25
+ function readTail(file, maxBytes) {
26
+ const fd = fs.openSync(file, 'r');
27
+ try {
28
+ const size = fs.fstatSync(fd).size;
29
+ const start = Math.max(0, size - maxBytes);
30
+ const buf = Buffer.alloc(size - start);
31
+ fs.readSync(fd, buf, 0, buf.length, start);
32
+ let text = buf.toString('utf8');
33
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
34
+ return text;
35
+ } finally {
36
+ fs.closeSync(fd);
37
+ }
38
+ }
39
+
40
+ const resultText = (content) =>
41
+ typeof content === 'string'
42
+ ? content
43
+ : Array.isArray(content)
44
+ ? content.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`)).join('\n')
45
+ : '';
46
+
47
+ const isNoise = (text) => /^\s*<(system-reminder|ide_|command-|local-command)/.test(text);
48
+
49
+ // Appends the messages in these transcript lines to out (metadata and noise skipped).
50
+ // Reads both Claude Code transcripts and Codex rollouts.
51
+ function parseLines(text, out) {
52
+ for (const line of text.split('\n')) {
53
+ if (!line.trim()) continue;
54
+ let e;
55
+ try {
56
+ e = JSON.parse(line);
57
+ } catch {
58
+ continue;
59
+ }
60
+ if (e.type === 'response_item') {
61
+ parseCodexItem(e, out);
62
+ continue;
63
+ }
64
+ if ((e.type !== 'user' && e.type !== 'assistant') || e.isMeta || !e.message) continue;
65
+ const ts = e.timestamp;
66
+ const content = e.message.content;
67
+ if (typeof content === 'string') {
68
+ if (!isNoise(content)) out.push({ role: e.type, kind: 'text', text: content, ts });
69
+ continue;
70
+ }
71
+ if (!Array.isArray(content)) continue;
72
+ for (const b of content) {
73
+ if (b.type === 'text' && b.text?.trim() && !isNoise(b.text)) out.push({ role: e.type, kind: 'text', text: b.text, ts });
74
+ else if (b.type === 'tool_use') out.push({ role: 'assistant', kind: 'tool', id: b.id, name: b.name, text: describeTool(b.name, b.input), ts });
75
+ else if (b.type === 'tool_result') out.push({ role: 'tool', kind: 'result', toolUseId: b.tool_use_id, text: resultText(b.content), isError: !!b.is_error, ts });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // Codex's exec tool takes a small script; the shell command inside it is what's worth showing.
82
+ function describeCodexCall(name, input) {
83
+ const raw = String(input ?? '');
84
+ if (name === 'wait') return 'Waiting for the running command';
85
+ const m = raw.match(/\bcmd\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/);
86
+ if (m) return m[2].replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c);
87
+ // Other built-in tools called from the script: name them, plus the search query if any.
88
+ const call = raw.match(/\btools\.(\w+)\(/)?.[1];
89
+ if (call === 'write_stdin') return 'Sending input to the running command';
90
+ if (call) {
91
+ const q = raw.match(/\bq\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/)?.[2];
92
+ return call.replace(/__/g, ' ') + (q ? `: ${q}` : '');
93
+ }
94
+ try {
95
+ const args = JSON.parse(raw);
96
+ if (args && typeof args === 'object') return describeTool(name, args);
97
+ } catch {}
98
+ return raw.split('\n')[0];
99
+ }
100
+
101
+ const codexOutput = (o) => (typeof o === 'string' ? o : Array.isArray(o) ? o.map((c) => c.text ?? `[${c.type}]`).join('\n') : '');
102
+
103
+ function parseCodexItem(e, out) {
104
+ const p = e.payload || {};
105
+ const ts = e.timestamp;
106
+ if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
107
+ // Only what you typed: Codex also sends context (environment, open pages) as user messages.
108
+ const kinds = p.internal_chat_message_metadata_passthrough?.content_item_kinds;
109
+ (p.content || []).forEach((c, i) => {
110
+ const text = c.text?.trim();
111
+ if (!text) return;
112
+ if (p.role === 'user' && (kinds ? kinds[i] !== 'user.text' : text.startsWith('<'))) return;
113
+ // The VS Code extension wraps what you typed in IDE context (open tabs etc.).
114
+ const typed = p.role === 'user' ? c.text.match(/^#+ My request:\s*\n([\s\S]*)$/m)?.[1] : null;
115
+ out.push({ role: p.role, kind: 'text', text: typed?.trim() || c.text, ts });
116
+ });
117
+ } else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
118
+ out.push({ role: 'assistant', kind: 'tool', id: p.call_id, name: p.name, text: describeCodexCall(p.name, p.arguments ?? p.input), ts });
119
+ } else if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
120
+ out.push({ role: 'tool', kind: 'result', toolUseId: p.call_id, text: codexOutput(p.output), isError: false, ts });
121
+ }
122
+ }
123
+
124
+ const clipMessage = (m) => ({ ...m, text: truncate(m.text, m.kind === 'result' ? 4000 : 20000) });
125
+
126
+ function parseTranscript(file, limit = 120) {
127
+ if (!file || !fs.existsSync(file)) return [];
128
+ return parseLines(readTail(file, TRANSCRIPT_TAIL), []).slice(-limit).map(clipMessage);
129
+ }
130
+
131
+ // The conversation open on the dashboard is read incrementally: each request parses
132
+ // only the bytes appended since the last one. Messages get increasing seq numbers so
133
+ // the dashboard can ask for just the new ones; epoch changes if the file is replaced.
134
+ const TRANSCRIPT_TAIL = 2 * 1024 * 1024;
135
+ const TRANSCRIPT_KEEP = 400;
136
+ const TRANSCRIPT_FILES = 8;
137
+ const transcriptCache = new Map(); // file -> { epoch, offset, rest, dropFirstLine, seq, messages }
138
+
139
+ function readTranscript(file) {
140
+ let size;
141
+ try {
142
+ size = fs.statSync(file).size;
143
+ } catch {
144
+ return null;
145
+ }
146
+ let c = transcriptCache.get(file);
147
+ if (!c || size < c.offset) {
148
+ const offset = Math.max(0, size - TRANSCRIPT_TAIL);
149
+ c = { epoch: crypto.randomUUID(), offset, rest: Buffer.alloc(0), dropFirstLine: offset > 0, seq: 0, messages: [] };
150
+ }
151
+ transcriptCache.delete(file); // most recently used goes last
152
+ transcriptCache.set(file, c);
153
+ if (transcriptCache.size > TRANSCRIPT_FILES) transcriptCache.delete(transcriptCache.keys().next().value);
154
+ if (size === c.offset) return c;
155
+
156
+ const fd = fs.openSync(file, 'r');
157
+ let buf;
158
+ try {
159
+ buf = Buffer.alloc(size - c.offset);
160
+ fs.readSync(fd, buf, 0, buf.length, c.offset);
161
+ } finally {
162
+ fs.closeSync(fd);
163
+ }
164
+ c.offset = size;
165
+ buf = Buffer.concat([c.rest, buf]);
166
+ let start = 0;
167
+ if (c.dropFirstLine) {
168
+ start = buf.indexOf(10) + 1; // we started mid-file: skip the partial first line
169
+ if (start) c.dropFirstLine = false;
170
+ }
171
+ // Only complete lines. Splitting on the newline byte never cuts a UTF-8 character.
172
+ const end = Math.max(start, buf.lastIndexOf(10) + 1);
173
+ c.rest = Buffer.from(buf.subarray(end));
174
+ for (const m of parseLines(buf.toString('utf8', start, end), [])) c.messages.push({ ...clipMessage(m), seq: ++c.seq });
175
+ if (c.messages.length > TRANSCRIPT_KEEP) c.messages.splice(0, c.messages.length - TRANSCRIPT_KEEP);
176
+ return c;
177
+ }
178
+
179
+ function lastAssistantText(file) {
180
+ const msgs = parseTranscript(file, 400);
181
+ for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant' && msgs[i].kind === 'text') return msgs[i].text;
182
+ return '';
183
+ }
184
+
185
+ function readHead(file, maxBytes) {
186
+ const fd = fs.openSync(file, 'r');
187
+ try {
188
+ const buf = Buffer.alloc(Math.min(maxBytes, fs.fstatSync(fd).size));
189
+ fs.readSync(fd, buf, 0, buf.length, 0);
190
+ return buf.toString('utf8');
191
+ } finally {
192
+ fs.closeSync(fd);
193
+ }
194
+ }
195
+
196
+ const firstText = (content) =>
197
+ typeof content === 'string' ? content : Array.isArray(content) ? content.find((b) => b.type === 'text' && !isNoise(b.text || ''))?.text || '' : '';
198
+
199
+ // cwd and a title (Claude's summary, else the first prompt) from the start of a transcript.
200
+ function transcriptInfo(file) {
201
+ let cwd = '';
202
+ let prompt = '';
203
+ let summary = '';
204
+ for (const line of readHead(file, 256 * 1024).split('\n')) {
205
+ let e;
206
+ try {
207
+ e = JSON.parse(line);
208
+ } catch {
209
+ continue;
210
+ }
211
+ if (!cwd && e.cwd) cwd = e.cwd;
212
+ if (e.type === 'summary' && e.summary && !summary) summary = e.summary;
213
+ if (!prompt && e.type === 'user' && !e.isMeta) {
214
+ const t = firstText(e.message?.content);
215
+ if (t && !isNoise(t)) prompt = t;
216
+ }
217
+ if (cwd && prompt) break;
218
+ }
219
+ return { cwd, title: (summary || prompt).split('\n')[0].slice(0, 140) };
220
+ }
221
+
222
+ module.exports = {
223
+ truncate,
224
+ describeTool,
225
+ describeCodexCall,
226
+ readTail,
227
+ readHead,
228
+ parseLines,
229
+ parseCodexItem,
230
+ parseTranscript,
231
+ readTranscript,
232
+ lastAssistantText,
233
+ transcriptInfo,
234
+ isNoise,
235
+ TRANSCRIPT_TAIL,
236
+ TRANSCRIPT_KEEP,
237
+ TRANSCRIPT_FILES,
238
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenter-app",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "One local dashboard to watch, approve and steer every Claude Code and Codex session on your machine.",
5
5
  "keywords": [
6
6
  "claude",
@@ -38,6 +38,7 @@
38
38
  "scripts": {
39
39
  "start": "node server.js",
40
40
  "open": "node open.js",
41
+ "test": "node --test",
41
42
  "shortcuts": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-shortcuts.ps1",
42
43
  "shortcuts:startup": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/install-shortcuts.ps1 -Startup",
43
44
  "install-hooks": "node install.js",
Binary file
package/public/app.js CHANGED
@@ -994,8 +994,23 @@ function keyAct(action) {
994
994
  act(card, action);
995
995
  }
996
996
 
997
- function renderToolBody(tool, input = {}) {
997
+ // One file's changes, as built by the server (lib/diff.js): unchanged lines around each change.
998
+ function renderDiff(d) {
999
+ const sign = { ctx: ' ', del: '−', add: '+' };
1000
+ const rows = d.rows
1001
+ .map((r) => {
1002
+ if (r.type === 'skip') return `<div class="skip">⋯ ${r.count} unchanged line${r.count === 1 ? '' : 's'}</div>`;
1003
+ if (r.type === 'more') return `<div class="skip">⋯ ${r.count} more lines not shown</div>`;
1004
+ return `<div class="${r.type}">${sign[r.type]} ${esc(r.text)}</div>`;
1005
+ })
1006
+ .join('');
1007
+ const stats = `<span class="add-n">+${d.added}</span> <span class="del-n">−${d.removed}</span>`;
1008
+ return `<div class="diff-head"><span class="path mono">${esc(d.file)}</span>${d.isNew ? '<span class="pill plain">New file</span>' : ''}<span class="diff-stats">${stats}</span></div><div class="diff mono">${rows || '<div class="skip">No changes</div>'}</div>`;
1009
+ }
1010
+
1011
+ function renderToolBody(tool, input = {}, diffs = null) {
998
1012
  input = input || {};
1013
+ if (diffs?.length) return diffs.map(renderDiff).join('');
999
1014
  if (['Bash', 'PowerShell'].includes(tool) || typeof input.command === 'string') {
1000
1015
  return `${input.description ? `<div class="desc">${esc(input.description)}</div>` : ''}<pre class="cmd shell">${esc(input.command)}</pre>`;
1001
1016
  }
@@ -1029,7 +1044,7 @@ function renderCard(p, showSession) {
1029
1044
  const risky = isDestructive(p);
1030
1045
  return `<div class="card kind-permission ${risky ? 'risky' : ''}" data-id="${p.id}">${sessionBar}<div class="card-body">
1031
1046
  ${head(toolIcon(p.tool), `Allow <span class="mono">${esc(toolName(p.tool))}</span>?`, 'Claude is asking for permission')}
1032
- <div class="tool-body">${renderToolBody(p.tool, p.input)}</div>
1047
+ <div class="tool-body">${renderToolBody(p.tool, p.input, p.diffs)}</div>
1033
1048
  ${risky ? `<div class="risk-note">${icon('shield')}<span>This looks destructive, so the <kbd>A</kbd> shortcut is off. Read it, then click Allow if it's fine.</span></div>` : ''}
1034
1049
  <div class="actions">
1035
1050
  <button class="btn primary" data-act="allow">${icon('check')}Allow${risky ? '' : ' <kbd>A</kbd>'}</button>
Binary file
package/public/index.html CHANGED
@@ -4,7 +4,9 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Agenter</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23b7d4a6'/%3E%3Cpath d='M16 7l8 9-8 9-8-9z' fill='%23161813'/%3E%3C/svg%3E">
7
+ <link rel="icon" href="/favicon.ico" sizes="any">
8
+ <link rel="icon" type="image/png" sizes="256x256" href="/icon.png">
9
+ <link rel="apple-touch-icon" href="/icon.png">
8
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
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">
package/public/style.css CHANGED
@@ -457,6 +457,12 @@ dialog.new-dialog { max-width: 520px; }
457
457
  .diff div { padding: 0 10px; white-space: pre-wrap; word-break: break-word; }
458
458
  .diff .del { background: var(--del-bg); }
459
459
  .diff .add { background: var(--add-bg); }
460
+ .diff .skip { color: var(--muted); font-size: 11.5px; padding-top: 2px; padding-bottom: 2px; background: var(--panel-2); user-select: none; }
461
+ .diff-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
462
+ .diff-head .path { margin-bottom: 0; min-width: 0; }
463
+ .diff-stats { margin-left: auto; flex: none; font-size: 12px; font-weight: 600; }
464
+ .diff-stats .add-n { color: var(--ok, #2e7d32); }
465
+ .diff-stats .del-n { color: var(--danger); }
460
466
 
461
467
  .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
462
468
  .actions .grow { flex: 1; min-width: 180px; }
@@ -20,7 +20,7 @@ foreach ($t in $targets) {
20
20
  $lnk.TargetPath = "$env:SystemRoot\System32\wscript.exe"
21
21
  $lnk.Arguments = "`"$root\Agenter.vbs`""
22
22
  $lnk.WorkingDirectory = $root
23
- $lnk.IconLocation = "$env:SystemRoot\System32\shell32.dll,94"
23
+ $lnk.IconLocation = "$root\public\agenter.ico,0"
24
24
  $lnk.Description = 'Watch and answer all your Claude Code sessions'
25
25
  $lnk.Save()
26
26
  "Created $t"
package/server.js CHANGED
@@ -9,6 +9,9 @@ const os = require('node:os');
9
9
  const crypto = require('node:crypto');
10
10
  const { spawn, execFile } = require('node:child_process');
11
11
  const openDashboard = require('./lib/open-dashboard');
12
+ const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscript, lastAssistantText, transcriptInfo } = require('./lib/transcript');
13
+ const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
14
+ const { buildDiffs } = require('./lib/diff');
12
15
 
13
16
  const PORT = Number(process.env.AGENTER_PORT) || 4317;
14
17
  const HOST = '127.0.0.1';
@@ -49,224 +52,6 @@ const saveSettings = () => {
49
52
  fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
50
53
  };
51
54
 
52
- // ---------- helpers ----------
53
-
54
- const truncate = (s, n) => {
55
- s = String(s ?? '');
56
- return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s;
57
- };
58
-
59
- function describeTool(name, input = {}) {
60
- if (!input || typeof input !== 'object') return '';
61
- if (input.command) return input.command;
62
- if (name === 'AskUserQuestion' && Array.isArray(input.questions)) return input.questions.map((q) => q.question).join(' / ');
63
- if (input.file_path) return input.file_path;
64
- if (input.notebook_path) return input.notebook_path;
65
- if (input.pattern) return input.pattern + (input.path ? ` in ${input.path}` : '');
66
- if (input.url) return input.url;
67
- if (input.query) return input.query;
68
- if (input.description) return input.description;
69
- if (input.prompt) return input.prompt;
70
- return JSON.stringify(input);
71
- }
72
-
73
- function readTail(file, maxBytes) {
74
- const fd = fs.openSync(file, 'r');
75
- try {
76
- const size = fs.fstatSync(fd).size;
77
- const start = Math.max(0, size - maxBytes);
78
- const buf = Buffer.alloc(size - start);
79
- fs.readSync(fd, buf, 0, buf.length, start);
80
- let text = buf.toString('utf8');
81
- if (start > 0) text = text.slice(text.indexOf('\n') + 1); // drop the partial first line
82
- return text;
83
- } finally {
84
- fs.closeSync(fd);
85
- }
86
- }
87
-
88
- const resultText = (content) =>
89
- typeof content === 'string'
90
- ? content
91
- : Array.isArray(content)
92
- ? content.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`)).join('\n')
93
- : '';
94
-
95
- const isNoise = (text) => /^\s*<(system-reminder|ide_|command-|local-command)/.test(text);
96
-
97
- // Appends the messages in these transcript lines to out (metadata and noise skipped).
98
- // Reads both Claude Code transcripts and Codex rollouts.
99
- function parseLines(text, out) {
100
- for (const line of text.split('\n')) {
101
- if (!line.trim()) continue;
102
- let e;
103
- try {
104
- e = JSON.parse(line);
105
- } catch {
106
- continue;
107
- }
108
- if (e.type === 'response_item') {
109
- parseCodexItem(e, out);
110
- continue;
111
- }
112
- if ((e.type !== 'user' && e.type !== 'assistant') || e.isMeta || !e.message) continue;
113
- const ts = e.timestamp;
114
- const content = e.message.content;
115
- if (typeof content === 'string') {
116
- if (!isNoise(content)) out.push({ role: e.type, kind: 'text', text: content, ts });
117
- continue;
118
- }
119
- if (!Array.isArray(content)) continue;
120
- for (const b of content) {
121
- if (b.type === 'text' && b.text?.trim() && !isNoise(b.text)) out.push({ role: e.type, kind: 'text', text: b.text, ts });
122
- else if (b.type === 'tool_use') out.push({ role: 'assistant', kind: 'tool', id: b.id, name: b.name, text: describeTool(b.name, b.input), ts });
123
- else if (b.type === 'tool_result') out.push({ role: 'tool', kind: 'result', toolUseId: b.tool_use_id, text: resultText(b.content), isError: !!b.is_error, ts });
124
- }
125
- }
126
- return out;
127
- }
128
-
129
- // Codex's exec tool takes a small script; the shell command inside it is what's worth showing.
130
- function describeCodexCall(name, input) {
131
- const raw = String(input ?? '');
132
- if (name === 'wait') return 'Waiting for the running command';
133
- const m = raw.match(/\bcmd\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/);
134
- if (m) return m[2].replace(/\\(.)/g, (_, c) => ({ n: '\n', t: '\t' })[c] ?? c);
135
- // Other built-in tools called from the script: name them, plus the search query if any.
136
- const call = raw.match(/\btools\.(\w+)\(/)?.[1];
137
- if (call === 'write_stdin') return 'Sending input to the running command';
138
- if (call) {
139
- const q = raw.match(/\bq\s*:\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/)?.[2];
140
- return call.replace(/__/g, ' ') + (q ? `: ${q}` : '');
141
- }
142
- try {
143
- const args = JSON.parse(raw);
144
- if (args && typeof args === 'object') return describeTool(name, args);
145
- } catch {}
146
- return raw.split('\n')[0];
147
- }
148
-
149
- const codexOutput = (o) => (typeof o === 'string' ? o : Array.isArray(o) ? o.map((c) => c.text ?? `[${c.type}]`).join('\n') : '');
150
-
151
- function parseCodexItem(e, out) {
152
- const p = e.payload || {};
153
- const ts = e.timestamp;
154
- if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
155
- // Only what you typed: Codex also sends context (environment, open pages) as user messages.
156
- const kinds = p.internal_chat_message_metadata_passthrough?.content_item_kinds;
157
- (p.content || []).forEach((c, i) => {
158
- const text = c.text?.trim();
159
- if (!text) return;
160
- if (p.role === 'user' && (kinds ? kinds[i] !== 'user.text' : text.startsWith('<'))) return;
161
- // The VS Code extension wraps what you typed in IDE context (open tabs etc.).
162
- const typed = p.role === 'user' ? c.text.match(/^#+ My request:\s*\n([\s\S]*)$/m)?.[1] : null;
163
- out.push({ role: p.role, kind: 'text', text: typed?.trim() || c.text, ts });
164
- });
165
- } else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
166
- out.push({ role: 'assistant', kind: 'tool', id: p.call_id, name: p.name, text: describeCodexCall(p.name, p.arguments ?? p.input), ts });
167
- } else if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
168
- out.push({ role: 'tool', kind: 'result', toolUseId: p.call_id, text: codexOutput(p.output), isError: false, ts });
169
- }
170
- }
171
-
172
- const clipMessage = (m) => ({ ...m, text: truncate(m.text, m.kind === 'result' ? 4000 : 20000) });
173
-
174
- function parseTranscript(file, limit = 120) {
175
- if (!file || !fs.existsSync(file)) return [];
176
- return parseLines(readTail(file, TRANSCRIPT_TAIL), []).slice(-limit).map(clipMessage);
177
- }
178
-
179
- // The conversation open on the dashboard is read incrementally: each request parses
180
- // only the bytes appended since the last one. Messages get increasing seq numbers so
181
- // the dashboard can ask for just the new ones; epoch changes if the file is replaced.
182
- const TRANSCRIPT_TAIL = 2 * 1024 * 1024;
183
- const TRANSCRIPT_KEEP = 400;
184
- const TRANSCRIPT_FILES = 8;
185
- const transcriptCache = new Map(); // file -> { epoch, offset, rest, dropFirstLine, seq, messages }
186
-
187
- function readTranscript(file) {
188
- let size;
189
- try {
190
- size = fs.statSync(file).size;
191
- } catch {
192
- return null;
193
- }
194
- let c = transcriptCache.get(file);
195
- if (!c || size < c.offset) {
196
- const offset = Math.max(0, size - TRANSCRIPT_TAIL);
197
- c = { epoch: crypto.randomUUID(), offset, rest: Buffer.alloc(0), dropFirstLine: offset > 0, seq: 0, messages: [] };
198
- }
199
- transcriptCache.delete(file); // most recently used goes last
200
- transcriptCache.set(file, c);
201
- if (transcriptCache.size > TRANSCRIPT_FILES) transcriptCache.delete(transcriptCache.keys().next().value);
202
- if (size === c.offset) return c;
203
-
204
- const fd = fs.openSync(file, 'r');
205
- let buf;
206
- try {
207
- buf = Buffer.alloc(size - c.offset);
208
- fs.readSync(fd, buf, 0, buf.length, c.offset);
209
- } finally {
210
- fs.closeSync(fd);
211
- }
212
- c.offset = size;
213
- buf = Buffer.concat([c.rest, buf]);
214
- let start = 0;
215
- if (c.dropFirstLine) {
216
- start = buf.indexOf(10) + 1; // we started mid-file: skip the partial first line
217
- if (start) c.dropFirstLine = false;
218
- }
219
- // Only complete lines. Splitting on the newline byte never cuts a UTF-8 character.
220
- const end = Math.max(start, buf.lastIndexOf(10) + 1);
221
- c.rest = Buffer.from(buf.subarray(end));
222
- for (const m of parseLines(buf.toString('utf8', start, end), [])) c.messages.push({ ...clipMessage(m), seq: ++c.seq });
223
- if (c.messages.length > TRANSCRIPT_KEEP) c.messages.splice(0, c.messages.length - TRANSCRIPT_KEEP);
224
- return c;
225
- }
226
-
227
- function lastAssistantText(file) {
228
- const msgs = parseTranscript(file, 400);
229
- for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant' && msgs[i].kind === 'text') return msgs[i].text;
230
- return '';
231
- }
232
-
233
- function readHead(file, maxBytes) {
234
- const fd = fs.openSync(file, 'r');
235
- try {
236
- const buf = Buffer.alloc(Math.min(maxBytes, fs.fstatSync(fd).size));
237
- fs.readSync(fd, buf, 0, buf.length, 0);
238
- return buf.toString('utf8');
239
- } finally {
240
- fs.closeSync(fd);
241
- }
242
- }
243
-
244
- const firstText = (content) =>
245
- typeof content === 'string' ? content : Array.isArray(content) ? content.find((b) => b.type === 'text' && !isNoise(b.text || ''))?.text || '' : '';
246
-
247
- // cwd and a title (Claude's summary, else the first prompt) from the start of a transcript.
248
- function transcriptInfo(file) {
249
- let cwd = '';
250
- let prompt = '';
251
- let summary = '';
252
- for (const line of readHead(file, 256 * 1024).split('\n')) {
253
- let e;
254
- try {
255
- e = JSON.parse(line);
256
- } catch {
257
- continue;
258
- }
259
- if (!cwd && e.cwd) cwd = e.cwd;
260
- if (e.type === 'summary' && e.summary && !summary) summary = e.summary;
261
- if (!prompt && e.type === 'user' && !e.isMeta) {
262
- const t = firstText(e.message?.content);
263
- if (t && !isNoise(t)) prompt = t;
264
- }
265
- if (cwd && prompt) break;
266
- }
267
- return { cwd, title: (summary || prompt).split('\n')[0].slice(0, 140) };
268
- }
269
-
270
55
  // ---------- sessions ----------
271
56
 
272
57
  const newSession = (id) => ({ id, agent: 'claude', cwd: '', project: '', title: '', transcriptPath: '', status: 'idle', hold: null, startedAt: Date.now(), lastEventAt: 0, lastAction: '', permissionMode: '', activity: [], queue: [] });
@@ -392,19 +177,6 @@ function startDiscovery() {
392
177
  // ---------- plan usage ----------
393
178
 
394
179
  let usage = null; // { fetchedAt, plan, limits: [...], error }
395
- const LIMIT_LABELS = { session: '5-hour session', weekly_all: 'Weekly · all models', weekly_opus: 'Weekly · Opus', weekly_sonnet: 'Weekly · Sonnet' };
396
-
397
- function normalizeLimits(j) {
398
- if (Array.isArray(j.limits) && j.limits.length) {
399
- return j.limits
400
- .filter((l) => typeof l.percent === 'number')
401
- .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 }));
402
- }
403
- // Older response shape.
404
- 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 };
405
- return [pick('five_hour', 'session'), pick('seven_day', 'weekly_all'), pick('seven_day_opus', 'weekly_opus'), pick('seven_day_sonnet', 'weekly_sonnet')].filter(Boolean);
406
- }
407
-
408
180
  // The usage endpoint is rate-limited per account (Claude Code's /usage calls it too).
409
181
  // After a 429 we wait before asking again, longer each time, instead of keeping the
410
182
  // limit tripped by retrying every couple of minutes.
@@ -666,14 +438,6 @@ function startCodexDiscovery() {
666
438
  // Codex records your plan limits with every turn (the same numbers its /status shows).
667
439
  let codexUsage = null; // { updatedAt, plan, limits, credits }
668
440
 
669
- function codexWindowLabel(minutes) {
670
- if (minutes === 300) return '5-hour session';
671
- if (minutes === 10080) return 'Weekly';
672
- if (minutes % 1440 === 0) return `${minutes / 1440}-day`;
673
- if (minutes % 60 === 0) return `${minutes / 60}-hour`;
674
- return `${minutes}-minute`;
675
- }
676
-
677
441
  function noteCodexLimits(rl, at) {
678
442
  if (codexUsage && at <= codexUsage.updatedAt) return;
679
443
  const limits = [];
@@ -1146,7 +910,8 @@ function handleHook(p, res) {
1146
910
  case 'PermissionRequest':
1147
911
  if (watching && p.tool_name !== 'AskUserQuestion') {
1148
912
  const canAlways = Array.isArray(p.permission_suggestions) && p.permission_suggestions.length > 0;
1149
- return hold(s, 'permission', { tool: p.tool_name, input: p.tool_input, canAlways }, p, res);
913
+ const diffs = buildDiffs(p.tool_name, p.tool_input, { cwd: p.cwd || s.cwd });
914
+ return hold(s, 'permission', { tool: p.tool_name, input: p.tool_input, canAlways, ...(diffs && { diffs }) }, p, res);
1150
915
  }
1151
916
  s.status = 'vscode';
1152
917
  break;
@@ -1230,6 +995,8 @@ const STATIC = {
1230
995
  '/': ['index.html', 'text/html; charset=utf-8'],
1231
996
  '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
1232
997
  '/style.css': ['style.css', 'text/css; charset=utf-8'],
998
+ '/icon.png': ['icon.png', 'image/png'],
999
+ '/favicon.ico': ['agenter.ico', 'image/x-icon'],
1233
1000
  };
1234
1001
 
1235
1002
  const ALLOWED_HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
@@ -1418,17 +1185,27 @@ const server = http.createServer(async (req, res) => {
1418
1185
  });
1419
1186
 
1420
1187
  server.requestTimeout = 0; // hooks can legitimately wait a long time for you
1421
- server.on('error', (err) => {
1422
- if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1423
- else console.error(err);
1424
- process.exit(1);
1425
- });
1426
- server.listen(PORT, HOST, () => {
1427
- const url = `http://localhost:${PORT}`;
1428
- console.log(`Agenter dashboard: ${url}`);
1429
- startDiscovery();
1430
- startCodexDiscovery();
1431
- fetchUsage();
1432
- setInterval(fetchUsage, USAGE_INTERVAL_MS);
1433
- if (!process.argv.includes('--no-open')) openDashboard(url);
1434
- });
1188
+ // Only listen when run directly; the tests load this file to drive the hook and steering logic.
1189
+ if (require.main === module) {
1190
+ server.on('error', (err) => {
1191
+ if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1192
+ else console.error(err);
1193
+ process.exit(1);
1194
+ });
1195
+ server.listen(PORT, HOST, () => {
1196
+ const url = `http://localhost:${PORT}`;
1197
+ console.log(`Agenter dashboard: ${url}`);
1198
+ startDiscovery();
1199
+ startCodexDiscovery();
1200
+ fetchUsage();
1201
+ setInterval(fetchUsage, USAGE_INTERVAL_MS);
1202
+ if (!process.argv.includes('--no-open')) openDashboard(url);
1203
+ });
1204
+ }
1205
+
1206
+ module.exports = {
1207
+ sessions, pending, clients, runners, dismissed,
1208
+ get settings() { return settings; },
1209
+ handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
1210
+ findClaude, findCodex,
1211
+ };