better-codex 0.1.4 → 0.2.1

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.
@@ -0,0 +1,561 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ export type McpServerConfig = {
5
+ name: string
6
+ command?: string
7
+ args?: string[]
8
+ env?: Record<string, string>
9
+ env_vars?: string[]
10
+ cwd?: string
11
+ url?: string
12
+ bearer_token_env_var?: string
13
+ http_headers?: Record<string, string>
14
+ env_http_headers?: Record<string, string>
15
+ enabled?: boolean
16
+ startup_timeout_sec?: number
17
+ startup_timeout_ms?: number
18
+ tool_timeout_sec?: number
19
+ enabled_tools?: string[]
20
+ disabled_tools?: string[]
21
+ }
22
+
23
+ export type ProfileConfigSnapshot = {
24
+ path: string
25
+ content: string
26
+ mcpServers: McpServerConfig[]
27
+ }
28
+
29
+ const CONFIG_FILENAME = 'config.toml'
30
+
31
+ const getConfigPath = (codexHome: string) => join(codexHome, CONFIG_FILENAME)
32
+
33
+ const stripInlineComment = (value: string) => {
34
+ let inSingle = false
35
+ let inDouble = false
36
+ let escaped = false
37
+ for (let i = 0; i < value.length; i += 1) {
38
+ const char = value[i]
39
+ if (escaped) {
40
+ escaped = false
41
+ continue
42
+ }
43
+ if (char === '\\' && inDouble) {
44
+ escaped = true
45
+ continue
46
+ }
47
+ if (char === "'" && !inDouble) {
48
+ inSingle = !inSingle
49
+ continue
50
+ }
51
+ if (char === '"' && !inSingle) {
52
+ inDouble = !inDouble
53
+ continue
54
+ }
55
+ if (char === '#' && !inSingle && !inDouble) {
56
+ return value.slice(0, i)
57
+ }
58
+ }
59
+ return value
60
+ }
61
+
62
+ const parseTomlString = (value: string) => {
63
+ const trimmed = value.trim()
64
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
65
+ const unquoted = trimmed.slice(1, -1)
66
+ return unquoted.replace(/\\(["\\])/g, '$1')
67
+ }
68
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
69
+ return trimmed.slice(1, -1)
70
+ }
71
+ return trimmed
72
+ }
73
+
74
+ const parseTomlPrimitive = (value: string): string | number | boolean => {
75
+ const trimmed = value.trim()
76
+ if (
77
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
78
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
79
+ ) {
80
+ return parseTomlString(trimmed)
81
+ }
82
+ if (trimmed === 'true') return true
83
+ if (trimmed === 'false') return false
84
+ if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
85
+ return Number(trimmed)
86
+ }
87
+ return trimmed
88
+ }
89
+
90
+ const parseTomlArray = (value: string): string[] => {
91
+ const trimmed = value.trim()
92
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
93
+ return []
94
+ }
95
+ const inner = trimmed.slice(1, -1)
96
+ const items: string[] = []
97
+ let current = ''
98
+ let inSingle = false
99
+ let inDouble = false
100
+ let escaped = false
101
+ for (let i = 0; i < inner.length; i += 1) {
102
+ const char = inner[i]
103
+ if (escaped) {
104
+ escaped = false
105
+ current += char
106
+ continue
107
+ }
108
+ if (char === '\\' && inDouble) {
109
+ escaped = true
110
+ current += char
111
+ continue
112
+ }
113
+ if (char === "'" && !inDouble) {
114
+ inSingle = !inSingle
115
+ current += char
116
+ continue
117
+ }
118
+ if (char === '"' && !inSingle) {
119
+ inDouble = !inDouble
120
+ current += char
121
+ continue
122
+ }
123
+ if (char === ',' && !inSingle && !inDouble) {
124
+ const cleaned = current.trim()
125
+ if (cleaned) {
126
+ items.push(String(parseTomlPrimitive(cleaned)))
127
+ }
128
+ current = ''
129
+ continue
130
+ }
131
+ current += char
132
+ }
133
+ const last = current.trim()
134
+ if (last) {
135
+ items.push(String(parseTomlPrimitive(last)))
136
+ }
137
+ return items
138
+ }
139
+
140
+ type TomlValue = string | number | boolean | string[] | Record<string, string>
141
+
142
+ const parseTomlInlineTable = (value: string): Record<string, string> => {
143
+ const trimmed = value.trim()
144
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
145
+ return {}
146
+ }
147
+ const inner = trimmed.slice(1, -1).trim()
148
+ if (!inner) {
149
+ return {}
150
+ }
151
+ const entries: string[] = []
152
+ let current = ''
153
+ let inSingle = false
154
+ let inDouble = false
155
+ let escaped = false
156
+ for (let i = 0; i < inner.length; i += 1) {
157
+ const char = inner[i]
158
+ if (escaped) {
159
+ escaped = false
160
+ current += char
161
+ continue
162
+ }
163
+ if (char === '\\' && inDouble) {
164
+ escaped = true
165
+ current += char
166
+ continue
167
+ }
168
+ if (char === "'" && !inDouble) {
169
+ inSingle = !inSingle
170
+ current += char
171
+ continue
172
+ }
173
+ if (char === '"' && !inSingle) {
174
+ inDouble = !inDouble
175
+ current += char
176
+ continue
177
+ }
178
+ if (char === ',' && !inSingle && !inDouble) {
179
+ const cleaned = current.trim()
180
+ if (cleaned) {
181
+ entries.push(cleaned)
182
+ }
183
+ current = ''
184
+ continue
185
+ }
186
+ current += char
187
+ }
188
+ const tail = current.trim()
189
+ if (tail) {
190
+ entries.push(tail)
191
+ }
192
+
193
+ const table: Record<string, string> = {}
194
+ entries.forEach((entry) => {
195
+ const eqIndex = entry.indexOf('=')
196
+ if (eqIndex <= 0) {
197
+ return
198
+ }
199
+ const rawKey = entry.slice(0, eqIndex).trim()
200
+ const rawValue = entry.slice(eqIndex + 1).trim()
201
+ if (!rawKey) {
202
+ return
203
+ }
204
+ const key = parseTomlString(rawKey)
205
+ const valueParsed = parseTomlPrimitive(rawValue)
206
+ table[key] = String(valueParsed)
207
+ })
208
+
209
+ return table
210
+ }
211
+
212
+ const parseTomlValue = (rawValue: string): TomlValue => {
213
+ const cleaned = stripInlineComment(rawValue).trim()
214
+ if (!cleaned) {
215
+ return ''
216
+ }
217
+ if (cleaned.startsWith('[') && cleaned.endsWith(']')) {
218
+ return parseTomlArray(cleaned)
219
+ }
220
+ if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
221
+ return parseTomlInlineTable(cleaned)
222
+ }
223
+ return parseTomlPrimitive(cleaned)
224
+ }
225
+
226
+ const parseAssignment = (line: string): { key: string; value: ReturnType<typeof parseTomlValue> } | null => {
227
+ const trimmed = line.trim()
228
+ if (!trimmed || trimmed.startsWith('#')) {
229
+ return null
230
+ }
231
+ const eqIndex = trimmed.indexOf('=')
232
+ if (eqIndex <= 0) {
233
+ return null
234
+ }
235
+ const key = trimmed.slice(0, eqIndex).trim()
236
+ const rawValue = trimmed.slice(eqIndex + 1).trim()
237
+ if (!key) {
238
+ return null
239
+ }
240
+ return { key, value: parseTomlValue(rawValue) }
241
+ }
242
+
243
+ const parseMcpServers = (content: string): McpServerConfig[] => {
244
+ const servers = new Map<string, McpServerConfig>()
245
+ const lines = content.split(/\r?\n/)
246
+ if (lines.length === 1 && !lines[0]?.trim()) {
247
+ lines.length = 0
248
+ }
249
+ let currentTable: string | null = null
250
+
251
+ for (const line of lines) {
252
+ const headerMatch = line.match(/^\s*\[([^\]]+)\]\s*$/)
253
+ if (headerMatch) {
254
+ currentTable = headerMatch[1]?.trim() ?? null
255
+ continue
256
+ }
257
+ if (!currentTable || !currentTable.startsWith('mcp_servers.')) {
258
+ continue
259
+ }
260
+ const pathParts = currentTable.split('.')
261
+ if (pathParts.length < 2) {
262
+ continue
263
+ }
264
+ const serverName = pathParts[1]
265
+ const subTable = pathParts.slice(2).join('.')
266
+ if (!serverName) {
267
+ continue
268
+ }
269
+ const assignment = parseAssignment(line)
270
+ if (!assignment) {
271
+ continue
272
+ }
273
+ const server = servers.get(serverName) ?? { name: serverName }
274
+ if (subTable === 'env') {
275
+ server.env = server.env ?? {}
276
+ server.env[assignment.key] = String(assignment.value)
277
+ servers.set(serverName, server)
278
+ continue
279
+ }
280
+ if (subTable === 'http_headers') {
281
+ server.http_headers = server.http_headers ?? {}
282
+ server.http_headers[assignment.key] = String(assignment.value)
283
+ servers.set(serverName, server)
284
+ continue
285
+ }
286
+ if (subTable === 'env_http_headers') {
287
+ server.env_http_headers = server.env_http_headers ?? {}
288
+ server.env_http_headers[assignment.key] = String(assignment.value)
289
+ servers.set(serverName, server)
290
+ continue
291
+ }
292
+ switch (assignment.key) {
293
+ case 'command':
294
+ server.command = String(assignment.value)
295
+ break
296
+ case 'args':
297
+ server.args = Array.isArray(assignment.value) ? assignment.value : [String(assignment.value)]
298
+ break
299
+ case 'env':
300
+ if (assignment.value && typeof assignment.value === 'object' && !Array.isArray(assignment.value)) {
301
+ server.env = { ...(server.env ?? {}), ...(assignment.value as Record<string, string>) }
302
+ }
303
+ break
304
+ case 'env_vars':
305
+ server.env_vars = Array.isArray(assignment.value) ? assignment.value : [String(assignment.value)]
306
+ break
307
+ case 'cwd':
308
+ server.cwd = String(assignment.value)
309
+ break
310
+ case 'url':
311
+ server.url = String(assignment.value)
312
+ break
313
+ case 'bearer_token_env_var':
314
+ server.bearer_token_env_var = String(assignment.value)
315
+ break
316
+ case 'http_headers':
317
+ if (assignment.value && typeof assignment.value === 'object' && !Array.isArray(assignment.value)) {
318
+ server.http_headers = assignment.value as Record<string, string>
319
+ }
320
+ break
321
+ case 'env_http_headers':
322
+ if (assignment.value && typeof assignment.value === 'object' && !Array.isArray(assignment.value)) {
323
+ server.env_http_headers = assignment.value as Record<string, string>
324
+ }
325
+ break
326
+ case 'enabled':
327
+ if (typeof assignment.value === 'boolean') {
328
+ server.enabled = assignment.value
329
+ } else if (typeof assignment.value === 'string') {
330
+ server.enabled = assignment.value === 'true'
331
+ } else {
332
+ server.enabled = Boolean(assignment.value)
333
+ }
334
+ break
335
+ case 'startup_timeout_sec':
336
+ {
337
+ const parsed = Number(assignment.value)
338
+ if (Number.isFinite(parsed)) {
339
+ server.startup_timeout_sec = parsed
340
+ }
341
+ }
342
+ break
343
+ case 'startup_timeout_ms':
344
+ {
345
+ const parsed = Number(assignment.value)
346
+ if (Number.isFinite(parsed)) {
347
+ server.startup_timeout_ms = parsed
348
+ }
349
+ }
350
+ break
351
+ case 'tool_timeout_sec':
352
+ {
353
+ const parsed = Number(assignment.value)
354
+ if (Number.isFinite(parsed)) {
355
+ server.tool_timeout_sec = parsed
356
+ }
357
+ }
358
+ break
359
+ case 'enabled_tools':
360
+ server.enabled_tools = Array.isArray(assignment.value) ? assignment.value : [String(assignment.value)]
361
+ break
362
+ case 'disabled_tools':
363
+ server.disabled_tools = Array.isArray(assignment.value) ? assignment.value : [String(assignment.value)]
364
+ break
365
+ default:
366
+ break
367
+ }
368
+ servers.set(serverName, server)
369
+ }
370
+
371
+ return [...servers.values()].sort((a, b) => a.name.localeCompare(b.name))
372
+ }
373
+
374
+ const formatTomlString = (value: string) => {
375
+ const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
376
+ return `"${escaped}"`
377
+ }
378
+
379
+ const formatInlineTable = (value: Record<string, string>) => {
380
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
381
+ const rendered = entries
382
+ .map(([key, entryValue]) => `${formatTomlString(key)} = ${formatTomlString(entryValue)}`)
383
+ .join(', ')
384
+ return `{ ${rendered} }`
385
+ }
386
+
387
+ const formatTomlValue = (value: string | number | boolean | string[] | Record<string, string>) => {
388
+ if (Array.isArray(value)) {
389
+ const rendered = value.map((item) => formatTomlString(String(item))).join(', ')
390
+ return `[${rendered}]`
391
+ }
392
+ if (value && typeof value === 'object') {
393
+ return formatInlineTable(value)
394
+ }
395
+ if (typeof value === 'string') {
396
+ return formatTomlString(value)
397
+ }
398
+ if (typeof value === 'boolean') {
399
+ return value ? 'true' : 'false'
400
+ }
401
+ return Number.isFinite(value) ? String(value) : formatTomlString(String(value))
402
+ }
403
+
404
+ const buildMcpBlock = (servers: McpServerConfig[]): string[] => {
405
+ if (!servers.length) {
406
+ return []
407
+ }
408
+ const ordered = [...servers].sort((a, b) => a.name.localeCompare(b.name))
409
+ const lines: string[] = []
410
+ ordered.forEach((server, index) => {
411
+ if (index > 0) {
412
+ lines.push('')
413
+ }
414
+ lines.push(`[mcp_servers.${server.name}]`)
415
+ const entries: Array<[string, string | number | boolean | string[] | Record<string, string>]> = []
416
+ if (server.command) entries.push(['command', server.command])
417
+ if (server.args && server.args.length) entries.push(['args', server.args])
418
+ if (server.url) entries.push(['url', server.url])
419
+ if (server.bearer_token_env_var) entries.push(['bearer_token_env_var', server.bearer_token_env_var])
420
+ if (server.http_headers && Object.keys(server.http_headers).length > 0) {
421
+ entries.push(['http_headers', server.http_headers])
422
+ }
423
+ if (server.env_http_headers && Object.keys(server.env_http_headers).length > 0) {
424
+ entries.push(['env_http_headers', server.env_http_headers])
425
+ }
426
+ if (typeof server.enabled === 'boolean') entries.push(['enabled', server.enabled])
427
+ if (typeof server.startup_timeout_sec === 'number') entries.push(['startup_timeout_sec', server.startup_timeout_sec])
428
+ if (typeof server.startup_timeout_ms === 'number') entries.push(['startup_timeout_ms', server.startup_timeout_ms])
429
+ if (typeof server.tool_timeout_sec === 'number') entries.push(['tool_timeout_sec', server.tool_timeout_sec])
430
+ if (server.enabled_tools && server.enabled_tools.length) entries.push(['enabled_tools', server.enabled_tools])
431
+ if (server.disabled_tools && server.disabled_tools.length) entries.push(['disabled_tools', server.disabled_tools])
432
+ if (server.env_vars && server.env_vars.length) entries.push(['env_vars', server.env_vars])
433
+ if (server.cwd) entries.push(['cwd', server.cwd])
434
+ entries.forEach(([key, value]) => {
435
+ lines.push(`${key} = ${formatTomlValue(value)}`)
436
+ })
437
+ if (server.env && Object.keys(server.env).length > 0) {
438
+ lines.push('')
439
+ lines.push(`[mcp_servers.${server.name}.env]`)
440
+ const envEntries = Object.entries(server.env).sort(([a], [b]) => a.localeCompare(b))
441
+ envEntries.forEach(([key, value]) => {
442
+ lines.push(`${key} = ${formatTomlValue(value)}`)
443
+ })
444
+ }
445
+ })
446
+ return lines
447
+ }
448
+
449
+ const isMcpTable = (table: string) => table === 'mcp_servers' || table.startsWith('mcp_servers.')
450
+
451
+ const replaceMcpBlock = (content: string, blockLines: string[]): string => {
452
+ const lines = content.split(/\r?\n/)
453
+ const ranges: Array<{ start: number; end: number }> = []
454
+ let activeStart: number | null = null
455
+
456
+ for (let i = 0; i < lines.length; i += 1) {
457
+ const match = lines[i]?.match(/^\s*\[([^\]]+)\]\s*$/)
458
+ if (!match) {
459
+ continue
460
+ }
461
+ const table = match[1]?.trim() ?? ''
462
+ const isMcp = isMcpTable(table)
463
+ if (isMcp && activeStart === null) {
464
+ activeStart = i
465
+ continue
466
+ }
467
+ if (!isMcp && activeStart !== null) {
468
+ ranges.push({ start: activeStart, end: i })
469
+ activeStart = null
470
+ }
471
+ }
472
+
473
+ if (activeStart !== null) {
474
+ ranges.push({ start: activeStart, end: lines.length })
475
+ }
476
+
477
+ const insertBlock = (target: string[], hasMoreContent: boolean) => {
478
+ if (!blockLines.length) {
479
+ return
480
+ }
481
+ if (target.length && target[target.length - 1]?.trim()) {
482
+ target.push('')
483
+ }
484
+ target.push(...blockLines)
485
+ if (hasMoreContent && blockLines[blockLines.length - 1]?.trim()) {
486
+ target.push('')
487
+ }
488
+ }
489
+
490
+ if (!ranges.length) {
491
+ const updated: string[] = [...lines]
492
+ insertBlock(updated, false)
493
+ return updated.join('\n')
494
+ }
495
+
496
+ const updated: string[] = []
497
+ let cursor = 0
498
+ let inserted = false
499
+ ranges.forEach((range, index) => {
500
+ updated.push(...lines.slice(cursor, range.start))
501
+ cursor = range.end
502
+ if (!inserted) {
503
+ insertBlock(updated, cursor < lines.length)
504
+ inserted = true
505
+ }
506
+ if (index === ranges.length - 1 && cursor < lines.length) {
507
+ updated.push(...lines.slice(cursor))
508
+ }
509
+ })
510
+
511
+ if (!inserted) {
512
+ insertBlock(updated, false)
513
+ }
514
+
515
+ return updated.join('\n')
516
+ }
517
+
518
+ const readConfigFile = async (configPath: string): Promise<string> => {
519
+ try {
520
+ return await readFile(configPath, 'utf8')
521
+ } catch {
522
+ return ''
523
+ }
524
+ }
525
+
526
+ export const readProfileConfig = async (codexHome: string): Promise<ProfileConfigSnapshot> => {
527
+ const path = getConfigPath(codexHome)
528
+ const content = await readConfigFile(path)
529
+ return {
530
+ path,
531
+ content,
532
+ mcpServers: parseMcpServers(content),
533
+ }
534
+ }
535
+
536
+ export const writeProfileConfig = async (codexHome: string, content: string): Promise<ProfileConfigSnapshot> => {
537
+ const path = getConfigPath(codexHome)
538
+ await mkdir(dirname(path), { recursive: true })
539
+ await writeFile(path, content)
540
+ return {
541
+ path,
542
+ content,
543
+ mcpServers: parseMcpServers(content),
544
+ }
545
+ }
546
+
547
+ export const updateProfileMcpServers = async (
548
+ codexHome: string,
549
+ servers: McpServerConfig[]
550
+ ): Promise<ProfileConfigSnapshot> => {
551
+ const path = getConfigPath(codexHome)
552
+ const current = await readConfigFile(path)
553
+ const updatedContent = replaceMcpBlock(current, buildMcpBlock(servers))
554
+ await mkdir(dirname(path), { recursive: true })
555
+ await writeFile(path, updatedContent)
556
+ return {
557
+ path,
558
+ content: updatedContent,
559
+ mcpServers: parseMcpServers(updatedContent),
560
+ }
561
+ }
@@ -0,0 +1,47 @@
1
+ export type ActiveThread = {
2
+ profileId: string
3
+ threadId: string
4
+ turnId: string | null
5
+ startedAt: number
6
+ }
7
+
8
+ export class ThreadActivityService {
9
+ private readonly active = new Map<string, Map<string, ActiveThread>>()
10
+
11
+ markStarted(profileId: string, threadId: string, turnId?: string | null, startedAt = Date.now()): void {
12
+ if (!profileId || !threadId) {
13
+ return
14
+ }
15
+ const byProfile = this.active.get(profileId) ?? new Map<string, ActiveThread>()
16
+ const existing = byProfile.get(threadId)
17
+ byProfile.set(threadId, {
18
+ profileId,
19
+ threadId,
20
+ turnId: turnId ?? existing?.turnId ?? null,
21
+ startedAt: existing?.startedAt ?? startedAt,
22
+ })
23
+ this.active.set(profileId, byProfile)
24
+ }
25
+
26
+ markCompleted(profileId: string, threadId: string): void {
27
+ const byProfile = this.active.get(profileId)
28
+ if (!byProfile) {
29
+ return
30
+ }
31
+ byProfile.delete(threadId)
32
+ if (!byProfile.size) {
33
+ this.active.delete(profileId)
34
+ }
35
+ }
36
+
37
+ list(profileId?: string): ActiveThread[] {
38
+ if (profileId) {
39
+ return [...(this.active.get(profileId)?.values() ?? [])]
40
+ }
41
+ return [...this.active.values()].flatMap((entries) => [...entries.values()])
42
+ }
43
+
44
+ clearProfile(profileId: string): void {
45
+ this.active.delete(profileId)
46
+ }
47
+ }
@@ -3,6 +3,11 @@
3
3
  Frontend for the multi-account Codex hub. It connects to the local backend over
4
4
  REST + WebSocket.
5
5
 
6
+ ## Requirements
7
+
8
+ - Bun
9
+ - Codex Hub backend running locally
10
+
6
11
  ## Setup
7
12
 
8
13
  1. Install dependencies
@@ -16,9 +21,20 @@ REST + WebSocket.
16
21
 
17
22
  ## Environment variables
18
23
 
19
- Create a `.env` file with:
24
+ Create a `.env` file with (optional):
20
25
 
21
26
  ```bash
22
27
  VITE_CODEX_HUB_URL=http://127.0.0.1:7711
23
- VITE_CODEX_HUB_TOKEN=... # printed by the backend on boot
28
+ VITE_CODEX_HUB_TOKEN=... # optional, fetched from /config when omitted
24
29
  ```
30
+
31
+ ## Token flow
32
+
33
+ - If `VITE_CODEX_HUB_TOKEN` is set, the UI uses it directly.
34
+ - Otherwise it calls `GET /config` on the backend and reuses the returned token.
35
+ - For local development this is convenient, but do not expose the backend port publicly.
36
+
37
+ ## Common issues
38
+
39
+ - "Missing hub token - backend may not be running": start the backend and verify `VITE_CODEX_HUB_URL` matches.
40
+ - WebSocket failures: confirm the backend token matches or clear the env token to re-fetch.