@gotcos/glasses-server 6.1.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/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// Codex bridge — streaming-compatible interface to `codex exec --json`
|
|
2
|
+
// MVP target: local subscription-authenticated GPT-5.5 High via Codex CLI.
|
|
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'
|
|
8
|
+
import { logTokenAudit } from './token-audit.js'
|
|
9
|
+
import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
|
|
10
|
+
import {
|
|
11
|
+
getHistory,
|
|
12
|
+
addExchange,
|
|
13
|
+
formatHistoryForPrompt,
|
|
14
|
+
getOrCreateSession,
|
|
15
|
+
isNewSession,
|
|
16
|
+
markSessionNotified,
|
|
17
|
+
getSessionRaw,
|
|
18
|
+
replaceLastExchangeWithSummary,
|
|
19
|
+
type PromptReference,
|
|
20
|
+
} from './conversation.js'
|
|
21
|
+
import { notifySessionStart, notifyExchange } from './telegram-notify.js'
|
|
22
|
+
import {
|
|
23
|
+
clearCodexEngineSession,
|
|
24
|
+
getCodexEngineSession,
|
|
25
|
+
saveCodexEngineSession,
|
|
26
|
+
type CodexEngineSession,
|
|
27
|
+
} from './codex-engine-sessions.js'
|
|
28
|
+
import {
|
|
29
|
+
CODEX_HIGH_MODEL,
|
|
30
|
+
CODEX_HIGH_REASONING_EFFORT,
|
|
31
|
+
CODEX_MODEL_ID,
|
|
32
|
+
type CodexModelPreference,
|
|
33
|
+
} from '../../shared/model-preference.js'
|
|
34
|
+
import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
|
|
35
|
+
import {
|
|
36
|
+
classifyCodexError,
|
|
37
|
+
extractCodexThreadId,
|
|
38
|
+
finishCodexRun,
|
|
39
|
+
getCodexExecutionCwd,
|
|
40
|
+
isCodexPersistenceEnabled,
|
|
41
|
+
getCodexTrustMode,
|
|
42
|
+
startCodexRun,
|
|
43
|
+
updateCodexRun,
|
|
44
|
+
type CodexRunStatus,
|
|
45
|
+
} from './codex-run-ledger.js'
|
|
46
|
+
|
|
47
|
+
const INACTIVITY_MS = 180_000
|
|
48
|
+
const WALL_MAX_MS = 900_000
|
|
49
|
+
const HEARTBEAT_INTERVAL_MS = 6_000
|
|
50
|
+
|
|
51
|
+
type Phase = 'context' | 'thinking' | 'generating'
|
|
52
|
+
|
|
53
|
+
const PHASE_LABELS: Record<Phase, string> = {
|
|
54
|
+
context: 'Loading context...',
|
|
55
|
+
thinking: 'Reasoning...',
|
|
56
|
+
generating: 'Writing...',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sandbox policy for `codex exec`. This public server NEVER runs codex
|
|
60
|
+
// unsandboxed — that would let a remote glasses query execute arbitrary commands
|
|
61
|
+
// on the host. Default: read-only (safe for chat). COS_CODEX_SANDBOX=workspace-write
|
|
62
|
+
// permits writes within the working directory only. Full host access is
|
|
63
|
+
// intentionally not exposed by this server.
|
|
64
|
+
function codexSandboxArgs(): string[] {
|
|
65
|
+
const mode = process.env.COS_CODEX_SANDBOX === 'workspace-write' ? 'workspace-write' : 'read-only'
|
|
66
|
+
return ['--sandbox', mode, '--skip-git-repo-check']
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function buildCodexExecArgs(input: {
|
|
70
|
+
codexCwd: string
|
|
71
|
+
imagePaths?: string[]
|
|
72
|
+
persistentCodexSession: boolean
|
|
73
|
+
codexThreadId?: string
|
|
74
|
+
}): string[] {
|
|
75
|
+
const imagePaths = input.imagePaths ?? []
|
|
76
|
+
const args = ['exec']
|
|
77
|
+
if (input.codexThreadId) {
|
|
78
|
+
args.push(
|
|
79
|
+
'resume',
|
|
80
|
+
'--json',
|
|
81
|
+
'--all',
|
|
82
|
+
...codexSandboxArgs(),
|
|
83
|
+
'-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
|
|
84
|
+
)
|
|
85
|
+
if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
|
|
86
|
+
for (const p of imagePaths) args.push('--image', p)
|
|
87
|
+
args.push(input.codexThreadId, '-')
|
|
88
|
+
return args
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
args.push(
|
|
92
|
+
'--json',
|
|
93
|
+
'--cd', input.codexCwd,
|
|
94
|
+
...codexSandboxArgs(),
|
|
95
|
+
'-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
|
|
96
|
+
)
|
|
97
|
+
if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
|
|
98
|
+
if (!input.persistentCodexSession) args.push('--ephemeral')
|
|
99
|
+
for (const p of imagePaths) args.push('--image', p)
|
|
100
|
+
args.push('-')
|
|
101
|
+
return args
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
|
|
105
|
+
return [
|
|
106
|
+
'SYSTEM INSTRUCTIONS',
|
|
107
|
+
systemPrompt,
|
|
108
|
+
'',
|
|
109
|
+
'USER REQUEST',
|
|
110
|
+
fullQuery,
|
|
111
|
+
].join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function eventText(event: any): string {
|
|
115
|
+
const item = event?.item ?? event?.payload ?? event?.message ?? event
|
|
116
|
+
|
|
117
|
+
if (typeof event?.delta === 'string') return event.delta
|
|
118
|
+
if (typeof event?.text === 'string' && /message|delta|answer/i.test(String(event.type ?? ''))) return event.text
|
|
119
|
+
if (typeof item?.text === 'string' && /agent_message|message|assistant/i.test(String(item.type ?? event?.type ?? ''))) return item.text
|
|
120
|
+
|
|
121
|
+
const content = item?.content ?? event?.content
|
|
122
|
+
if (Array.isArray(content)) {
|
|
123
|
+
let text = ''
|
|
124
|
+
for (const block of content) {
|
|
125
|
+
if (typeof block === 'string') text += block
|
|
126
|
+
if (typeof block?.text === 'string') text += block.text
|
|
127
|
+
if (typeof block?.content === 'string') text += block.content
|
|
128
|
+
if (typeof block?.output_text === 'string') text += block.output_text
|
|
129
|
+
}
|
|
130
|
+
return text
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (typeof item?.result === 'string' && /result|completed|answer/i.test(String(event?.type ?? ''))) return item.result
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toolStatus(event: any): string | undefined {
|
|
138
|
+
const type = String(event?.type ?? '')
|
|
139
|
+
const item = event?.item ?? event?.payload
|
|
140
|
+
const itemType = String(item?.type ?? '')
|
|
141
|
+
const name = item?.name ?? item?.tool_name
|
|
142
|
+
|
|
143
|
+
if (type === 'thread.started') return 'Starting Codex...'
|
|
144
|
+
if (type === 'turn.started') return 'Reasoning...'
|
|
145
|
+
if (/patch/i.test(type) || /patch/i.test(itemType)) return 'Applying patch...'
|
|
146
|
+
if (/command|exec|shell/i.test(type) || /command|exec|shell/i.test(itemType)) return 'Using shell...'
|
|
147
|
+
if (/tool/i.test(type) || /tool/i.test(itemType)) {
|
|
148
|
+
if (typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,24}$/.test(name)) return name
|
|
149
|
+
return 'Using tool...'
|
|
150
|
+
}
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function safeCodexUserError(message: string): string {
|
|
155
|
+
const code = classifyCodexError(message)
|
|
156
|
+
if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
|
|
157
|
+
if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
|
|
158
|
+
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 full-access configuration.'
|
|
160
|
+
return `Codex failed (${code}). Retry or check Codex Debug.`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function callCodexStreaming(
|
|
164
|
+
query: string,
|
|
165
|
+
sessionId: string | undefined,
|
|
166
|
+
callbacks: StreamCallbacks,
|
|
167
|
+
model: CodexModelPreference = CODEX_HIGH_MODEL,
|
|
168
|
+
images?: string[],
|
|
169
|
+
reference?: PromptReference,
|
|
170
|
+
globalMsgNum?: number,
|
|
171
|
+
options?: CallOptions,
|
|
172
|
+
): Promise<string> {
|
|
173
|
+
const sid = getOrCreateSession(sessionId)
|
|
174
|
+
const history = getHistory(sid)
|
|
175
|
+
const session = getSessionRaw(sid)
|
|
176
|
+
const contextBreaks = session?.contextBreaks ?? []
|
|
177
|
+
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
178
|
+
const contextPrompt = historyPrompt
|
|
179
|
+
const persistentCodexSession = isCodexPersistenceEnabled()
|
|
180
|
+
const codexCwd = getCodexExecutionCwd()
|
|
181
|
+
const codexTrustMode = getCodexTrustMode()
|
|
182
|
+
const engineSession = persistentCodexSession
|
|
183
|
+
? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
|
|
184
|
+
: null
|
|
185
|
+
const startTime = Date.now()
|
|
186
|
+
const run = startCodexRun({
|
|
187
|
+
cosSessionId: sid,
|
|
188
|
+
model,
|
|
189
|
+
cwd: codexCwd,
|
|
190
|
+
ephemeral: !persistentCodexSession,
|
|
191
|
+
resumed: !!engineSession,
|
|
192
|
+
trustMode: codexTrustMode,
|
|
193
|
+
codexThreadId: engineSession?.codexThreadId,
|
|
194
|
+
expiresAt: engineSession?.expiresAt,
|
|
195
|
+
query,
|
|
196
|
+
})
|
|
197
|
+
let codexThreadId: string | undefined = engineSession?.codexThreadId
|
|
198
|
+
callbacks.onStart?.(model, sid, undefined, { codexRunId: run.runId, codexThreadId })
|
|
199
|
+
|
|
200
|
+
let phase: Phase = 'context'
|
|
201
|
+
let systemPrompt: string
|
|
202
|
+
try {
|
|
203
|
+
if (options?.lightweight) {
|
|
204
|
+
systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
|
|
205
|
+
} else {
|
|
206
|
+
callbacks.onToolStatus?.('Loading context...')
|
|
207
|
+
systemPrompt = await buildSystemPrompt(contextPrompt)
|
|
208
|
+
}
|
|
209
|
+
} catch (err: any) {
|
|
210
|
+
finishCodexRun(run.runId, {
|
|
211
|
+
status: 'failed',
|
|
212
|
+
startedAtMs: startTime,
|
|
213
|
+
error: `codex-bridge: context build failed — ${err?.message ?? 'unknown error'}`,
|
|
214
|
+
exitCode: null,
|
|
215
|
+
})
|
|
216
|
+
throw err
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
phase = 'thinking'
|
|
220
|
+
callbacks.onToolStatus?.('Reasoning...')
|
|
221
|
+
|
|
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
|
+
const isFirstQuery = isNewSession(sid)
|
|
246
|
+
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
247
|
+
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
248
|
+
addExchange(sid, 'user', historyQuery, globalMsgNum)
|
|
249
|
+
|
|
250
|
+
let fullQuery: string
|
|
251
|
+
if (imagePaths.length === 1) {
|
|
252
|
+
fullQuery = `The user has shared a photo from their phone camera. Use the attached image, then respond to their request: ${query || 'Describe what you see in this image concisely.'}`
|
|
253
|
+
} else if (imagePaths.length > 1) {
|
|
254
|
+
fullQuery = `The user has shared ${imagePaths.length} photos from their phone camera. Use the attached images, then respond to their request: ${query || 'Describe what you see in these images concisely.'}`
|
|
255
|
+
} else {
|
|
256
|
+
fullQuery = query
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const prompt = buildCodexPrompt(systemPrompt, fullQuery)
|
|
260
|
+
const args = buildCodexExecArgs({
|
|
261
|
+
codexCwd,
|
|
262
|
+
imagePaths,
|
|
263
|
+
persistentCodexSession,
|
|
264
|
+
codexThreadId: engineSession?.codexThreadId,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const env = { ...process.env }
|
|
268
|
+
delete env.CLAUDECODE
|
|
269
|
+
|
|
270
|
+
const proc = spawn('codex', args, {
|
|
271
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
272
|
+
env,
|
|
273
|
+
cwd: codexCwd,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
let fullText = ''
|
|
277
|
+
let stderr = ''
|
|
278
|
+
let buffer = ''
|
|
279
|
+
let finalized = false
|
|
280
|
+
let lastActivity = Date.now()
|
|
281
|
+
const emittedBlocks = new Set<string>()
|
|
282
|
+
|
|
283
|
+
function cleanupImages() {
|
|
284
|
+
for (const p of imagePaths) {
|
|
285
|
+
try { unlinkSync(p) } catch { /* ignore */ }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cleanup() {
|
|
290
|
+
clearInterval(heartbeat)
|
|
291
|
+
clearTimeout(inactivityTimer)
|
|
292
|
+
clearTimeout(wallTimer)
|
|
293
|
+
options?.abortSignal?.removeEventListener('abort', handleAbort)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function emitText(text: string) {
|
|
297
|
+
if (!text || emittedBlocks.has(text)) return
|
|
298
|
+
emittedBlocks.add(text)
|
|
299
|
+
phase = 'generating'
|
|
300
|
+
fullText += text
|
|
301
|
+
callbacks.onChunk(text)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function finalize(text: string) {
|
|
305
|
+
if (finalized) return
|
|
306
|
+
finalized = true
|
|
307
|
+
cleanup()
|
|
308
|
+
cleanupImages()
|
|
309
|
+
|
|
310
|
+
const totalMs = Date.now() - startTime
|
|
311
|
+
logTokenAudit({
|
|
312
|
+
source: options?.lightweight ? 'g2-voice' : 'g2-query',
|
|
313
|
+
model,
|
|
314
|
+
inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
|
|
315
|
+
outputChars: text.length,
|
|
316
|
+
durationMs: totalMs,
|
|
317
|
+
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
318
|
+
})
|
|
319
|
+
if (persistentCodexSession && codexThreadId) {
|
|
320
|
+
const saved = saveCodexEngineSession({
|
|
321
|
+
cosSessionId: sid,
|
|
322
|
+
model,
|
|
323
|
+
codexThreadId,
|
|
324
|
+
cwd: codexCwd,
|
|
325
|
+
trustMode: codexTrustMode,
|
|
326
|
+
})
|
|
327
|
+
updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
finishCodexRun(run.runId, {
|
|
331
|
+
status: 'completed',
|
|
332
|
+
startedAtMs: startTime,
|
|
333
|
+
output: text,
|
|
334
|
+
exitCode: 0,
|
|
335
|
+
})
|
|
336
|
+
addExchange(sid, 'assistant', text, globalMsgNum)
|
|
337
|
+
if (imagePaths.length > 0) {
|
|
338
|
+
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
callbacks.onDone(text, model, undefined, { codexRunId: run.runId, codexThreadId })
|
|
342
|
+
|
|
343
|
+
if (isFirstQuery) {
|
|
344
|
+
notifySessionStart(sid, query)
|
|
345
|
+
markSessionNotified(sid)
|
|
346
|
+
}
|
|
347
|
+
notifyExchange(sid, query, text)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
|
|
351
|
+
if (finalized) return
|
|
352
|
+
finalized = true
|
|
353
|
+
cleanup()
|
|
354
|
+
cleanupImages()
|
|
355
|
+
if (engineSession) {
|
|
356
|
+
clearCodexEngineSession(sid, model)
|
|
357
|
+
}
|
|
358
|
+
finishCodexRun(run.runId, {
|
|
359
|
+
status,
|
|
360
|
+
startedAtMs: startTime,
|
|
361
|
+
error: msg,
|
|
362
|
+
exitCode,
|
|
363
|
+
})
|
|
364
|
+
callbacks.onError(safeCodexUserError(msg))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleAbort() {
|
|
368
|
+
if (finalized) return
|
|
369
|
+
proc.kill('SIGTERM')
|
|
370
|
+
finalizeError('codex-bridge: client disconnected before Codex completed.', null, 'client_disconnected')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const heartbeat = setInterval(() => {
|
|
374
|
+
if (finalized) return
|
|
375
|
+
callbacks.onToolStatus?.(PHASE_LABELS[phase] ?? 'Processing...')
|
|
376
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
377
|
+
|
|
378
|
+
let inactivityTimer = setTimeout(() => {
|
|
379
|
+
proc.kill('SIGTERM')
|
|
380
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
381
|
+
finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
|
|
382
|
+
}, INACTIVITY_MS)
|
|
383
|
+
|
|
384
|
+
function resetInactivity() {
|
|
385
|
+
lastActivity = Date.now()
|
|
386
|
+
clearTimeout(inactivityTimer)
|
|
387
|
+
inactivityTimer = setTimeout(() => {
|
|
388
|
+
proc.kill('SIGTERM')
|
|
389
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000)
|
|
390
|
+
finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
|
|
391
|
+
}, INACTIVITY_MS)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const wallTimer = setTimeout(() => {
|
|
395
|
+
proc.kill('SIGTERM')
|
|
396
|
+
if (fullText) {
|
|
397
|
+
finalize(fullText)
|
|
398
|
+
} else {
|
|
399
|
+
finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
|
|
400
|
+
}
|
|
401
|
+
}, WALL_MAX_MS)
|
|
402
|
+
|
|
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
|
+
function handleEvent(event: any) {
|
|
412
|
+
const nextThreadId = extractCodexThreadId(event)
|
|
413
|
+
if (nextThreadId && nextThreadId !== codexThreadId) {
|
|
414
|
+
codexThreadId = nextThreadId
|
|
415
|
+
updateCodexRun(run.runId, { codexThreadId })
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const status = toolStatus(event)
|
|
419
|
+
if (status) callbacks.onToolStatus?.(status)
|
|
420
|
+
|
|
421
|
+
const text = eventText(event)
|
|
422
|
+
if (text) emitText(text)
|
|
423
|
+
|
|
424
|
+
const type = String(event?.type ?? '')
|
|
425
|
+
if (type === 'turn.completed') {
|
|
426
|
+
finalize(fullText)
|
|
427
|
+
} else if (type === 'turn.failed' || type === 'error') {
|
|
428
|
+
finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
proc.stdout.on('data', (chunk: Buffer) => {
|
|
433
|
+
resetInactivity()
|
|
434
|
+
buffer += chunk.toString()
|
|
435
|
+
const lines = buffer.split('\n')
|
|
436
|
+
buffer = lines.pop() ?? ''
|
|
437
|
+
|
|
438
|
+
for (const line of lines) {
|
|
439
|
+
const trimmed = line.trim()
|
|
440
|
+
if (!trimmed) continue
|
|
441
|
+
try {
|
|
442
|
+
handleEvent(JSON.parse(trimmed))
|
|
443
|
+
} catch {
|
|
444
|
+
// Ignore non-JSON status lines from older CLI builds.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
proc.stderr.on('data', (chunk: Buffer) => {
|
|
450
|
+
resetInactivity()
|
|
451
|
+
stderr += chunk.toString()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
proc.on('close', (code) => {
|
|
455
|
+
if (buffer.trim()) {
|
|
456
|
+
try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
|
|
457
|
+
}
|
|
458
|
+
if (finalized) return
|
|
459
|
+
if (code !== 0) {
|
|
460
|
+
finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
|
|
461
|
+
} else if (fullText) {
|
|
462
|
+
finalize(fullText)
|
|
463
|
+
} else {
|
|
464
|
+
finalizeError('codex-bridge: Codex completed without a response.')
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
proc.on('error', (err) => {
|
|
469
|
+
finalizeError(`codex-bridge: ${err.message}`)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
proc.stdin.write(prompt)
|
|
473
|
+
proc.stdin.end()
|
|
474
|
+
|
|
475
|
+
return sid
|
|
476
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import type { CodexModelPreference } from '../../shared/model-preference.js'
|
|
4
|
+
|
|
5
|
+
export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
|
|
6
|
+
|
|
7
|
+
export type CodexTrustMode = 'read-only' | 'workspace-write'
|
|
8
|
+
|
|
9
|
+
export interface CodexEngineSession {
|
|
10
|
+
key: string
|
|
11
|
+
cosSessionId: string
|
|
12
|
+
model: CodexModelPreference
|
|
13
|
+
codexThreadId: string
|
|
14
|
+
cwd: string
|
|
15
|
+
trustMode: CodexTrustMode
|
|
16
|
+
savedAt: number
|
|
17
|
+
lastUsedAt: number
|
|
18
|
+
expiresAt: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CodexEngineSessionFile {
|
|
22
|
+
sessions: Record<string, CodexEngineSession>
|
|
23
|
+
savedAt: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionKey(cosSessionId: string, model: CodexModelPreference): string {
|
|
27
|
+
return `${cosSessionId}:${model}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getCodexEngineSessionPath(): string {
|
|
31
|
+
return resolve(process.env.COS_CODEX_ENGINE_SESSIONS_FILE || '/tmp/cos-codex-engine-sessions.json')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expiresAtFrom(now: number): string {
|
|
35
|
+
return new Date(now + CODEX_ENGINE_SESSION_TTL_MS).toISOString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readStore(): CodexEngineSessionFile {
|
|
39
|
+
const path = getCodexEngineSessionPath()
|
|
40
|
+
if (!existsSync(path)) return { sessions: {}, savedAt: new Date().toISOString() }
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<CodexEngineSessionFile>
|
|
43
|
+
return {
|
|
44
|
+
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
45
|
+
savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return { sessions: {}, savedAt: new Date().toISOString() }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeStore(store: CodexEngineSessionFile): void {
|
|
53
|
+
const path = getCodexEngineSessionPath()
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
55
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
56
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2))
|
|
57
|
+
renameSync(tmp, path)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pruneExpired(sessions: Record<string, CodexEngineSession>, now = Date.now()): Record<string, CodexEngineSession> {
|
|
61
|
+
const next: Record<string, CodexEngineSession> = {}
|
|
62
|
+
for (const [key, session] of Object.entries(sessions)) {
|
|
63
|
+
const expiresMs = Date.parse(session.expiresAt)
|
|
64
|
+
if (Number.isFinite(expiresMs) && expiresMs > now) next[key] = session
|
|
65
|
+
}
|
|
66
|
+
return next
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getCodexEngineSession(input: {
|
|
70
|
+
cosSessionId: string
|
|
71
|
+
model: CodexModelPreference
|
|
72
|
+
cwd: string
|
|
73
|
+
trustMode: CodexTrustMode
|
|
74
|
+
}): CodexEngineSession | null {
|
|
75
|
+
const store = readStore()
|
|
76
|
+
const now = Date.now()
|
|
77
|
+
const sessions = pruneExpired(store.sessions, now)
|
|
78
|
+
const existing = sessions[sessionKey(input.cosSessionId, input.model)]
|
|
79
|
+
if (!existing) {
|
|
80
|
+
if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
|
|
81
|
+
writeStore({ sessions, savedAt: new Date().toISOString() })
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
if (existing.cwd !== input.cwd || existing.trustMode !== input.trustMode) return null
|
|
86
|
+
return existing
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function saveCodexEngineSession(input: {
|
|
90
|
+
cosSessionId: string
|
|
91
|
+
model: CodexModelPreference
|
|
92
|
+
codexThreadId: string
|
|
93
|
+
cwd: string
|
|
94
|
+
trustMode: CodexTrustMode
|
|
95
|
+
now?: number
|
|
96
|
+
}): CodexEngineSession {
|
|
97
|
+
const now = input.now ?? Date.now()
|
|
98
|
+
const store = readStore()
|
|
99
|
+
const sessions = pruneExpired(store.sessions, now)
|
|
100
|
+
const key = sessionKey(input.cosSessionId, input.model)
|
|
101
|
+
const previous = sessions[key]
|
|
102
|
+
const session: CodexEngineSession = {
|
|
103
|
+
key,
|
|
104
|
+
cosSessionId: input.cosSessionId,
|
|
105
|
+
model: input.model,
|
|
106
|
+
codexThreadId: input.codexThreadId,
|
|
107
|
+
cwd: input.cwd,
|
|
108
|
+
trustMode: input.trustMode,
|
|
109
|
+
savedAt: previous?.savedAt ?? now,
|
|
110
|
+
lastUsedAt: now,
|
|
111
|
+
expiresAt: expiresAtFrom(now),
|
|
112
|
+
}
|
|
113
|
+
sessions[key] = session
|
|
114
|
+
writeStore({ sessions, savedAt: new Date().toISOString() })
|
|
115
|
+
return session
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference): number {
|
|
119
|
+
const store = readStore()
|
|
120
|
+
const sessions = pruneExpired(store.sessions)
|
|
121
|
+
let removed = 0
|
|
122
|
+
for (const key of Object.keys(sessions)) {
|
|
123
|
+
const session = sessions[key]
|
|
124
|
+
if (session.cosSessionId !== cosSessionId) continue
|
|
125
|
+
if (model && session.model !== model) continue
|
|
126
|
+
delete sessions[key]
|
|
127
|
+
removed += 1
|
|
128
|
+
}
|
|
129
|
+
if (removed > 0) writeStore({ sessions, savedAt: new Date().toISOString() })
|
|
130
|
+
return removed
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function listCodexEngineSessions(): CodexEngineSession[] {
|
|
134
|
+
const store = readStore()
|
|
135
|
+
const sessions = pruneExpired(store.sessions)
|
|
136
|
+
if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
|
|
137
|
+
writeStore({ sessions, savedAt: new Date().toISOString() })
|
|
138
|
+
}
|
|
139
|
+
return Object.values(sessions).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
|
140
|
+
}
|