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.
@@ -2,4 +2,28 @@ const FALLBACK_HUB_URL = 'http://127.0.0.1:7711'
2
2
 
3
3
  export const HUB_URL =
4
4
  import.meta.env.VITE_CODEX_HUB_URL ?? FALLBACK_HUB_URL
5
+
6
+ let cachedToken: string | null = null
7
+
8
+ export const getHubToken = async (): Promise<string> => {
9
+ const envToken = import.meta.env.VITE_CODEX_HUB_TOKEN
10
+ if (envToken) {
11
+ return envToken
12
+ }
13
+
14
+ if (!cachedToken) {
15
+ try {
16
+ const response = await fetch(`${HUB_URL}/config`)
17
+ if (response.ok) {
18
+ const data = (await response.json()) as { token?: string }
19
+ cachedToken = data.token ?? ''
20
+ }
21
+ } catch {
22
+ console.warn('Failed to fetch hub token from backend')
23
+ }
24
+ }
25
+
26
+ return cachedToken ?? ''
27
+ }
28
+
5
29
  export const HUB_TOKEN = import.meta.env.VITE_CODEX_HUB_TOKEN ?? ''
@@ -278,8 +278,9 @@ export const useHubConnection = () => {
278
278
  id: `diff-${turnId ?? threadId}`,
279
279
  role: 'assistant',
280
280
  kind: 'file',
281
- title: 'Diff · updated',
281
+ title: 'Diff',
282
282
  content,
283
+ meta: { diff },
283
284
  timestamp: nowTimestamp(),
284
285
  })
285
286
  }
@@ -537,8 +538,25 @@ export const useHubConnection = () => {
537
538
  })
538
539
  if (threads) {
539
540
  setThreadsForAccount(profile.id, toThreads(profile.id, threads))
540
- if (profile.id === profiles[0]?.id && threads.data?.length) {
541
- setSelectedThreadId(threads.data[0].id)
541
+ }
542
+ if (threads?.data?.length) {
543
+ try {
544
+ const activeThreads = await hubClient.listActiveThreads({ profileId: profile.id })
545
+ const knownThreadIds = new Set(threads.data.map((thread) => thread.id))
546
+ for (const entry of activeThreads) {
547
+ if (!knownThreadIds.has(entry.threadId)) {
548
+ continue
549
+ }
550
+ updateThread(entry.threadId, { status: 'active' })
551
+ if (entry.turnId) {
552
+ setThreadTurnId(entry.threadId, entry.turnId)
553
+ }
554
+ if (Number.isFinite(entry.startedAt)) {
555
+ setThreadTurnStartedAt(entry.threadId, entry.startedAt)
556
+ }
557
+ }
558
+ } catch (error) {
559
+ console.error(error)
542
560
  }
543
561
  }
544
562
 
@@ -24,6 +24,7 @@ type ThreadData = {
24
24
  type TurnData = {
25
25
  id: string
26
26
  items?: ThreadItem[]
27
+ status?: string
27
28
  }
28
29
 
29
30
  type ThreadItem = {
@@ -109,6 +110,72 @@ const buildMessagesFromTurns = (turns: TurnData[] = []): Message[] => {
109
110
  return messages
110
111
  }
111
112
 
113
+ const mergeMessages = (base: Message[], incoming: Message[]): Message[] => {
114
+ if (incoming.length === 0) {
115
+ return base
116
+ }
117
+ if (base.length === 0) {
118
+ return incoming
119
+ }
120
+ const merged = [...base]
121
+ const indexById = new Map(base.map((msg, index) => [msg.id, index]))
122
+ const userContentIndex = new Map<string, number[]>()
123
+
124
+ const contentKey = (message: Message) => message.content.trim()
125
+ const isUserChat = (message: Message) => message.role === 'user' && message.kind === 'chat'
126
+
127
+ base.forEach((message, index) => {
128
+ if (!isUserChat(message) || message.timestamp) {
129
+ return
130
+ }
131
+ const key = contentKey(message)
132
+ if (!key) {
133
+ return
134
+ }
135
+ const existing = userContentIndex.get(key)
136
+ if (existing) {
137
+ existing.push(index)
138
+ } else {
139
+ userContentIndex.set(key, [index])
140
+ }
141
+ })
142
+
143
+ for (const message of incoming) {
144
+ const existingIndex = indexById.get(message.id)
145
+ if (existingIndex === undefined) {
146
+ if (isUserChat(message)) {
147
+ const key = contentKey(message)
148
+ const candidates = key ? userContentIndex.get(key) : undefined
149
+ const targetIndex = candidates?.shift()
150
+ if (targetIndex !== undefined) {
151
+ const target = merged[targetIndex]
152
+ merged[targetIndex] = {
153
+ ...target,
154
+ ...message,
155
+ id: target.id,
156
+ timestamp: message.timestamp || target.timestamp,
157
+ }
158
+ if (candidates && candidates.length === 0) {
159
+ userContentIndex.delete(key)
160
+ }
161
+ continue
162
+ }
163
+ }
164
+ merged.push(message)
165
+ } else {
166
+ merged[existingIndex] = { ...merged[existingIndex], ...message }
167
+ }
168
+ }
169
+ return merged
170
+ }
171
+
172
+ const isTurnInProgress = (status?: string) => {
173
+ if (!status) {
174
+ return false
175
+ }
176
+ return status === 'inProgress' || status === 'in_progress' || status === 'inprogress'
177
+ }
178
+
112
179
  export const useThreadHistory = () => {
113
180
  const {
114
181
  threads,
@@ -121,9 +188,11 @@ export const useThreadHistory = () => {
121
188
  setThreadEffort,
122
189
  setThreadApproval,
123
190
  setThreadCwd,
191
+ setThreadTurnId,
124
192
  } = useAppStore()
125
193
 
126
194
  const inFlight = useRef<Set<string>>(new Set())
195
+ const loaded = useRef<Set<string>>(new Set())
127
196
 
128
197
  useEffect(() => {
129
198
  if (!selectedThreadId || connectionStatus !== 'connected') {
@@ -135,8 +204,7 @@ export const useThreadHistory = () => {
135
204
  return
136
205
  }
137
206
 
138
- const existing = messages[selectedThreadId]
139
- if (existing !== undefined) {
207
+ if (loaded.current.has(selectedThreadId)) {
140
208
  return
141
209
  }
142
210
 
@@ -162,14 +230,32 @@ export const useThreadHistory = () => {
162
230
  return
163
231
  }
164
232
 
165
- const loadedMessages = buildMessagesFromTurns(resumeThread.turns)
166
- setMessagesForThread(selectedThreadId, loadedMessages)
233
+ const turns = resumeThread.turns ?? []
234
+ const loadedMessages = buildMessagesFromTurns(turns)
235
+ const currentMessages = useAppStore.getState().messages[selectedThreadId] ?? []
236
+ const mergedMessages = mergeMessages(loadedMessages, currentMessages)
237
+ setMessagesForThread(selectedThreadId, mergedMessages)
238
+
239
+ let activeTurn: TurnData | null = null
240
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
241
+ if (isTurnInProgress(turns[index]?.status)) {
242
+ activeTurn = turns[index]
243
+ break
244
+ }
245
+ }
246
+ setThreadTurnId(selectedThreadId, activeTurn?.id ?? null)
247
+ const nextStatus = thread.status === 'archived'
248
+ ? 'archived'
249
+ : activeTurn
250
+ ? 'active'
251
+ : 'idle'
167
252
 
168
253
  updateThread(selectedThreadId, {
169
254
  title: resumeThread.preview?.trim() || thread.title,
170
255
  preview: resumeThread.preview?.trim() || thread.preview,
171
256
  model: resumeThread.modelProvider ?? thread.model,
172
- messageCount: loadedMessages.length,
257
+ messageCount: mergedMessages.length,
258
+ status: nextStatus,
173
259
  })
174
260
  if (result.model) {
175
261
  setThreadModel(selectedThreadId, result.model)
@@ -184,6 +270,7 @@ export const useThreadHistory = () => {
184
270
  if (result.cwd) {
185
271
  setThreadCwd(selectedThreadId, result.cwd)
186
272
  }
273
+ loaded.current.add(selectedThreadId)
187
274
  } catch (error) {
188
275
  console.error(error)
189
276
  } finally {
@@ -206,5 +293,7 @@ export const useThreadHistory = () => {
206
293
  setThreadApproval,
207
294
  threads,
208
295
  updateThread,
296
+ setThreadTurnId,
297
+ setThreadCwd,
209
298
  ])
210
299
  }
@@ -1,4 +1,4 @@
1
- import { HUB_TOKEN, HUB_URL } from '../config'
1
+ import { getHubToken, HUB_URL } from '../config'
2
2
 
3
3
  export type HubProfile = {
4
4
  id: string
@@ -12,6 +12,32 @@ export type PromptSummary = {
12
12
  description?: string
13
13
  }
14
14
 
15
+ export type McpServerConfig = {
16
+ name: string
17
+ command?: string
18
+ args?: string[]
19
+ env?: Record<string, string>
20
+ env_vars?: string[]
21
+ cwd?: string
22
+ url?: string
23
+ bearer_token_env_var?: string
24
+ http_headers?: Record<string, string>
25
+ env_http_headers?: Record<string, string>
26
+ enabled?: boolean
27
+ startup_timeout_sec?: number
28
+ startup_timeout_ms?: number
29
+ tool_timeout_sec?: number
30
+ enabled_tools?: string[]
31
+ disabled_tools?: string[]
32
+ }
33
+
34
+ export type ProfileConfigSnapshot = {
35
+ path: string
36
+ codexHome: string
37
+ content: string
38
+ mcpServers: McpServerConfig[]
39
+ }
40
+
15
41
  export type ThreadSearchResult = {
16
42
  threadId: string
17
43
  profileId: string
@@ -27,6 +53,13 @@ export type ThreadSearchResult = {
27
53
  lastSeenAt: number | null
28
54
  }
29
55
 
56
+ export type ActiveThread = {
57
+ threadId: string
58
+ profileId: string
59
+ turnId: string | null
60
+ startedAt: number
61
+ }
62
+
30
63
  export type ReviewSessionResult = {
31
64
  id: string
32
65
  threadId: string
@@ -128,11 +161,12 @@ class HubClient {
128
161
  if (this.ws?.readyState === WebSocket.OPEN) {
129
162
  return
130
163
  }
131
- if (!HUB_TOKEN) {
132
- throw new Error('Missing VITE_CODEX_HUB_TOKEN')
164
+ const token = await getHubToken()
165
+ if (!token) {
166
+ throw new Error('Missing hub token - backend may not be running')
133
167
  }
134
168
 
135
- const ws = new WebSocket(toWsUrl(HUB_URL, HUB_TOKEN))
169
+ const ws = new WebSocket(toWsUrl(HUB_URL, token))
136
170
  this.ws = ws
137
171
 
138
172
  ws.onmessage = (event) => {
@@ -233,6 +267,40 @@ class HubClient {
233
267
  return data.content ?? ''
234
268
  }
235
269
 
270
+ async getProfileConfig(profileId: string): Promise<ProfileConfigSnapshot> {
271
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/config`)
272
+ if (!response.ok) {
273
+ throw new Error('Failed to load config')
274
+ }
275
+ return (await response.json()) as ProfileConfigSnapshot
276
+ }
277
+
278
+ async saveProfileConfig(profileId: string, content: string): Promise<ProfileConfigSnapshot> {
279
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/config`, {
280
+ method: 'PUT',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ content }),
283
+ })
284
+ if (!response.ok) {
285
+ throw new Error('Failed to save config')
286
+ }
287
+ const data = (await response.json()) as { content?: string } & ProfileConfigSnapshot
288
+ return data
289
+ }
290
+
291
+ async saveMcpServers(profileId: string, servers: McpServerConfig[]): Promise<ProfileConfigSnapshot> {
292
+ const response = await fetch(`${HUB_URL}/profiles/${profileId}/mcp-servers`, {
293
+ method: 'PUT',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({ servers }),
296
+ })
297
+ if (!response.ok) {
298
+ throw new Error('Failed to save MCP servers')
299
+ }
300
+ const data = (await response.json()) as ProfileConfigSnapshot
301
+ return data
302
+ }
303
+
236
304
  async searchThreads(params: {
237
305
  query?: string
238
306
  profileId?: string
@@ -260,6 +328,17 @@ class HubClient {
260
328
  return data.threads ?? []
261
329
  }
262
330
 
331
+ async listActiveThreads(params?: { profileId?: string }): Promise<ActiveThread[]> {
332
+ const url = new URL('/threads/active', HUB_URL)
333
+ if (params?.profileId) url.searchParams.set('profileId', params.profileId)
334
+ const response = await fetch(url.toString())
335
+ if (!response.ok) {
336
+ throw new Error('Failed to load active threads')
337
+ }
338
+ const data = (await response.json()) as { threads?: ActiveThread[] }
339
+ return data.threads ?? []
340
+ }
341
+
263
342
  async listReviews(params?: { profileId?: string; limit?: number; offset?: number }): Promise<ReviewSessionResult[]> {
264
343
  const url = new URL('/reviews', HUB_URL)
265
344
  if (params?.profileId) url.searchParams.set('profileId', params.profileId)
@@ -273,7 +352,7 @@ class HubClient {
273
352
  return data.sessions ?? []
274
353
  }
275
354
 
276
- async request(profileId: string, method: string, params?: unknown): Promise<unknown> {
355
+ private async sendRequest(profileId: string, method: string, params?: unknown): Promise<unknown> {
277
356
  if (!this.ws) {
278
357
  console.error('[HubClient] WebSocket not connected')
279
358
  throw new Error('WebSocket not connected')
@@ -305,6 +384,18 @@ class HubClient {
305
384
  })
306
385
  }
307
386
 
387
+ async request(profileId: string, method: string, params?: unknown): Promise<unknown> {
388
+ try {
389
+ return await this.sendRequest(profileId, method, params)
390
+ } catch (error) {
391
+ if (this.isProfileNotRunning(error)) {
392
+ await this.startProfile(profileId)
393
+ return await this.sendRequest(profileId, method, params)
394
+ }
395
+ throw error
396
+ }
397
+ }
398
+
308
399
  respond(profileId: string, id: number, result?: unknown, error?: { code?: number; message: string }) {
309
400
  if (!this.ws) {
310
401
  return
@@ -353,6 +444,13 @@ class HubClient {
353
444
 
354
445
  this.listeners.forEach((listener) => listener(payload as WsEvent))
355
446
  }
447
+
448
+ private isProfileNotRunning(error: unknown): boolean {
449
+ if (!error || typeof error !== 'object') {
450
+ return false
451
+ }
452
+ return error instanceof Error && error.message.includes('profile app-server not running')
453
+ }
356
454
  }
357
455
 
358
456
  export const hubClient = new HubClient()
@@ -8,6 +8,29 @@ export type ReasoningSummary = 'auto' | 'concise' | 'detailed' | 'none'
8
8
  export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'
9
9
  export type ReviewStatus = 'pending' | 'running' | 'completed' | 'failed'
10
10
 
11
+ export type CommandAction =
12
+ | { type: 'read'; command: string; name: string; path: string }
13
+ | { type: 'listFiles'; command: string; path?: string | null }
14
+ | { type: 'search'; command: string; query?: string | null; path?: string | null }
15
+ | { type: 'unknown'; command: string }
16
+
17
+ export type FileChangeKind = 'add' | 'delete' | 'update'
18
+
19
+ export interface FileChangeMeta {
20
+ path: string
21
+ kind: FileChangeKind
22
+ diff?: string
23
+ movePath?: string | null
24
+ }
25
+
26
+ export interface MessageMeta {
27
+ commandActions?: CommandAction[]
28
+ command?: string
29
+ status?: string
30
+ fileChanges?: FileChangeMeta[]
31
+ diff?: string
32
+ }
33
+
11
34
  export interface Attachment {
12
35
  id: string
13
36
  type: 'image' | 'file'
@@ -69,6 +92,7 @@ export interface Message {
69
92
  kind?: MessageKind
70
93
  title?: string
71
94
  timestamp: string
95
+ meta?: MessageMeta
72
96
  }
73
97
 
74
98
  export interface QueuedMessage {
@@ -1,4 +1,4 @@
1
- import type { Message, MessageKind } from '../types'
1
+ import type { CommandAction, FileChangeMeta, Message, MessageKind, MessageMeta } from '../types'
2
2
 
3
3
  type ThreadItem = {
4
4
  type: string
@@ -6,11 +6,11 @@ type ThreadItem = {
6
6
  [key: string]: unknown
7
7
  }
8
8
 
9
- type CommandAction =
10
- | { type: 'read'; command: string; name: string; path: string }
11
- | { type: 'listFiles'; command: string; path?: string | null }
12
- | { type: 'search'; command: string; query?: string | null; path?: string | null }
13
- | { type: 'unknown'; command: string }
9
+ // type CommandAction =
10
+ // | { type: 'read'; command: string; name: string; path: string }
11
+ // | { type: 'listFiles'; command: string; path?: string | null }
12
+ // | { type: 'search'; command: string; query?: string | null; path?: string | null }
13
+ // | { type: 'unknown'; command: string }
14
14
 
15
15
  const clampText = (value: string, max = 1400) => {
16
16
  if (value.length <= max) {
@@ -99,7 +99,43 @@ const formatCommandActions = (actions: CommandAction[], commandFallback: string)
99
99
  }
100
100
  }
101
101
 
102
- export const formatThreadItem = (item: ThreadItem): { kind: MessageKind; content: string; title?: string } | null => {
102
+ type FormattedThreadItem = {
103
+ kind: MessageKind
104
+ content: string
105
+ title?: string
106
+ meta?: MessageMeta
107
+ }
108
+
109
+ const toFileChangeMeta = (change: {
110
+ path?: unknown
111
+ kind?: unknown
112
+ diff?: string
113
+ movePath?: unknown
114
+ move_path?: unknown
115
+ }): FileChangeMeta => {
116
+ const path = typeof change.path === 'string' ? change.path : 'unknown'
117
+ const kind =
118
+ typeof change.kind === 'string'
119
+ ? change.kind
120
+ : typeof (change.kind as { type?: string } | null | undefined)?.type === 'string'
121
+ ? String((change.kind as { type?: string }).type)
122
+ : 'update'
123
+ const movePath =
124
+ typeof change.movePath === 'string'
125
+ ? change.movePath
126
+ : typeof change.move_path === 'string'
127
+ ? change.move_path
128
+ : undefined
129
+
130
+ return {
131
+ path,
132
+ kind: kind === 'add' || kind === 'delete' ? kind : 'update',
133
+ diff: typeof change.diff === 'string' ? change.diff : undefined,
134
+ movePath: movePath ?? null,
135
+ }
136
+ }
137
+
138
+ export const formatThreadItem = (item: ThreadItem): FormattedThreadItem | null => {
103
139
  switch (item.type) {
104
140
  case 'reasoning': {
105
141
  const summary = Array.isArray(item.summary) ? item.summary.join('\n') : ''
@@ -113,13 +149,22 @@ export const formatThreadItem = (item: ThreadItem): { kind: MessageKind; content
113
149
  const actions = Array.isArray(item.commandActions) ? (item.commandActions as CommandAction[]) : []
114
150
  const actionSummary = formatCommandActions(actions, command)
115
151
  const content = output ? `${actionSummary.content}\n\n${output}` : actionSummary.content
116
- return { kind: actionSummary.kind, content: clampText(content), title: `${actionSummary.title} · ${status}` }
152
+ const meta: MessageMeta = {
153
+ commandActions: actions.length ? actions : undefined,
154
+ command,
155
+ status,
156
+ }
157
+ return { kind: actionSummary.kind, content: clampText(content), title: actionSummary.title, meta }
117
158
  }
118
159
  case 'fileChange': {
119
160
  const changes = Array.isArray(item.changes) ? item.changes : []
120
161
  const content = formatFileChanges(changes as Array<{ path?: string; kind?: string; diff?: string; movePath?: string }>)
121
162
  const status = typeof item.status === 'string' ? item.status : 'inProgress'
122
- return { kind: 'file', content, title: `Files · ${status}` }
163
+ const meta: MessageMeta = {
164
+ fileChanges: changes.length ? changes.map((change) => toFileChangeMeta(change as Record<string, unknown>)) : undefined,
165
+ status,
166
+ }
167
+ return { kind: 'file', content, title: 'Files', meta }
123
168
  }
124
169
  case 'mcpToolCall': {
125
170
  const server = typeof item.server === 'string' ? item.server : 'mcp'
@@ -166,5 +211,6 @@ export const buildSystemMessage = (item: ThreadItem): Message | null => {
166
211
  title: formatted.title,
167
212
  content: formatted.content,
168
213
  timestamp: '',
214
+ meta: formatted.meta,
169
215
  }
170
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-codex",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Web launcher for Codex Hub",
5
5
  "bin": {
6
6
  "better-codex": "bin/better-codex.cjs"