agenter-app 1.0.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Agenter.vbs +1 -1
- package/README.md +166 -121
- package/cli.js +45 -2
- package/hook.js +7 -2
- package/lib/memory.js +101 -0
- package/lib/open-dashboard.js +4 -1
- package/lib/terminal.js +127 -0
- package/lib/token.js +47 -0
- package/lib/update.js +69 -0
- package/package.json +1 -1
- package/public/app.js +387 -22
- package/public/index.html +84 -20
- package/public/style.css +109 -10
- package/public/theme.js +9 -0
- package/scripts/install-shortcuts.ps1 +3 -2
- package/scripts/pick-folder.ps1 +85 -0
- package/scripts/tray.ps1 +40 -0
- package/server.js +247 -21
package/server.js
CHANGED
|
@@ -12,6 +12,11 @@ const openDashboard = require('./lib/open-dashboard');
|
|
|
12
12
|
const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscript, lastAssistantText, transcriptInfo } = require('./lib/transcript');
|
|
13
13
|
const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
|
|
14
14
|
const { buildDiffs } = require('./lib/diff');
|
|
15
|
+
const { getToken, sameToken } = require('./lib/token');
|
|
16
|
+
const { version: VERSION } = require('./package.json');
|
|
17
|
+
const updates = require('./lib/update');
|
|
18
|
+
const { createTerminals } = require('./lib/terminal');
|
|
19
|
+
const { readMemory, saveMemory, carriedContext } = require('./lib/memory');
|
|
15
20
|
|
|
16
21
|
const PORT = Number(process.env.AGENTER_PORT) || 4317;
|
|
17
22
|
const HOST = '127.0.0.1';
|
|
@@ -26,6 +31,8 @@ const MAX_ACTIVITY = 200;
|
|
|
26
31
|
const DISCONNECT_GRACE_MS = 15000;
|
|
27
32
|
// If you switch away from the dashboard (to VS Code, say) this long, same thing.
|
|
28
33
|
const BLUR_GRACE_MS = 5000;
|
|
34
|
+
// While this exists hook.js won't restart the server, so an app you quit stays quit.
|
|
35
|
+
const STOPPED_FILE = path.join(SETTINGS_DIR, 'stopped');
|
|
29
36
|
const DISCOVER_INTERVAL_MS = 30000;
|
|
30
37
|
const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches changes in between
|
|
31
38
|
// Plan usage (5-hour session / weekly limits) comes from the same endpoint Claude
|
|
@@ -33,6 +40,9 @@ const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches chan
|
|
|
33
40
|
const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
34
41
|
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
35
42
|
const USAGE_INTERVAL_MS = 2 * 60 * 1000;
|
|
43
|
+
// A window opening also checks, so a long-running server still tells you about updates.
|
|
44
|
+
const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
45
|
+
const UPDATE_THROTTLE_MS = 60 * 1000;
|
|
36
46
|
|
|
37
47
|
const sessions = new Map(); // session_id -> session
|
|
38
48
|
const pending = new Map(); // pending id -> { item, res, payload }
|
|
@@ -339,9 +349,12 @@ async function checkRollout(file) {
|
|
|
339
349
|
s.codexTurnOpen = tail.turnOpen;
|
|
340
350
|
s.status = codexStatus(s);
|
|
341
351
|
const last = tail.last;
|
|
352
|
+
// While working, only a running tool is current; the last reply may be from the previous turn.
|
|
342
353
|
s.lastAction =
|
|
343
|
-
s.status === 'working'
|
|
344
|
-
?
|
|
354
|
+
s.status === 'working'
|
|
355
|
+
? last?.kind === 'tool'
|
|
356
|
+
? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
|
|
357
|
+
: ''
|
|
345
358
|
: (lastAssistantText(file) || '').split('\n')[0].slice(0, 160);
|
|
346
359
|
return true;
|
|
347
360
|
} catch {
|
|
@@ -457,6 +470,56 @@ function noteCodexLimits(rl, at) {
|
|
|
457
470
|
codexUsage = { updatedAt: at, plan: rl.plan_type || '', limits, credits: rl.credits || null };
|
|
458
471
|
}
|
|
459
472
|
|
|
473
|
+
// ---------- updates ----------
|
|
474
|
+
|
|
475
|
+
let latestVersion = updates.cachedLatest();
|
|
476
|
+
let updateCheckedAt = 0;
|
|
477
|
+
|
|
478
|
+
async function checkForUpdate() {
|
|
479
|
+
if (!updates.enabled()) return;
|
|
480
|
+
updateCheckedAt = Date.now();
|
|
481
|
+
latestVersion = (await updates.checkLatest({ timeoutMs: 10000 })) || latestVersion;
|
|
482
|
+
broadcast();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function updateState() {
|
|
486
|
+
const installed = updates.installedVersion();
|
|
487
|
+
return {
|
|
488
|
+
current: VERSION,
|
|
489
|
+
latest: latestVersion,
|
|
490
|
+
installed,
|
|
491
|
+
available: updates.isNewer(latestVersion, VERSION) && !updates.isNewer(installed, VERSION),
|
|
492
|
+
restart: updates.isNewer(installed, VERSION), // installed, but this server is still the old code
|
|
493
|
+
canSelfUpdate: updates.canSelfUpdate(),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------- terminal ----------
|
|
498
|
+
|
|
499
|
+
const TERMINAL_ENABLED = process.platform === 'win32' && process.env.AGENTER_TERMINAL !== '0';
|
|
500
|
+
const terminals = createTerminals((e) => sendAll('terminal', e));
|
|
501
|
+
const terminalId = (cwd) => path.resolve(cwd).toLowerCase();
|
|
502
|
+
|
|
503
|
+
// ---------- folder window ----------
|
|
504
|
+
// The dashboard can't browse this machine's folders itself, so the server opens Windows' own.
|
|
505
|
+
|
|
506
|
+
let pickingFolder = null;
|
|
507
|
+
|
|
508
|
+
function pickFolder(start) {
|
|
509
|
+
return new Promise((resolve) => {
|
|
510
|
+
if (pickingFolder) return resolve({ busy: true });
|
|
511
|
+
if (process.platform !== 'win32') return resolve({ unsupported: true });
|
|
512
|
+
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', path.join(__dirname, 'scripts', 'pick-folder.ps1')];
|
|
513
|
+
pickingFolder = execFile('powershell.exe', args, { windowsHide: true, env: { ...process.env, AGENTER_PICK_START: String(start || '') } }, (err, out) => {
|
|
514
|
+
pickingFolder = null;
|
|
515
|
+
onAttentionChanged();
|
|
516
|
+
const folder = String(out || '').trim();
|
|
517
|
+
resolve(err ? { unsupported: true } : folder ? { path: folder } : { cancelled: true });
|
|
518
|
+
});
|
|
519
|
+
onAttentionChanged();
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
460
523
|
function openInVSCode(cwd) {
|
|
461
524
|
if (!cwd || /["&|<>^%]/.test(cwd) || !fs.existsSync(cwd)) return false;
|
|
462
525
|
const child =
|
|
@@ -543,7 +606,9 @@ async function findApps(cwd) {
|
|
|
543
606
|
function addActivity(s, kind, text, extra = {}) {
|
|
544
607
|
s.activity.push({ t: Date.now(), kind, text: truncate(text, 4000), ...extra });
|
|
545
608
|
if (s.activity.length > MAX_ACTIVITY) s.activity.splice(0, s.activity.length - MAX_ACTIVITY);
|
|
546
|
-
|
|
609
|
+
// lastAction is what the agent is doing. A prompt is what you said, so it starts blank
|
|
610
|
+
// (the dashboard shows "Working…") until the agent reports something.
|
|
611
|
+
s.lastAction = kind === 'prompt' ? '' : (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
|
|
547
612
|
}
|
|
548
613
|
|
|
549
614
|
// Who drives a session: 'vscode' (Agenter only answers inside VS Code's turns) or
|
|
@@ -562,7 +627,8 @@ function hold(s, kind, data, payload, res) {
|
|
|
562
627
|
const item = { id: crypto.randomUUID(), sessionId: s.id, kind, createdAt: Date.now(), headless: runners.has(s.id), ...data };
|
|
563
628
|
const entry = { item, res, payload, timer: null };
|
|
564
629
|
const minutes = Number(settings.holdMinutes) || 0;
|
|
565
|
-
|
|
630
|
+
// A turn Agenter runs has no VS Code to go back to, so it waits for your answer.
|
|
631
|
+
if (minutes > 0 && !item.headless) {
|
|
566
632
|
item.releaseAt = item.createdAt + minutes * 60000;
|
|
567
633
|
entry.timer = setTimeout(() => respond(item.id, 'pass', { auto: true }), minutes * 60000);
|
|
568
634
|
}
|
|
@@ -579,6 +645,17 @@ function hold(s, kind, data, payload, res) {
|
|
|
579
645
|
broadcast();
|
|
580
646
|
}
|
|
581
647
|
|
|
648
|
+
// A turn Agenter ran is over, so anything it was waiting on can't be answered any more.
|
|
649
|
+
function releaseRun(s) {
|
|
650
|
+
for (const [id, e] of [...pending]) {
|
|
651
|
+
if (e.item.sessionId !== s.id || !e.item.headless) continue;
|
|
652
|
+
clearTimeout(e.timer);
|
|
653
|
+
pending.delete(id);
|
|
654
|
+
e.res.writeHead(200, { 'content-type': 'application/json' });
|
|
655
|
+
e.res.end('{}');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
582
659
|
// action: allow | always | deny | answer | reply | pass
|
|
583
660
|
function respond(id, action, body = {}) {
|
|
584
661
|
const entry = pending.get(id);
|
|
@@ -640,7 +717,8 @@ function respond(id, action, body = {}) {
|
|
|
640
717
|
|
|
641
718
|
// Sessions are only held while a dashboard window has focus. If you're in VS Code,
|
|
642
719
|
// Claude finishes (or asks) there as usual instead of spinning on the dashboard.
|
|
643
|
-
|
|
720
|
+
// The folder window takes focus from the dashboard, but you're still here.
|
|
721
|
+
const inUse = () => !!pickingFolder || [...clients.values()].some((c) => c.focused);
|
|
644
722
|
|
|
645
723
|
let releaseTimer = null;
|
|
646
724
|
function onAttentionChanged() {
|
|
@@ -652,6 +730,39 @@ function onAttentionChanged() {
|
|
|
652
730
|
}, clients.size ? BLUR_GRACE_MS : DISCONNECT_GRACE_MS);
|
|
653
731
|
}
|
|
654
732
|
|
|
733
|
+
// Quit, from the tray. Open windows close themselves on the 'quit' event.
|
|
734
|
+
// A restart (to finish an update) leaves them open; they reconnect to the new server.
|
|
735
|
+
function stopServer({ restart = false } = {}) {
|
|
736
|
+
if (!restart) sendAll('quit', {});
|
|
737
|
+
for (const id of [...pending.keys()]) respond(id, 'pass');
|
|
738
|
+
for (const child of runners.values()) {
|
|
739
|
+
child.interrupted = true;
|
|
740
|
+
killRun(child);
|
|
741
|
+
}
|
|
742
|
+
terminals.killAll();
|
|
743
|
+
pickingFolder?.kill();
|
|
744
|
+
if (restart) {
|
|
745
|
+
// It waits for this process to let go of the port.
|
|
746
|
+
const env = { ...process.env, AGENTER_RESTART: '1' };
|
|
747
|
+
spawn(process.execPath, [path.join(__dirname, 'server.js'), '--no-open'], { detached: true, stdio: 'ignore', windowsHide: true, env }).unref();
|
|
748
|
+
} else {
|
|
749
|
+
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
750
|
+
fs.writeFileSync(STOPPED_FILE, '');
|
|
751
|
+
}
|
|
752
|
+
// Long enough for the hooks to receive those answers.
|
|
753
|
+
setTimeout(() => process.exit(0), 300);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// The tray icon. It watches this process, so it goes away when the server does.
|
|
757
|
+
function startTray() {
|
|
758
|
+
if (process.platform !== 'win32' || process.env.AGENTER_TRAY === '0') return;
|
|
759
|
+
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-WindowStyle', 'Hidden', '-File', path.join(__dirname, 'scripts', 'tray.ps1'), '-ServerPid', String(process.pid), '-Node', process.execPath, '-Port', String(PORT)];
|
|
760
|
+
// Not detached: PowerShell exits at once without a console.
|
|
761
|
+
const child = spawn('powershell.exe', args, { stdio: 'ignore', windowsHide: true });
|
|
762
|
+
child.on('error', () => {});
|
|
763
|
+
child.unref();
|
|
764
|
+
}
|
|
765
|
+
|
|
655
766
|
// ---------- steering ----------
|
|
656
767
|
// Messages typed in the dashboard while Claude isn't waiting for a reply. Hooks only run
|
|
657
768
|
// at events, so they wait here and go out with the next one: after the current tool
|
|
@@ -729,6 +840,7 @@ function startRun(s, text, { fresh = false } = {}) {
|
|
|
729
840
|
child.on('error', (e) => (err = e.message));
|
|
730
841
|
child.on('close', (code) => {
|
|
731
842
|
runners.delete(s.id);
|
|
843
|
+
releaseRun(s);
|
|
732
844
|
if (code && !child.interrupted) addActivity(s, 'notice', `Claude stopped with an error${err.trim() ? `: ${err.trim()}` : ''}`);
|
|
733
845
|
if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
|
|
734
846
|
settleStatus(s, 'idle');
|
|
@@ -742,6 +854,12 @@ function startRun(s, text, { fresh = false } = {}) {
|
|
|
742
854
|
return true;
|
|
743
855
|
}
|
|
744
856
|
|
|
857
|
+
// Ends a turn along with what it started: tools, and hooks still waiting on the dashboard.
|
|
858
|
+
function killRun(child) {
|
|
859
|
+
if (process.platform !== 'win32') return child.kill();
|
|
860
|
+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, (err) => err && child.kill());
|
|
861
|
+
}
|
|
862
|
+
|
|
745
863
|
// ---------- steering Codex ----------
|
|
746
864
|
// Driven from the Codex app (default): `codex queue` hands the message to Codex, which runs
|
|
747
865
|
// it as the next turn of that session in the VS Code extension or the desktop app.
|
|
@@ -780,22 +898,37 @@ function findCodex() {
|
|
|
780
898
|
return null;
|
|
781
899
|
}
|
|
782
900
|
|
|
901
|
+
// Windows only runs a .cmd through a shell, which would treat your message as commands.
|
|
902
|
+
// npm's codex.cmd just starts a script with Node, so start that script directly.
|
|
903
|
+
function codexCommand(bin) {
|
|
904
|
+
if (!bin.endsWith('.cmd')) return [bin];
|
|
905
|
+
try {
|
|
906
|
+
const script = fs.readFileSync(bin, 'utf8').match(/"%~?dp0%?[\\/]([^"]+\.js)"/i)?.[1];
|
|
907
|
+
if (script) return [process.execPath, path.join(path.dirname(bin), script)];
|
|
908
|
+
} catch {}
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
|
|
783
912
|
async function steerCodex(s, text) {
|
|
913
|
+
// A turn Agenter is running keeps the session here, even if the switch moved since it started.
|
|
914
|
+
if (runners.has(s.id)) {
|
|
915
|
+
// `exec` can't take messages mid-turn; this runs as the next turn.
|
|
916
|
+
s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
|
|
917
|
+
broadcast();
|
|
918
|
+
return 'queued';
|
|
919
|
+
}
|
|
784
920
|
if (driveOf(s) === 'agenter') {
|
|
785
|
-
if (runners.has(s.id)) {
|
|
786
|
-
// `exec` can't take messages mid-turn; this runs as the next turn.
|
|
787
|
-
s.queue.push({ id: crypto.randomUUID(), text, t: Date.now() });
|
|
788
|
-
broadcast();
|
|
789
|
-
return 'queued';
|
|
790
|
-
}
|
|
791
921
|
if (!(await startCodexRun(s, text))) return 'no-codex';
|
|
792
922
|
addActivity(s, 'prompt', text, { fromDashboard: true });
|
|
793
923
|
return 'started';
|
|
794
924
|
}
|
|
795
925
|
const bin = findCodex();
|
|
796
926
|
if (!bin) return 'no-codex';
|
|
927
|
+
const command = codexCommand(bin);
|
|
928
|
+
if (!command) return 'error';
|
|
929
|
+
const [file, ...prefix] = command;
|
|
797
930
|
return new Promise((resolve) => {
|
|
798
|
-
execFile(
|
|
931
|
+
execFile(file, [...prefix, 'queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000 }, (err) => {
|
|
799
932
|
if (err) return resolve('error');
|
|
800
933
|
addActivity(s, 'prompt', text, { fromDashboard: true });
|
|
801
934
|
resolve('codex-queued');
|
|
@@ -849,6 +982,7 @@ function startCodexRun(s, text, { fresh = false } = {}) {
|
|
|
849
982
|
// Never got going (Codex missing a login, bad folder…): report why to whoever asked.
|
|
850
983
|
if (runners.get(s.id) !== child) return resolve(fresh ? { error: failure || err.trim().split('\n').pop() || `Codex exited (${code}).` } : false);
|
|
851
984
|
runners.delete(s.id);
|
|
985
|
+
releaseRun(s);
|
|
852
986
|
s.codexTurnOpen = false;
|
|
853
987
|
if ((code || failure) && !child.interrupted) addActivity(s, 'notice', `Codex stopped with an error${failure || err.trim() ? `: ${failure || err.trim().split('\n').pop()}` : ''}`);
|
|
854
988
|
if (child.interrupted) addActivity(s, 'notice', 'Stopped from Agenter');
|
|
@@ -862,6 +996,15 @@ function startCodexRun(s, text, { fresh = false } = {}) {
|
|
|
862
996
|
});
|
|
863
997
|
}
|
|
864
998
|
|
|
999
|
+
// ---------- memory ----------
|
|
1000
|
+
|
|
1001
|
+
const memoryContext = (s) => ({ agent: s.agent, cwd: s.cwd, transcriptPath: s.transcriptPath, home: os.homedir(), codexHome: CODEX_HOME });
|
|
1002
|
+
|
|
1003
|
+
function carryFrom(s) {
|
|
1004
|
+
const t = s?.transcriptPath && readTranscript(s.transcriptPath);
|
|
1005
|
+
return t ? carriedContext({ agent: s.agent, cwd: s.cwd, title: s.title, messages: t.messages }) : '';
|
|
1006
|
+
}
|
|
1007
|
+
|
|
865
1008
|
// ---------- hook events ----------
|
|
866
1009
|
|
|
867
1010
|
function handleHook(p, res) {
|
|
@@ -944,6 +1087,9 @@ function handleHook(p, res) {
|
|
|
944
1087
|
|
|
945
1088
|
function snapshot() {
|
|
946
1089
|
return {
|
|
1090
|
+
version: VERSION,
|
|
1091
|
+
update: updateState(),
|
|
1092
|
+
terminal: TERMINAL_ENABLED,
|
|
947
1093
|
settings,
|
|
948
1094
|
usage,
|
|
949
1095
|
codexUsage,
|
|
@@ -953,14 +1099,18 @@ function snapshot() {
|
|
|
953
1099
|
};
|
|
954
1100
|
}
|
|
955
1101
|
|
|
1102
|
+
const sendAll = (event, data) => {
|
|
1103
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
1104
|
+
for (const c of clients.keys()) c.write(msg);
|
|
1105
|
+
};
|
|
1106
|
+
|
|
956
1107
|
let broadcastQueued = false;
|
|
957
1108
|
function broadcast() {
|
|
958
1109
|
if (broadcastQueued) return;
|
|
959
1110
|
broadcastQueued = true;
|
|
960
1111
|
setTimeout(() => {
|
|
961
1112
|
broadcastQueued = false;
|
|
962
|
-
|
|
963
|
-
for (const c of clients.keys()) c.write(msg);
|
|
1113
|
+
sendAll('state', snapshot());
|
|
964
1114
|
}, 30);
|
|
965
1115
|
}
|
|
966
1116
|
|
|
@@ -994,11 +1144,29 @@ const json = (res, code, obj) => {
|
|
|
994
1144
|
const STATIC = {
|
|
995
1145
|
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
996
1146
|
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
1147
|
+
'/theme.js': ['theme.js', 'text/javascript; charset=utf-8'],
|
|
997
1148
|
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
998
1149
|
'/icon.png': ['icon.png', 'image/png'],
|
|
999
1150
|
'/favicon.ico': ['agenter.ico', 'image/x-icon'],
|
|
1000
1151
|
};
|
|
1001
1152
|
|
|
1153
|
+
// frame-src stays open for http(s): Preview shows whatever app address you type.
|
|
1154
|
+
const CSP = [
|
|
1155
|
+
"default-src 'none'",
|
|
1156
|
+
"script-src 'self'",
|
|
1157
|
+
"style-src-elem 'self' https://fonts.googleapis.com",
|
|
1158
|
+
"style-src-attr 'unsafe-inline'",
|
|
1159
|
+
'font-src https://fonts.gstatic.com',
|
|
1160
|
+
"img-src 'self' data:",
|
|
1161
|
+
"connect-src 'self'",
|
|
1162
|
+
'frame-src http: https:',
|
|
1163
|
+
"base-uri 'none'",
|
|
1164
|
+
"form-action 'none'",
|
|
1165
|
+
"frame-ancestors 'none'",
|
|
1166
|
+
].join('; ');
|
|
1167
|
+
|
|
1168
|
+
const TOKEN = require.main === module ? getToken() : '';
|
|
1169
|
+
|
|
1002
1170
|
const ALLOWED_HOSTS = new Set([`127.0.0.1:${PORT}`, `localhost:${PORT}`]);
|
|
1003
1171
|
const ALLOWED_ORIGINS = new Set([...ALLOWED_HOSTS].map((h) => `http://${h}`));
|
|
1004
1172
|
|
|
@@ -1008,6 +1176,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1008
1176
|
return json(res, 403, { error: 'forbidden' });
|
|
1009
1177
|
}
|
|
1010
1178
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1179
|
+
// The page itself holds no secrets. Everything else needs the token; a custom header also
|
|
1180
|
+
// forces a CORS preflight, which we never approve. EventSource can't set headers.
|
|
1181
|
+
const given = req.headers['x-agenter-token'] ?? (url.pathname === '/events' ? url.searchParams.get('token') : null);
|
|
1182
|
+
if (!(req.method === 'GET' && STATIC[url.pathname]) && !sameToken(given, TOKEN)) return json(res, 401, { error: 'unauthorized' });
|
|
1011
1183
|
|
|
1012
1184
|
try {
|
|
1013
1185
|
if (req.method === 'POST' && url.pathname === '/hook') {
|
|
@@ -1016,7 +1188,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1016
1188
|
|
|
1017
1189
|
if (req.method === 'GET' && STATIC[url.pathname]) {
|
|
1018
1190
|
const [file, type] = STATIC[url.pathname];
|
|
1019
|
-
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
|
|
1191
|
+
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store', 'content-security-policy': CSP });
|
|
1020
1192
|
return res.end(fs.readFileSync(path.join(PUBLIC_DIR, file)));
|
|
1021
1193
|
}
|
|
1022
1194
|
|
|
@@ -1026,6 +1198,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1026
1198
|
clients.set(res, { id: url.searchParams.get('client') || '', focused: url.searchParams.get('focused') === '1' });
|
|
1027
1199
|
onAttentionChanged();
|
|
1028
1200
|
if (!usage || Date.now() - usage.fetchedAt > 60000) fetchUsage();
|
|
1201
|
+
if (Date.now() - updateCheckedAt > UPDATE_THROTTLE_MS) checkForUpdate();
|
|
1029
1202
|
const ping = setInterval(() => res.write(': ping\n\n'), 20000);
|
|
1030
1203
|
req.on('close', () => {
|
|
1031
1204
|
clearInterval(ping);
|
|
@@ -1052,11 +1225,52 @@ const server = http.createServer(async (req, res) => {
|
|
|
1052
1225
|
return json(res, 200, { apps: await findApps(s.cwd), detects: process.platform === 'win32' });
|
|
1053
1226
|
}
|
|
1054
1227
|
|
|
1228
|
+
if (req.method === 'GET' && url.pathname === '/api/memory') {
|
|
1229
|
+
const s = sessions.get(url.searchParams.get('session'));
|
|
1230
|
+
if (!s) return json(res, 404, { error: 'unknown session' });
|
|
1231
|
+
return json(res, 200, { files: readMemory(memoryContext(s)) });
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1055
1234
|
if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
|
|
1056
|
-
// A custom header forces a CORS preflight, which we never approve.
|
|
1057
|
-
if (req.headers['x-agenter'] !== '1') return json(res, 403, { error: 'forbidden' });
|
|
1058
1235
|
const body = await readBody(req);
|
|
1059
1236
|
|
|
1237
|
+
if (url.pathname === '/api/quit') {
|
|
1238
|
+
stopServer();
|
|
1239
|
+
return json(res, 200, {});
|
|
1240
|
+
}
|
|
1241
|
+
if (url.pathname === '/api/restart') {
|
|
1242
|
+
stopServer({ restart: true });
|
|
1243
|
+
return json(res, 200, {});
|
|
1244
|
+
}
|
|
1245
|
+
if (url.pathname === '/api/update') {
|
|
1246
|
+
if (!TERMINAL_ENABLED) return json(res, 400, { error: 'Run agenter update in a terminal.' });
|
|
1247
|
+
if (!updates.canSelfUpdate()) return json(res, 400, { error: 'This copy is a git clone. Update it with git pull.' });
|
|
1248
|
+
const id = terminalId(os.homedir());
|
|
1249
|
+
const term = terminals.open(id, os.homedir());
|
|
1250
|
+
if (term.running) return json(res, 409, { error: 'That terminal is busy. Wait for it to finish, or press Stop.' });
|
|
1251
|
+
const quote = (p) => `'${p.replace(/'/g, "''")}'`;
|
|
1252
|
+
terminals.run(id, `& ${quote(process.execPath)} ${quote(path.join(__dirname, 'cli.js'))} update`, checkForUpdate);
|
|
1253
|
+
return json(res, 200, { id });
|
|
1254
|
+
}
|
|
1255
|
+
if (url.pathname.startsWith('/api/terminal/')) {
|
|
1256
|
+
if (!TERMINAL_ENABLED) return json(res, 400, { error: 'The terminal is turned off or not available here.' });
|
|
1257
|
+
if (url.pathname === '/api/terminal/open') {
|
|
1258
|
+
const cwd = sessions.get(body.sessionId)?.cwd;
|
|
1259
|
+
const folder = cwd && fs.existsSync(cwd) ? cwd : os.homedir();
|
|
1260
|
+
return json(res, 200, terminals.open(terminalId(folder), folder));
|
|
1261
|
+
}
|
|
1262
|
+
const ok = url.pathname === '/api/terminal/run' ? terminals.run(body.id, String(body.input ?? '')) : url.pathname === '/api/terminal/stop' && terminals.stop(body.id);
|
|
1263
|
+
return json(res, ok ? 200 : 404, {});
|
|
1264
|
+
}
|
|
1265
|
+
if (url.pathname === '/api/pick-folder') {
|
|
1266
|
+
return json(res, 200, await pickFolder(body.start));
|
|
1267
|
+
}
|
|
1268
|
+
if (url.pathname === '/api/memory/save') {
|
|
1269
|
+
const s = sessions.get(body.sessionId);
|
|
1270
|
+
if (!s) return json(res, 404, { error: 'unknown session' });
|
|
1271
|
+
const { status, ...out } = saveMemory(memoryContext(s), body.path, body.text, body.mtime);
|
|
1272
|
+
return json(res, status, out);
|
|
1273
|
+
}
|
|
1060
1274
|
if (url.pathname === '/api/respond') {
|
|
1061
1275
|
const s = sessions.get(pending.get(body.id)?.item.sessionId);
|
|
1062
1276
|
const ok = respond(body.id, body.action, body);
|
|
@@ -1087,6 +1301,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1087
1301
|
const cwd = String(body.cwd || '').trim().replace(/^"(.*)"$/, '$1');
|
|
1088
1302
|
const text = String(body.message || '').trim();
|
|
1089
1303
|
const codex = body.agent === 'codex';
|
|
1304
|
+
const carried = carryFrom(sessions.get(body.fromSessionId));
|
|
1305
|
+
const prompt = carried ? `${text}\n\n${carried}` : text;
|
|
1090
1306
|
if (!text) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
|
|
1091
1307
|
let isDir = false;
|
|
1092
1308
|
try {
|
|
@@ -1098,7 +1314,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1098
1314
|
// Codex names the session itself, so it's registered once Codex has started it.
|
|
1099
1315
|
const draft = Object.assign(newSession(''), { agent: 'codex', title: text.split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
|
|
1100
1316
|
setCwd(draft, path.resolve(cwd));
|
|
1101
|
-
const started = await Promise.race([startCodexRun(draft,
|
|
1317
|
+
const started = await Promise.race([startCodexRun(draft, prompt, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
|
|
1102
1318
|
if (!started?.id) return json(res, 500, { error: `Codex couldn't start: ${started?.error || 'unknown error'}` });
|
|
1103
1319
|
settings.drive = { ...settings.drive, [started.id]: 'agenter' };
|
|
1104
1320
|
saveSettings();
|
|
@@ -1113,7 +1329,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1113
1329
|
s.lastEventAt = Date.now();
|
|
1114
1330
|
settings.drive = { ...settings.drive, [s.id]: 'agenter' };
|
|
1115
1331
|
saveSettings();
|
|
1116
|
-
if (!startRun(s,
|
|
1332
|
+
if (!startRun(s, prompt, { fresh: true })) {
|
|
1117
1333
|
sessions.delete(s.id);
|
|
1118
1334
|
return json(res, 500, { error: "Couldn't start Claude." });
|
|
1119
1335
|
}
|
|
@@ -1138,7 +1354,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1138
1354
|
const child = runners.get(body.sessionId);
|
|
1139
1355
|
if (child) {
|
|
1140
1356
|
child.interrupted = true;
|
|
1141
|
-
child
|
|
1357
|
+
killRun(child);
|
|
1142
1358
|
}
|
|
1143
1359
|
return json(res, child ? 200 : 404, {});
|
|
1144
1360
|
}
|
|
@@ -1187,7 +1403,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1187
1403
|
server.requestTimeout = 0; // hooks can legitimately wait a long time for you
|
|
1188
1404
|
// Only listen when run directly; the tests load this file to drive the hook and steering logic.
|
|
1189
1405
|
if (require.main === module) {
|
|
1406
|
+
let portRetries = process.env.AGENTER_RESTART ? 50 : 0;
|
|
1190
1407
|
server.on('error', (err) => {
|
|
1408
|
+
if (err.code === 'EADDRINUSE' && portRetries-- > 0) return void setTimeout(() => server.listen(PORT, HOST), 200);
|
|
1191
1409
|
if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
|
|
1192
1410
|
else console.error(err);
|
|
1193
1411
|
process.exit(1);
|
|
@@ -1195,10 +1413,18 @@ if (require.main === module) {
|
|
|
1195
1413
|
server.listen(PORT, HOST, () => {
|
|
1196
1414
|
const url = `http://localhost:${PORT}`;
|
|
1197
1415
|
console.log(`Agenter dashboard: ${url}`);
|
|
1416
|
+
delete process.env.AGENTER_RESTART;
|
|
1417
|
+
fs.rmSync(STOPPED_FILE, { force: true });
|
|
1418
|
+
// Shortcuts start us inside the install folder, and on Windows a folder that's in use
|
|
1419
|
+
// can't be replaced by `npm i -g`. Everything here uses full paths.
|
|
1420
|
+
process.chdir(os.homedir());
|
|
1421
|
+
startTray();
|
|
1198
1422
|
startDiscovery();
|
|
1199
1423
|
startCodexDiscovery();
|
|
1200
1424
|
fetchUsage();
|
|
1201
1425
|
setInterval(fetchUsage, USAGE_INTERVAL_MS);
|
|
1426
|
+
checkForUpdate();
|
|
1427
|
+
setInterval(checkForUpdate, UPDATE_INTERVAL_MS);
|
|
1202
1428
|
if (!process.argv.includes('--no-open')) openDashboard(url);
|
|
1203
1429
|
});
|
|
1204
1430
|
}
|
|
@@ -1207,5 +1433,5 @@ module.exports = {
|
|
|
1207
1433
|
sessions, pending, clients, runners, dismissed,
|
|
1208
1434
|
get settings() { return settings; },
|
|
1209
1435
|
handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
|
|
1210
|
-
findClaude, findCodex,
|
|
1436
|
+
findClaude, findCodex, codexCommand, releaseRun,
|
|
1211
1437
|
};
|