agenter-app 1.0.2 → 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 +1 -0
- package/cli.js +9 -0
- 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/transcript.js +238 -0
- package/package.json +2 -1
- package/public/app.js +17 -2
- package/public/style.css +6 -0
- package/server.js +29 -254
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ and keeps running in the background. Just open the window when you want it:
|
|
|
36
36
|
| **Agenter shortcut** (Desktop / Start Menu / taskbar) | Opens the dashboard in its own app window and starts the server if needed. No console. |
|
|
37
37
|
| `agenter` | Same thing, from a terminal. |
|
|
38
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. |
|
|
39
40
|
| `agenter shortcuts --startup` | Also starts the server silently when you log in to Windows. |
|
|
40
41
|
|
|
41
42
|
Set `AGENTER_AUTOSTART=0` to stop hooks from starting the server.
|
package/cli.js
CHANGED
|
@@ -84,6 +84,7 @@ 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
89
|
shortcuts (Re)create the Windows shortcuts; --startup also starts the server at login
|
|
89
90
|
version Print the version
|
|
@@ -107,6 +108,14 @@ 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
120
|
status = shortcuts(rest.includes('--startup') ? ['-Startup'] : []);
|
|
112
121
|
break;
|
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 };
|
|
@@ -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.
|
|
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",
|
package/public/app.js
CHANGED
|
@@ -994,8 +994,23 @@ function keyAct(action) {
|
|
|
994
994
|
act(card, action);
|
|
995
995
|
}
|
|
996
996
|
|
|
997
|
-
|
|
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>
|
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; }
|
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
|
-
|
|
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;
|
|
@@ -1420,17 +1185,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
1420
1185
|
});
|
|
1421
1186
|
|
|
1422
1187
|
server.requestTimeout = 0; // hooks can legitimately wait a long time for you
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
+
};
|