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 +21 -0
- package/README.md +216 -203
- package/dist/server/src/app.js +3 -1
- package/dist/server/src/cli.js +1 -1
- package/dist/server/src/services/codex-rollout-sync.js +5 -16
- package/dist/server/src/services/event-store.js +28 -5
- package/dist/server/src/services/session-manager.js +37 -17
- package/dist/server/src/services/session-timeline-service.js +7 -169
- package/dist/server/src/utils/output-limits.js +73 -0
- package/package.json +3 -4
- package/web/session-timeline-reducer.js +66 -13
- package/docs/assets/approval-flow.png +0 -0
- package/docs/assets/hero-desktop.png +0 -0
- package/docs/assets/imported-session.png +0 -0
- package/docs/assets/mobile-session.png +0 -0
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
|
-
>
|
|
3
|
+
> Remote control for Codex.
|
|
4
|
+
> From your browser and phone.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Run Codex on one machine.
|
|
7
|
+
Monitor, approve, and control the same session from another.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
π https://remcodex.com
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
-
|
|
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
|
-
|
|
14
|
+

|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
---
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
## β¨ What is RemCodex?
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
RemCodex is **remote control for Codex**.
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
It lets you start Codex on one machine, then keep the same session visible,
|
|
23
|
+
interruptible, and controllable from another.
|
|
22
24
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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
|
-
|
|
31
|
+
> One session. Any device.
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
---
|
|
32
34
|
|
|
33
|
-
|
|
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
|
-
|
|
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
|

|
|
45
104
|
|
|
@@ -55,249 +114,203 @@ RemCodex turns Codex's event stream into a browser-based workspace that is easie
|
|
|
55
114
|
|
|
56
115
|

|
|
57
116
|
|
|
58
|
-
> Bring imported Codex rollouts into the same workspace
|
|
117
|
+
> Bring imported Codex rollouts into the same workspace.
|
|
118
|
+
|
|
119
|
+
---
|
|
59
120
|
|
|
60
|
-
##
|
|
121
|
+
## π§ What it actually is
|
|
61
122
|
|
|
62
|
-
|
|
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
|
-
|
|
125
|
+
It is built for real workflows:
|
|
68
126
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
127
|
+
- long-running sessions
|
|
128
|
+
- mobile check-ins
|
|
129
|
+
- approval prompts
|
|
130
|
+
- imported rollout history
|
|
131
|
+
- timeline-style execution flow
|
|
73
132
|
|
|
74
|
-
|
|
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
|
|
79
|
-
- Fixed composer
|
|
141
|
+
- Right-side execution timeline
|
|
142
|
+
- Fixed input composer
|
|
80
143
|
- Semantic timeline rendering for:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
144
|
+
- user messages
|
|
145
|
+
- assistant output
|
|
146
|
+
- thinking
|
|
147
|
+
- commands
|
|
148
|
+
- patches
|
|
149
|
+
- approvals
|
|
150
|
+
- system events
|
|
88
151
|
|
|
89
|
-
|
|
152
|
+
---
|
|
90
153
|
|
|
91
|
-
|
|
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
|
-
|
|
156
|
+
### Approvals
|
|
98
157
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
### Timeline
|
|
106
166
|
|
|
107
|
-
|
|
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
|
-
|
|
172
|
+
---
|
|
110
173
|
|
|
111
|
-
|
|
112
|
-
npm install
|
|
113
|
-
npm run build
|
|
114
|
-
npm link
|
|
115
|
-
remcodex start
|
|
116
|
-
```
|
|
174
|
+
### Imported sessions
|
|
117
175
|
|
|
118
|
-
|
|
176
|
+
- Import from `~/.codex/sessions/...`
|
|
177
|
+
- Keep syncing if still active
|
|
178
|
+
- Unified view with native sessions
|
|
179
|
+
|
|
180
|
+
---
|
|
119
181
|
|
|
120
|
-
|
|
121
|
-
|
|
182
|
+
## π§ Architecture
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
Codex CLI β Event stream β Semantic layer β Timeline β Web UI
|
|
122
186
|
```
|
|
123
187
|
|
|
124
|
-
|
|
188
|
+
---
|
|
125
189
|
|
|
126
|
-
##
|
|
190
|
+
## βοΈ Requirements
|
|
127
191
|
|
|
128
|
-
|
|
192
|
+
- Node.js
|
|
193
|
+
- Codex CLI (already working locally)
|
|
129
194
|
|
|
130
|
-
|
|
195
|
+
---
|
|
131
196
|
|
|
132
|
-
|
|
133
|
-
node dist/server/src/cli.js start --no-open
|
|
134
|
-
```
|
|
197
|
+
## βοΈ Configuration
|
|
135
198
|
|
|
136
|
-
|
|
199
|
+
Default port: **18840**
|
|
137
200
|
|
|
138
201
|
```bash
|
|
139
|
-
|
|
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
|
-
|
|
205
|
+
---
|
|
145
206
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
## π¦ Install FAQ
|
|
208
|
+
|
|
209
|
+
### Why does `npx remcodex` hang on Linux?
|
|
210
|
+
|
|
211
|
+
First install may compile native deps:
|
|
150
212
|
|
|
151
|
-
|
|
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
|
-
|
|
227
|
+
npm install -g remcodex
|
|
228
|
+
remcodex doctor
|
|
229
|
+
remcodex start
|
|
155
230
|
```
|
|
156
231
|
|
|
157
|
-
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
### Headless mode
|
|
158
235
|
|
|
159
236
|
```bash
|
|
160
|
-
|
|
161
|
-
npm run dev
|
|
237
|
+
npx remcodex --no-open
|
|
162
238
|
```
|
|
163
239
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
The app uses `codex app-server` as the primary runtime path.
|
|
240
|
+
---
|
|
167
241
|
|
|
168
|
-
|
|
242
|
+
## π§ How it works
|
|
169
243
|
|
|
170
|
-
1. Codex emits
|
|
171
|
-
2.
|
|
172
|
-
3.
|
|
173
|
-
4. Live updates
|
|
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
|
-
|
|
249
|
+
Result:
|
|
176
250
|
|
|
177
|
-
- smooth streaming
|
|
178
251
|
- recoverable sessions
|
|
179
|
-
-
|
|
180
|
-
-
|
|
252
|
+
- real-time UI
|
|
253
|
+
- consistent execution flow
|
|
181
254
|
|
|
182
|
-
|
|
255
|
+
---
|
|
183
256
|
|
|
184
|
-
|
|
257
|
+
## π Status
|
|
185
258
|
|
|
186
|
-
-
|
|
187
|
-
-
|
|
188
|
-
-
|
|
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
|
-
|
|
263
|
+
---
|
|
194
264
|
|
|
195
|
-
|
|
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
|
-
|
|
267
|
+
**Visibility**
|
|
201
268
|
|
|
202
|
-
-
|
|
203
|
-
-
|
|
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
|
-
|
|
272
|
+
**Control**
|
|
208
273
|
|
|
209
|
-
|
|
274
|
+
- fine-grained approvals
|
|
275
|
+
- safer execution
|
|
210
276
|
|
|
211
|
-
|
|
212
|
-
- `DATABASE_PATH`
|
|
213
|
-
- `PROJECT_ROOTS`
|
|
214
|
-
- `CODEX_COMMAND`
|
|
215
|
-
- `CODEX_MODE`
|
|
277
|
+
**Continuity**
|
|
216
278
|
|
|
217
|
-
|
|
279
|
+
- survive refresh / sleep
|
|
280
|
+
- stable long runs
|
|
218
281
|
|
|
219
|
-
|
|
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
|
-
|
|
284
|
+
- control from any device
|
|
256
285
|
|
|
257
|
-
|
|
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
|
-
|
|
288
|
+
- IDE integrations
|
|
289
|
+
- optional sharing
|
|
272
290
|
|
|
273
|
-
|
|
291
|
+
---
|
|
274
292
|
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
---
|
|
286
301
|
|
|
287
|
-
|
|
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
|
-
|
|
304
|
+
- no polished installer yet
|
|
305
|
+
- no desktop packaging
|
|
306
|
+
- no production-grade auth
|
|
307
|
+
- no release pipeline
|
|
294
308
|
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
312
|
+
---
|
|
300
313
|
|
|
301
|
-
|
|
314
|
+
## π License
|
|
302
315
|
|
|
303
|
-
|
|
316
|
+
MIT License
|
package/dist/server/src/app.js
CHANGED
|
@@ -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 ??
|
|
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();
|
package/dist/server/src/cli.js
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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.
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"description": "Control Codex from anywhere. Even on your phone.",
|
|
5
|
-
"license": "
|
|
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
|
-
:
|
|
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
|
-
?
|
|
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:
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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:
|
|
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:
|
|
602
|
-
|
|
603
|
-
|
|
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
|