better-codex 0.2.1 → 0.2.2

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.
@@ -371,6 +371,25 @@ const app = new Elysia()
371
371
  }),
372
372
  }
373
373
  )
374
+ .delete(
375
+ '/threads/active/:threadId',
376
+ ({ params, query }) => {
377
+ const profileId = typeof query.profileId === 'string' ? query.profileId : undefined
378
+ if (!profileId) {
379
+ return { ok: false, error: 'profileId is required' }
380
+ }
381
+ threadActivity.markCompleted(profileId, params.threadId)
382
+ return { ok: true }
383
+ },
384
+ {
385
+ params: t.Object({
386
+ threadId: t.String(),
387
+ }),
388
+ query: t.Object({
389
+ profileId: t.String(),
390
+ }),
391
+ }
392
+ )
374
393
  .post(
375
394
  '/threads/reindex',
376
395
  async ({ body }) => {
@@ -3,7 +3,7 @@ import { useAppStore } from '../../store'
3
3
  import { type SelectOption } from '../ui'
4
4
  import { VirtualizedMessageList } from './virtualized-message-list'
5
5
  import { hubClient } from '../../services/hub-client'
6
- import type { ApprovalPolicy, Attachment, FileMention, MessageKind, ReasoningEffort, ReasoningSummary } from '../../types'
6
+ import type { ApprovalPolicy, Attachment, FileMention, MessageKind, ReasoningEffort, ReasoningSummary, SandboxMode, SandboxPolicy } from '../../types'
7
7
  import { INIT_PROMPT } from '../../utils/init-prompt'
8
8
  import { filterSlashCommands, findSlashCommand, getSlashQuery, parseSlashInput, type SlashCommandDefinition } from '../../utils/slash-commands'
9
9
  import { approvalPolicyDescription, approvalPolicyLabel, normalizeApprovalPolicy } from '../../utils/approval-policy'
@@ -17,6 +17,22 @@ import { SessionDialogs } from '../session-view/session-dialogs'
17
17
  import { SessionEmpty } from '../session-view/session-empty'
18
18
  import { RateLimitBanner, isRateLimitError } from '../session-view/rate-limit-banner'
19
19
 
20
+ const ALWAYS_ALLOW_SANDBOX_MODE: SandboxMode = 'workspace-write'
21
+ const ALWAYS_ALLOW_SANDBOX_POLICY: SandboxPolicy = { type: 'workspaceWrite' }
22
+
23
+ const resolveSandboxPolicy = (mode?: SandboxMode | null): SandboxPolicy | null => {
24
+ switch (mode) {
25
+ case 'danger-full-access':
26
+ return { type: 'dangerFullAccess' }
27
+ case 'read-only':
28
+ return { type: 'readOnly' }
29
+ case 'workspace-write':
30
+ return { type: 'workspaceWrite' }
31
+ default:
32
+ return null
33
+ }
34
+ }
35
+
20
36
  export function SessionView() {
21
37
  const [inputValue, setInputValue] = useState('')
22
38
  const [slashIndex, setSlashIndex] = useState(0)
@@ -76,6 +92,7 @@ export function SessionView() {
76
92
  threadEfforts,
77
93
  threadApprovals,
78
94
  threadWebSearch,
95
+ threadSandboxes,
79
96
  threadTurnIds,
80
97
  threadTurnStartedAt,
81
98
  threadLastTurnDuration,
@@ -85,6 +102,7 @@ export function SessionView() {
85
102
  setThreadEffort,
86
103
  setThreadApproval,
87
104
  setThreadWebSearch,
105
+ setThreadSandbox,
88
106
  threadSummaries,
89
107
  setThreadSummary,
90
108
  threadCwds,
@@ -121,6 +139,7 @@ export function SessionView() {
121
139
  const selectedEffort = selectedThreadId ? threadEfforts[selectedThreadId] : undefined
122
140
  const effectiveEffort = selectedEffort ?? defaultEffort ?? null
123
141
  const selectedApproval = selectedThreadId ? threadApprovals[selectedThreadId] : undefined
142
+ const selectedSandbox = selectedThreadId ? threadSandboxes[selectedThreadId] : undefined
124
143
  const selectedSummary = selectedThreadId ? threadSummaries[selectedThreadId] : undefined
125
144
  const selectedCwd = selectedThreadId ? threadCwds[selectedThreadId] : undefined
126
145
  const selectedUsage = selectedThreadId ? threadTokenUsage[selectedThreadId] : undefined
@@ -212,6 +231,18 @@ export function SessionView() {
212
231
  description: approvalPolicyDescription(value),
213
232
  })
214
233
  )
234
+ const sandboxOptions: SelectOption[] = [
235
+ {
236
+ value: '',
237
+ label: 'Sandboxed',
238
+ description: 'Use configured sandbox settings',
239
+ },
240
+ {
241
+ value: 'danger-full-access',
242
+ label: 'Dangerously allow full access',
243
+ description: 'No filesystem sandbox (trusted only)',
244
+ },
245
+ ]
215
246
  const resumeCandidates = threadAccountId
216
247
  ? threads.filter((thread) => thread.accountId === threadAccountId)
217
248
  : []
@@ -439,6 +470,7 @@ export function SessionView() {
439
470
  summary: selectedSummary ?? null,
440
471
  cwd: selectedCwd ?? null,
441
472
  approvalPolicy: selectedApproval ?? null,
473
+ sandbox: selectedSandbox ?? null,
442
474
  createdAt: Date.now(),
443
475
  })
444
476
  setInputValue('')
@@ -473,13 +505,23 @@ export function SessionView() {
473
505
  const newAccountId = selectedThread.accountId
474
506
  const accountModels = modelsByAccount[newAccountId] || []
475
507
  const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
476
- const startParams: { model?: string; approvalPolicy?: ApprovalPolicy; config?: Record<string, unknown> } = {}
508
+ const startParams: {
509
+ model?: string
510
+ approvalPolicy?: ApprovalPolicy
511
+ sandbox?: SandboxMode
512
+ config?: Record<string, unknown>
513
+ } = {}
477
514
  if (defaultThreadModel?.id) {
478
515
  startParams.model = defaultThreadModel.id
479
516
  }
480
517
  if (selectedApproval) {
481
518
  startParams.approvalPolicy = selectedApproval
482
519
  }
520
+ if (selectedSandbox) {
521
+ startParams.sandbox = selectedSandbox
522
+ } else if (selectedApproval === 'never') {
523
+ startParams.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
524
+ }
483
525
  if (selectedWebSearch) {
484
526
  startParams.config = { 'features.web_search_request': true }
485
527
  }
@@ -543,7 +585,8 @@ export function SessionView() {
543
585
  input.push({ type: 'image', url: attachment.url })
544
586
  }
545
587
  }
546
-
588
+ const sandboxPolicyOverride = resolveSandboxPolicy(selectedSandbox)
589
+
547
590
  const params: {
548
591
  threadId: string
549
592
  input: Array<{ type: string; text?: string; url?: string; path?: string }>
@@ -552,6 +595,7 @@ export function SessionView() {
552
595
  summary?: ReasoningSummary
553
596
  cwd?: string
554
597
  approvalPolicy?: ApprovalPolicy
598
+ sandboxPolicy?: SandboxPolicy
555
599
  } = {
556
600
  threadId: actualThreadId,
557
601
  input,
@@ -565,6 +609,11 @@ export function SessionView() {
565
609
  if (selectedApproval) {
566
610
  params.approvalPolicy = selectedApproval
567
611
  }
612
+ if (sandboxPolicyOverride) {
613
+ params.sandboxPolicy = sandboxPolicyOverride
614
+ } else if (selectedApproval === 'never') {
615
+ params.sandboxPolicy = ALWAYS_ALLOW_SANDBOX_POLICY
616
+ }
568
617
  if (selectedSummary) {
569
618
  params.summary = selectedSummary
570
619
  }
@@ -587,13 +636,23 @@ export function SessionView() {
587
636
  const startNewThread = async (accountId: string, approvalOverride?: ApprovalPolicy | null, webSearch?: boolean) => {
588
637
  const accountModels = modelsByAccount[accountId] || []
589
638
  const defaultThreadModel = accountModels.find((model) => model.isDefault) ?? accountModels[0]
590
- const params: { model?: string; approvalPolicy?: ApprovalPolicy; config?: Record<string, unknown> } = {}
639
+ const params: {
640
+ model?: string
641
+ approvalPolicy?: ApprovalPolicy
642
+ sandbox?: SandboxMode
643
+ config?: Record<string, unknown>
644
+ } = {}
591
645
  if (defaultThreadModel?.id) {
592
646
  params.model = defaultThreadModel.id
593
647
  }
594
648
  if (approvalOverride) {
595
649
  params.approvalPolicy = approvalOverride
596
650
  }
651
+ if (selectedSandbox) {
652
+ params.sandbox = selectedSandbox
653
+ } else if (approvalOverride === 'never') {
654
+ params.sandbox = ALWAYS_ALLOW_SANDBOX_MODE
655
+ }
597
656
  if (webSearch) {
598
657
  params.config = { 'features.web_search_request': true }
599
658
  }
@@ -642,6 +701,9 @@ export function SessionView() {
642
701
  if (effort) {
643
702
  setThreadEffort(threadId, effort)
644
703
  }
704
+ if (selectedSandbox) {
705
+ setThreadSandbox(threadId, selectedSandbox)
706
+ }
645
707
  if (selectedSummary) {
646
708
  setThreadSummary(threadId, selectedSummary)
647
709
  }
@@ -707,6 +769,7 @@ export function SessionView() {
707
769
  `Reasoning summary: ${selectedSummary ?? 'default'}`,
708
770
  `Working directory: ${selectedCwd || 'default'}`,
709
771
  `Approvals: ${selectedApproval ?? 'default'}`,
772
+ `Sandbox: ${selectedSandbox ?? 'default'}`,
710
773
  `Connection: ${connectionStatus}`,
711
774
  ]
712
775
  if (usageLine) {
@@ -1343,7 +1406,7 @@ export function SessionView() {
1343
1406
  }
1344
1407
  }
1345
1408
 
1346
- const handleSwitchThreadAccount = (newAccountId: string) => {
1409
+ const handleSwitchThreadAccount = async (newAccountId: string) => {
1347
1410
  if (!selectedThreadId || !selectedThread) {
1348
1411
  return
1349
1412
  }
@@ -1360,6 +1423,27 @@ export function SessionView() {
1360
1423
  }
1361
1424
 
1362
1425
  const previousAccountId = selectedThread.accountId
1426
+ const previousAccount = accounts.find((a) => a.id === previousAccountId)
1427
+
1428
+ // If a task is running on the current account, interrupt it first
1429
+ if (isTaskRunning && previousAccount) {
1430
+ const turnId = threadTurnIds[selectedThreadId]
1431
+ if (turnId) {
1432
+ try {
1433
+ await hubClient.request(previousAccount.id, 'turn/interrupt', {
1434
+ threadId: effectiveBackendThreadId,
1435
+ turnId,
1436
+ })
1437
+ // Wait a moment for the interrupt to process
1438
+ await new Promise((resolve) => setTimeout(resolve, 200))
1439
+ } catch {
1440
+ // Continue with switch even if interrupt fails
1441
+ console.warn('Failed to interrupt turn before account switch')
1442
+ }
1443
+ }
1444
+ // Reset thread status
1445
+ updateThread(selectedThreadId, { status: 'idle' })
1446
+ }
1363
1447
 
1364
1448
  // Mark this thread as having a pending account switch
1365
1449
  // The next message sent will start a fresh conversation on the new account
@@ -1488,6 +1572,17 @@ export function SessionView() {
1488
1572
  }}
1489
1573
  showModelSelect={models.length > 0}
1490
1574
  showEffortSelect={effortOptions.length > 0}
1575
+ sandboxOptions={sandboxOptions}
1576
+ effectiveSandbox={selectedSandbox ?? ''}
1577
+ onSandboxChange={(value) => {
1578
+ if (!selectedThreadId) return
1579
+ if (!value) {
1580
+ setThreadSandbox(selectedThreadId, null)
1581
+ return
1582
+ }
1583
+ setThreadSandbox(selectedThreadId, value as SandboxMode)
1584
+ }}
1585
+ showSandboxSelect
1491
1586
  queuedCount={queuedCount}
1492
1587
  webSearchEnabled={webSearchEnabled}
1493
1588
  onWebSearchToggle={() => {
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
2
  import { useAppStore } from '../../store'
3
3
  import { hubClient } from '../../services/hub-client'
4
- import { Avatar, Button, Dialog, IconButton, Icons, SectionHeader, AlertDialog, PromptDialog, CopyDialog } from '../ui'
4
+ import { refreshAccountSnapshot } from '../../utils/account-refresh'
5
+ import { Avatar, Button, Dialog, IconButton, Icons, SectionHeader, AlertDialog, CopyDialog } from '../ui'
5
6
  import { AccountUsagePanel } from './account-usage-panel'
6
7
  import { SettingsDialog } from './settings-dialog'
7
8
 
@@ -9,13 +10,24 @@ interface SidebarProps {
9
10
  onNavigate?: () => void
10
11
  }
11
12
 
13
+ type AuthMethod = 'chatgpt' | 'apiKey'
14
+
12
15
  export function Sidebar({ onNavigate }: SidebarProps) {
13
16
  const [isAdding, setIsAdding] = useState(false)
14
17
  const [isRemoving, setIsRemoving] = useState(false)
15
- const [showPrompt, setShowPrompt] = useState(false)
18
+ const [showAddDialog, setShowAddDialog] = useState(false)
16
19
  const [showUsage, setShowUsage] = useState(false)
17
20
  const [showSettings, setShowSettings] = useState(false)
18
21
  const [authPendingId, setAuthPendingId] = useState<string | null>(null)
22
+ const [authDialog, setAuthDialog] = useState<{
23
+ open: boolean
24
+ mode: 'create' | 'login'
25
+ accountId?: string
26
+ accountName?: string
27
+ }>({ open: false, mode: 'create' })
28
+ const [authMethod, setAuthMethod] = useState<AuthMethod>('chatgpt')
29
+ const [authApiKey, setAuthApiKey] = useState('')
30
+ const [newAccountName, setNewAccountName] = useState('')
19
31
  const [removeDialog, setRemoveDialog] = useState<{ open: boolean; accountId: string; name: string }>({
20
32
  open: false,
21
33
  accountId: '',
@@ -43,6 +55,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
43
55
  setShowAnalytics,
44
56
  showReviews,
45
57
  setShowReviews,
58
+ setAccountLoginId,
59
+ setModelsForAccount,
46
60
  } = useAppStore()
47
61
 
48
62
  const getStatusColor = (status: 'online' | 'degraded' | 'offline') => {
@@ -53,7 +67,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
53
67
  }
54
68
  }
55
69
 
56
- const handleChatgptAuth = async (accountId: string) => {
70
+ const resetAuthDialog = () => {
71
+ setAuthMethod('chatgpt')
72
+ setAuthApiKey('')
73
+ setNewAccountName('')
74
+ }
75
+
76
+ const handleAccountAuth = async (accountId: string, method: AuthMethod, apiKey?: string): Promise<boolean> => {
57
77
  if (connectionStatus !== 'connected') {
58
78
  setAlertDialog({
59
79
  open: true,
@@ -61,27 +81,37 @@ export function Sidebar({ onNavigate }: SidebarProps) {
61
81
  message: 'Backend not connected. Start the hub and refresh the page.',
62
82
  variant: 'error',
63
83
  })
64
- return
84
+ return false
65
85
  }
66
86
  setAuthPendingId(accountId)
67
87
  updateAccount(accountId, (prev) => ({ ...prev, status: 'degraded' }))
68
88
  try {
69
89
  const login = (await hubClient.request(accountId, 'account/login/start', {
70
- type: 'chatgpt',
71
- })) as { authUrl?: string }
90
+ type: method,
91
+ apiKey: method === 'apiKey' ? apiKey : undefined,
92
+ })) as { authUrl?: string; loginId?: string }
93
+ if (login?.loginId) {
94
+ setAccountLoginId(accountId, login.loginId)
95
+ }
72
96
  if (login?.authUrl) {
73
97
  const opened = window.open(login.authUrl, '_blank', 'noopener,noreferrer')
74
98
  if (!opened) {
75
99
  setCopyDialog({ open: true, url: login.authUrl })
76
100
  }
77
101
  }
102
+ if (method === 'apiKey') {
103
+ setAccountLoginId(accountId, null)
104
+ await refreshAccountSnapshot(accountId, updateAccount, setModelsForAccount)
105
+ }
106
+ return true
78
107
  } catch {
79
108
  setAlertDialog({
80
109
  open: true,
81
110
  title: 'Sign In Failed',
82
- message: 'Unable to start ChatGPT sign-in. Please try again.',
111
+ message: `Unable to start ${method === 'apiKey' ? 'API key' : 'ChatGPT'} sign-in. Please try again.`,
83
112
  variant: 'error',
84
113
  })
114
+ return false
85
115
  } finally {
86
116
  setAuthPendingId(null)
87
117
  }
@@ -145,7 +175,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
145
175
  type="button"
146
176
  onClick={(e) => {
147
177
  e.stopPropagation()
148
- void handleChatgptAuth(account.id)
178
+ setAuthDialog({
179
+ open: true,
180
+ mode: 'login',
181
+ accountId: account.id,
182
+ accountName: account.name,
183
+ })
184
+ resetAuthDialog()
149
185
  }}
150
186
  disabled={authPendingId === account.id}
151
187
  className="text-[10px] text-accent-green hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -209,56 +245,236 @@ export function Sidebar({ onNavigate }: SidebarProps) {
209
245
  })
210
246
  return
211
247
  }
212
- setShowPrompt(true)
248
+ setShowAddDialog(true)
249
+ resetAuthDialog()
213
250
  }}
214
251
  >
215
252
  <Icons.Plus className="w-3.5 h-3.5" />
216
253
  Add Account
217
254
  </button>
218
255
 
219
- <PromptDialog
220
- open={showPrompt}
221
- onClose={() => setShowPrompt(false)}
222
- onSubmit={async (name) => {
223
- if (isAdding) return
224
- setIsAdding(true)
225
- try {
226
- const profile = await hubClient.createProfile(name)
227
- addAccount({
228
- id: profile.id,
229
- name: profile.name,
230
- email: '',
231
- plan: 'Unknown',
232
- status: 'offline',
233
- rateLimit: 0,
234
- })
235
- setSelectedAccountId(profile.id)
236
- await hubClient.startProfile(profile.id)
237
- updateAccount(profile.id, (prev) => ({ ...prev, status: 'degraded' }))
238
- const login = (await hubClient.request(profile.id, 'account/login/start', {
239
- type: 'chatgpt',
240
- })) as { authUrl?: string }
241
- if (login?.authUrl) {
242
- const opened = window.open(login.authUrl, '_blank', 'noopener,noreferrer')
243
- if (!opened) {
244
- setCopyDialog({ open: true, url: login.authUrl })
256
+ <Dialog
257
+ open={showAddDialog}
258
+ onClose={() => {
259
+ setShowAddDialog(false)
260
+ resetAuthDialog()
261
+ }}
262
+ title="Add Account"
263
+ >
264
+ <form
265
+ onSubmit={async (event) => {
266
+ event.preventDefault()
267
+ if (isAdding) return
268
+ const name = newAccountName.trim()
269
+ if (!name) return
270
+ if (authMethod === 'apiKey' && !authApiKey.trim()) return
271
+ setIsAdding(true)
272
+ try {
273
+ const profile = await hubClient.createProfile(name)
274
+ addAccount({
275
+ id: profile.id,
276
+ name: profile.name,
277
+ email: '',
278
+ plan: 'Unknown',
279
+ status: 'offline',
280
+ rateLimit: 0,
281
+ })
282
+ setSelectedAccountId(profile.id)
283
+ await hubClient.startProfile(profile.id)
284
+ updateAccount(profile.id, (prev) => ({ ...prev, status: 'degraded' }))
285
+ const success = await handleAccountAuth(profile.id, authMethod, authApiKey.trim() || undefined)
286
+ if (success) {
287
+ setShowAddDialog(false)
245
288
  }
289
+ } catch {
290
+ setAlertDialog({
291
+ open: true,
292
+ title: 'Error',
293
+ message: 'Failed to create account. Please try again.',
294
+ variant: 'error',
295
+ })
296
+ } finally {
297
+ setIsAdding(false)
246
298
  }
247
- } catch {
248
- setAlertDialog({
249
- open: true,
250
- title: 'Error',
251
- message: 'Failed to create account. Please try again.',
252
- variant: 'error',
253
- })
254
- } finally {
255
- setIsAdding(false)
256
- }
299
+ }}
300
+ >
301
+ <div className="space-y-4">
302
+ <div className="space-y-1">
303
+ <div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">Account name</div>
304
+ <input
305
+ value={newAccountName}
306
+ onChange={(event) => setNewAccountName(event.target.value)}
307
+ placeholder="Personal, Team, or Client..."
308
+ className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
309
+ />
310
+ </div>
311
+ <div className="space-y-2">
312
+ <div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">Sign-in method</div>
313
+ <div className="grid grid-cols-1 gap-2">
314
+ <button
315
+ type="button"
316
+ onClick={() => setAuthMethod('chatgpt')}
317
+ className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
318
+ authMethod === 'chatgpt'
319
+ ? 'border-accent-green bg-accent-green/10 text-text-primary'
320
+ : 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
321
+ }`}
322
+ >
323
+ <div>
324
+ <div className="text-xs font-semibold">ChatGPT sign-in</div>
325
+ <div className="text-[10px] text-text-muted">Opens a browser window to authorize.</div>
326
+ </div>
327
+ <Icons.ArrowRight className="w-4 h-4" />
328
+ </button>
329
+ <button
330
+ type="button"
331
+ onClick={() => setAuthMethod('apiKey')}
332
+ className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
333
+ authMethod === 'apiKey'
334
+ ? 'border-accent-blue bg-accent-blue/10 text-text-primary'
335
+ : 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
336
+ }`}
337
+ >
338
+ <div>
339
+ <div className="text-xs font-semibold">API key</div>
340
+ <div className="text-[10px] text-text-muted">Use a key instead of browser auth.</div>
341
+ </div>
342
+ <Icons.Key className="w-4 h-4" />
343
+ </button>
344
+ </div>
345
+ </div>
346
+ {authMethod === 'apiKey' && (
347
+ <div className="space-y-1">
348
+ <div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">API key</div>
349
+ <input
350
+ type="password"
351
+ value={authApiKey}
352
+ onChange={(event) => setAuthApiKey(event.target.value)}
353
+ placeholder="sk-..."
354
+ className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
355
+ />
356
+ <p className="text-[11px] text-text-muted">Stored locally by the hub.</p>
357
+ </div>
358
+ )}
359
+ </div>
360
+ <div className="flex justify-end gap-2 mt-5">
361
+ <Button
362
+ variant="ghost"
363
+ size="sm"
364
+ onClick={() => setShowAddDialog(false)}
365
+ >
366
+ Cancel
367
+ </Button>
368
+ <Button
369
+ variant="primary"
370
+ size="sm"
371
+ disabled={
372
+ isAdding ||
373
+ !newAccountName.trim() ||
374
+ (authMethod === 'apiKey' && !authApiKey.trim())
375
+ }
376
+ >
377
+ {isAdding ? 'Creating...' : 'Create & Connect'}
378
+ </Button>
379
+ </div>
380
+ </form>
381
+ </Dialog>
382
+
383
+ <Dialog
384
+ open={authDialog.open}
385
+ onClose={() => {
386
+ setAuthDialog({ open: false, mode: 'create' })
387
+ resetAuthDialog()
257
388
  }}
258
- title="Add Account"
259
- placeholder="Account name..."
260
- submitLabel="Create"
261
- />
389
+ title={`Sign in ${authDialog.accountName ? `• ${authDialog.accountName}` : ''}`}
390
+ >
391
+ <form
392
+ onSubmit={async (event) => {
393
+ event.preventDefault()
394
+ if (!authDialog.accountId) return
395
+ if (authMethod === 'apiKey' && !authApiKey.trim()) return
396
+ const success = await handleAccountAuth(
397
+ authDialog.accountId,
398
+ authMethod,
399
+ authApiKey.trim() || undefined
400
+ )
401
+ if (success) {
402
+ setAuthDialog({ open: false, mode: 'create' })
403
+ }
404
+ }}
405
+ >
406
+ <div className="space-y-4">
407
+ <div className="text-xs text-text-muted">
408
+ Choose how you want to authenticate this account. ChatGPT is the fastest option, but API keys work offline.
409
+ </div>
410
+ <div className="grid grid-cols-1 gap-2">
411
+ <button
412
+ type="button"
413
+ onClick={() => setAuthMethod('chatgpt')}
414
+ className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
415
+ authMethod === 'chatgpt'
416
+ ? 'border-accent-green bg-accent-green/10 text-text-primary'
417
+ : 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
418
+ }`}
419
+ >
420
+ <div>
421
+ <div className="text-xs font-semibold">ChatGPT sign-in</div>
422
+ <div className="text-[10px] text-text-muted">Opens a browser window to authorize.</div>
423
+ </div>
424
+ <Icons.ArrowRight className="w-4 h-4" />
425
+ </button>
426
+ <button
427
+ type="button"
428
+ onClick={() => setAuthMethod('apiKey')}
429
+ className={`flex items-center justify-between gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
430
+ authMethod === 'apiKey'
431
+ ? 'border-accent-blue bg-accent-blue/10 text-text-primary'
432
+ : 'border-border bg-bg-tertiary text-text-secondary hover:bg-bg-hover'
433
+ }`}
434
+ >
435
+ <div>
436
+ <div className="text-xs font-semibold">API key</div>
437
+ <div className="text-[10px] text-text-muted">Use a key instead of browser auth.</div>
438
+ </div>
439
+ <Icons.Key className="w-4 h-4" />
440
+ </button>
441
+ </div>
442
+ {authMethod === 'apiKey' && (
443
+ <div className="space-y-1">
444
+ <div className="text-[10px] uppercase tracking-[0.2em] text-text-muted">API key</div>
445
+ <input
446
+ type="password"
447
+ value={authApiKey}
448
+ onChange={(event) => setAuthApiKey(event.target.value)}
449
+ placeholder="sk-..."
450
+ className="w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted outline-none focus:border-text-muted transition-colors"
451
+ />
452
+ <p className="text-[11px] text-text-muted">Stored locally by the hub.</p>
453
+ </div>
454
+ )}
455
+ </div>
456
+ <div className="flex justify-end gap-2 mt-5">
457
+ <Button
458
+ variant="ghost"
459
+ size="sm"
460
+ onClick={() => setAuthDialog({ open: false, mode: 'create' })}
461
+ >
462
+ Cancel
463
+ </Button>
464
+ <Button
465
+ variant="primary"
466
+ size="sm"
467
+ disabled={
468
+ !authDialog.accountId ||
469
+ (authMethod === 'apiKey' && !authApiKey.trim()) ||
470
+ authPendingId === authDialog.accountId
471
+ }
472
+ >
473
+ {authPendingId === authDialog.accountId ? 'Connecting...' : 'Connect'}
474
+ </Button>
475
+ </div>
476
+ </form>
477
+ </Dialog>
262
478
 
263
479
  <AlertDialog
264
480
  open={alertDialog.open}
@@ -379,7 +379,7 @@ function getActionType(msg: Message): AssistantAction['type'] {
379
379
  if (title.includes('read') || title.includes('view') || title.includes('list')) return 'explored'
380
380
  if (title.includes('edit') || title.includes('wrote') || title.includes('creat')) return 'edited'
381
381
  if (title.includes('ran') || title.includes('exec') || title.includes('command')) return 'ran'
382
- return 'explored'
382
+ return 'chat'
383
383
  }
384
384
  return 'chat'
385
385
  }
@@ -444,15 +444,31 @@ function getActionLabel(type: AssistantAction['type'], messages: Message[]): { l
444
444
  }
445
445
  }
446
446
 
447
+ const extractBoldHeadline = (content: string): string | null => {
448
+ const match = content.match(/\*\*(.+?)\*\*/)
449
+ return match ? match[1] : null
450
+ }
451
+
447
452
  const extractReasoningHeadline = (content: string) => {
448
- const match = content.match(/(?:^|\n)\s*\*\*(.+?)\*\*/u)
449
- if (match?.[1]) {
450
- return match[1].trim()
453
+ const boldHeadline = extractBoldHeadline(content)
454
+ if (boldHeadline) {
455
+ return boldHeadline
451
456
  }
452
457
  const firstLine = content.split('\n').map((line) => line.trim()).find(Boolean)
453
458
  return firstLine || null
454
459
  }
455
460
 
461
+ const stripBoldHeadlines = (content: string): string => {
462
+ return content
463
+ .split('\n')
464
+ .filter((line) => {
465
+ const trimmed = line.trim()
466
+ return !/^\*\*.+?\*\*$/u.test(trimmed)
467
+ })
468
+ .join('\n')
469
+ .trim()
470
+ }
471
+
456
472
  function groupMessagesIntoTurns(messages: Message[]): Turn[] {
457
473
  const turns: Turn[] = []
458
474
  let currentTurn: Turn | null = null
@@ -1054,8 +1070,8 @@ function ActionRow({ action }: { action: AssistantAction }) {
1054
1070
  }
1055
1071
 
1056
1072
  if (action.type === 'reasoning') {
1057
- const content = action.messages.map(m => m.content).join('\n\n')
1058
- const trimmed = content.trim().toLowerCase()
1073
+ const rawContent = action.messages.map(m => m.content).join('\n\n')
1074
+ const trimmed = rawContent.trim().toLowerCase()
1059
1075
  const isPlaceholder = !trimmed || trimmed === 'reasoning' || trimmed === 'reasoning summary' || trimmed === 'thinking'
1060
1076
 
1061
1077
  if (isPlaceholder) {
@@ -1065,6 +1081,12 @@ function ActionRow({ action }: { action: AssistantAction }) {
1065
1081
  </div>
1066
1082
  )
1067
1083
  }
1084
+
1085
+ const content = stripBoldHeadlines(rawContent)
1086
+
1087
+ if (!content.trim()) {
1088
+ return null
1089
+ }
1068
1090
 
1069
1091
  return (
1070
1092
  <div>
@@ -23,37 +23,40 @@ export const SessionAuthBanner = ({
23
23
 
24
24
  return (
25
25
  <div className="px-4 py-3 border-b border-border bg-bg-secondary/70">
26
- <div className="bg-bg-tertiary border border-border rounded-xl p-4 flex flex-col gap-3">
27
- <div className="flex items-start gap-3">
28
- <div className="w-9 h-9 rounded-lg bg-bg-primary border border-border flex items-center justify-center">
29
- <Icons.Warning className="w-4 h-4 text-yellow-500" />
26
+ <div className="relative overflow-hidden rounded-2xl border border-border bg-bg-tertiary px-4 py-4 shadow-lg">
27
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_0%_0%,rgba(16,185,129,0.18),transparent_55%)]" />
28
+ <div className="relative flex flex-col gap-4">
29
+ <div className="flex items-start gap-3">
30
+ <div className="w-9 h-9 rounded-lg bg-bg-primary border border-border flex items-center justify-center">
31
+ <Icons.Warning className="w-4 h-4 text-yellow-500" />
32
+ </div>
33
+ <div>
34
+ <h3 className="text-sm font-semibold text-text-primary">Connect this account</h3>
35
+ <p className="text-xs text-text-muted mt-1">
36
+ {pending
37
+ ? 'Waiting for sign-in to complete. Return here after authorizing the account.'
38
+ : 'Choose ChatGPT for the fastest setup, or drop in an API key for direct access.'}
39
+ </p>
40
+ </div>
30
41
  </div>
31
- <div>
32
- <h3 className="text-sm font-semibold text-text-primary">Authenticate to start sessions</h3>
33
- <p className="text-xs text-text-muted mt-1">
34
- {pending
35
- ? 'Waiting for sign-in to complete. Return here after authorizing the account.'
36
- : 'This account is not signed in. Connect with ChatGPT or provide an API key to enable new sessions and messaging.'}
37
- </p>
38
- </div>
39
- </div>
40
- <div className="flex flex-wrap gap-2">
41
- <Button variant="primary" size="sm" onClick={onChatgpt}>
42
- Sign in with ChatGPT
43
- </Button>
44
- <Button variant="ghost" size="sm" onClick={onApiKey}>
45
- Use API key
46
- </Button>
47
- {pending && onCancel && (
48
- <Button variant="ghost" size="sm" onClick={onCancel}>
49
- Cancel login
42
+ <div className="flex flex-wrap gap-2">
43
+ <Button variant="primary" size="sm" onClick={onChatgpt}>
44
+ Sign in with ChatGPT
50
45
  </Button>
51
- )}
52
- {onRefresh && (
53
- <Button variant="ghost" size="sm" onClick={onRefresh}>
54
- Check status
46
+ <Button variant="ghost" size="sm" onClick={onApiKey}>
47
+ Use API key
55
48
  </Button>
56
- )}
49
+ {pending && onCancel && (
50
+ <Button variant="ghost" size="sm" onClick={onCancel}>
51
+ Cancel login
52
+ </Button>
53
+ )}
54
+ {onRefresh && (
55
+ <Button variant="ghost" size="sm" onClick={onRefresh}>
56
+ Check status
57
+ </Button>
58
+ )}
59
+ </div>
57
60
  </div>
58
61
  </div>
59
62
  </div>
@@ -28,6 +28,10 @@ interface SessionComposerProps {
28
28
  onEffortChange: (value: string) => void
29
29
  showModelSelect: boolean
30
30
  showEffortSelect: boolean
31
+ sandboxOptions: SelectOption[]
32
+ effectiveSandbox: string
33
+ onSandboxChange: (value: string) => void
34
+ showSandboxSelect: boolean
31
35
  queuedCount: number
32
36
  webSearchEnabled: boolean
33
37
  onWebSearchToggle: () => void
@@ -65,6 +69,10 @@ export const SessionComposer = ({
65
69
  onEffortChange,
66
70
  showModelSelect,
67
71
  showEffortSelect,
72
+ sandboxOptions,
73
+ effectiveSandbox,
74
+ onSandboxChange,
75
+ showSandboxSelect,
68
76
  queuedCount,
69
77
  webSearchEnabled,
70
78
  onWebSearchToggle,
@@ -292,6 +300,18 @@ export const SessionComposer = ({
292
300
  label="Reasoning Effort"
293
301
  />
294
302
  )}
303
+ {showSandboxSelect && (
304
+ <Select
305
+ options={sandboxOptions}
306
+ value={effectiveSandbox}
307
+ onChange={onSandboxChange}
308
+ placeholder="Sandbox"
309
+ size="sm"
310
+ disabled={!canInteract}
311
+ className="min-w-[150px] sm:min-w-[220px] shrink-0"
312
+ label="Sandbox"
313
+ />
314
+ )}
295
315
  </div>
296
316
  <div className="flex items-center gap-2 sm:gap-3 shrink-0">
297
317
  {queuedCount > 0 && (
@@ -196,4 +196,10 @@ export const Icons = {
196
196
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
197
197
  </svg>
198
198
  ),
199
+
200
+ Key: ({ className }: IconProps) => (
201
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
202
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a5 5 0 11-4.757 6.683L7 17H4v3h3v-2h2v-2l1.243-1.317A5 5 0 0115 7z" />
203
+ </svg>
204
+ ),
199
205
  }
@@ -1,7 +1,7 @@
1
1
  import { useEffect } from 'react'
2
2
  import { hubClient, type HubProfile } from '../services/hub-client'
3
3
  import { useAppStore } from '../store'
4
- import type { Account, Thread } from '../types'
4
+ import type { Account, Thread, SandboxMode, SandboxPolicy } from '../types'
5
5
  import { buildSystemMessage } from '../utils/item-format'
6
6
  import { accountStatusFromRead, parseUsage, refreshAccountSnapshot, fetchAllModels, type AccountReadResult, type RateLimitResult } from '../utils/account-refresh'
7
7
 
@@ -19,6 +19,21 @@ type ItemPayload = { id?: string; type?: string } & Record<string, unknown>
19
19
  const isThreadItem = (item: ItemPayload | undefined): item is { id: string; type: string } & Record<string, unknown> =>
20
20
  !!item && typeof item.id === 'string' && typeof item.type === 'string'
21
21
 
22
+ const resolveSandboxPolicy = (mode?: SandboxMode | null): SandboxPolicy | null => {
23
+ switch (mode) {
24
+ case 'danger-full-access':
25
+ return { type: 'dangerFullAccess' }
26
+ case 'read-only':
27
+ return { type: 'readOnly' }
28
+ case 'workspace-write':
29
+ return { type: 'workspaceWrite' }
30
+ default:
31
+ return null
32
+ }
33
+ }
34
+
35
+ const ALWAYS_ALLOW_SANDBOX_POLICY: SandboxPolicy = { type: 'workspaceWrite' }
36
+
22
37
  const formatDate = (timestamp?: number): string => {
23
38
  if (!timestamp) {
24
39
  return ''
@@ -145,6 +160,9 @@ export const useHubConnection = () => {
145
160
  })
146
161
  updateThread(threadId, { status: 'active' })
147
162
  try {
163
+ const sandboxPolicy = resolveSandboxPolicy(next.sandbox)
164
+ const effectiveSandboxPolicy =
165
+ sandboxPolicy ?? (next.approvalPolicy === 'never' ? ALWAYS_ALLOW_SANDBOX_POLICY : null)
148
166
  await hubClient.request(profileId, 'turn/start', {
149
167
  threadId,
150
168
  input: [{ type: 'text', text: next.text }],
@@ -153,6 +171,7 @@ export const useHubConnection = () => {
153
171
  summary: next.summary ?? undefined,
154
172
  cwd: next.cwd ?? undefined,
155
173
  approvalPolicy: next.approvalPolicy ?? undefined,
174
+ sandboxPolicy: effectiveSandboxPolicy ?? undefined,
156
175
  })
157
176
  } catch (error) {
158
177
  console.error(error)
@@ -189,6 +189,7 @@ export const useThreadHistory = () => {
189
189
  setThreadApproval,
190
190
  setThreadCwd,
191
191
  setThreadTurnId,
192
+ setThreadTurnStartedAt,
192
193
  } = useAppStore()
193
194
 
194
195
  const inFlight = useRef<Set<string>>(new Set())
@@ -238,12 +239,35 @@ export const useThreadHistory = () => {
238
239
 
239
240
  let activeTurn: TurnData | null = null
240
241
  for (let index = turns.length - 1; index >= 0; index -= 1) {
241
- if (isTurnInProgress(turns[index]?.status)) {
242
+ const turnStatus = turns[index]?.status
243
+ if (isTurnInProgress(turnStatus)) {
242
244
  activeTurn = turns[index]
243
245
  break
244
246
  }
245
247
  }
248
+
246
249
  setThreadTurnId(selectedThreadId, activeTurn?.id ?? null)
250
+
251
+ // If there's an active turn, ensure startedAt is set (may have been set by hub connection)
252
+ if (activeTurn) {
253
+ const existingStartedAt = useAppStore.getState().threadTurnStartedAt[selectedThreadId]
254
+ if (!existingStartedAt) {
255
+ // Use current time as fallback if no startedAt was set
256
+ setThreadTurnStartedAt(selectedThreadId, Date.now())
257
+ }
258
+ } else {
259
+ // No active turn according to codex - check if backend thought it was active
260
+ const existingStartedAt = useAppStore.getState().threadTurnStartedAt[selectedThreadId]
261
+ if (existingStartedAt) {
262
+ // Backend had stale data - notify it to clear the active thread
263
+ console.log('[useThreadHistory] Clearing stale active thread from backend:', { threadId: selectedThreadId, profileId: thread.accountId })
264
+ hubClient.clearActiveThread({ profileId: thread.accountId, threadId: selectedThreadId }).catch((err) => {
265
+ console.error('[useThreadHistory] Failed to clear stale active thread:', err)
266
+ })
267
+ }
268
+ setThreadTurnStartedAt(selectedThreadId, null)
269
+ }
270
+
247
271
  const nextStatus = thread.status === 'archived'
248
272
  ? 'archived'
249
273
  : activeTurn
@@ -294,6 +318,7 @@ export const useThreadHistory = () => {
294
318
  threads,
295
319
  updateThread,
296
320
  setThreadTurnId,
321
+ setThreadTurnStartedAt,
297
322
  setThreadCwd,
298
323
  ])
299
324
  }
@@ -339,6 +339,15 @@ class HubClient {
339
339
  return data.threads ?? []
340
340
  }
341
341
 
342
+ async clearActiveThread(params: { profileId: string; threadId: string }): Promise<void> {
343
+ const url = new URL(`/threads/active/${params.threadId}`, HUB_URL)
344
+ url.searchParams.set('profileId', params.profileId)
345
+ const response = await fetch(url.toString(), { method: 'DELETE' })
346
+ if (!response.ok) {
347
+ throw new Error('Failed to clear active thread')
348
+ }
349
+ }
350
+
342
351
  async listReviews(params?: { profileId?: string; limit?: number; offset?: number }): Promise<ReviewSessionResult[]> {
343
352
  const url = new URL('/reviews', HUB_URL)
344
353
  if (params?.profileId) url.searchParams.set('profileId', params.profileId)
@@ -1,5 +1,5 @@
1
1
  import { create } from 'zustand'
2
- import type { Account, Thread, Message, ApprovalRequest, TabType, ModelInfo, ReasoningEffort, ReasoningSummary, ApprovalPolicy, QueuedMessage, ReviewSession } from '../types'
2
+ import type { Account, Thread, Message, ApprovalRequest, TabType, ModelInfo, ReasoningEffort, ReasoningSummary, ApprovalPolicy, QueuedMessage, ReviewSession, SandboxMode } from '../types'
3
3
 
4
4
  interface AppState {
5
5
  accounts: Account[]
@@ -16,6 +16,7 @@ interface AppState {
16
16
  threadCwds: Record<string, string>
17
17
  threadApprovals: Record<string, ApprovalPolicy>
18
18
  threadWebSearch: Record<string, boolean>
19
+ threadSandboxes: Record<string, SandboxMode>
19
20
  threadTurnIds: Record<string, string>
20
21
  threadTurnStartedAt: Record<string, number>
21
22
  threadLastTurnDuration: Record<string, number>
@@ -56,6 +57,7 @@ interface AppState {
56
57
  setThreadSummary: (threadId: string, summary: ReasoningSummary) => void
57
58
  setThreadCwd: (threadId: string, cwd: string) => void
58
59
  setThreadApproval: (threadId: string, approval: ApprovalPolicy) => void
60
+ setThreadSandbox: (threadId: string, sandbox: SandboxMode | null) => void
59
61
  setThreadWebSearch: (threadId: string, enabled: boolean) => void
60
62
  setThreadTurnId: (threadId: string, turnId: string | null) => void
61
63
  setThreadTurnStartedAt: (threadId: string, startedAt: number | null) => void
@@ -106,12 +108,20 @@ export const useAppStore = create<AppState>((set, get) => ({
106
108
  threadCwds: {},
107
109
  threadApprovals: {},
108
110
  threadWebSearch: {},
111
+ threadSandboxes: {},
109
112
  threadTurnIds: {},
110
113
  threadTurnStartedAt: {},
111
114
  threadLastTurnDuration: {},
112
115
  threadTokenUsage: {},
113
116
  threadPendingAccountSwitch: {},
114
- backendToUiThreadId: {},
117
+ backendToUiThreadId: (() => {
118
+ try {
119
+ const stored = localStorage.getItem('backendToUiThreadId')
120
+ return stored ? JSON.parse(stored) : {}
121
+ } catch {
122
+ return {}
123
+ }
124
+ })(),
115
125
  messages: {},
116
126
  queuedMessages: {},
117
127
  approvals: [],
@@ -150,6 +160,9 @@ export const useAppStore = create<AppState>((set, get) => ({
150
160
  const threadApprovals = Object.fromEntries(
151
161
  Object.entries(state.threadApprovals).filter(([threadId]) => remainingThreadIds.has(threadId))
152
162
  )
163
+ const threadSandboxes = Object.fromEntries(
164
+ Object.entries(state.threadSandboxes).filter(([threadId]) => remainingThreadIds.has(threadId))
165
+ )
153
166
  const threadTokenUsage = Object.fromEntries(
154
167
  Object.entries(state.threadTokenUsage).filter(([threadId]) => remainingThreadIds.has(threadId))
155
168
  )
@@ -173,6 +186,7 @@ export const useAppStore = create<AppState>((set, get) => ({
173
186
  threadSummaries,
174
187
  threadCwds,
175
188
  threadApprovals,
189
+ threadSandboxes,
176
190
  threadTokenUsage,
177
191
  modelsByAccount,
178
192
  accountLoginIds,
@@ -221,6 +235,9 @@ export const useAppStore = create<AppState>((set, get) => ({
221
235
  threadCwds: Object.fromEntries(
222
236
  Object.entries(state.threadCwds).filter(([threadId]) => threadId !== id)
223
237
  ),
238
+ threadSandboxes: Object.fromEntries(
239
+ Object.entries(state.threadSandboxes).filter(([threadId]) => threadId !== id)
240
+ ),
224
241
  threadTokenUsage: Object.fromEntries(
225
242
  Object.entries(state.threadTokenUsage).filter(([threadId]) => threadId !== id)
226
243
  ),
@@ -284,6 +301,19 @@ export const useAppStore = create<AppState>((set, get) => ({
284
301
  [threadId]: approval,
285
302
  },
286
303
  })),
304
+ setThreadSandbox: (threadId, sandbox) => set((state) => {
305
+ if (!sandbox) {
306
+ const next = { ...state.threadSandboxes }
307
+ delete next[threadId]
308
+ return { threadSandboxes: next }
309
+ }
310
+ return {
311
+ threadSandboxes: {
312
+ ...state.threadSandboxes,
313
+ [threadId]: sandbox,
314
+ },
315
+ }
316
+ }),
287
317
  setThreadWebSearch: (threadId, enabled) => set((state) => ({
288
318
  threadWebSearch: {
289
319
  ...state.threadWebSearch,
@@ -345,16 +375,21 @@ export const useAppStore = create<AppState>((set, get) => ({
345
375
  }
346
376
  }),
347
377
  setBackendToUiThreadId: (backendThreadId, uiThreadId) => set((state) => {
378
+ let newMapping: Record<string, string>
348
379
  if (uiThreadId === null) {
349
380
  const { [backendThreadId]: _, ...rest } = state.backendToUiThreadId
350
- return { backendToUiThreadId: rest }
351
- }
352
- return {
353
- backendToUiThreadId: {
381
+ newMapping = rest
382
+ } else {
383
+ newMapping = {
354
384
  ...state.backendToUiThreadId,
355
385
  [backendThreadId]: uiThreadId,
356
- },
386
+ }
387
+ }
388
+ try {
389
+ localStorage.setItem('backendToUiThreadId', JSON.stringify(newMapping))
390
+ } catch {
357
391
  }
392
+ return { backendToUiThreadId: newMapping }
358
393
  }),
359
394
  resolveThreadId: (threadId: string): string => {
360
395
  const state = get()
@@ -6,6 +6,11 @@ export type TabType = 'sessions' | 'reviews' | 'archive'
6
6
  export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
7
7
  export type ReasoningSummary = 'auto' | 'concise' | 'detailed' | 'none'
8
8
  export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'
9
+ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
10
+ export type SandboxPolicy =
11
+ | { type: 'dangerFullAccess' }
12
+ | { type: 'readOnly' }
13
+ | { type: 'workspaceWrite'; networkAccess?: boolean }
9
14
  export type ReviewStatus = 'pending' | 'running' | 'completed' | 'failed'
10
15
 
11
16
  export type CommandAction =
@@ -104,6 +109,7 @@ export interface QueuedMessage {
104
109
  summary?: ReasoningSummary | null
105
110
  cwd?: string | null
106
111
  approvalPolicy?: ApprovalPolicy | null
112
+ sandbox?: SandboxMode | null
107
113
  createdAt: number
108
114
  }
109
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-codex",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Web launcher for Codex Hub",
5
5
  "bin": {
6
6
  "better-codex": "bin/better-codex.cjs"