better-codex 0.1.4 → 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.
@@ -3,16 +3,31 @@
3
3
  Local backend that supervises multiple `codex app-server` processes and exposes a
4
4
  WebSocket bridge for the web UI.
5
5
 
6
+ ## Requirements
7
+
8
+ - Bun
9
+ - Codex CLI available in `PATH` (or configure `CODEX_BIN`)
10
+
6
11
  ## Setup
7
12
 
8
13
  1. Install dependencies
9
14
  ```bash
10
15
  bun install
11
16
  ```
12
- 2. Run the server
17
+ 2. Run the server (hot reload)
13
18
  ```bash
14
19
  bun run dev
15
20
  ```
21
+ 3. Or run a single instance
22
+ ```bash
23
+ bun run start
24
+ ```
25
+
26
+ ## Data locations
27
+
28
+ - Analytics, reviews, and thread index live in `CODEX_HUB_DATA_DIR` (defaults to `~/.codex-hub`).
29
+ - Profiles metadata stored at `CODEX_HUB_DATA_DIR/profiles.json`.
30
+ - Per-profile Codex homes are created under `CODEX_HUB_PROFILES_DIR` (defaults to `~/.codex/profiles`).
16
31
 
17
32
  ## Protocol types
18
33
 
@@ -30,17 +45,43 @@ bun run generate:protocol
30
45
  - `CODEX_HUB_DATA_DIR` (default: `~/.codex-hub`)
31
46
  - `CODEX_HUB_PROFILES_DIR` (default: `~/.codex/profiles`)
32
47
  - `CODEX_HUB_DEFAULT_CODEX_HOME` (default: `~/.codex`)
48
+ - `CODEX_HUB_DEFAULT_CWD` (default: workspace root or process `cwd`)
49
+ - `CODEX_DEFAULT_CWD` (fallback for default cwd)
33
50
  - `CODEX_BIN` (default: `codex`)
34
51
  - `CODEX_FLAGS` (space-delimited)
35
52
  - `CODEX_FLAGS_JSON` (JSON array, preferred)
36
53
  - `CODEX_APP_SERVER_FLAGS`
37
54
  - `CODEX_APP_SERVER_FLAGS_JSON`
55
+ - `CODEX_HUB_APP_SERVER_STARTUP_TIMEOUT_MS` (default: `15000`)
56
+ - `CODEX_HUB_DEBUG_ROUTES=1` (log non-404 route errors)
57
+
58
+ Notes:
59
+ - Prefer `CODEX_FLAGS_JSON` and `CODEX_APP_SERVER_FLAGS_JSON` to avoid shell quoting issues.
60
+ - `CODEX_HUB_TOKEN` should be set if you want a stable token across restarts.
38
61
 
39
62
  ## Endpoints
40
63
 
41
64
  - `GET /health`
65
+ - `GET /config` (returns `{ token }` for the web UI)
66
+ - `GET /analytics/daily?metric=turns_started&days=365&profileId=...&model=...`
67
+ - `GET /reviews?profileId=...&limit=100&offset=0`
42
68
  - `GET /profiles`
43
- - `POST /profiles`
69
+ - `POST /profiles` (optional `{ name }`)
44
70
  - `POST /profiles/:profileId/start`
45
71
  - `POST /profiles/:profileId/stop`
72
+ - `DELETE /profiles/:profileId`
73
+ - `GET /profiles/:profileId/prompts` (returns prompt names + optional descriptions)
74
+ - `GET /profiles/:profileId/prompts/:name` (returns file contents)
75
+ - `GET /profiles/:profileId/config` (returns config + parsed MCP servers)
76
+ - `PUT /profiles/:profileId/config` (body `{ content: string }`)
77
+ - `PUT /profiles/:profileId/mcp-servers` (body `{ servers: McpServerConfig[] }`)
78
+ - `GET /threads/search?q=...&profileId=...&model=...&status=active|archived&createdAfter=...&createdBefore=...&limit=...&offset=...`
79
+ - `GET /threads/active?profileId=...`
80
+ - `POST /threads/reindex` (body `{ profileId?, limit?, autoStart? }`)
46
81
  - `WS /ws?token=...`
82
+
83
+ ## Troubleshooting
84
+
85
+ - "Missing hub token" in the web UI: ensure the backend is running and `GET /config` is reachable.
86
+ - "app-server startup timed out": raise `CODEX_HUB_APP_SERVER_STARTUP_TIMEOUT_MS` or run the CLI once manually to warm caches.
87
+ - Profiles not starting: verify the Codex CLI is in `PATH` or set `CODEX_BIN`.
@@ -31,12 +31,18 @@ export class CodexAppServer extends EventEmitter {
31
31
  private connection?: JsonRpcConnection
32
32
  private ready: Promise<void>
33
33
  private resolveReady?: () => void
34
+ private readonly startupTimeoutMs: number
35
+ private readonly startupStderr: string[] = []
36
+ private readonly maxStartupStderr = 20
37
+ private startupComplete = false
34
38
 
35
39
  constructor(private readonly options: CodexAppServerOptions) {
36
40
  super()
37
41
  this.ready = new Promise((resolve) => {
38
42
  this.resolveReady = resolve
39
43
  })
44
+ const parsed = Number(process.env.CODEX_HUB_APP_SERVER_STARTUP_TIMEOUT_MS ?? 15000)
45
+ this.startupTimeoutMs = Number.isFinite(parsed) ? Math.max(parsed, 0) : 15000
40
46
  }
41
47
 
42
48
  async start(): Promise<void> {
@@ -73,6 +79,12 @@ export class CodexAppServer extends EventEmitter {
73
79
  this.emit('serverRequest', message)
74
80
  })
75
81
  this.connection.on('stderr', (message) => {
82
+ if (!this.startupComplete) {
83
+ this.startupStderr.push(message)
84
+ if (this.startupStderr.length > this.maxStartupStderr) {
85
+ this.startupStderr.shift()
86
+ }
87
+ }
76
88
  this.emit('stderr', message)
77
89
  })
78
90
  this.connection.on('error', (error) => {
@@ -82,8 +94,17 @@ export class CodexAppServer extends EventEmitter {
82
94
  this.emit('exit', code)
83
95
  })
84
96
 
85
- await this.initialize()
86
- this.resolveReady?.()
97
+ try {
98
+ await this.initializeWithTimeout()
99
+ this.startupComplete = true
100
+ this.resolveReady?.()
101
+ } catch (error) {
102
+ this.process?.kill()
103
+ this.process = undefined
104
+ this.connection = undefined
105
+ this.emit('error', error instanceof Error ? error : new Error(String(error)))
106
+ throw error
107
+ }
87
108
  }
88
109
 
89
110
  async request(method: string, params?: unknown): Promise<unknown> {
@@ -126,6 +147,51 @@ export class CodexAppServer extends EventEmitter {
126
147
  })
127
148
  this.connection.sendNotification('initialized', {})
128
149
  }
150
+
151
+ private async initializeWithTimeout(): Promise<void> {
152
+ if (this.startupTimeoutMs <= 0) {
153
+ await this.initialize()
154
+ return
155
+ }
156
+ if (!this.process) {
157
+ throw new Error('missing app-server process')
158
+ }
159
+ let timeoutId: NodeJS.Timeout | null = null
160
+ const cleanup: Array<() => void> = []
161
+ const timeoutPromise = new Promise<never>((_, reject) => {
162
+ timeoutId = setTimeout(() => {
163
+ reject(new Error(`app-server startup timed out after ${this.startupTimeoutMs}ms`))
164
+ }, this.startupTimeoutMs)
165
+ })
166
+ const exitPromise = new Promise<never>((_, reject) => {
167
+ const onExit = (code: number | null) => {
168
+ reject(new Error(`app-server exited before initialize${code !== null ? ` (code ${code})` : ''}`))
169
+ }
170
+ this.process?.once('exit', onExit)
171
+ cleanup.push(() => this.process?.removeListener('exit', onExit))
172
+ })
173
+ const errorPromise = new Promise<never>((_, reject) => {
174
+ const onError = (error: Error) => reject(error)
175
+ this.process?.once('error', onError)
176
+ cleanup.push(() => this.process?.removeListener('error', onError))
177
+ })
178
+ try {
179
+ await Promise.race([this.initialize(), timeoutPromise, exitPromise, errorPromise])
180
+ } catch (error) {
181
+ if (error instanceof Error && error.message.includes('startup timed out')) {
182
+ const stderr = this.startupStderr.join('\n')
183
+ if (stderr) {
184
+ throw new Error(`${error.message}\n\n${stderr}`)
185
+ }
186
+ }
187
+ throw error
188
+ } finally {
189
+ if (timeoutId) {
190
+ clearTimeout(timeoutId)
191
+ }
192
+ cleanup.forEach((fn) => fn())
193
+ }
194
+ }
129
195
  }
130
196
 
131
197
  export type CodexAppServerEventsMap = CodexAppServerEvents
@@ -5,10 +5,12 @@ import { join, extname, basename } from 'node:path'
5
5
  import { loadConfig } from './config'
6
6
  import { CodexSupervisor } from './services/supervisor'
7
7
  import { ProfileStore } from './services/profile-store'
8
+ import { readProfileConfig, updateProfileMcpServers, writeProfileConfig, type McpServerConfig } from './services/codex-config'
8
9
  import { AnalyticsStore } from './analytics/store'
9
10
  import { AnalyticsService } from './analytics/service'
10
11
  import { ThreadIndexStore } from './thread-index/store'
11
12
  import { ThreadIndexService, type ThreadListItem } from './thread-index/service'
13
+ import { ThreadActivityService } from './thread-activity/service'
12
14
  import { ReviewService } from './reviews/service'
13
15
  import { ReviewStore } from './reviews/store'
14
16
  import type { WsEvent, WsRequest, WsResponse } from './ws/messages'
@@ -24,6 +26,7 @@ const analytics = new AnalyticsService(new AnalyticsStore(join(config.dataDir, '
24
26
  analytics.init()
25
27
  const threadIndex = new ThreadIndexService(new ThreadIndexStore(join(config.dataDir, 'threads.sqlite')))
26
28
  threadIndex.init()
29
+ const threadActivity = new ThreadActivityService()
27
30
  const reviews = new ReviewService(new ReviewStore(join(config.dataDir, 'reviews.sqlite')))
28
31
  reviews.init()
29
32
 
@@ -36,8 +39,25 @@ const sendWsEvent = (event: WsEvent) => {
36
39
  clients.forEach((client) => client.send(payload))
37
40
  }
38
41
 
42
+ const shouldLogRouteErrors =
43
+ process.env.CODEX_HUB_DEBUG_ROUTES === '1' || process.env.NODE_ENV !== 'production'
44
+
45
+ const formatError = (error: unknown) => {
46
+ if (error instanceof Error) {
47
+ return error.stack ?? `${error.name}: ${error.message}`
48
+ }
49
+ return String(error)
50
+ }
51
+
39
52
  const app = new Elysia()
40
53
  .use(cors({ origin: true }))
54
+ .onError(({ code, error, request }) => {
55
+ if (!shouldLogRouteErrors || code === 'NOT_FOUND') {
56
+ return
57
+ }
58
+ console.error(`[HubBackend] ${request.method} ${request.url} -> ${code}`)
59
+ console.error(formatError(error))
60
+ })
41
61
  .get('/health', () => ({ ok: true }))
42
62
  .get('/config', () => ({ token: config.authToken }))
43
63
  .get(
@@ -215,6 +235,94 @@ const app = new Elysia()
215
235
  }),
216
236
  }
217
237
  )
238
+ .get(
239
+ '/profiles/:profileId/config',
240
+ async ({ params }) => {
241
+ const profile = profileStore.get(params.profileId)
242
+ if (!profile) {
243
+ return new Response('Profile not found', { status: 404 })
244
+ }
245
+ const snapshot = await readProfileConfig(profile.codexHome)
246
+ return {
247
+ path: snapshot.path,
248
+ codexHome: profile.codexHome,
249
+ content: snapshot.content,
250
+ mcpServers: snapshot.mcpServers,
251
+ }
252
+ },
253
+ {
254
+ params: t.Object({
255
+ profileId: t.String(),
256
+ }),
257
+ }
258
+ )
259
+ .put(
260
+ '/profiles/:profileId/config',
261
+ async ({ params, body }) => {
262
+ const profile = profileStore.get(params.profileId)
263
+ if (!profile) {
264
+ return new Response('Profile not found', { status: 404 })
265
+ }
266
+ if (!body || typeof body !== 'object' || typeof (body as { content?: string }).content !== 'string') {
267
+ return new Response('Invalid config payload', { status: 400 })
268
+ }
269
+ const snapshot = await writeProfileConfig(profile.codexHome, (body as { content: string }).content)
270
+ return {
271
+ ok: true,
272
+ path: snapshot.path,
273
+ codexHome: profile.codexHome,
274
+ content: snapshot.content,
275
+ mcpServers: snapshot.mcpServers,
276
+ }
277
+ },
278
+ {
279
+ params: t.Object({
280
+ profileId: t.String(),
281
+ }),
282
+ body: t.Object({
283
+ content: t.String(),
284
+ }),
285
+ }
286
+ )
287
+ .put(
288
+ '/profiles/:profileId/mcp-servers',
289
+ async ({ params, body }) => {
290
+ const profile = profileStore.get(params.profileId)
291
+ if (!profile) {
292
+ return new Response('Profile not found', { status: 404 })
293
+ }
294
+ if (!body || typeof body !== 'object' || !Array.isArray((body as { servers?: McpServerConfig[] }).servers)) {
295
+ return new Response('Invalid MCP server payload', { status: 400 })
296
+ }
297
+ const servers = (body as { servers: McpServerConfig[] }).servers
298
+ const seenNames = new Set<string>()
299
+ for (const server of servers) {
300
+ if (!server?.name || !/^[a-zA-Z0-9_-]+$/.test(server.name)) {
301
+ return new Response('Invalid MCP server name', { status: 400 })
302
+ }
303
+ if (seenNames.has(server.name)) {
304
+ return new Response('Duplicate MCP server name', { status: 400 })
305
+ }
306
+ seenNames.add(server.name)
307
+ }
308
+ const snapshot = await updateProfileMcpServers(profile.codexHome, servers)
309
+ return {
310
+ ok: true,
311
+ path: snapshot.path,
312
+ codexHome: profile.codexHome,
313
+ content: snapshot.content,
314
+ mcpServers: snapshot.mcpServers,
315
+ }
316
+ },
317
+ {
318
+ params: t.Object({
319
+ profileId: t.String(),
320
+ }),
321
+ body: t.Object({
322
+ servers: t.Array(t.Any()),
323
+ }),
324
+ }
325
+ )
218
326
  .get(
219
327
  '/threads/search',
220
328
  ({ query }) => {
@@ -251,6 +359,18 @@ const app = new Elysia()
251
359
  }),
252
360
  }
253
361
  )
362
+ .get(
363
+ '/threads/active',
364
+ ({ query }) => {
365
+ const profileId = typeof query.profileId === 'string' ? query.profileId : undefined
366
+ return { threads: threadActivity.list(profileId) }
367
+ },
368
+ {
369
+ query: t.Object({
370
+ profileId: t.Optional(t.String()),
371
+ }),
372
+ }
373
+ )
254
374
  .post(
255
375
  '/threads/reindex',
256
376
  async ({ body }) => {
@@ -351,6 +471,7 @@ const app = new Elysia()
351
471
 
352
472
  if (payload.type === 'profile.stop') {
353
473
  await supervisor.stop(payload.profileId)
474
+ threadActivity.clearProfile(payload.profileId)
354
475
  const response: WsResponse = {
355
476
  type: 'profile.stopped',
356
477
  profileId: payload.profileId,
@@ -380,13 +501,30 @@ const app = new Elysia()
380
501
  threadIndex.recordThreadStart(payload.profileId, thread)
381
502
  }
382
503
  if (payload.method === 'thread/resume') {
383
- const thread = (result as { thread?: ThreadListItem }).thread
504
+ const thread = (result as { thread?: ThreadListItem & { turns?: Array<{ id?: string; status?: string }> } }).thread
384
505
  threadIndex.recordThreadResume(payload.profileId, thread)
506
+ if (thread?.id) {
507
+ let activeTurnId: string | null = null
508
+ if (Array.isArray(thread.turns)) {
509
+ for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
510
+ if (thread.turns[index]?.status === 'inProgress') {
511
+ activeTurnId = thread.turns[index]?.id ?? null
512
+ break
513
+ }
514
+ }
515
+ }
516
+ if (activeTurnId) {
517
+ threadActivity.markStarted(payload.profileId, thread.id, activeTurnId)
518
+ } else {
519
+ threadActivity.markCompleted(payload.profileId, thread.id)
520
+ }
521
+ }
385
522
  }
386
523
  if (payload.method === 'thread/archive') {
387
524
  const params = payload.params as { threadId?: string } | undefined
388
525
  if (params?.threadId) {
389
526
  threadIndex.recordThreadArchive(payload.profileId, params.threadId)
527
+ threadActivity.markCompleted(payload.profileId, params.threadId)
390
528
  }
391
529
  }
392
530
  analytics.trackRpcResponse({
@@ -424,6 +562,18 @@ const app = new Elysia()
424
562
  })
425
563
 
426
564
  supervisor.on('notification', (event) => {
565
+ if (event.method === 'turn/started' && event.params && typeof event.params === 'object') {
566
+ const { threadId, turn } = event.params as { threadId?: string; turn?: { id?: string } }
567
+ if (threadId) {
568
+ threadActivity.markStarted(event.profileId, threadId, turn?.id ?? null)
569
+ }
570
+ }
571
+ if (event.method === 'turn/completed' && event.params && typeof event.params === 'object') {
572
+ const { threadId } = event.params as { threadId?: string }
573
+ if (threadId) {
574
+ threadActivity.markCompleted(event.profileId, threadId)
575
+ }
576
+ }
427
577
  if (event.method === 'item/started' && event.params && typeof event.params === 'object') {
428
578
  const { threadId, turnId, item } = event.params as {
429
579
  threadId?: string
@@ -503,6 +653,7 @@ supervisor.on('diagnostic', (event) => {
503
653
  })
504
654
 
505
655
  supervisor.on('exit', (event) => {
656
+ threadActivity.clearProfile(event.profileId)
506
657
  const wsEvent: WsEvent = {
507
658
  type: 'profile.exit',
508
659
  profileId: event.profileId,
@@ -512,6 +663,10 @@ supervisor.on('exit', (event) => {
512
663
  })
513
664
 
514
665
  supervisor.on('error', (event) => {
666
+ if (shouldLogRouteErrors) {
667
+ console.error(`[HubBackend] profile ${event.profileId} error`)
668
+ console.error(formatError(event.error))
669
+ }
515
670
  const wsEvent: WsEvent = {
516
671
  type: 'profile.error',
517
672
  profileId: event.profileId,