agenter-app 1.1.0 → 1.3.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/server.js CHANGED
@@ -13,6 +13,10 @@ const { truncate, describeTool, readTail, readHead, parseCodexItem, readTranscri
13
13
  const { normalizeLimits, codexWindowLabel } = require('./lib/limits');
14
14
  const { buildDiffs } = require('./lib/diff');
15
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');
16
20
 
17
21
  const PORT = Number(process.env.AGENTER_PORT) || 4317;
18
22
  const HOST = '127.0.0.1';
@@ -36,6 +40,9 @@ const DISCOVER_FULL_SCAN_MS = 5 * 60 * 1000; // when a file watcher catches chan
36
40
  const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
37
41
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
38
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;
39
46
 
40
47
  const sessions = new Map(); // session_id -> session
41
48
  const pending = new Map(); // pending id -> { item, res, payload }
@@ -463,6 +470,56 @@ function noteCodexLimits(rl, at) {
463
470
  codexUsage = { updatedAt: at, plan: rl.plan_type || '', limits, credits: rl.credits || null };
464
471
  }
465
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
+
466
523
  function openInVSCode(cwd) {
467
524
  if (!cwd || /["&|<>^%]/.test(cwd) || !fs.existsSync(cwd)) return false;
468
525
  const child =
@@ -660,7 +717,8 @@ function respond(id, action, body = {}) {
660
717
 
661
718
  // Sessions are only held while a dashboard window has focus. If you're in VS Code,
662
719
  // Claude finishes (or asks) there as usual instead of spinning on the dashboard.
663
- const inUse = () => [...clients.values()].some((c) => c.focused);
720
+ // The folder window takes focus from the dashboard, but you're still here.
721
+ const inUse = () => !!pickingFolder || [...clients.values()].some((c) => c.focused);
664
722
 
665
723
  let releaseTimer = null;
666
724
  function onAttentionChanged() {
@@ -673,15 +731,24 @@ function onAttentionChanged() {
673
731
  }
674
732
 
675
733
  // Quit, from the tray. Open windows close themselves on the 'quit' event.
676
- function stopServer() {
677
- for (const c of clients.keys()) c.write('event: quit\ndata: {}\n\n');
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', {});
678
737
  for (const id of [...pending.keys()]) respond(id, 'pass');
679
738
  for (const child of runners.values()) {
680
739
  child.interrupted = true;
681
740
  killRun(child);
682
741
  }
683
- fs.mkdirSync(SETTINGS_DIR, { recursive: true });
684
- fs.writeFileSync(STOPPED_FILE, '');
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
+ }
685
752
  // Long enough for the hooks to receive those answers.
686
753
  setTimeout(() => process.exit(0), 300);
687
754
  }
@@ -929,6 +996,64 @@ function startCodexRun(s, text, { fresh = false } = {}) {
929
996
  });
930
997
  }
931
998
 
999
+ // ---------- images ----------
1000
+ // Hooks and the CLIs only carry text, so pasted images are saved here and their paths go
1001
+ // into the message. Claude opens them with its Read tool, Codex with its image viewer.
1002
+
1003
+ const UPLOADS_DIR = path.join(SETTINGS_DIR, 'uploads');
1004
+ const IMAGE_TYPES = { png: 'png', jpeg: 'jpg', gif: 'gif', webp: 'webp' };
1005
+ const MAX_IMAGES = 8;
1006
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
1007
+
1008
+ // images: data: URLs from the dashboard. Returns the saved files' paths.
1009
+ function saveImages(images) {
1010
+ if (!Array.isArray(images) || !images.length) return [];
1011
+ if (images.length > MAX_IMAGES) throw new Error(`Attach at most ${MAX_IMAGES} images.`);
1012
+ const files = images.map((src) => {
1013
+ const m = /^data:image\/(png|jpeg|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(src));
1014
+ if (!m) throw new Error('Only PNG, JPEG, GIF and WebP images can be attached.');
1015
+ const data = Buffer.from(m[2], 'base64');
1016
+ if (data.length > MAX_IMAGE_BYTES) throw new Error('Each image must be under 10 MB.');
1017
+ return { data, file: path.join(UPLOADS_DIR, `${crypto.randomUUID()}.${IMAGE_TYPES[m[1]]}`) };
1018
+ });
1019
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
1020
+ for (const f of files) fs.writeFileSync(f.file, f.data);
1021
+ return files.map((f) => f.file);
1022
+ }
1023
+
1024
+ function withImages(text, paths) {
1025
+ text = String(text || '').trim();
1026
+ if (!paths.length) return text;
1027
+ const list = paths.map((p) => `- ${p}`).join('\n');
1028
+ const note = `The user attached ${paths.length === 1 ? 'an image' : `${paths.length} images`}. Open ${paths.length === 1 ? 'it' : 'them'} to see:\n${list}`;
1029
+ return text ? `${text}\n\n${note}` : note;
1030
+ }
1031
+
1032
+ // Uploads only matter for the turn they went with; clear out old ones.
1033
+ function pruneUploads(maxAgeMs = 30 * 24 * 3600 * 1000) {
1034
+ let names = [];
1035
+ try {
1036
+ names = fs.readdirSync(UPLOADS_DIR);
1037
+ } catch {
1038
+ return;
1039
+ }
1040
+ for (const n of names) {
1041
+ const f = path.join(UPLOADS_DIR, n);
1042
+ try {
1043
+ if (Date.now() - fs.statSync(f).mtimeMs > maxAgeMs) fs.rmSync(f, { force: true });
1044
+ } catch {}
1045
+ }
1046
+ }
1047
+
1048
+ // ---------- memory ----------
1049
+
1050
+ const memoryContext = (s) => ({ agent: s.agent, cwd: s.cwd, transcriptPath: s.transcriptPath, home: os.homedir(), codexHome: CODEX_HOME });
1051
+
1052
+ function carryFrom(s) {
1053
+ const t = s?.transcriptPath && readTranscript(s.transcriptPath);
1054
+ return t ? carriedContext({ agent: s.agent, cwd: s.cwd, title: s.title, messages: t.messages }) : '';
1055
+ }
1056
+
932
1057
  // ---------- hook events ----------
933
1058
 
934
1059
  function handleHook(p, res) {
@@ -1011,6 +1136,9 @@ function handleHook(p, res) {
1011
1136
 
1012
1137
  function snapshot() {
1013
1138
  return {
1139
+ version: VERSION,
1140
+ update: updateState(),
1141
+ terminal: TERMINAL_ENABLED,
1014
1142
  settings,
1015
1143
  usage,
1016
1144
  codexUsage,
@@ -1020,14 +1148,18 @@ function snapshot() {
1020
1148
  };
1021
1149
  }
1022
1150
 
1151
+ const sendAll = (event, data) => {
1152
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1153
+ for (const c of clients.keys()) c.write(msg);
1154
+ };
1155
+
1023
1156
  let broadcastQueued = false;
1024
1157
  function broadcast() {
1025
1158
  if (broadcastQueued) return;
1026
1159
  broadcastQueued = true;
1027
1160
  setTimeout(() => {
1028
1161
  broadcastQueued = false;
1029
- const msg = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`;
1030
- for (const c of clients.keys()) c.write(msg);
1162
+ sendAll('state', snapshot());
1031
1163
  }, 30);
1032
1164
  }
1033
1165
 
@@ -1115,6 +1247,7 @@ const server = http.createServer(async (req, res) => {
1115
1247
  clients.set(res, { id: url.searchParams.get('client') || '', focused: url.searchParams.get('focused') === '1' });
1116
1248
  onAttentionChanged();
1117
1249
  if (!usage || Date.now() - usage.fetchedAt > 60000) fetchUsage();
1250
+ if (Date.now() - updateCheckedAt > UPDATE_THROTTLE_MS) checkForUpdate();
1118
1251
  const ping = setInterval(() => res.write(': ping\n\n'), 20000);
1119
1252
  req.on('close', () => {
1120
1253
  clearInterval(ping);
@@ -1141,6 +1274,12 @@ const server = http.createServer(async (req, res) => {
1141
1274
  return json(res, 200, { apps: await findApps(s.cwd), detects: process.platform === 'win32' });
1142
1275
  }
1143
1276
 
1277
+ if (req.method === 'GET' && url.pathname === '/api/memory') {
1278
+ const s = sessions.get(url.searchParams.get('session'));
1279
+ if (!s) return json(res, 404, { error: 'unknown session' });
1280
+ return json(res, 200, { files: readMemory(memoryContext(s)) });
1281
+ }
1282
+
1144
1283
  if (req.method === 'POST' && url.pathname.startsWith('/api/')) {
1145
1284
  const body = await readBody(req);
1146
1285
 
@@ -1148,7 +1287,44 @@ const server = http.createServer(async (req, res) => {
1148
1287
  stopServer();
1149
1288
  return json(res, 200, {});
1150
1289
  }
1290
+ if (url.pathname === '/api/restart') {
1291
+ stopServer({ restart: true });
1292
+ return json(res, 200, {});
1293
+ }
1294
+ if (url.pathname === '/api/update') {
1295
+ if (!TERMINAL_ENABLED) return json(res, 400, { error: 'Run agenter update in a terminal.' });
1296
+ if (!updates.canSelfUpdate()) return json(res, 400, { error: 'This copy is a git clone. Update it with git pull.' });
1297
+ const id = terminalId(os.homedir());
1298
+ const term = terminals.open(id, os.homedir());
1299
+ if (term.running) return json(res, 409, { error: 'That terminal is busy. Wait for it to finish, or press Stop.' });
1300
+ const quote = (p) => `'${p.replace(/'/g, "''")}'`;
1301
+ terminals.run(id, `& ${quote(process.execPath)} ${quote(path.join(__dirname, 'cli.js'))} update`, checkForUpdate);
1302
+ return json(res, 200, { id });
1303
+ }
1304
+ if (url.pathname.startsWith('/api/terminal/')) {
1305
+ if (!TERMINAL_ENABLED) return json(res, 400, { error: 'The terminal is turned off or not available here.' });
1306
+ if (url.pathname === '/api/terminal/open') {
1307
+ const cwd = sessions.get(body.sessionId)?.cwd;
1308
+ const folder = cwd && fs.existsSync(cwd) ? cwd : os.homedir();
1309
+ return json(res, 200, terminals.open(terminalId(folder), folder));
1310
+ }
1311
+ const ok = url.pathname === '/api/terminal/run' ? terminals.run(body.id, String(body.input ?? '')) : url.pathname === '/api/terminal/stop' && terminals.stop(body.id);
1312
+ return json(res, ok ? 200 : 404, {});
1313
+ }
1314
+ if (url.pathname === '/api/pick-folder') {
1315
+ return json(res, 200, await pickFolder(body.start));
1316
+ }
1317
+ if (url.pathname === '/api/memory/save') {
1318
+ const s = sessions.get(body.sessionId);
1319
+ if (!s) return json(res, 404, { error: 'unknown session' });
1320
+ const { status, ...out } = saveMemory(memoryContext(s), body.path, body.text, body.mtime);
1321
+ return json(res, status, out);
1322
+ }
1151
1323
  if (url.pathname === '/api/respond') {
1324
+ if (body.action === 'reply' && body.images?.length) {
1325
+ if (!pending.has(body.id)) return json(res, 404, {});
1326
+ body.message = withImages(body.message, saveImages(body.images));
1327
+ }
1152
1328
  const s = sessions.get(pending.get(body.id)?.item.sessionId);
1153
1329
  const ok = respond(body.id, body.action, body);
1154
1330
  // Handing something back to VS Code: bring up that project's window so it's right there.
@@ -1169,16 +1345,19 @@ const server = http.createServer(async (req, res) => {
1169
1345
  }
1170
1346
  if (url.pathname === '/api/session/steer') {
1171
1347
  const s = sessions.get(body.sessionId);
1172
- const text = String(body.message || '').trim();
1348
+ const hasImages = Array.isArray(body.images) && body.images.length > 0;
1173
1349
  // A closed Claude session can only be continued by Agenter; Codex queues for its app either way.
1174
- if (!s || !text || (s.agent !== 'codex' && driveOf(s) === 'vscode' && (s.status === 'dormant' || s.status === 'ended'))) return json(res, 404, {});
1175
- return json(res, 200, { result: await steer(s, text) });
1350
+ if (!s || !(String(body.message || '').trim() || hasImages) || (s.agent !== 'codex' && driveOf(s) === 'vscode' && (s.status === 'dormant' || s.status === 'ended'))) return json(res, 404, {});
1351
+ return json(res, 200, { result: await steer(s, withImages(body.message, saveImages(body.images))) });
1176
1352
  }
1177
1353
  if (url.pathname === '/api/session/new') {
1178
1354
  const cwd = String(body.cwd || '').trim().replace(/^"(.*)"$/, '$1');
1179
- const text = String(body.message || '').trim();
1355
+ const typed = String(body.message || '').trim();
1180
1356
  const codex = body.agent === 'codex';
1181
- if (!text) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1357
+ if (!typed && !body.images?.length) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1358
+ const text = withImages(typed, saveImages(body.images));
1359
+ const carried = carryFrom(sessions.get(body.fromSessionId));
1360
+ const prompt = carried ? `${text}\n\n${carried}` : text;
1182
1361
  let isDir = false;
1183
1362
  try {
1184
1363
  isDir = path.isAbsolute(cwd) && fs.statSync(cwd).isDirectory();
@@ -1187,9 +1366,9 @@ const server = http.createServer(async (req, res) => {
1187
1366
  if (codex) {
1188
1367
  if (!findCodex()) return json(res, 400, { error: "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe." });
1189
1368
  // Codex names the session itself, so it's registered once Codex has started it.
1190
- const draft = Object.assign(newSession(''), { agent: 'codex', title: text.split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1369
+ const draft = Object.assign(newSession(''), { agent: 'codex', title: (typed || 'Image').split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1191
1370
  setCwd(draft, path.resolve(cwd));
1192
- const started = await Promise.race([startCodexRun(draft, text, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1371
+ const started = await Promise.race([startCodexRun(draft, prompt, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1193
1372
  if (!started?.id) return json(res, 500, { error: `Codex couldn't start: ${started?.error || 'unknown error'}` });
1194
1373
  settings.drive = { ...settings.drive, [started.id]: 'agenter' };
1195
1374
  saveSettings();
@@ -1200,11 +1379,11 @@ const server = http.createServer(async (req, res) => {
1200
1379
  if (!findClaude()) return json(res, 400, { error: "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe." });
1201
1380
  const s = getOrCreateSession(crypto.randomUUID());
1202
1381
  setCwd(s, path.resolve(cwd));
1203
- s.title = text.split('\n')[0].slice(0, 140);
1382
+ s.title = (typed || 'Image').split('\n')[0].slice(0, 140);
1204
1383
  s.lastEventAt = Date.now();
1205
1384
  settings.drive = { ...settings.drive, [s.id]: 'agenter' };
1206
1385
  saveSettings();
1207
- if (!startRun(s, text, { fresh: true })) {
1386
+ if (!startRun(s, prompt, { fresh: true })) {
1208
1387
  sessions.delete(s.id);
1209
1388
  return json(res, 500, { error: "Couldn't start Claude." });
1210
1389
  }
@@ -1278,7 +1457,9 @@ const server = http.createServer(async (req, res) => {
1278
1457
  server.requestTimeout = 0; // hooks can legitimately wait a long time for you
1279
1458
  // Only listen when run directly; the tests load this file to drive the hook and steering logic.
1280
1459
  if (require.main === module) {
1460
+ let portRetries = process.env.AGENTER_RESTART ? 50 : 0;
1281
1461
  server.on('error', (err) => {
1462
+ if (err.code === 'EADDRINUSE' && portRetries-- > 0) return void setTimeout(() => server.listen(PORT, HOST), 200);
1282
1463
  if (err.code === 'EADDRINUSE') console.error(`Port ${PORT} is busy. Is Agenter already running? (Set AGENTER_PORT to use another port.)`);
1283
1464
  else console.error(err);
1284
1465
  process.exit(1);
@@ -1286,12 +1467,19 @@ if (require.main === module) {
1286
1467
  server.listen(PORT, HOST, () => {
1287
1468
  const url = `http://localhost:${PORT}`;
1288
1469
  console.log(`Agenter dashboard: ${url}`);
1470
+ delete process.env.AGENTER_RESTART;
1289
1471
  fs.rmSync(STOPPED_FILE, { force: true });
1472
+ // Shortcuts start us inside the install folder, and on Windows a folder that's in use
1473
+ // can't be replaced by `npm i -g`. Everything here uses full paths.
1474
+ process.chdir(os.homedir());
1290
1475
  startTray();
1291
1476
  startDiscovery();
1477
+ pruneUploads();
1292
1478
  startCodexDiscovery();
1293
1479
  fetchUsage();
1294
1480
  setInterval(fetchUsage, USAGE_INTERVAL_MS);
1481
+ checkForUpdate();
1482
+ setInterval(checkForUpdate, UPDATE_INTERVAL_MS);
1295
1483
  if (!process.argv.includes('--no-open')) openDashboard(url);
1296
1484
  });
1297
1485
  }
@@ -1300,5 +1488,5 @@ module.exports = {
1300
1488
  sessions, pending, clients, runners, dismissed,
1301
1489
  get settings() { return settings; },
1302
1490
  handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
1303
- findClaude, findCodex, codexCommand, releaseRun,
1491
+ findClaude, findCodex, codexCommand, releaseRun, saveImages, withImages, UPLOADS_DIR,
1304
1492
  };