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/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' && last?.kind === 'tool'
344
- ? `${last.name}: ${last.text.split('\n')[0]}`.slice(0, 160)
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
- s.lastAction = (extra.tool ? `${extra.tool}: ` : '') + String(text).split('\n')[0].slice(0, 160);
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
- if (minutes > 0) {
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
- 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);
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(bin, ['queue', '--thread', s.id, '--message', text], { windowsHide: true, timeout: 30000, shell: bin.endsWith('.cmd') }, (err) => {
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
- const msg = `event: state\ndata: ${JSON.stringify(snapshot())}\n\n`;
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, text, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
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, text, { fresh: true })) {
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.kill();
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
  };