better-codex 0.1.3 → 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,9 +39,27 @@ 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 }))
62
+ .get('/config', () => ({ token: config.authToken }))
42
63
  .get(
43
64
  '/analytics/daily',
44
65
  ({ query }) => {
@@ -214,6 +235,94 @@ const app = new Elysia()
214
235
  }),
215
236
  }
216
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
+ )
217
326
  .get(
218
327
  '/threads/search',
219
328
  ({ query }) => {
@@ -250,6 +359,18 @@ const app = new Elysia()
250
359
  }),
251
360
  }
252
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
+ )
253
374
  .post(
254
375
  '/threads/reindex',
255
376
  async ({ body }) => {
@@ -350,6 +471,7 @@ const app = new Elysia()
350
471
 
351
472
  if (payload.type === 'profile.stop') {
352
473
  await supervisor.stop(payload.profileId)
474
+ threadActivity.clearProfile(payload.profileId)
353
475
  const response: WsResponse = {
354
476
  type: 'profile.stopped',
355
477
  profileId: payload.profileId,
@@ -379,13 +501,30 @@ const app = new Elysia()
379
501
  threadIndex.recordThreadStart(payload.profileId, thread)
380
502
  }
381
503
  if (payload.method === 'thread/resume') {
382
- const thread = (result as { thread?: ThreadListItem }).thread
504
+ const thread = (result as { thread?: ThreadListItem & { turns?: Array<{ id?: string; status?: string }> } }).thread
383
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
+ }
384
522
  }
385
523
  if (payload.method === 'thread/archive') {
386
524
  const params = payload.params as { threadId?: string } | undefined
387
525
  if (params?.threadId) {
388
526
  threadIndex.recordThreadArchive(payload.profileId, params.threadId)
527
+ threadActivity.markCompleted(payload.profileId, params.threadId)
389
528
  }
390
529
  }
391
530
  analytics.trackRpcResponse({
@@ -423,6 +562,18 @@ const app = new Elysia()
423
562
  })
424
563
 
425
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
+ }
426
577
  if (event.method === 'item/started' && event.params && typeof event.params === 'object') {
427
578
  const { threadId, turnId, item } = event.params as {
428
579
  threadId?: string
@@ -502,6 +653,7 @@ supervisor.on('diagnostic', (event) => {
502
653
  })
503
654
 
504
655
  supervisor.on('exit', (event) => {
656
+ threadActivity.clearProfile(event.profileId)
505
657
  const wsEvent: WsEvent = {
506
658
  type: 'profile.exit',
507
659
  profileId: event.profileId,
@@ -511,6 +663,10 @@ supervisor.on('exit', (event) => {
511
663
  })
512
664
 
513
665
  supervisor.on('error', (event) => {
666
+ if (shouldLogRouteErrors) {
667
+ console.error(`[HubBackend] profile ${event.profileId} error`)
668
+ console.error(formatError(event.error))
669
+ }
514
670
  const wsEvent: WsEvent = {
515
671
  type: 'profile.error',
516
672
  profileId: event.profileId,