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.
@@ -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
  }
@@ -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
@@ -234,6 +267,40 @@ class HubClient {
234
267
  return data.content ?? ''
235
268
  }
236
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
+
237
304
  async searchThreads(params: {
238
305
  query?: string
239
306
  profileId?: string
@@ -261,6 +328,17 @@ class HubClient {
261
328
  return data.threads ?? []
262
329
  }
263
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
+
264
342
  async listReviews(params?: { profileId?: string; limit?: number; offset?: number }): Promise<ReviewSessionResult[]> {
265
343
  const url = new URL('/reviews', HUB_URL)
266
344
  if (params?.profileId) url.searchParams.set('profileId', params.profileId)
@@ -274,7 +352,7 @@ class HubClient {
274
352
  return data.sessions ?? []
275
353
  }
276
354
 
277
- async request(profileId: string, method: string, params?: unknown): Promise<unknown> {
355
+ private async sendRequest(profileId: string, method: string, params?: unknown): Promise<unknown> {
278
356
  if (!this.ws) {
279
357
  console.error('[HubClient] WebSocket not connected')
280
358
  throw new Error('WebSocket not connected')
@@ -306,6 +384,18 @@ class HubClient {
306
384
  })
307
385
  }
308
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
+
309
399
  respond(profileId: string, id: number, result?: unknown, error?: { code?: number; message: string }) {
310
400
  if (!this.ws) {
311
401
  return
@@ -354,6 +444,13 @@ class HubClient {
354
444
 
355
445
  this.listeners.forEach((listener) => listener(payload as WsEvent))
356
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
+ }
357
454
  }
358
455
 
359
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.4",
3
+ "version": "0.2.0",
4
4
  "description": "Web launcher for Codex Hub",
5
5
  "bin": {
6
6
  "better-codex": "bin/better-codex.cjs"