remcodex 0.1.0-beta.7 β†’ 0.1.0-beta.9

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RemCodex contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,45 +1,104 @@
1
- # RemCodex
1
+ # πŸš€ RemCodex
2
2
 
3
- > Control Codex from anywhere. Even on your phone.
3
+ > Remote control for Codex.
4
+ > From your browser and phone.
4
5
 
5
- RemCodex is a local-first web UI for running, reviewing, approving, and resuming Codex sessions from your browser.
6
+ Run Codex on one machine.
7
+ Monitor, approve, and control the same session from another.
6
8
 
7
- It is built for the real workflow: long-running sessions, mobile check-ins, approval prompts, imported rollout history, and timeline-style execution flow.
9
+ 🌐 https://remcodex.com
8
10
 
9
- - Watch live Codex runs without staying in the terminal
10
- - Approve sensitive actions from a cleaner UI
11
- - Pick up the same session again after refresh, sleep, or reconnect
11
+ > Not a remote desktop. Not a proxy.
12
+ > A local-first way to control Codex away from the terminal.
12
13
 
13
- ## Status
14
+ ![RemCodex hero cover](docs/assets/hero-cover.png)
14
15
 
15
- This project is currently a **beta / developer preview**.
16
+ ---
16
17
 
17
- It is already usable for local and internal workflows, but it is not yet packaged as a one-click desktop app.
18
+ ## ✨ What is RemCodex?
18
19
 
19
- ## Why People Use It
20
+ RemCodex is **remote control for Codex**.
20
21
 
21
- Codex is powerful in the terminal, but many real workflows need a better control surface:
22
+ It lets you start Codex on one machine, then keep the same session visible,
23
+ interruptible, and controllable from another.
22
24
 
23
- - checking progress from your phone
24
- - watching a long run without babysitting a terminal window
25
- - approving writes from a cleaner interface
26
- - reopening a session after refresh, sleep, or reconnect
27
- - reviewing imported rollout history next to native sessions
25
+ - πŸ‘€ See what the AI is doing β€” in real time
26
+ - βœ… Approve or reject actions before execution
27
+ - ⏹ Interrupt or stop at any moment
28
+ - πŸ“± Access your session from any device
29
+ - πŸ”„ Sessions don’t break β€” they resume
28
30
 
29
- RemCodex turns Codex's event stream into a browser-based workspace that is easier to follow, easier to resume, and easier to operate.
31
+ > One session. Any device.
30
32
 
31
- ## What It Does
33
+ ---
32
34
 
33
- - Run Codex sessions from a browser
34
- - View sessions in a single-page workspace with a sidebar and execution timeline
35
- - Follow streaming assistant output, commands, patches, and approvals
36
- - Approve or reject file-system actions from the UI
37
- - Import existing Codex rollout sessions from `~/.codex/sessions/...`
38
- - Keep imported sessions in sync while they are still active
39
- - Resume stale sessions after the page comes back from background
40
- - Work well on desktop and on mobile
35
+ ## 🎬 A real workflow
41
36
 
42
- ## Screenshots
37
+ You start a long Codex session on your machine.
38
+
39
+ Then you leave your desk.
40
+
41
+ On your phone:
42
+
43
+ - you see progress in real time
44
+ - you receive an approval request
45
+ - you approve it
46
+
47
+ The session continues instantly.
48
+
49
+ > Everything else can disconnect β€” your session won’t.
50
+
51
+ ---
52
+
53
+ ## πŸ”₯ Why RemCodex exists
54
+
55
+ AI coding agents are powerful.
56
+ But today, they run like a black box.
57
+
58
+ You either:
59
+
60
+ - trust everything blindly
61
+ - or sit in front of your terminal watching it
62
+
63
+ RemCodex fixes that.
64
+
65
+ > AI is no longer a black box.
66
+
67
+ ---
68
+
69
+ ## ⚑ What it does
70
+
71
+ - Real-time execution timeline (messages, commands, approvals)
72
+ - Human-in-the-loop command approval
73
+ - Multi-device access to the same live session
74
+ - Resume after refresh, sleep, or reconnect
75
+ - Browser-based UI β€” **no extra client required**
76
+ - Works with Codex CLI
77
+
78
+ > No extra client install. Just open a browser.
79
+ > Your code never leaves your machine.
80
+
81
+ ---
82
+
83
+ ## πŸš€ Quick start
84
+
85
+ ```bash
86
+ npx remcodex
87
+ ```
88
+
89
+ Then open:
90
+
91
+ http://127.0.0.1:18840
92
+
93
+ Access from another device:
94
+
95
+ http://<your-ip>:18840
96
+
97
+ > Runs entirely on your local machine. No cloud, no data upload.
98
+
99
+ ---
100
+
101
+ ## πŸ–₯ Screenshots
43
102
 
44
103
  ![RemCodex desktop workspace](docs/assets/hero-desktop.png)
45
104
 
@@ -55,249 +114,203 @@ RemCodex turns Codex's event stream into a browser-based workspace that is easie
55
114
 
56
115
  ![RemCodex imported Codex session](docs/assets/imported-session.png)
57
116
 
58
- > Bring imported Codex rollouts into the same workspace and keep them easy to review.
117
+ > Bring imported Codex rollouts into the same workspace.
118
+
119
+ ---
59
120
 
60
- ## Who It Is For
121
+ ## 🧠 What it actually is
61
122
 
62
- - developers who already use Codex locally
63
- - people who want a browser-based control surface instead of raw terminal watching
64
- - teams who want to review or monitor runs from another device on the same network
65
- - anyone who wants approvals, timeline view, and imported rollout history in one place
123
+ RemCodex is a **browser-based workspace for Codex sessions**.
66
124
 
67
- ## Screens It Aims To Replace
125
+ It is built for real workflows:
68
126
 
69
- - terminal-only session watching
70
- - ad-hoc mobile remote desktop checks
71
- - raw log scrolling for approvals and command progress
72
- - fragmented session history between local and imported rollouts
127
+ - long-running sessions
128
+ - mobile check-ins
129
+ - approval prompts
130
+ - imported rollout history
131
+ - timeline-style execution flow
73
132
 
74
- ## Current Product Shape
133
+ Instead of raw terminal logs, you get a structured, visual timeline you can follow and control.
134
+
135
+ ---
136
+
137
+ ## 🧩 Current product shape
75
138
 
76
139
  - Single-page workspace UI
77
140
  - Left sidebar for session navigation
78
- - Right-side timeline / execution flow for the active session
79
- - Fixed composer at the bottom
141
+ - Right-side execution timeline
142
+ - Fixed input composer
80
143
  - Semantic timeline rendering for:
81
- - user messages
82
- - assistant commentary / final messages
83
- - thinking
84
- - commands
85
- - patches
86
- - approvals
87
- - system events
144
+ - user messages
145
+ - assistant output
146
+ - thinking
147
+ - commands
148
+ - patches
149
+ - approvals
150
+ - system events
88
151
 
89
- ## Tech Stack
152
+ ---
90
153
 
91
- - Backend: Node.js + TypeScript + Express + WebSocket
92
- - Database: SQLite via `better-sqlite3`
93
- - Terminal/runtime integration: `node-pty` + Codex app-server
94
- - Frontend: zero-build static web app (`web/`)
95
- - Markdown rendering: `markdown-it`
154
+ ## βš™οΈ Key behaviors
96
155
 
97
- ## Requirements
156
+ ### Approvals
98
157
 
99
- Before running this project, you should have:
158
+ - Writes inside working area β†’ auto allowed
159
+ - Writes outside β†’ require approval
160
+ - `Allow once` / `Allow for this turn` supported
161
+ - Approval history stays visible in timeline
100
162
 
101
- - Node.js installed
102
- - Codex CLI installed and already working locally
103
- - A machine where this app can access your local Codex data and working directories
163
+ ---
104
164
 
105
- This project is currently developed primarily around a local macOS workflow.
165
+ ### Timeline
106
166
 
107
- ## Quick Start
167
+ - Semantic rendering (not raw logs)
168
+ - Commands grouped into readable activity blocks
169
+ - Running / failed states clearly visible
170
+ - Smooth streaming + recovery after refresh
108
171
 
109
- For the current developer preview, the recommended local install path is:
172
+ ---
110
173
 
111
- ```bash
112
- npm install
113
- npm run build
114
- npm link
115
- remcodex start
116
- ```
174
+ ### Imported sessions
117
175
 
118
- Then open:
176
+ - Import from `~/.codex/sessions/...`
177
+ - Keep syncing if still active
178
+ - Unified view with native sessions
179
+
180
+ ---
119
181
 
120
- ```text
121
- http://127.0.0.1:3000
182
+ ## 🧠 Architecture
183
+
184
+ ```
185
+ Codex CLI β†’ Event stream β†’ Semantic layer β†’ Timeline β†’ Web UI
122
186
  ```
123
187
 
124
- If you want to make it reachable from your phone, expose the local machine on your LAN and open the same URL with your host IP.
188
+ ---
125
189
 
126
- ## Local CLI
190
+ ## βš™οΈ Requirements
127
191
 
128
- RemCodex already ships with a local CLI entrypoint, even though the npm package is not published yet.
192
+ - Node.js
193
+ - Codex CLI (already working locally)
129
194
 
130
- If you do not want to run `npm link`, you can call the built CLI directly:
195
+ ---
131
196
 
132
- ```bash
133
- node dist/server/src/cli.js start --no-open
134
- ```
197
+ ## βš™οΈ Configuration
135
198
 
136
- Useful commands:
199
+ Default port: **18840**
137
200
 
138
201
  ```bash
139
- node dist/server/src/cli.js doctor
140
- node dist/server/src/cli.js start --no-open
141
- node dist/server/src/cli.js version
202
+ PORT=18841 npx remcodex
142
203
  ```
143
204
 
144
- Use a specific database:
205
+ ---
145
206
 
146
- ```bash
147
- node dist/server/src/cli.js start --db ~/.remcodex/remcodex-demo.db --no-open
148
- node dist/server/src/cli.js doctor --db ~/.remcodex/remcodex-demo.db
149
- ```
207
+ ## πŸ“¦ Install FAQ
208
+
209
+ ### Why does `npx remcodex` hang on Linux?
210
+
211
+ First install may compile native deps:
150
212
 
151
- Planned install target after the npm package is published:
213
+ - `better-sqlite3`
214
+ - `node-pty`
215
+
216
+ Make sure you have:
217
+
218
+ - `python3`
219
+ - `make`
220
+ - `g++`
221
+
222
+ ---
223
+
224
+ ### Debug install issues
152
225
 
153
226
  ```bash
154
- npx remcodex
227
+ npm install -g remcodex
228
+ remcodex doctor
229
+ remcodex start
155
230
  ```
156
231
 
157
- ## Development
232
+ ---
233
+
234
+ ### Headless mode
158
235
 
159
236
  ```bash
160
- npm install
161
- npm run dev
237
+ npx remcodex --no-open
162
238
  ```
163
239
 
164
- ## How It Works
165
-
166
- The app uses `codex app-server` as the primary runtime path.
240
+ ---
167
241
 
168
- At a high level:
242
+ ## πŸ”§ How it works
169
243
 
170
- 1. Codex emits semantic events
171
- 2. The backend stores them in SQLite
172
- 3. The frontend reads an aggregated timeline view for initial load
173
- 4. Live updates continue over WebSocket with catch-up after refresh
244
+ 1. Codex emits events
245
+ 2. Backend stores them (SQLite)
246
+ 3. Frontend loads timeline snapshot
247
+ 4. Live updates stream via WebSocket
174
248
 
175
- This gives the UI:
249
+ Result:
176
250
 
177
- - smooth streaming
178
251
  - recoverable sessions
179
- - imported rollout support
180
- - a consistent execution timeline instead of raw terminal logs
252
+ - real-time UI
253
+ - consistent execution flow
181
254
 
182
- ## Key Behaviors
255
+ ---
183
256
 
184
- ### Approvals
257
+ ## πŸ“Š Status
185
258
 
186
- - Writes inside the working area usually pass directly
187
- - Writes outside the working area trigger approval
188
- - `Allow once` approves only the current request
189
- - `Allow for this turn` expands writable roots for the active turn
190
- - Historical approval records stay visible in the timeline
191
- - Only live approvals stay actionable in the bottom approval bar
259
+ - Beta / developer preview
260
+ - Local-first architecture
261
+ - No cloud dependency
192
262
 
193
- ### Imported Codex Sessions
263
+ ---
194
264
 
195
- - Existing Codex rollouts can be imported from local session history
196
- - Imported sessions keep their own source metadata
197
- - Imported sessions can continue syncing after you open them
198
- - Native sessions are excluded from the import picker
265
+ ## πŸ—Ί Roadmap
199
266
 
200
- ### Timeline and Execution Flow
267
+ **Visibility**
201
268
 
202
- - The UI renders semantic timeline items, not raw logs
203
- - Commands and patches can be grouped into lighter activity summaries
204
- - Running and failed commands remain visually important
205
- - The final thinking placeholder appears only at the end of the active flow
269
+ - fully observable execution
270
+ - clear action timeline
206
271
 
207
- ## Configuration
272
+ **Control**
208
273
 
209
- Supported environment variables:
274
+ - fine-grained approvals
275
+ - safer execution
210
276
 
211
- - `PORT`
212
- - `DATABASE_PATH`
213
- - `PROJECT_ROOTS`
214
- - `CODEX_COMMAND`
215
- - `CODEX_MODE`
277
+ **Continuity**
216
278
 
217
- For launch screenshots or demo data, you can rebuild a clean demo database with:
279
+ - survive refresh / sleep
280
+ - stable long runs
218
281
 
219
- ```bash
220
- DATABASE_PATH="$HOME/.remcodex/remcodex-demo.db" ~/.nvm/versions/node/v20.19.5/bin/node scripts/seed-launch-demo-data.js --clean
221
- ```
222
- - `REMOTE_HOSTS`
223
- - `ACTIVE_REMOTE_HOST`
224
-
225
- Notes:
226
-
227
- - The default runtime mode is `app-server`
228
- - `exec-json` is kept only as a fallback compatibility path
229
- - If `PROJECT_ROOTS` is not set, the app falls back to a broad local browsing root
230
-
231
- ## Project Structure
232
-
233
- ```text
234
- server/
235
- src/
236
- app.ts
237
- controllers/
238
- db/
239
- gateways/
240
- services/
241
- types/
242
- utils/
243
- web/
244
- index.html
245
- styles.css
246
- api.js
247
- session-ws.js
248
- app.js
249
- scripts/
250
- fix-node-pty-helper.js
251
- import-codex-rollout.js
252
- reset-semantic-demo-data.js
253
- ```
282
+ **Access**
254
283
 
255
- ## Main Endpoints
284
+ - control from any device
256
285
 
257
- - `GET /health`
258
- - `GET /api/codex/mode`
259
- - `GET /api/codex/status`
260
- - `GET /api/codex/quota`
261
- - `GET /api/sessions`
262
- - `GET /api/sessions/:sessionId`
263
- - `GET /api/sessions/:sessionId/timeline`
264
- - `GET /api/sessions/:sessionId/events`
265
- - `POST /api/sessions`
266
- - `POST /api/sessions/:sessionId/messages`
267
- - `POST /api/sessions/:sessionId/stop`
268
- - `POST /api/sessions/:sessionId/approvals/:requestId`
269
- - `WS /ws/sessions/:sessionId`
286
+ **Integration**
270
287
 
271
- ## What Is Not Finished Yet
288
+ - IDE integrations
289
+ - optional sharing
272
290
 
273
- This is the honest list:
291
+ ---
274
292
 
275
- - no polished installer yet
276
- - no desktop packaging yet
277
- - no full automated test suite yet
278
- - no production-grade auth / multi-user hardening yet
279
- - no release pipeline yet
280
-
281
- If you are comfortable cloning a repo and running a local Node app, you can use it today.
293
+ ## πŸ‘₯ Who it’s for
282
294
 
283
- ## Roadmap
295
+ - developers already using Codex
296
+ - people tired of terminal-only workflows
297
+ - anyone who wants **control, not just output**
298
+ - multi-device workflows
284
299
 
285
- Near-term:
300
+ ---
286
301
 
287
- - improve onboarding and installation
288
- - ship a cleaner public README and screenshots
289
- - add stronger regression coverage
290
- - harden long-running session recovery
291
- - continue refining the execution timeline UI
302
+ ## ⚠️ What’s not finished yet
292
303
 
293
- Later:
304
+ - no polished installer yet
305
+ - no desktop packaging
306
+ - no production-grade auth
307
+ - no release pipeline
294
308
 
295
- - package for easier local install
296
- - optional sync / multi-device helpers
297
- - stronger sharing, auditing, and team workflows
309
+ If you're comfortable running a local Node app β€”
310
+ you can use it today.
298
311
 
299
- ## License
312
+ ---
300
313
 
301
- No license has been added yet.
314
+ ## πŸ“„ License
302
315
 
303
- Until a license is added, assume this repository is **source-available for review only**, not open source for reuse.
316
+ MIT License
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_PORT = void 0;
6
7
  exports.resolvePackageRoot = resolvePackageRoot;
7
8
  exports.resolveDefaultDatabasePath = resolveDefaultDatabasePath;
8
9
  exports.startRemCodexServer = startRemCodexServer;
@@ -46,9 +47,10 @@ function resolvePackageRoot(startDir = __dirname) {
46
47
  function resolveDefaultDatabasePath() {
47
48
  return node_path_1.default.join((0, node_os_1.homedir)(), ".remcodex", "remcodex.db");
48
49
  }
50
+ exports.DEFAULT_PORT = 18840;
49
51
  function buildRemCodexServer(options = {}) {
50
52
  const repoRoot = options.repoRoot ? node_path_1.default.resolve(options.repoRoot) : resolvePackageRoot();
51
- const port = options.port ?? Number.parseInt(process.env.PORT ?? "3000", 10);
53
+ const port = options.port ?? Number.parseInt(process.env.PORT ?? String(exports.DEFAULT_PORT), 10);
52
54
  const databasePath = options.databasePath ??
53
55
  process.env.DATABASE_PATH ??
54
56
  resolveDefaultDatabasePath();
@@ -169,7 +169,7 @@ async function runStart(flags) {
169
169
  printError("Install Codex first, or set CODEX_COMMAND to the correct executable.");
170
170
  return 1;
171
171
  }
172
- const preferredPort = flags.port ?? Number.parseInt(process.env.PORT ?? "3000", 10);
172
+ const preferredPort = flags.port ?? Number.parseInt(process.env.PORT ?? String(app_1.DEFAULT_PORT), 10);
173
173
  const codexMode = process.env.CODEX_MODE === "exec-json" ? "exec-json" : "app-server";
174
174
  let started = null;
175
175
  let activePort = preferredPort;
@@ -9,6 +9,7 @@ const node_os_1 = __importDefault(require("node:os"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const errors_1 = require("../utils/errors");
11
11
  const ids_1 = require("../utils/ids");
12
+ const output_limits_1 = require("../utils/output-limits");
12
13
  function computeSourceRolloutHasOpenTurnFromRecords(records) {
13
14
  const openTurnIds = new Set();
14
15
  for (const record of records) {
@@ -571,22 +572,7 @@ function translateRolloutRecords(records, emitFromRecordIndex = 0) {
571
572
  : `call_${index}`;
572
573
  const started = commandStarts.get(callId) || null;
573
574
  const parsed = parseExecOutput(payload.output);
574
- if (parsed.outputText) {
575
- appendSemantic(index, {
576
- type: "command.output.delta",
577
- turnId: currentTurnId,
578
- messageId: null,
579
- callId,
580
- requestId: null,
581
- phase: null,
582
- stream: "stdout",
583
- payload: {
584
- stream: "stdout",
585
- textDelta: parsed.outputText,
586
- },
587
- timestamp,
588
- });
589
- }
575
+ const cappedOutput = parsed.outputText ? (0, output_limits_1.capTextValue)(parsed.outputText) : null;
590
576
  appendSemantic(index, {
591
577
  type: "command.end",
592
578
  turnId: currentTurnId,
@@ -598,6 +584,9 @@ function translateRolloutRecords(records, emitFromRecordIndex = 0) {
598
584
  payload: {
599
585
  command: parsed.commandLine || started?.commandPayload.command || null,
600
586
  cwd: started?.commandPayload.cwd || null,
587
+ stdout: cappedOutput?.text || null,
588
+ aggregatedOutput: cappedOutput?.text || null,
589
+ stdoutTruncated: cappedOutput?.truncated || undefined,
601
590
  status: parsed.exitCode == null
602
591
  ? "completed"
603
592
  : parsed.exitCode === 0
@@ -55,9 +55,24 @@ class EventStore {
55
55
  `)
56
56
  .get(id);
57
57
  const event = this.toPayload(row);
58
- this.captureLatestQuota(sessionId, event);
59
- this.emitter.emit(this.channel(sessionId), event);
60
- return event;
58
+ return this.publish(event);
59
+ }
60
+ publishTransient(sessionId, input, seq) {
61
+ const event = {
62
+ id: input.id?.trim() || (0, ids_1.createId)("evt"),
63
+ sessionId,
64
+ type: input.type,
65
+ seq,
66
+ timestamp: input.timestamp?.trim() || new Date().toISOString(),
67
+ turnId: input.turnId ?? null,
68
+ messageId: input.messageId ?? null,
69
+ callId: input.callId ?? null,
70
+ requestId: input.requestId ?? null,
71
+ phase: input.phase ?? null,
72
+ stream: this.normalizeStream(input.stream),
73
+ payload: input.payload ?? {},
74
+ };
75
+ return this.publish(event);
61
76
  }
62
77
  list(sessionId, options = {}) {
63
78
  const safeLimit = Math.max(1, Math.min(options.limit ?? 200, 200));
@@ -241,7 +256,7 @@ class EventStore {
241
256
  this.emitter.off(channel, listener);
242
257
  };
243
258
  }
244
- nextSeq(sessionId) {
259
+ latestSeq(sessionId) {
245
260
  const row = this.db
246
261
  .prepare(`
247
262
  SELECT COALESCE(MAX(seq), 0) AS current_seq
@@ -249,7 +264,15 @@ class EventStore {
249
264
  WHERE session_id = ?
250
265
  `)
251
266
  .get(sessionId);
252
- return row.current_seq + 1;
267
+ return row.current_seq;
268
+ }
269
+ nextSeq(sessionId) {
270
+ return this.latestSeq(sessionId) + 1;
271
+ }
272
+ publish(event) {
273
+ this.captureLatestQuota(event.sessionId, event);
274
+ this.emitter.emit(this.channel(event.sessionId), event);
275
+ return event;
253
276
  }
254
277
  toPayload(row) {
255
278
  return {
@@ -9,11 +9,13 @@ const node_os_1 = __importDefault(require("node:os"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const errors_1 = require("../utils/errors");
11
11
  const ids_1 = require("../utils/ids");
12
+ const output_limits_1 = require("../utils/output-limits");
12
13
  const codex_runner_1 = require("./codex-runner");
13
14
  const codex_stream_events_1 = require("./codex-stream-events");
14
15
  function nowIso() {
15
16
  return new Date().toISOString();
16
17
  }
18
+ const TRANSIENT_SEQ_STEP = 0.00001;
17
19
  function shouldAutotitleSession(title) {
18
20
  const normalized = String(title || "").trim();
19
21
  return (!normalized ||
@@ -277,6 +279,17 @@ class SessionManager {
277
279
  seq: event.seq,
278
280
  };
279
281
  }
282
+ stopSession(sessionId) {
283
+ const runtime = this.runners.get(sessionId);
284
+ if (!runtime || !runtime.runner.isAlive()) {
285
+ this.setStatus(sessionId, "idle");
286
+ return { accepted: true };
287
+ }
288
+ runtime.stopRequested = true;
289
+ this.setStatus(sessionId, "stopping");
290
+ runtime.runner.stop();
291
+ return { accepted: true };
292
+ }
280
293
  retryApprovalRequest(sessionId, requestId, codexLaunch) {
281
294
  const session = this.getSessionOrThrow(sessionId);
282
295
  const project = this.options.projectManager.getProject(session.project_id);
@@ -301,17 +314,6 @@ class SessionManager {
301
314
  turnId,
302
315
  };
303
316
  }
304
- stopSession(sessionId) {
305
- const runtime = this.runners.get(sessionId);
306
- if (!runtime || !runtime.runner.isAlive()) {
307
- this.setStatus(sessionId, "idle");
308
- return { accepted: true };
309
- }
310
- runtime.stopRequested = true;
311
- this.setStatus(sessionId, "stopping");
312
- runtime.runner.stop();
313
- return { accepted: true };
314
- }
315
317
  resolveApproval(sessionId, requestId, decision) {
316
318
  const runtime = this.runners.get(sessionId);
317
319
  if (!runtime?.runner.isAlive()) {
@@ -346,6 +348,7 @@ class SessionManager {
346
348
  const runtime = {
347
349
  runner,
348
350
  stopRequested: false,
351
+ transientSeqCursor: this.options.eventStore.latestSeq(sessionId),
349
352
  turnId,
350
353
  appTurnId: null,
351
354
  turnStarted: false,
@@ -945,6 +948,8 @@ class SessionManager {
945
948
  cwd: payload.cwd || null,
946
949
  stdout: "",
947
950
  stderr: "",
951
+ stdoutTruncated: false,
952
+ stderrTruncated: false,
948
953
  started: true,
949
954
  completed: false,
950
955
  });
@@ -971,14 +976,15 @@ class SessionManager {
971
976
  if (!current) {
972
977
  return;
973
978
  }
974
- if (stream === "stderr") {
975
- current.stderr += textDelta;
976
- }
977
- else {
978
- current.stdout += textDelta;
979
+ const targetKey = stream === "stderr" ? "stderr" : "stdout";
980
+ const truncatedKey = stream === "stderr" ? "stderrTruncated" : "stdoutTruncated";
981
+ const capped = (0, output_limits_1.appendCappedText)(current[targetKey], textDelta);
982
+ current[targetKey] = capped.nextText;
983
+ if (capped.truncated) {
984
+ current[truncatedKey] = true;
979
985
  }
980
986
  runtime.activeCommandCallId = callId;
981
- this.appendEvent(sessionId, {
987
+ this.publishTransientEvent(sessionId, runtime, {
982
988
  type: "command.output.delta",
983
989
  turnId: runtime.turnId,
984
990
  messageId: null,
@@ -1013,6 +1019,11 @@ class SessionManager {
1013
1019
  payload: {
1014
1020
  command: payload.command || current.command,
1015
1021
  cwd: payload.cwd || current.cwd,
1022
+ stdout: current.stdout || null,
1023
+ stderr: current.stderr || null,
1024
+ aggregatedOutput: current.stdout || current.stderr || null,
1025
+ stdoutTruncated: current.stdoutTruncated || undefined,
1026
+ stderrTruncated: current.stderrTruncated || undefined,
1016
1027
  status: payload.status || (payload.exitCode === 0 ? "completed" : "failed"),
1017
1028
  exitCode: payload.exitCode ?? null,
1018
1029
  durationMs: payload.durationMs ?? null,
@@ -1218,9 +1229,18 @@ class SessionManager {
1218
1229
  }
1219
1230
  appendEvent(sessionId, input) {
1220
1231
  const event = this.options.eventStore.append(sessionId, input);
1232
+ const runtime = this.runners.get(sessionId);
1233
+ if (runtime) {
1234
+ runtime.transientSeqCursor = Math.max(runtime.transientSeqCursor, Number(event.seq || 0));
1235
+ }
1221
1236
  this.touchSession(sessionId);
1222
1237
  return event;
1223
1238
  }
1239
+ publishTransientEvent(sessionId, runtime, input) {
1240
+ runtime.transientSeqCursor =
1241
+ Math.round((runtime.transientSeqCursor + TRANSIENT_SEQ_STEP) * 100000) / 100000;
1242
+ return this.options.eventStore.publishTransient(sessionId, input, runtime.transientSeqCursor);
1243
+ }
1224
1244
  touchSession(sessionId) {
1225
1245
  this.options.db
1226
1246
  .prepare(`
@@ -1,181 +1,19 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SessionTimelineService = void 0;
4
- const DEFAULT_TIMELINE_LIMIT = 200;
5
- const MAX_TIMELINE_LIMIT = 400;
6
- function clampLimit(limit) {
7
- const numeric = Number(limit || DEFAULT_TIMELINE_LIMIT);
8
- if (!Number.isFinite(numeric) || numeric <= 0) {
9
- return DEFAULT_TIMELINE_LIMIT;
10
- }
11
- return Math.max(1, Math.min(Math.trunc(numeric), MAX_TIMELINE_LIMIT));
12
- }
13
- function normalizeCursor(value) {
14
- const numeric = Number(value || 0);
15
- if (!Number.isFinite(numeric) || numeric <= 0) {
16
- return 0;
17
- }
18
- return Math.trunc(numeric);
19
- }
20
- function cloneEvent(event) {
21
- return {
22
- ...event,
23
- payload: event.payload && typeof event.payload === "object"
24
- ? { ...event.payload }
25
- : event.payload,
26
- };
27
- }
28
- function appendTextDelta(currentValue, nextValue) {
29
- if (!nextValue) {
30
- return currentValue || "";
31
- }
32
- return `${currentValue || ""}${nextValue}`;
33
- }
34
- function compareEvents(left, right) {
35
- if (left.seq !== right.seq) {
36
- return left.seq - right.seq;
37
- }
38
- return String(left.id || "").localeCompare(String(right.id || ""));
39
- }
40
- function upsertTimelineEvent(items, indexById, nextEvent) {
41
- const existing = indexById.get(nextEvent.id);
42
- if (existing) {
43
- Object.assign(existing, nextEvent);
44
- return existing;
45
- }
46
- const cloned = cloneEvent(nextEvent);
47
- items.push(cloned);
48
- indexById.set(cloned.id, cloned);
49
- return cloned;
50
- }
51
- function timelineAssistantDeltaId(event) {
52
- return `timeline:assistant:delta:${event.messageId || event.id}`;
53
- }
54
- function timelineReasoningDeltaId(event) {
55
- return `timeline:reasoning:delta:${event.messageId || event.id}`;
56
- }
57
- function timelineCommandOutputId(event) {
58
- return `timeline:command:output:${event.callId || event.id}:${event.stream || "stdout"}`;
59
- }
60
- function timelinePatchOutputId(event) {
61
- return `timeline:patch:output:${event.callId || event.id}`;
62
- }
63
- function aggregateSemanticTimeline(rawEvents) {
64
- const items = [];
65
- const indexById = new Map();
66
- rawEvents.forEach((event) => {
67
- switch (event.type) {
68
- case "message.assistant.delta": {
69
- const syntheticId = timelineAssistantDeltaId(event);
70
- const existing = indexById.get(syntheticId);
71
- upsertTimelineEvent(items, indexById, {
72
- ...cloneEvent(event),
73
- id: syntheticId,
74
- payload: {
75
- ...(event.payload || {}),
76
- textDelta: appendTextDelta(existing?.payload?.textDelta || "", String(event.payload?.textDelta || "")),
77
- },
78
- });
79
- break;
80
- }
81
- case "reasoning.delta": {
82
- const syntheticId = timelineReasoningDeltaId(event);
83
- const existing = indexById.get(syntheticId);
84
- const nextText = appendTextDelta(existing?.payload?.textDelta || "", String(event.payload?.textDelta || ""));
85
- upsertTimelineEvent(items, indexById, {
86
- ...cloneEvent(event),
87
- id: syntheticId,
88
- payload: {
89
- ...(event.payload || {}),
90
- textDelta: nextText,
91
- summary: event.payload?.summary ||
92
- existing?.payload?.summary ||
93
- nextText ||
94
- null,
95
- },
96
- });
97
- break;
98
- }
99
- case "command.output.delta": {
100
- const syntheticId = timelineCommandOutputId(event);
101
- const existing = indexById.get(syntheticId);
102
- upsertTimelineEvent(items, indexById, {
103
- ...cloneEvent(event),
104
- id: syntheticId,
105
- payload: {
106
- ...(event.payload || {}),
107
- textDelta: appendTextDelta(existing?.payload?.textDelta || "", String(event.payload?.textDelta || "")),
108
- stream: event.payload?.stream || event.stream || "stdout",
109
- },
110
- });
111
- break;
112
- }
113
- case "patch.output.delta": {
114
- const syntheticId = timelinePatchOutputId(event);
115
- const existing = indexById.get(syntheticId);
116
- upsertTimelineEvent(items, indexById, {
117
- ...cloneEvent(event),
118
- id: syntheticId,
119
- payload: {
120
- ...(event.payload || {}),
121
- textDelta: appendTextDelta(existing?.payload?.textDelta || "", String(event.payload?.textDelta || "")),
122
- },
123
- });
124
- break;
125
- }
126
- default:
127
- upsertTimelineEvent(items, indexById, event);
128
- break;
129
- }
130
- });
131
- return items.sort(compareEvents);
132
- }
133
- function paginateTimelineItems(items, options, lastSeq) {
134
- const limit = clampLimit(options.limit);
135
- const after = normalizeCursor(options.after);
136
- const before = normalizeCursor(options.before);
137
- if (before > 0) {
138
- const matches = items.filter((item) => item.seq < before);
139
- const hasMoreBefore = matches.length > limit;
140
- const pageItems = matches.slice(Math.max(0, matches.length - limit));
141
- return {
142
- items: pageItems,
143
- nextCursor: pageItems.at(-1)?.seq || after,
144
- beforeCursor: pageItems[0]?.seq || before,
145
- hasMoreBefore,
146
- lastSeq,
147
- };
148
- }
149
- if (after > 0) {
150
- const pageItems = items.filter((item) => item.seq > after).slice(0, limit);
151
- return {
152
- items: pageItems,
153
- nextCursor: pageItems.at(-1)?.seq || after,
154
- beforeCursor: pageItems[0]?.seq || 0,
155
- hasMoreBefore: pageItems.length > 0 ? pageItems[0].seq > 1 : items.length > 0,
156
- lastSeq,
157
- };
158
- }
159
- const hasMoreBefore = items.length > limit;
160
- const pageItems = items.slice(Math.max(0, items.length - limit));
161
- return {
162
- items: pageItems,
163
- nextCursor: pageItems.at(-1)?.seq || 0,
164
- beforeCursor: pageItems[0]?.seq || 0,
165
- hasMoreBefore,
166
- lastSeq,
167
- };
168
- }
169
4
  class SessionTimelineService {
170
5
  eventStore;
171
6
  constructor(eventStore) {
172
7
  this.eventStore = eventStore;
173
8
  }
174
9
  list(sessionId, options = {}) {
175
- const rawEvents = this.eventStore.listAll(sessionId);
176
- const lastSeq = rawEvents.at(-1)?.seq || 0;
177
- const aggregatedItems = aggregateSemanticTimeline(rawEvents);
178
- return paginateTimelineItems(aggregatedItems, options, lastSeq);
10
+ const page = this.eventStore.list(sessionId, options);
11
+ return {
12
+ ...page,
13
+ // The initial detail load only needs the latest observed seq so resume sync
14
+ // can continue from the newest page we fetched.
15
+ lastSeq: page.nextCursor || Math.max(0, Number(options.after || 0)),
16
+ };
179
17
  }
180
18
  }
181
19
  exports.SessionTimelineService = SessionTimelineService;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMMAND_STREAM_TRUNCATION_NOTICE = exports.MAX_PERSISTED_COMMAND_STREAM_CHARS = void 0;
4
+ exports.appendCappedText = appendCappedText;
5
+ exports.capTextValue = capTextValue;
6
+ exports.MAX_PERSISTED_COMMAND_STREAM_CHARS = 80 * 1024;
7
+ exports.COMMAND_STREAM_TRUNCATION_NOTICE = "\n\n[command output truncated]\n";
8
+ function normalizeMaxChars(maxChars, notice) {
9
+ const numeric = Number(maxChars || exports.MAX_PERSISTED_COMMAND_STREAM_CHARS);
10
+ if (!Number.isFinite(numeric) || numeric <= notice.length + 1) {
11
+ return exports.MAX_PERSISTED_COMMAND_STREAM_CHARS;
12
+ }
13
+ return Math.trunc(numeric);
14
+ }
15
+ function appendCappedText(currentText, nextDelta, options = {}) {
16
+ const notice = String(options.notice || exports.COMMAND_STREAM_TRUNCATION_NOTICE);
17
+ const safeCurrent = String(currentText || "");
18
+ const safeDelta = String(nextDelta || "");
19
+ if (!safeDelta) {
20
+ return {
21
+ nextText: safeCurrent,
22
+ appendedText: "",
23
+ truncated: false,
24
+ };
25
+ }
26
+ const maxChars = normalizeMaxChars(options.maxChars, notice);
27
+ const contentLimit = Math.max(0, maxChars - notice.length);
28
+ if (safeCurrent.endsWith(notice) || safeCurrent.length >= maxChars) {
29
+ return {
30
+ nextText: safeCurrent.length > maxChars ? safeCurrent.slice(0, maxChars) : safeCurrent,
31
+ appendedText: "",
32
+ truncated: true,
33
+ };
34
+ }
35
+ if (safeCurrent.length >= contentLimit) {
36
+ return {
37
+ nextText: `${safeCurrent.slice(0, contentLimit)}${notice}`,
38
+ appendedText: notice,
39
+ truncated: true,
40
+ };
41
+ }
42
+ if (safeCurrent.length + safeDelta.length <= contentLimit) {
43
+ return {
44
+ nextText: safeCurrent + safeDelta,
45
+ appendedText: safeDelta,
46
+ truncated: false,
47
+ };
48
+ }
49
+ const available = Math.max(0, contentLimit - safeCurrent.length);
50
+ const preserved = safeDelta.slice(0, available);
51
+ const appendedText = `${preserved}${notice}`;
52
+ return {
53
+ nextText: `${safeCurrent}${appendedText}`,
54
+ appendedText,
55
+ truncated: true,
56
+ };
57
+ }
58
+ function capTextValue(text, options = {}) {
59
+ const notice = String(options.notice || exports.COMMAND_STREAM_TRUNCATION_NOTICE);
60
+ const safeText = String(text || "");
61
+ const maxChars = normalizeMaxChars(options.maxChars, notice);
62
+ const contentLimit = Math.max(0, maxChars - notice.length);
63
+ if (safeText.length <= contentLimit) {
64
+ return {
65
+ text: safeText,
66
+ truncated: false,
67
+ };
68
+ }
69
+ return {
70
+ text: `${safeText.slice(0, contentLimit)}${notice}`,
71
+ truncated: true,
72
+ };
73
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "remcodex",
3
- "version": "0.1.0-beta.7",
3
+ "version": "0.1.0-beta.9",
4
4
  "description": "Control Codex from anywhere. Even on your phone.",
5
- "license": "UNLICENSED",
5
+ "license": "MIT",
6
6
  "bin": {
7
7
  "remcodex": "dist/server/src/cli.js"
8
8
  },
@@ -10,8 +10,7 @@
10
10
  "dist",
11
11
  "web",
12
12
  "scripts/fix-node-pty-helper.js",
13
- "README.md",
14
- "docs/assets"
13
+ "README.md"
15
14
  ],
16
15
  "scripts": {
17
16
  "postinstall": "node scripts/fix-node-pty-helper.js",
@@ -8,11 +8,35 @@ const TURN_STATUS_PRIORITY = {
8
8
  failed: 3,
9
9
  aborted: 4,
10
10
  };
11
+ const MAX_COMMAND_OUTPUT_CHARS = 80 * 1024;
12
+ const MAX_PATCH_OUTPUT_CHARS = 48 * 1024;
13
+ const OUTPUT_TRUNCATION_NOTICE = "\n\n[output truncated]\n";
11
14
 
12
15
  function createItemIndex() {
13
16
  return new Map();
14
17
  }
15
18
 
19
+ function clampOutputText(text, maxChars = MAX_COMMAND_OUTPUT_CHARS) {
20
+ const safeText = String(text || "");
21
+ if (!safeText) {
22
+ return "";
23
+ }
24
+ if (safeText.endsWith(OUTPUT_TRUNCATION_NOTICE)) {
25
+ return safeText;
26
+ }
27
+
28
+ const contentLimit = Math.max(0, maxChars - OUTPUT_TRUNCATION_NOTICE.length);
29
+ if (safeText.length <= contentLimit) {
30
+ return safeText;
31
+ }
32
+
33
+ return `${safeText.slice(0, contentLimit)}${OUTPUT_TRUNCATION_NOTICE}`;
34
+ }
35
+
36
+ function appendClampedOutput(currentText, textDelta, maxChars = MAX_COMMAND_OUTPUT_CHARS) {
37
+ return clampOutputText(`${String(currentText || "")}${String(textDelta || "")}`, maxChars);
38
+ }
39
+
16
40
  function nextTurnFallbackId(event) {
17
41
  return event?.turnId || `turn:${event?.id || crypto.randomUUID?.() || Date.now()}`;
18
42
  }
@@ -526,10 +550,18 @@ export function reduceTimeline(state, event) {
526
550
  const nextStdout =
527
551
  event.payload?.stream === "stderr"
528
552
  ? currentCommand?.stdout || ""
529
- : appendDeltaText(currentCommand?.stdout, event.payload?.textDelta);
553
+ : appendClampedOutput(
554
+ currentCommand?.stdout,
555
+ event.payload?.textDelta,
556
+ MAX_COMMAND_OUTPUT_CHARS,
557
+ );
530
558
  const nextStderr =
531
559
  event.payload?.stream === "stderr"
532
- ? appendDeltaText(currentCommand?.stderr, event.payload?.textDelta)
560
+ ? appendClampedOutput(
561
+ currentCommand?.stderr,
562
+ event.payload?.textDelta,
563
+ MAX_COMMAND_OUTPUT_CHARS,
564
+ )
533
565
  : currentCommand?.stderr || "";
534
566
  upsertCommand(state, event, turnId, {
535
567
  status: currentCommand?.status === "awaiting_approval" ? "awaiting_approval" : "running",
@@ -552,14 +584,22 @@ export function reduceTimeline(state, event) {
552
584
  status: completedStatus,
553
585
  command: event.payload?.command || state.commandsByCallId[event.callId]?.command || "",
554
586
  cwd: event.payload?.cwd || state.commandsByCallId[event.callId]?.cwd || null,
555
- stdout: event.payload?.stdout || state.commandsByCallId[event.callId]?.stdout || "",
556
- stderr: event.payload?.stderr || state.commandsByCallId[event.callId]?.stderr || "",
557
- output:
587
+ stdout: clampOutputText(
588
+ event.payload?.stdout || state.commandsByCallId[event.callId]?.stdout || "",
589
+ MAX_COMMAND_OUTPUT_CHARS,
590
+ ),
591
+ stderr: clampOutputText(
592
+ event.payload?.stderr || state.commandsByCallId[event.callId]?.stderr || "",
593
+ MAX_COMMAND_OUTPUT_CHARS,
594
+ ),
595
+ output: clampOutputText(
558
596
  event.payload?.aggregatedOutput ||
559
- event.payload?.formattedOutput ||
560
- event.payload?.output ||
561
- state.commandsByCallId[event.callId]?.output ||
562
- "",
597
+ event.payload?.formattedOutput ||
598
+ event.payload?.output ||
599
+ state.commandsByCallId[event.callId]?.output ||
600
+ "",
601
+ MAX_COMMAND_OUTPUT_CHARS,
602
+ ),
563
603
  exitCode:
564
604
  event.payload?.exitCode ?? state.commandsByCallId[event.callId]?.exitCode ?? null,
565
605
  duration: event.payload?.duration || state.commandsByCallId[event.callId]?.duration || null,
@@ -583,7 +623,11 @@ export function reduceTimeline(state, event) {
583
623
  const currentPatch = state.patchesByCallId[patchId];
584
624
  upsertPatch(state, event, turnId, {
585
625
  status: currentPatch?.status || "running",
586
- output: appendDeltaText(currentPatch?.output, event.payload?.textDelta),
626
+ output: appendClampedOutput(
627
+ currentPatch?.output,
628
+ event.payload?.textDelta,
629
+ MAX_PATCH_OUTPUT_CHARS,
630
+ ),
587
631
  outputStatus: "streaming",
588
632
  });
589
633
  setTurnStatus(turn, "running");
@@ -598,9 +642,18 @@ export function reduceTimeline(state, event) {
598
642
  status: patchStatus,
599
643
  patchText:
600
644
  event.payload?.patchText || state.patchesByCallId[event.callId]?.patchText || "",
601
- output: event.payload?.output || state.patchesByCallId[event.callId]?.output || "",
602
- stdout: event.payload?.stdout || state.patchesByCallId[event.callId]?.stdout || "",
603
- stderr: event.payload?.stderr || state.patchesByCallId[event.callId]?.stderr || "",
645
+ output: clampOutputText(
646
+ event.payload?.output || state.patchesByCallId[event.callId]?.output || "",
647
+ MAX_PATCH_OUTPUT_CHARS,
648
+ ),
649
+ stdout: clampOutputText(
650
+ event.payload?.stdout || state.patchesByCallId[event.callId]?.stdout || "",
651
+ MAX_PATCH_OUTPUT_CHARS,
652
+ ),
653
+ stderr: clampOutputText(
654
+ event.payload?.stderr || state.patchesByCallId[event.callId]?.stderr || "",
655
+ MAX_PATCH_OUTPUT_CHARS,
656
+ ),
604
657
  changes: event.payload?.changes || state.patchesByCallId[event.callId]?.changes || {},
605
658
  success:
606
659
  event.payload?.success ?? state.patchesByCallId[event.callId]?.success ?? null,
Binary file
Binary file
Binary file
Binary file