@ian2018cs/agenthub 0.1.16 → 0.1.17

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/dist/index.html CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-DuyUUXPp.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-DzJ3SGxJ.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BeVl62c0.js">
30
30
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-C_VWDoZS.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-utils-00TdZexr.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian2018cs/agenthub",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "A web-based UI for AI Agents",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
package/server/index.js CHANGED
@@ -54,9 +54,9 @@ import adminRoutes from './routes/admin.js';
54
54
  import usageRoutes from './routes/usage.js';
55
55
  import skillsRoutes from './routes/skills.js';
56
56
  import settingsRoutes from './routes/settings.js';
57
- import { initializeDatabase } from './database/db.js';
57
+ import { initializeDatabase, userDb } from './database/db.js';
58
58
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
59
- import { getUserPaths } from './services/user-directories.js';
59
+ import { getUserPaths, initCodexDirectories } from './services/user-directories.js';
60
60
  import { startUsageScanner } from './services/usage-scanner.js';
61
61
 
62
62
  // File system watcher for projects folder - per user
@@ -183,6 +183,7 @@ const app = express();
183
183
  const server = http.createServer(app);
184
184
 
185
185
  const ptySessionsMap = new Map();
186
+ const codexPtySessionsMap = new Map();
186
187
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
187
188
 
188
189
  // Single WebSocket server that handles both paths
@@ -599,6 +600,8 @@ wss.on('connection', (ws, request) => {
599
600
 
600
601
  if (pathname === '/shell') {
601
602
  handleShellConnection(ws, userData);
603
+ } else if (pathname === '/codex') {
604
+ handleCodexConnection(ws, userData);
602
605
  } else if (pathname === '/ws') {
603
606
  handleChatConnection(ws, userData);
604
607
  } else {
@@ -1047,7 +1050,225 @@ function handleShellConnection(ws, userData) {
1047
1050
  console.error('[ERROR] Shell WebSocket error:', error);
1048
1051
  });
1049
1052
  }
1050
- // Audio transcription endpoint
1053
+
1054
+ function handleCodexConnection(ws, userData) {
1055
+ console.log('🤖 Codex client connected');
1056
+ const userUuid = userData?.uuid || null;
1057
+ let codexProcess = null;
1058
+ let ptySessionKey = null;
1059
+ let outputBuffer = [];
1060
+
1061
+ ws.on('message', async (message) => {
1062
+ try {
1063
+ const data = JSON.parse(message);
1064
+ console.log('📨 Codex message received:', data.type);
1065
+
1066
+ if (data.type === 'init') {
1067
+ const projectPath = data.projectPath || process.cwd();
1068
+
1069
+ ptySessionKey = `codex_${projectPath}_${userUuid || 'default'}`;
1070
+
1071
+ const existingSession = codexPtySessionsMap.get(ptySessionKey);
1072
+ if (existingSession) {
1073
+ console.log('♻️ Reconnecting to existing Codex PTY session:', ptySessionKey);
1074
+ codexProcess = existingSession.pty;
1075
+ clearTimeout(existingSession.timeoutId);
1076
+
1077
+ ws.send(JSON.stringify({
1078
+ type: 'output',
1079
+ data: `\x1b[36m[Reconnected to existing Codex session]\x1b[0m\r\n`
1080
+ }));
1081
+
1082
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
1083
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered Codex messages`);
1084
+ existingSession.buffer.forEach(bufferedData => {
1085
+ ws.send(JSON.stringify({ type: 'output', data: bufferedData }));
1086
+ });
1087
+ }
1088
+
1089
+ existingSession.ws = ws;
1090
+ return;
1091
+ }
1092
+
1093
+ console.log('[INFO] Starting Codex in:', projectPath);
1094
+
1095
+ ws.send(JSON.stringify({
1096
+ type: 'output',
1097
+ data: `\x1b[36mStarting Codex in: ${projectPath}\x1b[0m\r\n`
1098
+ }));
1099
+
1100
+ try {
1101
+ const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
1102
+ const shellCommand = os.platform() === 'win32'
1103
+ ? `Set-Location -Path "${projectPath}"; codex`
1104
+ : `cd "${projectPath}" && codex`;
1105
+ const shellArgs = os.platform() === 'win32'
1106
+ ? ['-Command', shellCommand]
1107
+ : ['-c', shellCommand];
1108
+
1109
+ const termCols = data.cols || 80;
1110
+ const termRows = data.rows || 24;
1111
+ console.log('📐 Codex terminal dimensions:', termCols, 'x', termRows);
1112
+
1113
+ // Build environment with per-user isolated HOME for codex config isolation
1114
+ const codexEnv = {
1115
+ ...process.env,
1116
+ TERM: 'xterm-256color',
1117
+ COLORTERM: 'truecolor',
1118
+ FORCE_COLOR: '3',
1119
+ BROWSER: 'echo "OPEN_URL:"'
1120
+ };
1121
+
1122
+ if (userUuid) {
1123
+ const userPaths = getUserPaths(userUuid);
1124
+ // Set HOME to per-user fake home so codex reads <codexHomeDir>/.codex/
1125
+ // This completely isolates from the host user's ~/.codex
1126
+ codexEnv.HOME = userPaths.codexHomeDir;
1127
+ console.log('🔐 Set HOME for Codex user isolation:', userPaths.codexHomeDir);
1128
+ }
1129
+
1130
+ // Inject API credentials from server environment variables
1131
+ if (process.env.OPENAI_API_KEY) {
1132
+ codexEnv.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
1133
+ }
1134
+ if (process.env.OPENAI_BASE_URL) {
1135
+ codexEnv.OPENAI_BASE_URL = process.env.OPENAI_BASE_URL;
1136
+ }
1137
+
1138
+ codexProcess = pty.spawn(shell, shellArgs, {
1139
+ name: 'xterm-256color',
1140
+ cols: termCols,
1141
+ rows: termRows,
1142
+ cwd: os.homedir(),
1143
+ env: codexEnv
1144
+ });
1145
+
1146
+ console.log('🟢 Codex process started with PTY, PID:', codexProcess.pid);
1147
+
1148
+ codexPtySessionsMap.set(ptySessionKey, {
1149
+ pty: codexProcess,
1150
+ ws: ws,
1151
+ buffer: [],
1152
+ timeoutId: null,
1153
+ projectPath
1154
+ });
1155
+
1156
+ codexProcess.onData((rawData) => {
1157
+ const session = codexPtySessionsMap.get(ptySessionKey);
1158
+ if (!session) return;
1159
+
1160
+ if (session.buffer.length < 5000) {
1161
+ session.buffer.push(rawData);
1162
+ } else {
1163
+ session.buffer.shift();
1164
+ session.buffer.push(rawData);
1165
+ }
1166
+
1167
+ if (session.ws && session.ws.readyState === WebSocket.OPEN) {
1168
+ let outputData = rawData;
1169
+
1170
+ // URL detection
1171
+ const patterns = [
1172
+ /(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
1173
+ /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
1174
+ /Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
1175
+ /Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
1176
+ ];
1177
+
1178
+ patterns.forEach(pattern => {
1179
+ let match;
1180
+ while ((match = pattern.exec(rawData)) !== null) {
1181
+ const url = match[1];
1182
+ session.ws.send(JSON.stringify({ type: 'url_open', url }));
1183
+ if (pattern.source.includes('OPEN_URL')) {
1184
+ outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
1185
+ }
1186
+ }
1187
+ });
1188
+
1189
+ session.ws.send(JSON.stringify({ type: 'output', data: outputData }));
1190
+ }
1191
+ });
1192
+
1193
+ codexProcess.onExit((exitCode) => {
1194
+ console.log('🔚 Codex process exited with code:', exitCode.exitCode);
1195
+ const session = codexPtySessionsMap.get(ptySessionKey);
1196
+ if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
1197
+ session.ws.send(JSON.stringify({
1198
+ type: 'output',
1199
+ data: `\r\n\x1b[33mCodex exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
1200
+ }));
1201
+ }
1202
+ if (session && session.timeoutId) {
1203
+ clearTimeout(session.timeoutId);
1204
+ }
1205
+ codexPtySessionsMap.delete(ptySessionKey);
1206
+ codexProcess = null;
1207
+ });
1208
+
1209
+ } catch (spawnError) {
1210
+ console.error('[ERROR] Error spawning Codex process:', spawnError);
1211
+ ws.send(JSON.stringify({
1212
+ type: 'output',
1213
+ data: `\r\n\x1b[31mError starting Codex: ${spawnError.message}\x1b[0m\r\n`
1214
+ }));
1215
+ }
1216
+
1217
+ } else if (data.type === 'input') {
1218
+ if (codexProcess && codexProcess.write) {
1219
+ try {
1220
+ codexProcess.write(data.data);
1221
+ } catch (error) {
1222
+ console.error('Error writing to Codex process:', error);
1223
+ }
1224
+ }
1225
+ } else if (data.type === 'resize') {
1226
+ if (codexProcess && codexProcess.resize) {
1227
+ codexProcess.resize(data.cols, data.rows);
1228
+ }
1229
+ } else if (data.type === 'terminate') {
1230
+ console.log('🛑 Codex terminate request for session:', ptySessionKey);
1231
+ const session = codexPtySessionsMap.get(ptySessionKey);
1232
+ if (session) {
1233
+ if (session.timeoutId) clearTimeout(session.timeoutId);
1234
+ if (session.pty && session.pty.kill) session.pty.kill();
1235
+ codexPtySessionsMap.delete(ptySessionKey);
1236
+ }
1237
+ codexProcess = null;
1238
+ }
1239
+ } catch (error) {
1240
+ console.error('[ERROR] Codex WebSocket error:', error.message);
1241
+ if (ws.readyState === WebSocket.OPEN) {
1242
+ ws.send(JSON.stringify({
1243
+ type: 'output',
1244
+ data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
1245
+ }));
1246
+ }
1247
+ }
1248
+ });
1249
+
1250
+ ws.on('close', () => {
1251
+ console.log('🔌 Codex client disconnected');
1252
+ if (ptySessionKey) {
1253
+ const session = codexPtySessionsMap.get(ptySessionKey);
1254
+ if (session) {
1255
+ console.log('⏳ Codex PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);
1256
+ session.ws = null;
1257
+ session.timeoutId = setTimeout(() => {
1258
+ console.log('⏰ Codex PTY session timeout, killing process:', ptySessionKey);
1259
+ if (session.pty && session.pty.kill) {
1260
+ session.pty.kill();
1261
+ }
1262
+ codexPtySessionsMap.delete(ptySessionKey);
1263
+ }, PTY_SESSION_TIMEOUT);
1264
+ }
1265
+ }
1266
+ });
1267
+
1268
+ ws.on('error', (error) => {
1269
+ console.error('[ERROR] Codex WebSocket error:', error);
1270
+ });
1271
+ }
1051
1272
  app.post('/api/transcribe', authenticateToken, async (req, res) => {
1052
1273
  try {
1053
1274
  const multer = (await import('multer')).default;
@@ -1698,6 +1919,22 @@ async function startServer() {
1698
1919
  // Start usage scanner service
1699
1920
  startUsageScanner();
1700
1921
 
1922
+ // Migrate existing users: ensure codex directories and config files exist
1923
+ try {
1924
+ const allUsers = userDb.getAllUsers();
1925
+ for (const user of allUsers) {
1926
+ if (!user.uuid) continue;
1927
+ try {
1928
+ await initCodexDirectories(user.uuid);
1929
+ } catch (err) {
1930
+ console.error(`[WARN] Failed to init codex dirs for user ${user.uuid}:`, err.message);
1931
+ }
1932
+ }
1933
+ console.log(`[INFO] Codex directory migration complete for ${allUsers.length} user(s)`);
1934
+ } catch (err) {
1935
+ console.error('[WARN] Codex migration error:', err.message);
1936
+ }
1937
+
1701
1938
  // Projects watcher is now per-user, initialized when user connects via WebSocket
1702
1939
  });
1703
1940
  } catch (error) {
@@ -26,6 +26,7 @@ export function getUserPaths(userUuid) {
26
26
  skillsDir: path.join(DATA_DIR, 'user-data', userUuid, '.claude', 'skills'),
27
27
  skillsImportDir: path.join(DATA_DIR, 'user-data', userUuid, 'skills-import'),
28
28
  skillsRepoDir: path.join(DATA_DIR, 'user-data', userUuid, 'skills-repo'),
29
+ codexHomeDir: path.join(DATA_DIR, 'user-data', userUuid, 'codex-home'),
29
30
  };
30
31
  }
31
32
 
@@ -38,6 +39,44 @@ export function getPublicPaths() {
38
39
  };
39
40
  }
40
41
 
42
+ /**
43
+ * Initialize codex home directory and config files for a user.
44
+ * Safe to call multiple times - only creates files that don't already exist.
45
+ */
46
+ export async function initCodexDirectories(userUuid) {
47
+ validateUuid(userUuid);
48
+ const paths = getUserPaths(userUuid);
49
+ const codexDir = path.join(paths.codexHomeDir, '.codex');
50
+
51
+ await fs.mkdir(codexDir, { recursive: true });
52
+
53
+ // Write config.toml (overwrite to keep in sync with env)
54
+ const baseUrl = process.env.OPENAI_BASE_URL || '';
55
+ const configToml = `model_provider = "custom"
56
+
57
+ [model_providers.custom]
58
+ name = "custom"
59
+ wire_api = "responses"
60
+ requires_openai_auth = true
61
+ base_url = "${baseUrl}"
62
+ `;
63
+ await fs.writeFile(path.join(codexDir, 'config.toml'), configToml, 'utf8');
64
+
65
+ // Write auth.json only if it doesn't exist, to avoid overwriting user tokens
66
+ const authJsonPath = path.join(codexDir, 'auth.json');
67
+ try {
68
+ await fs.access(authJsonPath);
69
+ // File exists - skip to preserve any user-level overrides
70
+ } catch {
71
+ const apiKey = process.env.OPENAI_API_KEY || '';
72
+ const authJson = JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2);
73
+ await fs.writeFile(authJsonPath, authJson, 'utf8');
74
+ }
75
+
76
+ console.log(`[Codex] Initialized codex directories for user ${userUuid}`);
77
+ return codexDir;
78
+ }
79
+
41
80
  /**
42
81
  * Initialize directories for a new user
43
82
  */
@@ -49,6 +88,9 @@ export async function initUserDirectories(userUuid) {
49
88
  await fs.mkdir(paths.claudeDir, { recursive: true });
50
89
  await fs.mkdir(paths.projectsDir, { recursive: true });
51
90
 
91
+ // Initialize codex home directory with config files
92
+ await initCodexDirectories(userUuid);
93
+
52
94
  // Create projects directory for Claude session files
53
95
  const projectsDir = path.join(paths.claudeDir, 'projects');
54
96
  await fs.mkdir(projectsDir, { recursive: true });