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.
- package/apps/backend/README.md +43 -2
- package/apps/backend/src/core/app-server.ts +68 -2
- package/apps/backend/src/server.ts +156 -1
- package/apps/backend/src/services/codex-config.ts +561 -0
- package/apps/backend/src/thread-activity/service.ts +47 -0
- package/apps/web/README.md +18 -2
- package/apps/web/src/components/layout/codex-settings.tsx +1208 -0
- package/apps/web/src/components/layout/settings-dialog.tsx +9 -1
- package/apps/web/src/components/layout/virtualized-message-list.tsx +581 -86
- package/apps/web/src/hooks/use-hub-connection.ts +21 -3
- package/apps/web/src/hooks/use-thread-history.ts +94 -5
- package/apps/web/src/services/hub-client.ts +98 -1
- package/apps/web/src/types/index.ts +24 -0
- package/apps/web/src/utils/item-format.ts +55 -9
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/apps/web/README.md
CHANGED
|
@@ -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=... #
|
|
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.
|