oh-my-agent-web 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +484 -0
- package/bin/oh-my-agent-web-ps.mjs +203 -0
- package/bin/oh-my-agent-web-ws.mjs +593 -0
- package/bin/oh-my-agent-web.mjs +321 -0
- package/bin/update-check.mjs +142 -0
- package/dist/server/codex-client.js +310 -0
- package/dist/server/codex-supervisor.js +446 -0
- package/dist/server/fs-service.js +144 -0
- package/dist/server/index.js +543 -0
- package/dist/server/request-inspector.js +458 -0
- package/dist/shared/protocol.js +112 -0
- package/package.json +82 -0
- package/scripts/manage-server.mjs +155 -0
- package/scripts/omaw.mjs +74 -0
- package/shared/protocol.ts +228 -0
- package/web/dist/assets/index-B8-VzDf2.css +1 -0
- package/web/dist/assets/index-DeC2eiiq.js +42 -0
- package/web/dist/assets/markdown-CYXe6JZx.js +55 -0
- package/web/dist/favicon.svg +12 -0
- package/web/dist/index.html +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# oh-my-agent-web
|
|
2
|
+
|
|
3
|
+
A browser cockpit for the [Codex CLI](https://github.com/openai/codex) `app-server`.
|
|
4
|
+
Chat, tool calls, diffs and approval dialogs in one tab.
|
|
5
|
+
|
|
6
|
+
The server is a **transparent JSON-RPC proxy + process supervisor**. It starts
|
|
7
|
+
`codex app-server` on boot, keeps it alive, and lets the browser invoke any
|
|
8
|
+
app-server method — while adding a few local `omaw/*` helpers.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
browser <--ws /ws--> oh-my-agent-web server <--ws--> codex app-server (ws://127.0.0.1:25258)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
The published package is **`oh-my-agent-web`**. It installs two equivalent
|
|
17
|
+
commands: **`oh-my-agent-web`** and the short alias **`omaw`**.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# global install
|
|
21
|
+
npm install -g oh-my-agent-web
|
|
22
|
+
npm install -g oh-my-agent-web@latest # force the newest version
|
|
23
|
+
|
|
24
|
+
# or run without installing
|
|
25
|
+
npx oh-my-agent-web # start the UI
|
|
26
|
+
npx oh-my-agent-web ws # WS client (default ws://127.0.0.1:25258)
|
|
27
|
+
npx oh-my-agent-web ws --port 25258 # WS client on an explicit port
|
|
28
|
+
npx oh-my-agent-web ps # list processes and ports
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
After a global install, `oh-my-agent-web` and `omaw` are the same program:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
omaw # http://127.0.0.1:25257 (= oh-my-agent-web)
|
|
35
|
+
omaw ws # interactive WS client
|
|
36
|
+
omaw ws --port 25259
|
|
37
|
+
omaw ps # processes + ports
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> Requires Node.js >= 22. If `npx oh-my-agent-web` fails to find the binary
|
|
41
|
+
> on your setup, use `npx -p oh-my-agent-web oh-my-agent-web ...` or a global
|
|
42
|
+
> install instead.
|
|
43
|
+
|
|
44
|
+
### Update notification
|
|
45
|
+
|
|
46
|
+
Every run of `oh-my-agent-web` (including the `ws` and `ps` subcommands) checks npm for
|
|
47
|
+
a newer version and prints a short notice when one exists:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Update available: oh-my-agent-web 0.1.0 → 0.2.0
|
|
51
|
+
npm install -g oh-my-agent-web@latest
|
|
52
|
+
(disable this check with OMAW_NO_UPDATE_CHECK=1)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The check hits the registry at most once per 24h (result cached in
|
|
56
|
+
`~/.cache/oh-my-agent-web/update-check.json`), never blocks on errors, and is skipped
|
|
57
|
+
for `--help` / `--version` / `--json`.
|
|
58
|
+
|
|
59
|
+
| Env | Default | Meaning |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `OMAW_NO_UPDATE_CHECK` | *(unset)* | `1` disables the version check |
|
|
62
|
+
| `OMAW_UPDATE_CHECK_INTERVAL_MS` | `86400000` (24h) | Minimum gap between registry checks |
|
|
63
|
+
|
|
64
|
+
Also skipped automatically when `CI` or `NO_UPDATE_NOTIFIER` is set.
|
|
65
|
+
|
|
66
|
+
## Quick start (from source)
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install
|
|
70
|
+
npm run build # builds web/ (Vite) + server/ (tsc)
|
|
71
|
+
npm start # http://127.0.0.1:25257
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`npm start` boots the server, spawns `codex app-server --listen ws://127.0.0.1:25258`
|
|
75
|
+
(unless a healthy one is already running), and opens your browser. The app-server
|
|
76
|
+
is started as-is: by default oh-my-agent-web injects **no** `chatgpt_base_url` /
|
|
77
|
+
`model_providers.capture.base_url` overrides and runs no capture proxy. Set
|
|
78
|
+
`OMAW_REQUEST_INSPECTOR=1` to opt into the loopback request/response inspector
|
|
79
|
+
(which is what makes each session show live request/response logs).
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm run dev
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Runs the backend (`tsx watch`, port **25257**) and mounts Vite **in the same
|
|
88
|
+
process** as middleware (`OMAW_DEV_WEB=1`). There is only one port and one entry
|
|
89
|
+
point — **http://127.0.0.1:25257** — which serves the live frontend source (with
|
|
90
|
+
HMR) and the `/ws` hub from the same origin. No build step, no second dev port.
|
|
91
|
+
|
|
92
|
+
Use `npm run build:web` (or `npm start`) when you want the static `web/dist`
|
|
93
|
+
bundle instead. The frontend lives in `web/`; backend in `server/`.
|
|
94
|
+
|
|
95
|
+
To iterate on the UI without spending model tokens, `web/dev-mock-server.mjs`
|
|
96
|
+
is a scripted stand-in for the backend that speaks the same protocol and
|
|
97
|
+
replays a recorded turn (reasoning, streamed message, command output, diff,
|
|
98
|
+
token usage, an approval request). It listens on `:25257/ws`, so the Vite proxy
|
|
99
|
+
picks it up with no config change. This is the one flow where the plain `vite`
|
|
100
|
+
CLI is the entry point (there is no backend to host it as middleware), so open
|
|
101
|
+
the URL Vite prints — `npm run dev:web`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
node web/dev-mock-server.mjs # 代替 npm run dev(它只提供 :25257/ws)
|
|
105
|
+
npm run dev:web
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Other useful scripts:
|
|
109
|
+
|
|
110
|
+
| Script | What it does |
|
|
111
|
+
| ------------------------ | ----------------------------------------------------- |
|
|
112
|
+
| `npm run build:server` | `tsc -p tsconfig.server.json` → `dist/` |
|
|
113
|
+
| `npm run stop` | Gracefully stops the service on `OMAW_PORT` (default 25257) |
|
|
114
|
+
| `npm run restart` | Stops and starts the service again |
|
|
115
|
+
| `npm run typecheck` | Typechecks server + web without emitting |
|
|
116
|
+
| `npm run smoke` | End-to-end smoke test against a real codex app-server |
|
|
117
|
+
| `npm run ws` | WebSocket client for the app-server (see below) |
|
|
118
|
+
| `npm run omaw` | Run from source: no args → dev stack on http://127.0.0.1:25257 (Vite in-process, no build) |
|
|
119
|
+
| `npm run omaw -- <args>` | Run the CLI from source, e.g. `npm run omaw -- ps`, `npm run omaw -- ws -p 25259` |
|
|
120
|
+
|
|
121
|
+
## CLI
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
oh-my-agent-web [options]
|
|
125
|
+
|
|
126
|
+
--port <n> Port for the web UI (env OMAW_PORT, default 25257)
|
|
127
|
+
--host <h> Host to bind (env OMAW_HOST, default 127.0.0.1)
|
|
128
|
+
--cwd <path> Working directory for codex (env OMAW_CWD, default cwd)
|
|
129
|
+
--codex-port <n> app-server port (env OMAW_CODEX_PORT, default 25258)
|
|
130
|
+
--codex-bin <name> codex executable (env OMAW_CODEX_BIN, default "codex")
|
|
131
|
+
--no-browser Do not open the browser (env OMAW_OPEN=0)
|
|
132
|
+
--help, -h Show help
|
|
133
|
+
--version, -v Show version
|
|
134
|
+
|
|
135
|
+
ws [options] ["msg"] WebSocket client for the app-server
|
|
136
|
+
ps [--json] Running processes and their ports
|
|
137
|
+
stop [--port <n>] Stop the service listening on the UI port
|
|
138
|
+
restart [options] Stop the service, then serve again in the foreground
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Flags win over environment variables.
|
|
142
|
+
|
|
143
|
+
Anything that is not a flag and not one of the subcommands above is rejected —
|
|
144
|
+
only `[options]` starts a server, so a mistyped subcommand can never silently
|
|
145
|
+
launch one.
|
|
146
|
+
|
|
147
|
+
### `oh-my-agent-web stop` / `restart`
|
|
148
|
+
|
|
149
|
+
Gracefully stop the service listening on the UI port (the same code path as
|
|
150
|
+
`npm run stop`): SIGTERM, then SIGKILL after an 8s grace period. It refuses to
|
|
151
|
+
touch the port if the listener is not one of ours.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
oh-my-agent-web stop # stop on OMAW_PORT (default 25257)
|
|
155
|
+
oh-my-agent-web stop --port 3000 # stop the instance on another port
|
|
156
|
+
oh-my-agent-web restart --no-browser # stop, then serve again in this process
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `oh-my-agent-web ws` — WebSocket client
|
|
160
|
+
|
|
161
|
+
Connect straight to the app-server over its WebSocket (default
|
|
162
|
+
`ws://127.0.0.1:25258`), without going through the browser UI:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
oh-my-agent-web ws # interactive: type a message, Enter to send (Ctrl-D to exit)
|
|
166
|
+
oh-my-agent-web ws "list the files here" # one-shot, then exit
|
|
167
|
+
echo "write a hello world" | oh-my-agent-web ws
|
|
168
|
+
oh-my-agent-web ws --port 25259 # explicit port
|
|
169
|
+
oh-my-agent-web ws --url ws://host:port # explicit address
|
|
170
|
+
oh-my-agent-web ws -t <threadId> "continue" # resume an existing thread
|
|
171
|
+
oh-my-agent-web ws -y --model gpt-5.5 "..." # auto-approve approvals, pick a model
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Run the same client through npm (pass flags after `--`):
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm run ws # interactive
|
|
178
|
+
npm run ws -- --port 25259
|
|
179
|
+
npm run ws -- -t <threadId> "continue"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
| Flag | Meaning |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `--url <ws://host:port>` | Full address (bare `host:port` also works) |
|
|
185
|
+
| `--addr <host:port>` / `--host <h>` / `-p, --port <n>` | Address or port (default `127.0.0.1:25258`) |
|
|
186
|
+
| `--cwd <dir>` | Working directory (default: current dir) |
|
|
187
|
+
| `-t, --thread <id>` | Resume an existing thread |
|
|
188
|
+
| `--model <name>` | Model id |
|
|
189
|
+
| `--sandbox <mode>` / `--approval <mode>` | Session sandbox / approval policy |
|
|
190
|
+
| `-y, --auto-approve` / `--decline` | Auto-answer approvals instead of prompting |
|
|
191
|
+
| `-v, --verbose` / `--json` | Print reasoning deltas / raw event JSON |
|
|
192
|
+
| `--timeout <sec>` | Max wait per turn (default 1800) |
|
|
193
|
+
|
|
194
|
+
Env: `CODEX_WS_URL` / `CODEX_WS_HOST` / `CODEX_WS_PORT`.
|
|
195
|
+
|
|
196
|
+
Connection priority: `--url` > `--host`/`--port` (`--addr`) > env vars > defaults
|
|
197
|
+
(`127.0.0.1:25258`).
|
|
198
|
+
|
|
199
|
+
### `oh-my-agent-web ps` — processes & ports
|
|
200
|
+
|
|
201
|
+
Show every running oh-my-agent-web / codex app-server process, whether it is managed by
|
|
202
|
+
this project or external, and which TCP ports it listens on:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
oh-my-agent-web ps # table
|
|
206
|
+
oh-my-agent-web ps --json # machine-readable
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
TYPE PID PPID SCOPE PORTS COMMAND
|
|
211
|
+
app-server 16397 96476 managed 25258 node .../codex app-server --listen ws://127.0.0.1:25258
|
|
212
|
+
app-server 5567 5216 external - /Applications/ChatGPT.app/.../codex app-server ...
|
|
213
|
+
oh-my-agent-web 96476 1 - 25257 node bin/oh-my-agent-web.mjs --no-browser
|
|
214
|
+
|
|
215
|
+
ports: ui=25257 app-server=25258
|
|
216
|
+
25257 listening: yes pid: 96476
|
|
217
|
+
25258 listening: yes pid: 16398
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Environment variables
|
|
221
|
+
|
|
222
|
+
| Variable | Default | Meaning |
|
|
223
|
+
| ------------------ | ------------- | ---------------------------------------------------------------- |
|
|
224
|
+
| `OMAW_PORT` | `25257` | Web UI / HTTP port |
|
|
225
|
+
| `OMAW_HOST` | `127.0.0.1` | Bind host (loopback by default) |
|
|
226
|
+
| `OMAW_CWD` | `process.cwd()` | Default working directory for codex |
|
|
227
|
+
| `OMAW_CODEX_PORT` | `25258` | app-server port |
|
|
228
|
+
| `OMAW_CODEX_BIN` | `codex` | codex executable |
|
|
229
|
+
| `OMAW_ALLOW_ORIGINS` | *(unset)* | Comma-separated extra allowed WS origins. When unset: same-origin + localhost only |
|
|
230
|
+
| `OMAW_OPEN` | *(unset)* | `0` disables auto-opening the browser |
|
|
231
|
+
| `OMAW_REQUEST_INSPECTOR` | *(unset)* | Set to `1` to enable the loopback request/response capture proxy. Off by default; when off, no `chatgpt_base_url` / `model_providers.capture.base_url` overrides are injected |
|
|
232
|
+
| `OMAW_CODEX_UPSTREAM` | `https://chatgpt.com/backend-api/codex` | Upstream URL for the capture proxy |
|
|
233
|
+
| `OMAW_LOG_MAX_BODY` | `16 MiB` | Maximum body retained per exchange |
|
|
234
|
+
|
|
235
|
+
## Architecture
|
|
236
|
+
|
|
237
|
+
- **`server/request-inspector.ts`** — optional (opt-in via `OMAW_REQUEST_INSPECTOR=1`)
|
|
238
|
+
loopback Responses API proxy: forwards
|
|
239
|
+
managed Codex traffic, redacts sensitive headers, incrementally captures SSE,
|
|
240
|
+
persists `data/YYYY-MM-DD/*.json` with `0600` permissions, and serves
|
|
241
|
+
thread-filtered summaries/details to the UI.
|
|
242
|
+
- **`server/codex-supervisor.ts`** — owns the `codex app-server` child:
|
|
243
|
+
- Reuses a healthy app-server if the port already answers `GET /readyz`
|
|
244
|
+
(external attach); otherwise spawns one.
|
|
245
|
+
- Readiness via stdout (`listening on:`) **and** `/readyz` polling.
|
|
246
|
+
- stdout/stderr → 500-line ring buffer (`omaw/codex/log`).
|
|
247
|
+
- Auto-restart with exponential backoff (cap 10s), `restarts` counter.
|
|
248
|
+
- Clean shutdown: SIGTERM then SIGKILL; only kills children **we** spawned.
|
|
249
|
+
- **`server/codex-client.ts`** — WS JSON-RPC client: `initialize` on every
|
|
250
|
+
(re)connect, id-correlated `request()`, `notification` / `serverRequest`
|
|
251
|
+
events, `respond()` / `respondError()`.
|
|
252
|
+
- **`server/fs-service.ts`** — `omaw/paths`, `omaw/fs/list`, `omaw/fs/read`.
|
|
253
|
+
- **`server/index.ts`** — express + `ws` hub, `/api/health`, `/api/version`,
|
|
254
|
+
static SPA serving from `web/dist`, origin checks, 30s heartbeat.
|
|
255
|
+
- **`bin/oh-my-agent-web.mjs`** — CLI entry, loads `dist/server/index.js`.
|
|
256
|
+
|
|
257
|
+
## Browser ↔ server protocol
|
|
258
|
+
|
|
259
|
+
See [`shared/protocol.ts`](./shared/protocol.ts) for the frozen envelope.
|
|
260
|
+
Everything except `omaw/*` is proxied verbatim to codex.
|
|
261
|
+
|
|
262
|
+
Client → server: `rpc`, `reply`, `ping`.
|
|
263
|
+
Server → client: `welcome`, `status`, `rpcResult`, `event`, `serverRequest`, `pong`.
|
|
264
|
+
|
|
265
|
+
### Local methods (`omaw/*`)
|
|
266
|
+
|
|
267
|
+
| Method | Params | Result |
|
|
268
|
+
| --------------------- | ----------------------------- | ---------------------------------------- |
|
|
269
|
+
| `omaw/paths` | — | `{ cwd, home, codexHome }` |
|
|
270
|
+
| `omaw/fs/list` | `{ path?, maxEntries? }` | `{ path, entries: FsEntry[] }` |
|
|
271
|
+
| `omaw/fs/read` | `{ path, maxBytes? }` | `{ path, text, truncated }` |
|
|
272
|
+
| `omaw/codex/status` | — | `{ status: CodexStatus }` |
|
|
273
|
+
| `omaw/codex/restart` | — | `{ ok: true }` |
|
|
274
|
+
| `omaw/codex/log` | — | `{ lines: string[] }` |
|
|
275
|
+
| `omaw/request-logs/list` | `{ threadId, limit? }` | Thread-filtered live/history summaries |
|
|
276
|
+
| `omaw/request-logs/detail` | `{ id }` | Full request/response exchange |
|
|
277
|
+
|
|
278
|
+
`omaw/fs/list` sorts directories first and skips `node_modules` / `.git` unless
|
|
279
|
+
you are already inside one. `omaw/fs/read` refuses binary files (NUL byte) and
|
|
280
|
+
truncates at `maxBytes` (default 256 KB).
|
|
281
|
+
|
|
282
|
+
### Error codes
|
|
283
|
+
|
|
284
|
+
RPC failures come back as `{ type: "rpcResult", ok: false, error: { code, message, data? } }`:
|
|
285
|
+
|
|
286
|
+
| Code | Meaning |
|
|
287
|
+
| -------- | -------------------------------------------------------------- |
|
|
288
|
+
| `-32001` | codex app-server not connected (proxy guard) |
|
|
289
|
+
| `-32002` | codex connection closed / client closed |
|
|
290
|
+
| `-32003` | local request timeout (initialize only) |
|
|
291
|
+
| `-32000` | generic fs / internal error |
|
|
292
|
+
| `-32601` | unknown `omaw/*` method |
|
|
293
|
+
| `-32602` | invalid params |
|
|
294
|
+
|
|
295
|
+
### Approvals / server requests
|
|
296
|
+
Codex server→client requests (approvals, user input, dynamic tool calls) are
|
|
297
|
+
broadcast to **all** connected tabs as `serverRequest`. The first `reply` with a
|
|
298
|
+
matching `id` wins; later replies are ignored. After resolving, the server
|
|
299
|
+
broadcasts a codex-style notification:
|
|
300
|
+
|
|
301
|
+
```jsonc
|
|
302
|
+
{ "type": "event", "method": "serverRequest/resolved", "params": { "threadId": "...", "requestId": 123 } }
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
so other tabs can dismiss their dialog.
|
|
306
|
+
|
|
307
|
+
### Notes for the frontend
|
|
308
|
+
|
|
309
|
+
- Messages are accepted with or without `"jsonrpc": "2.0"`.
|
|
310
|
+
- `status.codex.pid === null && status.codex.phase === "ready"` means the server
|
|
311
|
+
attached to an **external** app-server (it will not be killed on shutdown).
|
|
312
|
+
- The server re-sends `initialize` after every codex reconnect; browsers can
|
|
313
|
+
hydrate a thread mid-session with `thread/read` (`includeTurns: true`).
|
|
314
|
+
- `ServerInfo.codexVersion` is parsed from the handshake `userAgent` and may be
|
|
315
|
+
`null` until the first connection completes.
|
|
316
|
+
|
|
317
|
+
## Approvals never show up?
|
|
318
|
+
|
|
319
|
+
Codex routes escalation requests (sandbox escapes, blocked network access, MCP
|
|
320
|
+
prompts) to a **reviewer**, configurable per thread via `approvalsReviewer`:
|
|
321
|
+
|
|
322
|
+
| Value | Behaviour |
|
|
323
|
+
| -------------------- | ---------------------------------------------------------------- |
|
|
324
|
+
| `user` | Every request becomes a dialog in this UI |
|
|
325
|
+
| `auto_review` | A prompted subagent decides — **no dialog is ever sent to the UI** |
|
|
326
|
+
| `guardian_subagent` | Same idea, guardian variant |
|
|
327
|
+
|
|
328
|
+
The default is `user`, but if your `~/.codex/config.toml` contains
|
|
329
|
+
`approvals_reviewer = "auto_review"` the browser will stay silent and commands
|
|
330
|
+
just run. Open **Settings → Who reviews approvals** and pick **Ask me** to route
|
|
331
|
+
them to this UI for a thread you start (or the running turn).
|
|
332
|
+
|
|
333
|
+
Read-only commands that the sandbox already allows never prompt at all — ask
|
|
334
|
+
codex to write outside the workspace (e.g. into `~`) or hit the network to
|
|
335
|
+
exercise the dialog.
|
|
336
|
+
|
|
337
|
+
## Frontend features
|
|
338
|
+
|
|
339
|
+
- **Thread sidebar** — paginated/searchable `thread/list`, rename, archive,
|
|
340
|
+
delete, fork; status dots and relative times. The list shows **active threads
|
|
341
|
+
only** and the project picker scopes it, so there is no active/archived tab;
|
|
342
|
+
`Archive` is available from a thread's `…` menu and hides it.
|
|
343
|
+
- **Streaming transcript** — agent text, reasoning summaries, plans, live
|
|
344
|
+
command output, `turn/diff/updated`. Deltas are coalesced on
|
|
345
|
+
`requestAnimationFrame` so long turns stay smooth.
|
|
346
|
+
- **Markdown** rendering with GFM + syntax highlighting; `[path](/abs/path)`
|
|
347
|
+
links open a file preview sheet backed by `omaw/fs/read`.
|
|
348
|
+
- **Command cards** — command, cwd, streamed output; only running/failed
|
|
349
|
+
statuses are shown when they carry useful signal.
|
|
350
|
+
- **Diff cards** — unified-diff rendering with line numbers and +/- colouring.
|
|
351
|
+
Note that codex's `FileUpdateChange.diff` is **not** always a diff: it is raw
|
|
352
|
+
file content for `add`/`delete` and bare `@@` hunks for `update`, so the
|
|
353
|
+
renderer normalises all three.
|
|
354
|
+
- **Approval dialogs** — command / file-change / user-input requests, with
|
|
355
|
+
Accept, Accept for session, Decline, Cancel, plus an auto-approve toggle.
|
|
356
|
+
- **Composer** — Enter to send, Stop to interrupt, `@file` mentions via
|
|
357
|
+
`fuzzyFileSearch`, image paste, message queueing while a turn runs.
|
|
358
|
+
- **Header** — model + reasoning effort, approval policy, sandbox, approvals
|
|
359
|
+
reviewer, token/context meter, codex status + restart + log drawer.
|
|
360
|
+
- **Session logs** — open from the active thread's terminal icon or `…` menu;
|
|
361
|
+
groups requests by turn, polls live exchanges, and provides readable,
|
|
362
|
+
request JSON, response JSON, and raw SSE views. Capture is enabled for
|
|
363
|
+
app-server processes managed by this project; an external app-server cannot
|
|
364
|
+
be intercepted and will show no new captures.
|
|
365
|
+
|
|
366
|
+
## Skins
|
|
367
|
+
|
|
368
|
+
The UI ships four skins, switchable from the palette button in the header and
|
|
369
|
+
remembered per browser (`localStorage`):
|
|
370
|
+
|
|
371
|
+
| Skin | Look |
|
|
372
|
+
| --- | --- |
|
|
373
|
+
| **White** *(default)* | White canvas, grey chrome, blue accent — GitHub / pi-web style |
|
|
374
|
+
| **Warm paper** | Cream canvas, ochre accent, easier on the eyes |
|
|
375
|
+
| **Mist** | Cool grey-green canvas, teal accent |
|
|
376
|
+
| **Midnight** | Near-black canvas, violet accent |
|
|
377
|
+
|
|
378
|
+
Implementation notes:
|
|
379
|
+
|
|
380
|
+
- A skin is nothing but a `[data-theme="<id>"]` block of CSS variables in
|
|
381
|
+
`web/src/styles.css`; the layout never changes between skins.
|
|
382
|
+
- `<html>` carries **two** attributes: `data-theme` (the skin) and `data-scheme`
|
|
383
|
+
(`light` / `dark`). Rules that only make sense for light backgrounds — the
|
|
384
|
+
syntax-highlighting palette, for instance — key off `data-scheme` so they are
|
|
385
|
+
written once instead of per skin.
|
|
386
|
+
- The registry lives in `web/src/lib/theme.ts`. Adding a skin = one entry there
|
|
387
|
+
plus one variable block in `styles.css`.
|
|
388
|
+
- `web/index.html` applies the stored skin in an inline script *before first
|
|
389
|
+
paint*, otherwise the `:root` (dark) fallback flashes on load.
|
|
390
|
+
|
|
391
|
+
## Projects (workspaces)
|
|
392
|
+
|
|
393
|
+
The selector at the top of the sidebar switches the **project** — codex's `cwd`.
|
|
394
|
+
It is the app's primary navigation control:
|
|
395
|
+
|
|
396
|
+
- The list is derived from the `cwd` of known codex threads, so projects appear
|
|
397
|
+
automatically as soon as you use them anywhere (CLI, TUI, this UI).
|
|
398
|
+
- Each row shows a compact path, a dot and the number of known threads; the
|
|
399
|
+
current project is checked, and the app-server's default directory is pinned
|
|
400
|
+
to the top.
|
|
401
|
+
- Selecting a project **re-scopes the thread list** (via `thread/list`'s `cwd`
|
|
402
|
+
filter), clears the transcript, and becomes the `cwd` for new threads.
|
|
403
|
+
- `Use default directory` reverts to the app-server's cwd; `Custom path…` opens
|
|
404
|
+
a thread in any directory you type. The choice persists across reloads.
|
|
405
|
+
- Counts come from a separate, **unscoped** `thread/list` (`refreshProjects`),
|
|
406
|
+
because the visible list is filtered and therefore cannot provide totals.
|
|
407
|
+
|
|
408
|
+
## Layout controls
|
|
409
|
+
|
|
410
|
+
Everything about the workspace layout is draggable and remembered per browser:
|
|
411
|
+
|
|
412
|
+
| Control | How | Persisted as |
|
|
413
|
+
| --- | --- | --- |
|
|
414
|
+
| Collapse the sidebar | Header panel button, the `X` in the sidebar, or `⌘/Ctrl+B` | `cw-sidebar-collapsed` |
|
|
415
|
+
| Sidebar width | Drag its right edge (220–560px, max 50% of the window) | `cw-sidebar-width` |
|
|
416
|
+
| File panel width | Drag its left edge | `cw-file-panel-width` |
|
|
417
|
+
|
|
418
|
+
Details worth knowing:
|
|
419
|
+
|
|
420
|
+
- **Double-click a resize handle** to reset that panel to its default width.
|
|
421
|
+
- Widths are applied through CSS variables (`--sidebar-w`, `--file-panel-w`)
|
|
422
|
+
rather than inline `width`, so the mobile media queries can still turn the
|
|
423
|
+
sidebar into a drawer and the file panel into a full-height sheet. Resize
|
|
424
|
+
handles are not rendered at all in those modes.
|
|
425
|
+
- Dragging uses **pointer capture**, so the drag survives the cursor leaving the
|
|
426
|
+
handle or the window.
|
|
427
|
+
- `⌘/Ctrl+K` reveals the sidebar before focusing search, so it works while
|
|
428
|
+
collapsed. `⌘/Ctrl+N` starts a thread, `⌘/Ctrl+B` toggles the sidebar.
|
|
429
|
+
|
|
430
|
+
## Composer
|
|
431
|
+
|
|
432
|
+
The message box is deliberately compact and self-sizing — there is nothing to
|
|
433
|
+
configure:
|
|
434
|
+
|
|
435
|
+
- It **hugs the pane**: 10px from either side (no centred column), with the text
|
|
436
|
+
~9px from the box's left edge and the Send button ~5px from its right edge.
|
|
437
|
+
- It is **one line tall** by default, with the Send button on the same row,
|
|
438
|
+
vertically centred. The button is a flex sibling (not an overlay), so long
|
|
439
|
+
text can never slide underneath it.
|
|
440
|
+
- It **grows automatically** as the text wraps, up to ~9 lines (200px); past
|
|
441
|
+
that the textarea scrolls internally instead of pushing the transcript off
|
|
442
|
+
screen. Deleting lines shrinks it back.
|
|
443
|
+
- `Enter` sends, `Shift+Enter` inserts a newline, and IME composition is
|
|
444
|
+
respected (the keydown is ignored while composing).
|
|
445
|
+
- While a turn is running the row swaps to **Stop** + **Queue**, and queued
|
|
446
|
+
messages are flushed automatically when the turn ends.
|
|
447
|
+
|
|
448
|
+
## Verification
|
|
449
|
+
|
|
450
|
+
`npm run smoke` runs a real end-to-end pass (spawns an app-server, starts a
|
|
451
|
+
thread, runs a turn, asserts streamed events, `thread/read` hydration, and
|
|
452
|
+
approval broadcast/dedupe).
|
|
453
|
+
|
|
454
|
+
The UI was additionally driven in headless Chromium against a real
|
|
455
|
+
`codex-cli 0.156.1` app-server and verified for: thread creation, streamed
|
|
456
|
+
turn completion, command cards, add/update/delete diff rendering, the full
|
|
457
|
+
approval round-trip (dialog → Accept → escalated command executes → turn
|
|
458
|
+
completes), all four skins, project switching (thread list re-scoping +
|
|
459
|
+
persistence across reload), sidebar collapse/expand, panel resizing
|
|
460
|
+
(sidebar 288→428px, file panel 560→740px, both persisted), and the composer
|
|
461
|
+
(48px / one line with Send on the same row → 87px at three lines → capped at
|
|
462
|
+
200px and scrolling at twenty lines → shrinks back) — plus no theme flash before
|
|
463
|
+
first paint and zero console errors throughout.
|
|
464
|
+
|
|
465
|
+
### Startup races and process ownership
|
|
466
|
+
|
|
467
|
+
Two things the server does so clients don't have to care:
|
|
468
|
+
|
|
469
|
+
- **Ready wait.** A browser can attach while `codex app-server` is still booting
|
|
470
|
+
(or while the supervisor restarts it after a crash). Instead of failing the
|
|
471
|
+
client's first `model/list` / `thread/list` with `-32001`, the proxy waits up to
|
|
472
|
+
`CODEX_READY_WAIT_MS` (20s) for the app-server and only then reports an error.
|
|
473
|
+
The UI additionally re-runs its codex-dependent bootstrap whenever the
|
|
474
|
+
app-server transitions to connected, so a crash-restart recovers by itself.
|
|
475
|
+
- **Process-group ownership.** `codex app-server` re-execs: the spawned shim forks
|
|
476
|
+
the real listener. The child is therefore started `detached: true` and stopped
|
|
477
|
+
by signalling the whole **process group** (`-pid`), with a group `SIGKILL` on
|
|
478
|
+
exit as a safety net. Signalling only the direct child would orphan the
|
|
479
|
+
listener, which keeps the port bound — the next start would then "attach" to
|
|
480
|
+
that stale process and `restart()` would be a silent no-op.
|
|
481
|
+
|
|
482
|
+
## License
|
|
483
|
+
|
|
484
|
+
MIT
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* oh-my-agent-web ps — 列出正在运行的 oh-my-agent-web / codex app-server 进程及其监听端口。
|
|
4
|
+
*
|
|
5
|
+
* oh-my-agent-web ps # 表格
|
|
6
|
+
* oh-my-agent-web ps --json # 机器可读
|
|
7
|
+
*
|
|
8
|
+
* 仅用 Node 内置能力(必要时调用 lsof / ps),无第三方依赖。
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_UI_PORT = 25257;
|
|
13
|
+
const DEFAULT_CODEX_PORT = 25258;
|
|
14
|
+
|
|
15
|
+
const HELP = `oh-my-agent-web ps — show running oh-my-agent-web / app-server processes and their ports
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
oh-my-agent-web ps [--json] [--help]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--json Print machine-readable JSON
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function sh(cmd, args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** All processes as { pid, ppid, command }. */
|
|
34
|
+
function listProcesses() {
|
|
35
|
+
const out = sh("ps", ["-eo", "pid=,ppid=,command="]);
|
|
36
|
+
const rows = [];
|
|
37
|
+
for (const line of out.split("\n")) {
|
|
38
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
39
|
+
if (!m) continue;
|
|
40
|
+
rows.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3].trim() });
|
|
41
|
+
}
|
|
42
|
+
return rows;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The CLI answers to a long name and a short alias, and which one shows up in
|
|
47
|
+
* `ps` depends on how it was invoked: the npm bin symlink puts the alias in the
|
|
48
|
+
* process command line (`.../bin/omaw`), while a direct `node bin/…mjs` puts the
|
|
49
|
+
* file name there instead. Both must classify the same, or `omaw ps` would fail
|
|
50
|
+
* to see its own server and mislabel its managed app-server as "external".
|
|
51
|
+
*/
|
|
52
|
+
const CLI_NAMES = ["oh-my-agent-web", "omaw"];
|
|
53
|
+
const CLI = CLI_NAMES.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
54
|
+
|
|
55
|
+
/** Classify a command line: "oh-my-agent-web" | "app-server" | "ws-client" | null. */
|
|
56
|
+
function classify(command) {
|
|
57
|
+
// skip our own `... ps` invocation lines
|
|
58
|
+
if (new RegExp(`\\b(?:${CLI})(?:\\.mjs)?\\s+(?:ps|status|ports)\\b`).test(command)) return null;
|
|
59
|
+
// the ws client is a short-lived client, not a server
|
|
60
|
+
if (new RegExp(`\\b(?:${CLI})(?:\\.mjs)?\\s+ws\\b`).test(command)) return "ws-client";
|
|
61
|
+
if (new RegExp(`\\b(?:${CLI})(?:\\.mjs)?\\b`).test(command)) return "oh-my-agent-web";
|
|
62
|
+
if (/\bapp-server\b/.test(command) && /\bcodex\b/.test(command)) return "app-server";
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** TCP ports this pid is LISTENing on (via lsof), best-effort. */
|
|
67
|
+
function listenPorts(pid) {
|
|
68
|
+
const out = sh("lsof", ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN"]);
|
|
69
|
+
const ports = new Set();
|
|
70
|
+
for (const line of out.split("\n").slice(1)) {
|
|
71
|
+
const m = line.match(/:(\d+)\s+\(LISTEN\)/);
|
|
72
|
+
if (m) ports.add(Number(m[1]));
|
|
73
|
+
}
|
|
74
|
+
return ports;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ports guessed from the command line (fallback when lsof is unavailable).
|
|
78
|
+
* Only app-servers declare a listen port; the ws client's `--port` is a remote
|
|
79
|
+
* target, so it must NOT be reported as a local listening port. */
|
|
80
|
+
function portsFromCmd(command, kind) {
|
|
81
|
+
const ports = new Set();
|
|
82
|
+
if (kind !== "app-server") return ports;
|
|
83
|
+
const listen = command.match(/--listen[= ]+ws:\/\/[^:\s]+:(\d+)/);
|
|
84
|
+
if (listen) ports.add(Number(listen[1]));
|
|
85
|
+
return ports;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pidsListeningOn(port) {
|
|
89
|
+
const out = sh("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
|
|
90
|
+
return [...new Set(out.split(/\s+/).filter(Boolean).map(Number))];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function truncate(s, n) {
|
|
94
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function collect() {
|
|
98
|
+
const self = process.pid;
|
|
99
|
+
const all = listProcesses();
|
|
100
|
+
const ppidOf = new Map(all.map((p) => [p.pid, p.ppid]));
|
|
101
|
+
const matched = [];
|
|
102
|
+
for (const p of all) {
|
|
103
|
+
if (p.pid === self) continue;
|
|
104
|
+
const kind = classify(p.command);
|
|
105
|
+
if (kind) matched.push({ ...p, kind });
|
|
106
|
+
}
|
|
107
|
+
const omawPids = new Set(matched.filter((p) => p.kind === "oh-my-agent-web").map((p) => p.pid));
|
|
108
|
+
// Walk up the ancestor chain (the app-server re-execs, so the listener's
|
|
109
|
+
// parent is the shim, not oh-my-agent-web directly).
|
|
110
|
+
const isManaged = (pid) => {
|
|
111
|
+
let cur = pid;
|
|
112
|
+
for (let hops = 0; hops < 12; hops += 1) {
|
|
113
|
+
const parent = ppidOf.get(cur);
|
|
114
|
+
if (parent === undefined || parent <= 1) return false;
|
|
115
|
+
if (omawPids.has(parent)) return true;
|
|
116
|
+
cur = parent;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const procs = matched.map((p) => {
|
|
122
|
+
const ports = listenPorts(p.pid);
|
|
123
|
+
for (const extra of portsFromCmd(p.command, p.kind)) ports.add(extra);
|
|
124
|
+
const scope = p.kind === "app-server" ? (isManaged(p.pid) ? "managed" : "external") : "";
|
|
125
|
+
return {
|
|
126
|
+
kind: p.kind,
|
|
127
|
+
pid: p.pid,
|
|
128
|
+
ppid: p.ppid,
|
|
129
|
+
scope,
|
|
130
|
+
ports: [...ports].sort((a, b) => a - b),
|
|
131
|
+
command: p.command,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
procs.sort((a, b) => (a.kind === b.kind ? a.pid - b.pid : a.kind.localeCompare(b.kind)));
|
|
135
|
+
|
|
136
|
+
const uiPort = Number(process.env.OMAW_PORT) || DEFAULT_UI_PORT;
|
|
137
|
+
const codexPort = Number(process.env.OMAW_CODEX_PORT) || DEFAULT_CODEX_PORT;
|
|
138
|
+
const portOwners = {};
|
|
139
|
+
for (const port of new Set([uiPort, codexPort])) {
|
|
140
|
+
portOwners[port] = pidsListeningOn(port);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { processes: procs, uiPort, codexPort, portOwners };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function render(data) {
|
|
147
|
+
const { processes, uiPort, codexPort, portOwners } = data;
|
|
148
|
+
if (processes.length === 0) {
|
|
149
|
+
console.log("no oh-my-agent-web / app-server process found.");
|
|
150
|
+
} else {
|
|
151
|
+
const rows = [
|
|
152
|
+
["TYPE", "PID", "PPID", "SCOPE", "PORTS", "COMMAND"],
|
|
153
|
+
...processes.map((p) => [
|
|
154
|
+
p.kind,
|
|
155
|
+
String(p.pid),
|
|
156
|
+
String(p.ppid),
|
|
157
|
+
p.scope || "-",
|
|
158
|
+
p.ports.length ? p.ports.join(",") : "-",
|
|
159
|
+
truncate(p.command, 60),
|
|
160
|
+
]),
|
|
161
|
+
];
|
|
162
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
163
|
+
for (const r of rows) {
|
|
164
|
+
console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(`ports: ui=${uiPort}${process.env.OMAW_PORT ? " (OMAW_PORT)" : ""} app-server=${codexPort}${process.env.OMAW_CODEX_PORT ? " (OMAW_CODEX_PORT)" : ""}`);
|
|
170
|
+
for (const [port, pids] of Object.entries(portOwners)) {
|
|
171
|
+
const who = pids.length ? pids.join(",") : "—";
|
|
172
|
+
console.log(` ${String(port).padEnd(6)} listening: ${pids.length ? "yes" : "no "} pid: ${who}`);
|
|
173
|
+
}
|
|
174
|
+
if (processes.some((p) => p.kind === "app-server")) {
|
|
175
|
+
const ws =
|
|
176
|
+
processes.find((p) => p.kind === "app-server" && p.scope === "managed" && p.ports.length) ??
|
|
177
|
+
processes.find((p) => p.kind === "app-server" && p.ports.length);
|
|
178
|
+
if (ws) console.log(`\ntip: oh-my-agent-web ws --port ${ws.ports[0]}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runPs(argv) {
|
|
183
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
184
|
+
process.stdout.write(HELP);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const data = collect();
|
|
188
|
+
if (argv.includes("--json")) {
|
|
189
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
render(data);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Allow running this file directly: `node bin/oh-my-agent-web-ps.mjs`
|
|
196
|
+
import { pathToFileURL } from "node:url";
|
|
197
|
+
const invoked = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
198
|
+
if (invoked === import.meta.url) {
|
|
199
|
+
runPs(process.argv.slice(2)).catch((err) => {
|
|
200
|
+
console.error(`[oh-my-agent-web ps] ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
|
203
|
+
}
|