@gotcos/glasses-server 6.2.1 → 6.5.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/.env.example +18 -7
- package/CHANGELOG.md +116 -0
- package/README.md +26 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +65 -15
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +27 -9
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/routes/archive.ts +79 -0
- package/server/routes/health.ts +17 -1
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +202 -0
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +299 -0
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// Codex bridge — streaming-compatible interface to `codex exec --json
|
|
2
|
-
//
|
|
1
|
+
// Codex bridge — streaming-compatible interface to `codex exec --json`.
|
|
2
|
+
// Concrete GPT ids resolve from Codex's live model catalog at run time.
|
|
3
3
|
|
|
4
|
-
import { spawn } from 'node:child_process'
|
|
5
|
-
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import crypto from 'node:crypto'
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
8
5
|
import { logTokenAudit } from './token-audit.js'
|
|
6
|
+
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
9
7
|
import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
|
|
10
8
|
import {
|
|
11
9
|
getHistory,
|
|
12
10
|
addExchange,
|
|
11
|
+
setExchangeAttachments,
|
|
12
|
+
removeExchange,
|
|
13
13
|
formatHistoryForPrompt,
|
|
14
14
|
getOrCreateSession,
|
|
15
15
|
isNewSession,
|
|
@@ -27,10 +27,16 @@ import {
|
|
|
27
27
|
} from './codex-engine-sessions.js'
|
|
28
28
|
import {
|
|
29
29
|
CODEX_HIGH_MODEL,
|
|
30
|
-
CODEX_HIGH_REASONING_EFFORT,
|
|
31
|
-
CODEX_MODEL_ID,
|
|
32
30
|
type CodexModelPreference,
|
|
31
|
+
type EffortPreference,
|
|
33
32
|
} from '../../shared/model-preference.js'
|
|
33
|
+
import {
|
|
34
|
+
getCodexModelCatalog,
|
|
35
|
+
resolveCodexEffortForModel,
|
|
36
|
+
resolveCodexModelOption,
|
|
37
|
+
resolveCodexServiceTier,
|
|
38
|
+
type CodexModelOption,
|
|
39
|
+
} from './codex-model-catalog.js'
|
|
34
40
|
import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
|
|
35
41
|
import {
|
|
36
42
|
classifyCodexError,
|
|
@@ -43,6 +49,16 @@ import {
|
|
|
43
49
|
updateCodexRun,
|
|
44
50
|
type CodexRunStatus,
|
|
45
51
|
} from './codex-run-ledger.js'
|
|
52
|
+
import { codexActivityPreviewLines } from './activity-preview.js'
|
|
53
|
+
import {
|
|
54
|
+
createRunOutputImagePublisher,
|
|
55
|
+
isRunOutputImagePublisherCommand,
|
|
56
|
+
type RunOutputImageCollectionStats,
|
|
57
|
+
} from './run-output-images.js'
|
|
58
|
+
import {
|
|
59
|
+
MAX_ATTACHMENTS_PER_PROMPT,
|
|
60
|
+
type MediaAttachmentRef,
|
|
61
|
+
} from '../../shared/media-attachment.js'
|
|
46
62
|
|
|
47
63
|
const INACTIVITY_MS = 180_000
|
|
48
64
|
const WALL_MAX_MS = 900_000
|
|
@@ -66,23 +82,61 @@ function codexSandboxArgs(): string[] {
|
|
|
66
82
|
return ['--sandbox', mode, '--skip-git-repo-check']
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
let addDirSupported: boolean | undefined
|
|
86
|
+
|
|
87
|
+
/** Older Codex CLIs do not expose --add-dir. Probe lazily and disable only
|
|
88
|
+
* output publishing when unavailable; chat remains read-only and functional. */
|
|
89
|
+
export function codexSupportsAdditionalDir(): boolean {
|
|
90
|
+
if (addDirSupported !== undefined) return addDirSupported
|
|
91
|
+
try {
|
|
92
|
+
const result = spawnSync('codex', ['exec', '--help'], {
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
timeout: 5_000,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
})
|
|
97
|
+
addDirSupported = result.status === 0 && `${result.stdout}\n${result.stderr}`.includes('--add-dir')
|
|
98
|
+
} catch {
|
|
99
|
+
addDirSupported = false
|
|
100
|
+
}
|
|
101
|
+
return addDirSupported
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
export function buildCodexExecArgs(input: {
|
|
70
105
|
codexCwd: string
|
|
71
106
|
imagePaths?: string[]
|
|
72
107
|
persistentCodexSession: boolean
|
|
73
108
|
codexThreadId?: string
|
|
109
|
+
model?: CodexModelPreference
|
|
110
|
+
resolvedModel?: CodexModelOption
|
|
111
|
+
effort?: EffortPreference
|
|
112
|
+
publisherWritableDirectory?: string
|
|
74
113
|
}): string[] {
|
|
75
114
|
const imagePaths = input.imagePaths ?? []
|
|
76
|
-
const
|
|
115
|
+
const resolvedModel = input.resolvedModel
|
|
116
|
+
?? resolveCodexModelOption(input.model ?? CODEX_HIGH_MODEL)
|
|
117
|
+
const reasoningEffort = resolveCodexEffortForModel(resolvedModel, input.effort)
|
|
118
|
+
const serviceTier = resolveCodexServiceTier(resolvedModel)
|
|
119
|
+
// Sandbox + publisher capability are global `codex exec` options and MUST
|
|
120
|
+
// appear before the `resume` subcommand. The publisher grants write access
|
|
121
|
+
// only to its random run directory; the rest of the host stays read-only.
|
|
122
|
+
const args = ['exec', ...codexSandboxArgs()]
|
|
123
|
+
if (input.publisherWritableDirectory) {
|
|
124
|
+
args.push('--add-dir', input.publisherWritableDirectory)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const appendModelConfig = () => {
|
|
128
|
+
if (resolvedModel.id) args.push('--model', resolvedModel.id)
|
|
129
|
+
args.push('-c', `model_reasoning_effort="${reasoningEffort}"`)
|
|
130
|
+
if (serviceTier) args.push('-c', `service_tier="${serviceTier}"`)
|
|
131
|
+
}
|
|
132
|
+
|
|
77
133
|
if (input.codexThreadId) {
|
|
78
134
|
args.push(
|
|
79
135
|
'resume',
|
|
80
136
|
'--json',
|
|
81
137
|
'--all',
|
|
82
|
-
...codexSandboxArgs(),
|
|
83
|
-
'-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
|
|
84
138
|
)
|
|
85
|
-
|
|
139
|
+
appendModelConfig()
|
|
86
140
|
for (const p of imagePaths) args.push('--image', p)
|
|
87
141
|
args.push(input.codexThreadId, '-')
|
|
88
142
|
return args
|
|
@@ -91,10 +145,8 @@ export function buildCodexExecArgs(input: {
|
|
|
91
145
|
args.push(
|
|
92
146
|
'--json',
|
|
93
147
|
'--cd', input.codexCwd,
|
|
94
|
-
...codexSandboxArgs(),
|
|
95
|
-
'-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
|
|
96
148
|
)
|
|
97
|
-
|
|
149
|
+
appendModelConfig()
|
|
98
150
|
if (!input.persistentCodexSession) args.push('--ephemeral')
|
|
99
151
|
for (const p of imagePaths) args.push('--image', p)
|
|
100
152
|
args.push('-')
|
|
@@ -111,26 +163,36 @@ function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
|
|
|
111
163
|
].join('\n')
|
|
112
164
|
}
|
|
113
165
|
|
|
114
|
-
|
|
166
|
+
/** Extract only observable assistant response text. Reasoning/tool payloads
|
|
167
|
+
* must remain invisible even if a future Codex JSON shape also has delta or
|
|
168
|
+
* content fields. */
|
|
169
|
+
export function extractCodexResponseText(event: any): string {
|
|
115
170
|
const item = event?.item ?? event?.payload ?? event?.message ?? event
|
|
171
|
+
const eventType = String(event?.type ?? '').toLowerCase()
|
|
172
|
+
const itemType = String(item?.type ?? '').toLowerCase()
|
|
173
|
+
const assistantEvent = /(?:^|[._-])(agent_message|assistant_message|output_text)(?:$|[._-])/.test(eventType)
|
|
174
|
+
|| /^(?:agent_message|assistant_message|output_text)$/.test(itemType)
|
|
175
|
+
if (!assistantEvent) return ''
|
|
116
176
|
|
|
117
177
|
if (typeof event?.delta === 'string') return event.delta
|
|
118
|
-
if (typeof event?.text === 'string'
|
|
119
|
-
if (typeof item?.text === 'string'
|
|
178
|
+
if (typeof event?.text === 'string') return event.text
|
|
179
|
+
if (typeof item?.text === 'string') return item.text
|
|
120
180
|
|
|
121
181
|
const content = item?.content ?? event?.content
|
|
122
182
|
if (Array.isArray(content)) {
|
|
123
183
|
let text = ''
|
|
124
184
|
for (const block of content) {
|
|
125
185
|
if (typeof block === 'string') text += block
|
|
126
|
-
|
|
127
|
-
if (
|
|
186
|
+
const blockType = String(block?.type ?? '').toLowerCase()
|
|
187
|
+
if (/(?:reasoning|thinking|tool|command|input)/.test(blockType)) continue
|
|
128
188
|
if (typeof block?.output_text === 'string') text += block.output_text
|
|
189
|
+
else if (typeof block?.text === 'string') text += block.text
|
|
190
|
+
else if (typeof block?.content === 'string') text += block.content
|
|
129
191
|
}
|
|
130
192
|
return text
|
|
131
193
|
}
|
|
132
194
|
|
|
133
|
-
if (typeof item?.result === 'string'
|
|
195
|
+
if (typeof item?.result === 'string') return item.result
|
|
134
196
|
return ''
|
|
135
197
|
}
|
|
136
198
|
|
|
@@ -156,7 +218,7 @@ function safeCodexUserError(message: string): string {
|
|
|
156
218
|
if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
|
|
157
219
|
if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
|
|
158
220
|
if (code === 'codex.timeout') return 'Codex timed out. Retry or start a new chat.'
|
|
159
|
-
if (code === 'codex.permission_denied') return 'Codex permission failed. Check
|
|
221
|
+
if (code === 'codex.permission_denied') return 'Codex permission failed. Check COS_CODEX_SANDBOX and the work directory.'
|
|
160
222
|
return `Codex failed (${code}). Retry or check Codex Debug.`
|
|
161
223
|
}
|
|
162
224
|
|
|
@@ -165,12 +227,18 @@ export async function callCodexStreaming(
|
|
|
165
227
|
sessionId: string | undefined,
|
|
166
228
|
callbacks: StreamCallbacks,
|
|
167
229
|
model: CodexModelPreference = CODEX_HIGH_MODEL,
|
|
168
|
-
images?:
|
|
230
|
+
images?: ModelImageInput[],
|
|
169
231
|
reference?: PromptReference,
|
|
170
232
|
globalMsgNum?: number,
|
|
171
233
|
options?: CallOptions,
|
|
172
234
|
): Promise<string> {
|
|
173
235
|
const sid = getOrCreateSession(sessionId)
|
|
236
|
+
// Refresh is TTL-cached and coalesced, so every run sees the newest known
|
|
237
|
+
// catalog without creating duplicate app-server discovery processes.
|
|
238
|
+
await getCodexModelCatalog()
|
|
239
|
+
if (options?.abortSignal?.aborted) {
|
|
240
|
+
throw new Error('codex-bridge: client disconnected before Codex started.')
|
|
241
|
+
}
|
|
174
242
|
const history = getHistory(sid)
|
|
175
243
|
const session = getSessionRaw(sid)
|
|
176
244
|
const contextBreaks = session?.contextBreaks ?? []
|
|
@@ -179,9 +247,14 @@ export async function callCodexStreaming(
|
|
|
179
247
|
const persistentCodexSession = isCodexPersistenceEnabled()
|
|
180
248
|
const codexCwd = getCodexExecutionCwd()
|
|
181
249
|
const codexTrustMode = getCodexTrustMode()
|
|
250
|
+
const resolvedCodexModel = resolveCodexModelOption(model)
|
|
251
|
+
const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
|
|
182
252
|
const engineSession = persistentCodexSession
|
|
183
253
|
? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
|
|
184
254
|
: null
|
|
255
|
+
const imageInputs: ModelImageInput[] = images ?? []
|
|
256
|
+
const imagePaths = imageInputs.map(input => input.path)
|
|
257
|
+
const outputImageBudget = Math.max(0, MAX_ATTACHMENTS_PER_PROMPT - imageInputs.length)
|
|
185
258
|
const startTime = Date.now()
|
|
186
259
|
const run = startCodexRun({
|
|
187
260
|
cosSessionId: sid,
|
|
@@ -192,8 +265,23 @@ export async function callCodexStreaming(
|
|
|
192
265
|
trustMode: codexTrustMode,
|
|
193
266
|
codexThreadId: engineSession?.codexThreadId,
|
|
194
267
|
expiresAt: engineSession?.expiresAt,
|
|
268
|
+
cliModel: resolvedCodexModel.id || 'codex-cli-default',
|
|
269
|
+
reasoningEffort: resolvedCodexEffort,
|
|
195
270
|
query,
|
|
196
271
|
})
|
|
272
|
+
let outputImagePublisher: ReturnType<typeof createRunOutputImagePublisher> | null = null
|
|
273
|
+
if (!options?.lightweight && outputImageBudget > 0 && codexSupportsAdditionalDir()) {
|
|
274
|
+
try {
|
|
275
|
+
outputImagePublisher = createRunOutputImagePublisher({
|
|
276
|
+
sessionId: sid,
|
|
277
|
+
globalMsgNum,
|
|
278
|
+
runId: run.runId,
|
|
279
|
+
maxImages: outputImageBudget,
|
|
280
|
+
})
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error('[codex-bridge] output image publisher unavailable:', err)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
197
285
|
let codexThreadId: string | undefined = engineSession?.codexThreadId
|
|
198
286
|
callbacks.onStart?.(model, sid, undefined, { codexRunId: run.runId, codexThreadId })
|
|
199
287
|
|
|
@@ -207,6 +295,7 @@ export async function callCodexStreaming(
|
|
|
207
295
|
systemPrompt = await buildSystemPrompt(contextPrompt)
|
|
208
296
|
}
|
|
209
297
|
} catch (err: any) {
|
|
298
|
+
outputImagePublisher?.cleanup()
|
|
210
299
|
finishCodexRun(run.runId, {
|
|
211
300
|
status: 'failed',
|
|
212
301
|
startedAtMs: startTime,
|
|
@@ -215,37 +304,15 @@ export async function callCodexStreaming(
|
|
|
215
304
|
})
|
|
216
305
|
throw err
|
|
217
306
|
}
|
|
307
|
+
if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
|
|
218
308
|
|
|
219
309
|
phase = 'thinking'
|
|
220
310
|
callbacks.onToolStatus?.('Reasoning...')
|
|
221
311
|
|
|
222
|
-
const imagePaths: string[] = []
|
|
223
|
-
try {
|
|
224
|
-
if (images && images.length > 0) {
|
|
225
|
-
for (const img of images) {
|
|
226
|
-
const id = crypto.randomUUID().slice(0, 8)
|
|
227
|
-
const p = join('/tmp', `cos-vision-${id}.jpg`)
|
|
228
|
-
writeFileSync(p, Buffer.from(img, 'base64'))
|
|
229
|
-
imagePaths.push(p)
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
} catch (err: any) {
|
|
233
|
-
for (const p of imagePaths) {
|
|
234
|
-
try { unlinkSync(p) } catch { /* ignore */ }
|
|
235
|
-
}
|
|
236
|
-
finishCodexRun(run.runId, {
|
|
237
|
-
status: 'failed',
|
|
238
|
-
startedAtMs: startTime,
|
|
239
|
-
error: `codex-bridge: image staging failed — ${err?.message ?? 'unknown error'}`,
|
|
240
|
-
exitCode: null,
|
|
241
|
-
})
|
|
242
|
-
throw err
|
|
243
|
-
}
|
|
244
|
-
|
|
245
312
|
const isFirstQuery = isNewSession(sid)
|
|
246
313
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
247
314
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
248
|
-
addExchange(sid, 'user', historyQuery, globalMsgNum)
|
|
315
|
+
const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
|
|
249
316
|
|
|
250
317
|
let fullQuery: string
|
|
251
318
|
if (imagePaths.length === 1) {
|
|
@@ -262,10 +329,15 @@ export async function callCodexStreaming(
|
|
|
262
329
|
imagePaths,
|
|
263
330
|
persistentCodexSession,
|
|
264
331
|
codexThreadId: engineSession?.codexThreadId,
|
|
332
|
+
model,
|
|
333
|
+
resolvedModel: resolvedCodexModel,
|
|
334
|
+
effort: options?.effort,
|
|
335
|
+
publisherWritableDirectory: outputImagePublisher?.writableDirectory,
|
|
265
336
|
})
|
|
266
337
|
|
|
267
338
|
const env = { ...process.env }
|
|
268
339
|
delete env.CLAUDECODE
|
|
340
|
+
if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
|
|
269
341
|
|
|
270
342
|
const proc = spawn('codex', args, {
|
|
271
343
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -281,9 +353,7 @@ export async function callCodexStreaming(
|
|
|
281
353
|
const emittedBlocks = new Set<string>()
|
|
282
354
|
|
|
283
355
|
function cleanupImages() {
|
|
284
|
-
|
|
285
|
-
try { unlinkSync(p) } catch { /* ignore */ }
|
|
286
|
-
}
|
|
356
|
+
cleanupModelImageInputs(imageInputs)
|
|
287
357
|
}
|
|
288
358
|
|
|
289
359
|
function cleanup() {
|
|
@@ -301,12 +371,42 @@ export async function callCodexStreaming(
|
|
|
301
371
|
callbacks.onChunk(text)
|
|
302
372
|
}
|
|
303
373
|
|
|
304
|
-
function finalize(text: string) {
|
|
374
|
+
async function finalize(text: string) {
|
|
305
375
|
if (finalized) return
|
|
306
376
|
finalized = true
|
|
307
377
|
cleanup()
|
|
308
378
|
cleanupImages()
|
|
309
379
|
|
|
380
|
+
const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
|
|
381
|
+
if (imagePaths.length > 0) {
|
|
382
|
+
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let outputAttachments: MediaAttachmentRef[] = []
|
|
386
|
+
let outputImageStats: RunOutputImageCollectionStats | undefined
|
|
387
|
+
if (outputImagePublisher) {
|
|
388
|
+
callbacks.onToolStatus?.('Preparing images...')
|
|
389
|
+
const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
|
|
390
|
+
preparingHeartbeat.unref?.()
|
|
391
|
+
try {
|
|
392
|
+
outputAttachments = await outputImagePublisher.collect()
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error('[codex-bridge] output image collection failed:', err)
|
|
395
|
+
} finally {
|
|
396
|
+
clearInterval(preparingHeartbeat)
|
|
397
|
+
outputImageStats = outputImagePublisher.stats
|
|
398
|
+
outputImagePublisher.cleanup()
|
|
399
|
+
}
|
|
400
|
+
if (outputAttachments.length > 0) {
|
|
401
|
+
setExchangeAttachments(sid, assistantExchange, outputAttachments)
|
|
402
|
+
}
|
|
403
|
+
if (outputImageStats && outputImageStats.rejected > 0) {
|
|
404
|
+
callbacks.onToolStatus?.(outputImageStats.attached > 0
|
|
405
|
+
? 'Some images could not be attached'
|
|
406
|
+
: 'Image attachment unavailable')
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
310
410
|
const totalMs = Date.now() - startTime
|
|
311
411
|
logTokenAudit({
|
|
312
412
|
source: options?.lightweight ? 'g2-voice' : 'g2-query',
|
|
@@ -333,12 +433,12 @@ export async function callCodexStreaming(
|
|
|
333
433
|
output: text,
|
|
334
434
|
exitCode: 0,
|
|
335
435
|
})
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
436
|
+
callbacks.onDone(text, model, undefined, {
|
|
437
|
+
codexRunId: run.runId,
|
|
438
|
+
codexThreadId,
|
|
439
|
+
...(outputAttachments.length > 0 ? { outputAttachments } : {}),
|
|
440
|
+
...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
|
|
441
|
+
})
|
|
342
442
|
|
|
343
443
|
if (isFirstQuery) {
|
|
344
444
|
notifySessionStart(sid, query)
|
|
@@ -352,6 +452,8 @@ export async function callCodexStreaming(
|
|
|
352
452
|
finalized = true
|
|
353
453
|
cleanup()
|
|
354
454
|
cleanupImages()
|
|
455
|
+
outputImagePublisher?.cleanup()
|
|
456
|
+
removeExchange(sid, pendingUserExchange)
|
|
355
457
|
if (engineSession) {
|
|
356
458
|
clearCodexEngineSession(sid, model)
|
|
357
459
|
}
|
|
@@ -394,20 +496,12 @@ export async function callCodexStreaming(
|
|
|
394
496
|
const wallTimer = setTimeout(() => {
|
|
395
497
|
proc.kill('SIGTERM')
|
|
396
498
|
if (fullText) {
|
|
397
|
-
finalize(fullText)
|
|
499
|
+
void finalize(fullText)
|
|
398
500
|
} else {
|
|
399
501
|
finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
|
|
400
502
|
}
|
|
401
503
|
}, WALL_MAX_MS)
|
|
402
504
|
|
|
403
|
-
if (options?.abortSignal) {
|
|
404
|
-
if (options.abortSignal.aborted) {
|
|
405
|
-
handleAbort()
|
|
406
|
-
} else {
|
|
407
|
-
options.abortSignal.addEventListener('abort', handleAbort, { once: true })
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
505
|
function handleEvent(event: any) {
|
|
412
506
|
const nextThreadId = extractCodexThreadId(event)
|
|
413
507
|
if (nextThreadId && nextThreadId !== codexThreadId) {
|
|
@@ -418,12 +512,17 @@ export async function callCodexStreaming(
|
|
|
418
512
|
const status = toolStatus(event)
|
|
419
513
|
if (status) callbacks.onToolStatus?.(status)
|
|
420
514
|
|
|
421
|
-
const
|
|
515
|
+
const command = event?.item?.command ?? event?.item?.input ?? event?.payload?.command ?? event?.payload?.input
|
|
516
|
+
if (!isRunOutputImagePublisherCommand(command)) {
|
|
517
|
+
for (const preview of codexActivityPreviewLines(event)) callbacks.onActivityLine?.(preview)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const text = extractCodexResponseText(event)
|
|
422
521
|
if (text) emitText(text)
|
|
423
522
|
|
|
424
523
|
const type = String(event?.type ?? '')
|
|
425
524
|
if (type === 'turn.completed') {
|
|
426
|
-
finalize(fullText)
|
|
525
|
+
void finalize(fullText)
|
|
427
526
|
} else if (type === 'turn.failed' || type === 'error') {
|
|
428
527
|
finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
|
|
429
528
|
}
|
|
@@ -459,7 +558,7 @@ export async function callCodexStreaming(
|
|
|
459
558
|
if (code !== 0) {
|
|
460
559
|
finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
|
|
461
560
|
} else if (fullText) {
|
|
462
|
-
finalize(fullText)
|
|
561
|
+
void finalize(fullText)
|
|
463
562
|
} else {
|
|
464
563
|
finalizeError('codex-bridge: Codex completed without a response.')
|
|
465
564
|
}
|
|
@@ -468,9 +567,25 @@ export async function callCodexStreaming(
|
|
|
468
567
|
proc.on('error', (err) => {
|
|
469
568
|
finalizeError(`codex-bridge: ${err.message}`)
|
|
470
569
|
})
|
|
570
|
+
proc.stdin.on('error', (err) => {
|
|
571
|
+
finalizeError(`codex-bridge: stdin failed — ${err.message}`)
|
|
572
|
+
})
|
|
471
573
|
|
|
472
|
-
|
|
473
|
-
|
|
574
|
+
if (options?.abortSignal) {
|
|
575
|
+
if (options.abortSignal.aborted) {
|
|
576
|
+
handleAbort()
|
|
577
|
+
return sid
|
|
578
|
+
}
|
|
579
|
+
options.abortSignal.addEventListener('abort', handleAbort, { once: true })
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
proc.stdin.write(prompt)
|
|
584
|
+
proc.stdin.end()
|
|
585
|
+
} catch (err) {
|
|
586
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
587
|
+
finalizeError(`codex-bridge: stdin failed — ${message}`)
|
|
588
|
+
}
|
|
474
589
|
|
|
475
590
|
return sid
|
|
476
591
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
isCodexModel,
|
|
5
|
+
normalizeModelPreference,
|
|
6
|
+
type CodexModelPreference,
|
|
7
|
+
} from '../../shared/model-preference.js'
|
|
4
8
|
|
|
5
9
|
export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
|
|
6
10
|
|
|
@@ -40,8 +44,26 @@ function readStore(): CodexEngineSessionFile {
|
|
|
40
44
|
if (!existsSync(path)) return { sessions: {}, savedAt: new Date().toISOString() }
|
|
41
45
|
try {
|
|
42
46
|
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<CodexEngineSessionFile>
|
|
47
|
+
const sessions: Record<string, CodexEngineSession> = {}
|
|
48
|
+
for (const raw of Object.values(parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {})) {
|
|
49
|
+
const model = normalizeModelPreference(raw?.model)
|
|
50
|
+
if (
|
|
51
|
+
!model ||
|
|
52
|
+
!isCodexModel(model) ||
|
|
53
|
+
typeof raw?.cosSessionId !== 'string' ||
|
|
54
|
+
typeof raw?.codexThreadId !== 'string' ||
|
|
55
|
+
typeof raw?.cwd !== 'string' ||
|
|
56
|
+
(raw?.trustMode !== 'read-only' && raw?.trustMode !== 'workspace-write') ||
|
|
57
|
+
typeof raw?.lastUsedAt !== 'number' ||
|
|
58
|
+
typeof raw?.expiresAt !== 'string'
|
|
59
|
+
) continue
|
|
60
|
+
const key = sessionKey(raw.cosSessionId, model)
|
|
61
|
+
const normalized = { ...raw, key, model } as CodexEngineSession
|
|
62
|
+
const prior = sessions[key]
|
|
63
|
+
if (!prior || normalized.lastUsedAt > prior.lastUsedAt) sessions[key] = normalized
|
|
64
|
+
}
|
|
43
65
|
return {
|
|
44
|
-
sessions
|
|
66
|
+
sessions,
|
|
45
67
|
savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
|
|
46
68
|
}
|
|
47
69
|
} catch {
|