replicas-engine 0.1.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/README.md ADDED
@@ -0,0 +1,286 @@
1
+ # Replicas Engine
2
+
3
+ A lightweight REST API server that runs on each Replicas workspace, providing programmatic access to workspace functionality without requiring SSH access.
4
+
5
+ ## Overview
6
+
7
+ The Replicas Engine is automatically installed on every workspace and runs as a systemd service. It exposes a simple HTTP API protected by a secret key, allowing the Replicas backend to interact with workspaces for operations like reading logs, checking status, and more.
8
+
9
+ ## Features
10
+
11
+ - **Lightweight**: Built with Hono for minimal overhead
12
+ - **Secure**: All endpoints (except health check) require authentication via secret header
13
+ - **Automatic**: Installed and configured automatically on workspace creation
14
+ - **Reliable**: Runs as a systemd service with automatic restart
15
+
16
+ ## Installation
17
+
18
+ The engine is automatically installed during workspace initialization. For manual installation:
19
+
20
+ ```bash
21
+ yarn global add replicas-engine
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ The engine requires the following environment variables:
27
+
28
+ - `PORT` - Port to listen on (default: 3737)
29
+ - `REPLICAS_ENGINE_SECRET` - Secret key for authentication (required)
30
+ - `NODE_ENV` - Environment mode (production/development)
31
+ - `WORKSPACE_HOME` - Working directory for Codex (default: `/home/ubuntu`)
32
+
33
+ ### Running as a Service
34
+
35
+ The engine automatically runs as a systemd service named `replicas-engine`. You can manage it using:
36
+
37
+ ```bash
38
+ # Check status
39
+ systemctl status replicas-engine
40
+
41
+ # View logs
42
+ journalctl -u replicas-engine -f
43
+
44
+ # Restart service
45
+ sudo systemctl restart replicas-engine
46
+
47
+ # Stop service
48
+ sudo systemctl stop replicas-engine
49
+ ```
50
+
51
+ ## API Endpoints
52
+
53
+ ### Health Check
54
+
55
+ **GET /health**
56
+
57
+ Public endpoint for health checks (no authentication required).
58
+
59
+ ```bash
60
+ curl http://localhost:3737/health
61
+ ```
62
+
63
+ Response:
64
+ ```json
65
+ {
66
+ "status": "ok",
67
+ "timestamp": "2025-10-26T04:00:00.000Z"
68
+ }
69
+ ```
70
+
71
+ ### Ping
72
+
73
+ **GET /ping**
74
+
75
+ Authenticated endpoint for testing connectivity.
76
+
77
+ ```bash
78
+ curl http://localhost:3737/ping \
79
+ -H "X-Replicas-Engine-Secret: your-secret-here"
80
+ ```
81
+
82
+ Response:
83
+ ```json
84
+ {
85
+ "message": "pong",
86
+ "timestamp": "2025-10-26T04:00:00.000Z"
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Codex Integration
93
+
94
+ The engine provides remote control of Codex coding agents running on the workspace.
95
+
96
+ ### Send Message (with Streaming)
97
+
98
+ **POST /codex/send**
99
+
100
+ Send a message to Codex and stream events back via Server-Sent Events (SSE).
101
+
102
+ ```bash
103
+ curl -N http://localhost:3737/codex/send \
104
+ -H "X-Replicas-Engine-Secret: your-secret-here" \
105
+ -H "Content-Type: application/json" \
106
+ -d '{"message": "Add error handling to the login function"}'
107
+ ```
108
+
109
+ **Request Body:**
110
+ ```json
111
+ {
112
+ "message": "Your instruction to Codex"
113
+ }
114
+ ```
115
+
116
+ **Response (SSE Stream):**
117
+ ```
118
+ event: thread.started
119
+ data: {"thread_id":"019a1caa-bceb-7731-b607-e9d22c1933a5"}
120
+
121
+ event: turn.started
122
+ data: {}
123
+
124
+ event: item.started
125
+ data: {"item":{"id":"...","type":"agent_message","text":"..."}}
126
+
127
+ event: item.completed
128
+ data: {"item":{"id":"...","type":"agent_message","text":"I'll help add error handling..."}}
129
+
130
+ event: turn.completed
131
+ data: {"usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":0}}
132
+
133
+ event: done
134
+ data: {}
135
+ ```
136
+
137
+ **Event Types:**
138
+ - `thread.started` - New thread created (includes thread_id)
139
+ - `turn.started` - Turn begins
140
+ - `item.started` - New item (message, command, file change, etc.)
141
+ - `item.updated` - Item updated
142
+ - `item.completed` - Item finished
143
+ - `turn.completed` - Turn ends (includes token usage)
144
+ - `error` - Error occurred
145
+ - `done` - Stream completed
146
+
147
+ ### Get Conversation History
148
+
149
+ **GET /codex/history**
150
+
151
+ Retrieve the full conversation history from the JSONL session file.
152
+
153
+ ```bash
154
+ curl http://localhost:3737/codex/history \
155
+ -H "X-Replicas-Engine-Secret: your-secret-here"
156
+ ```
157
+
158
+ **Response:**
159
+ ```json
160
+ {
161
+ "thread_id": "019a1caa-bceb-7731-b607-e9d22c1933a5",
162
+ "events": [
163
+ {
164
+ "timestamp": "2025-10-25T18:39:02.919Z",
165
+ "type": "session_meta",
166
+ "payload": {...}
167
+ },
168
+ {
169
+ "timestamp": "2025-10-25T18:52:11.923Z",
170
+ "type": "response_item",
171
+ "payload": {"type": "message", "role": "user", ...}
172
+ }
173
+ ]
174
+ }
175
+ ```
176
+
177
+ ### Get Thread Status
178
+
179
+ **GET /codex/status**
180
+
181
+ Get information about the current Codex thread.
182
+
183
+ ```bash
184
+ curl http://localhost:3737/codex/status \
185
+ -H "X-Replicas-Engine-Secret: your-secret-here"
186
+ ```
187
+
188
+ **Response:**
189
+ ```json
190
+ {
191
+ "has_active_thread": true,
192
+ "thread_id": "019a1caa-bceb-7731-b607-e9d22c1933a5",
193
+ "session_file": "/home/ubuntu/.codex/sessions/2025/10/25/rollout-2025-10-25T18:39:02.891Z-019a1caa.jsonl",
194
+ "working_directory": "/home/ubuntu"
195
+ }
196
+ ```
197
+
198
+ ### Reset Thread
199
+
200
+ **POST /codex/reset**
201
+
202
+ Clear the current thread and start a fresh conversation.
203
+
204
+ ```bash
205
+ curl -X POST http://localhost:3737/codex/reset \
206
+ -H "X-Replicas-Engine-Secret: your-secret-here"
207
+ ```
208
+
209
+ **Response:**
210
+ ```json
211
+ {
212
+ "message": "Thread reset successfully",
213
+ "success": true
214
+ }
215
+ ```
216
+
217
+ ### Codex Usage Notes
218
+
219
+ - **Single Session**: Currently supports one active thread per workspace
220
+ - **Thread Continuity**: Subsequent calls to `/codex/send` continue the same conversation
221
+ - **Persistence**: Threads are automatically saved to `~/.codex/sessions/`
222
+ - **Working Directory**: Codex runs in `WORKSPACE_HOME` (default: `/home/ubuntu`)
223
+ - **Git Repository**: Git check is skipped to allow running in any directory
224
+
225
+ ---
226
+
227
+ ## Authentication
228
+
229
+ All endpoints (except `/health`) require the `X-Replicas-Engine-Secret` header:
230
+
231
+ ```
232
+ X-Replicas-Engine-Secret: <workspace-engine-secret>
233
+ ```
234
+
235
+ Requests without this header or with an invalid secret will receive a 401 Unauthorized response.
236
+
237
+ ## Development
238
+
239
+ ```bash
240
+ # Install dependencies
241
+ yarn install
242
+
243
+ # Run in development mode
244
+ yarn dev
245
+
246
+ # Build for production
247
+ yarn build
248
+
249
+ # Run built version
250
+ yarn start
251
+ ```
252
+
253
+ ## Security
254
+
255
+ - The engine only listens on port 3737
256
+ - All requests require a secret key
257
+ - The secret is generated uniquely per workspace
258
+ - The service runs as the workspace user (no root access)
259
+
260
+ ## Troubleshooting
261
+
262
+ ### Engine not responding
263
+
264
+ 1. Check if the service is running:
265
+ ```bash
266
+ systemctl status replicas-engine
267
+ ```
268
+
269
+ 2. Check the logs:
270
+ ```bash
271
+ journalctl -u replicas-engine -n 50
272
+ ```
273
+
274
+ 3. Verify the port is listening:
275
+ ```bash
276
+ sudo netstat -tlnp | grep 3737
277
+ ```
278
+
279
+ ### Authentication errors
280
+
281
+ - Ensure you're using the correct `engine_secret` from the workspace record
282
+ - Verify the `X-Replicas-Engine-Secret` header is being sent
283
+
284
+ ## License
285
+
286
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { serve } from '@hono/node-server';
4
+ import { Hono } from 'hono';
5
+ import { authMiddleware } from './middleware/auth.js';
6
+ import ping from './routes/ping.js';
7
+ import codex from './routes/codex.js';
8
+ const app = new Hono();
9
+ // Health check endpoint (no auth required)
10
+ app.get('/health', (c) => {
11
+ return c.json({ status: 'ok', timestamp: new Date().toISOString() });
12
+ });
13
+ // Apply auth middleware to all routes below
14
+ app.use('*', authMiddleware);
15
+ // Protected routes
16
+ app.route('/ping', ping);
17
+ app.route('/codex', codex);
18
+ const port = Number(process.env.PORT) || 3737;
19
+ serve({
20
+ fetch: app.fetch,
21
+ port,
22
+ }, (info) => {
23
+ console.log(`Replicas Engine running on port ${info.port}`);
24
+ });
@@ -0,0 +1,14 @@
1
+ export const authMiddleware = async (c, next) => {
2
+ const secret = c.req.header('X-Replicas-Engine-Secret');
3
+ const expectedSecret = process.env.REPLICAS_ENGINE_SECRET;
4
+ if (!expectedSecret) {
5
+ return c.json({ error: 'Server configuration error: REPLICAS_ENGINE_SECRET not set' }, 500);
6
+ }
7
+ if (!secret) {
8
+ return c.json({ error: 'Unauthorized: X-Replicas-Engine-Secret header required' }, 401);
9
+ }
10
+ if (secret !== expectedSecret) {
11
+ return c.json({ error: 'Unauthorized: Invalid secret' }, 401);
12
+ }
13
+ await next();
14
+ };
@@ -0,0 +1,112 @@
1
+ import { Hono } from 'hono';
2
+ import { stream } from 'hono/streaming';
3
+ import { CodexManager } from '../services/codex-manager.js';
4
+ const codex = new Hono();
5
+ // Create a singleton instance of CodexManager
6
+ const codexManager = new CodexManager();
7
+ /**
8
+ * POST /codex/send
9
+ * Send a message to Codex and stream events back via Server-Sent Events (SSE)
10
+ */
11
+ codex.post('/send', async (c) => {
12
+ try {
13
+ const body = await c.req.json();
14
+ const { message } = body;
15
+ if (!message || typeof message !== 'string') {
16
+ return c.json({ error: 'Message is required and must be a string' }, 400);
17
+ }
18
+ // Stream events using SSE
19
+ return stream(c, async (stream) => {
20
+ // Set SSE headers
21
+ stream.onAbort(() => {
22
+ console.log('Client aborted SSE connection');
23
+ });
24
+ try {
25
+ // Stream Codex events
26
+ for await (const event of codexManager.sendMessage(message)) {
27
+ // Format as Server-Sent Event
28
+ const sseData = `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
29
+ await stream.write(sseData);
30
+ }
31
+ // Send a final 'done' event to signal completion
32
+ await stream.write('event: done\ndata: {}\n\n');
33
+ }
34
+ catch (error) {
35
+ console.error('Error during Codex streaming:', error);
36
+ const errorData = `event: error\ndata: ${JSON.stringify({
37
+ message: error instanceof Error ? error.message : 'Unknown error occurred',
38
+ })}\n\n`;
39
+ await stream.write(errorData);
40
+ }
41
+ });
42
+ }
43
+ catch (error) {
44
+ console.error('Error in /codex/send:', error);
45
+ return c.json({
46
+ error: 'Failed to process message',
47
+ details: error instanceof Error ? error.message : 'Unknown error',
48
+ }, 500);
49
+ }
50
+ });
51
+ /**
52
+ * GET /codex/history
53
+ * Get the conversation history from the JSONL session file
54
+ */
55
+ codex.get('/history', async (c) => {
56
+ try {
57
+ const history = await codexManager.getHistory();
58
+ if (!history.thread_id) {
59
+ return c.json({
60
+ message: 'No active thread',
61
+ thread_id: null,
62
+ events: [],
63
+ });
64
+ }
65
+ return c.json(history);
66
+ }
67
+ catch (error) {
68
+ console.error('Error in /codex/history:', error);
69
+ return c.json({
70
+ error: 'Failed to retrieve history',
71
+ details: error instanceof Error ? error.message : 'Unknown error',
72
+ }, 500);
73
+ }
74
+ });
75
+ /**
76
+ * GET /codex/status
77
+ * Get current thread status and information
78
+ */
79
+ codex.get('/status', async (c) => {
80
+ try {
81
+ const status = await codexManager.getStatus();
82
+ return c.json(status);
83
+ }
84
+ catch (error) {
85
+ console.error('Error in /codex/status:', error);
86
+ return c.json({
87
+ error: 'Failed to retrieve status',
88
+ details: error instanceof Error ? error.message : 'Unknown error',
89
+ }, 500);
90
+ }
91
+ });
92
+ /**
93
+ * POST /codex/reset
94
+ * Reset the current thread and start fresh
95
+ */
96
+ codex.post('/reset', async (c) => {
97
+ try {
98
+ codexManager.reset();
99
+ return c.json({
100
+ message: 'Thread reset successfully',
101
+ success: true,
102
+ });
103
+ }
104
+ catch (error) {
105
+ console.error('Error in /codex/reset:', error);
106
+ return c.json({
107
+ error: 'Failed to reset thread',
108
+ details: error instanceof Error ? error.message : 'Unknown error',
109
+ }, 500);
110
+ }
111
+ });
112
+ export default codex;
@@ -0,0 +1,9 @@
1
+ import { Hono } from 'hono';
2
+ const ping = new Hono();
3
+ ping.get('/', (c) => {
4
+ return c.json({
5
+ message: 'pong',
6
+ timestamp: new Date().toISOString(),
7
+ });
8
+ });
9
+ export default ping;
@@ -0,0 +1,103 @@
1
+ import { Codex, Thread } from '@openai/codex-sdk';
2
+ import { findSessionFile, readJSONL } from '../utils/jsonl-reader.js';
3
+ /**
4
+ * Manages Codex thread lifecycle and session state
5
+ * Currently supports a single active thread per engine instance
6
+ */
7
+ export class CodexManager {
8
+ codex;
9
+ currentThreadId = null;
10
+ currentThread = null;
11
+ workingDirectory;
12
+ constructor(workingDirectory) {
13
+ this.codex = new Codex();
14
+ this.workingDirectory = workingDirectory || process.env.WORKSPACE_HOME || process.env.HOME || '/home/ubuntu';
15
+ }
16
+ /**
17
+ * Send a message to Codex and stream events back
18
+ * Creates a new thread on first call, resumes existing thread on subsequent calls
19
+ */
20
+ async *sendMessage(message) {
21
+ // Initialize or resume thread
22
+ if (!this.currentThread) {
23
+ if (this.currentThreadId) {
24
+ console.log(`Resuming thread ${this.currentThreadId}`);
25
+ this.currentThread = this.codex.resumeThread(this.currentThreadId, {
26
+ workingDirectory: this.workingDirectory,
27
+ skipGitRepoCheck: true,
28
+ });
29
+ }
30
+ else {
31
+ console.log('Starting new thread');
32
+ this.currentThread = this.codex.startThread({
33
+ workingDirectory: this.workingDirectory,
34
+ skipGitRepoCheck: true,
35
+ });
36
+ }
37
+ }
38
+ // Stream events from Codex
39
+ const { events } = await this.currentThread.runStreamed(message);
40
+ for await (const event of events) {
41
+ // Capture thread ID when thread starts
42
+ if (event.type === 'thread.started') {
43
+ this.currentThreadId = event.thread_id;
44
+ console.log(`Thread started: ${this.currentThreadId}`);
45
+ }
46
+ yield event;
47
+ }
48
+ }
49
+ /**
50
+ * Get conversation history by reading the JSONL session file
51
+ */
52
+ async getHistory() {
53
+ if (!this.currentThreadId) {
54
+ return {
55
+ thread_id: null,
56
+ events: [],
57
+ };
58
+ }
59
+ const sessionFile = await findSessionFile(this.currentThreadId);
60
+ if (!sessionFile) {
61
+ console.warn(`Session file not found for thread ${this.currentThreadId}`);
62
+ return {
63
+ thread_id: this.currentThreadId,
64
+ events: [],
65
+ };
66
+ }
67
+ console.log(`Reading session file: ${sessionFile}`);
68
+ const events = await readJSONL(sessionFile);
69
+ return {
70
+ thread_id: this.currentThreadId,
71
+ events,
72
+ };
73
+ }
74
+ /**
75
+ * Get current thread status
76
+ */
77
+ async getStatus() {
78
+ let sessionFile = null;
79
+ if (this.currentThreadId) {
80
+ sessionFile = await findSessionFile(this.currentThreadId);
81
+ }
82
+ return {
83
+ has_active_thread: this.currentThreadId !== null,
84
+ thread_id: this.currentThreadId,
85
+ session_file: sessionFile,
86
+ working_directory: this.workingDirectory,
87
+ };
88
+ }
89
+ /**
90
+ * Reset the current thread (start fresh conversation)
91
+ */
92
+ reset() {
93
+ console.log('Resetting Codex thread');
94
+ this.currentThread = null;
95
+ this.currentThreadId = null;
96
+ }
97
+ /**
98
+ * Get the current thread ID
99
+ */
100
+ getThreadId() {
101
+ return this.currentThreadId;
102
+ }
103
+ }
@@ -0,0 +1,127 @@
1
+ import { readFile, readdir, stat } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ /**
5
+ * Read and parse a JSONL file
6
+ */
7
+ export async function readJSONL(filePath) {
8
+ try {
9
+ const content = await readFile(filePath, 'utf-8');
10
+ return content
11
+ .split('\n')
12
+ .filter((line) => line.trim())
13
+ .map((line) => {
14
+ try {
15
+ return JSON.parse(line);
16
+ }
17
+ catch (e) {
18
+ console.error(`Failed to parse JSONL line: ${line}`, e);
19
+ return null;
20
+ }
21
+ })
22
+ .filter((event) => event !== null);
23
+ }
24
+ catch (error) {
25
+ console.error(`Failed to read JSONL file ${filePath}:`, error);
26
+ return [];
27
+ }
28
+ }
29
+ /**
30
+ * Find the session file for a given thread ID
31
+ * Sessions are stored in ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
32
+ */
33
+ export async function findSessionFile(threadId) {
34
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
35
+ try {
36
+ // Get current date for searching
37
+ const now = new Date();
38
+ const year = now.getFullYear();
39
+ const month = String(now.getMonth() + 1).padStart(2, '0');
40
+ const day = String(now.getDate()).padStart(2, '0');
41
+ // Search in today's directory first
42
+ const todayDir = join(sessionsDir, String(year), month, day);
43
+ const file = await findFileInDirectory(todayDir, threadId);
44
+ if (file)
45
+ return file;
46
+ // If not found, search recent days (last 7 days)
47
+ for (let daysAgo = 1; daysAgo <= 7; daysAgo++) {
48
+ const date = new Date(now);
49
+ date.setDate(date.getDate() - daysAgo);
50
+ const searchYear = date.getFullYear();
51
+ const searchMonth = String(date.getMonth() + 1).padStart(2, '0');
52
+ const searchDay = String(date.getDate()).padStart(2, '0');
53
+ const searchDir = join(sessionsDir, String(searchYear), searchMonth, searchDay);
54
+ const file = await findFileInDirectory(searchDir, threadId);
55
+ if (file)
56
+ return file;
57
+ }
58
+ return null;
59
+ }
60
+ catch (error) {
61
+ console.error('Error finding session file:', error);
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * Search for a file containing the thread ID in a specific directory
67
+ */
68
+ async function findFileInDirectory(directory, threadId) {
69
+ try {
70
+ const files = await readdir(directory);
71
+ for (const file of files) {
72
+ if (file.endsWith('.jsonl') && file.includes(threadId)) {
73
+ const fullPath = join(directory, file);
74
+ const stats = await stat(fullPath);
75
+ if (stats.isFile()) {
76
+ return fullPath;
77
+ }
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ catch (error) {
83
+ // Directory might not exist, which is fine
84
+ return null;
85
+ }
86
+ }
87
+ /**
88
+ * Get the most recent session file path (for debugging/info)
89
+ */
90
+ export async function getMostRecentSessionFile() {
91
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
92
+ try {
93
+ const now = new Date();
94
+ // Search last 7 days for any session file
95
+ for (let daysAgo = 0; daysAgo <= 7; daysAgo++) {
96
+ const date = new Date(now);
97
+ date.setDate(date.getDate() - daysAgo);
98
+ const year = date.getFullYear();
99
+ const month = String(date.getMonth() + 1).padStart(2, '0');
100
+ const day = String(date.getDate()).padStart(2, '0');
101
+ const searchDir = join(sessionsDir, String(year), month, day);
102
+ try {
103
+ const files = await readdir(searchDir);
104
+ const jsonlFiles = files
105
+ .filter((f) => f.endsWith('.jsonl'))
106
+ .map((f) => join(searchDir, f));
107
+ if (jsonlFiles.length > 0) {
108
+ // Return the most recent file based on modification time
109
+ const stats = await Promise.all(jsonlFiles.map(async (f) => ({
110
+ path: f,
111
+ mtime: (await stat(f)).mtime,
112
+ })));
113
+ stats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
114
+ return stats[0].path;
115
+ }
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ catch (error) {
124
+ console.error('Error finding most recent session file:', error);
125
+ return null;
126
+ }
127
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "replicas-engine",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight API server for Replicas workspaces",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "replicas-engine": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "dev": "tsx watch src/index.ts",
15
+ "build": "tsc && node scripts/postbuild.js",
16
+ "start": "node dist/index.js",
17
+ "prepublishOnly": "yarn build"
18
+ },
19
+ "keywords": [
20
+ "replicas",
21
+ "workspace",
22
+ "api",
23
+ "hono"
24
+ ],
25
+ "author": "Replicas",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@hono/node-server": "^1.19.5",
29
+ "@openai/codex-sdk": "^0.50.0",
30
+ "dotenv": "^17.2.3",
31
+ "hono": "^4.10.3"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^20.11.17",
35
+ "tsx": "^4.7.1",
36
+ "typescript": "^5.8.3"
37
+ }
38
+ }