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 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
+ }