@xallindrome-labs/codex-bridge 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -2
- package/SECURITY.md +23 -0
- package/docs/demo.svg +40 -0
- package/lib.mjs +208 -0
- package/package.json +38 -4
- package/plugins/codex-jobs/.claude-plugin/plugin.json +9 -0
- package/plugins/codex-jobs/LICENSE +21 -0
- package/plugins/codex-jobs/README.md +45 -0
- package/plugins/codex-jobs/hooks/hooks.json +1 -0
- package/plugins/codex-jobs/hooks/register.tsx +390 -0
- package/plugins/codex-jobs/types/index.d.ts +54 -0
- package/runner.mjs +103 -0
- package/server.mjs +594 -0
- package/smoke-test.mjs +111 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { ImageTally, JobRow, JobStatus, JobsSnapshot, Usage, UsageWindow } from '../types'
|
|
5
|
+
|
|
6
|
+
// A pane beside the chat that lists codex-bridge jobs. It only reads files:
|
|
7
|
+
// the job files codex-bridge keeps (jobs/<id>/job.json, attempt-<n>/result.json
|
|
8
|
+
// and heartbeat), so it works for jobs started by any Claude session, and the
|
|
9
|
+
// newest Codex session logs, where Codex records how much of its 5-hour and
|
|
10
|
+
// weekly usage windows is used and when each resets.
|
|
11
|
+
|
|
12
|
+
const PANE = 'codex-jobs'
|
|
13
|
+
const POLL_MS = 3000
|
|
14
|
+
const SHOWN = 12
|
|
15
|
+
// Jobs read per poll (the rest of the list is older). Finished ones are cached, so this is cheap.
|
|
16
|
+
const READ = 60
|
|
17
|
+
// Codex usage is re-read every this many polls (30s); its logs change slowly.
|
|
18
|
+
const USAGE_EVERY = 10
|
|
19
|
+
const USAGE_FILES = 4
|
|
20
|
+
const DAY_MS = 24 * 3600 * 1000
|
|
21
|
+
// The runner beats every 2s; a job whose heartbeat is older than this is dead.
|
|
22
|
+
const BEAT_STALE_MS = 30_000
|
|
23
|
+
const LAUNCH_GRACE_MS = 60_000
|
|
24
|
+
|
|
25
|
+
// Change the tag when the snapshot's fields change: it declines what an earlier
|
|
26
|
+
// version stored, which outlives a reload.
|
|
27
|
+
const snapshot = atom(
|
|
28
|
+
{ plugin: 'codex-jobs', key: 'snapshot' } as const,
|
|
29
|
+
{ rows: [], images: [], at: 0 } as JobsSnapshot,
|
|
30
|
+
{ shape: 'snapshot-2' },
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
type Job = {
|
|
34
|
+
id: string
|
|
35
|
+
tool: string
|
|
36
|
+
created: number
|
|
37
|
+
attempts: number
|
|
38
|
+
attemptStarted?: number
|
|
39
|
+
cwd?: string
|
|
40
|
+
outDir?: string
|
|
41
|
+
args?: { request?: string; prompt?: string; count?: number }
|
|
42
|
+
}
|
|
43
|
+
type Result = { status: JobStatus; finished?: number; images?: string[] }
|
|
44
|
+
|
|
45
|
+
const ICON: Record<JobStatus, string> = {
|
|
46
|
+
running: '●',
|
|
47
|
+
done: '✓',
|
|
48
|
+
failed: '✗',
|
|
49
|
+
interrupted: '!',
|
|
50
|
+
cancelled: '○',
|
|
51
|
+
timed_out: '✗',
|
|
52
|
+
}
|
|
53
|
+
const COLOR: Record<JobStatus, string> = {
|
|
54
|
+
running: 'yellow',
|
|
55
|
+
done: 'green',
|
|
56
|
+
failed: 'red',
|
|
57
|
+
interrupted: 'red',
|
|
58
|
+
cancelled: 'gray',
|
|
59
|
+
timed_out: 'red',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const ago = (ms: number): string => {
|
|
63
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
64
|
+
if (s < 120) return `${s}s`
|
|
65
|
+
const m = Math.round(s / 60)
|
|
66
|
+
return m < 120 ? `${m}m` : `${Math.round(m / 60)}h`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// "3h 12m", "2d 4h": how long until a usage window resets.
|
|
70
|
+
export const until = (ms: number): string => {
|
|
71
|
+
const m = Math.max(0, Math.round(ms / 60_000))
|
|
72
|
+
if (m < 60) return `${m}m`
|
|
73
|
+
const h = Math.floor(m / 60)
|
|
74
|
+
if (h < 24) return `${h}h ${m % 60}m`
|
|
75
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bar = (percent: number, width = 10): string => {
|
|
79
|
+
const filled = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
|
|
80
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Jobs finished, failed or dead are history; only these can still change.
|
|
84
|
+
const isFinal = (s: JobStatus) => s !== 'running' && s !== 'interrupted'
|
|
85
|
+
|
|
86
|
+
// What this module remembers between ticks. A reload starts it over.
|
|
87
|
+
const cache = new Map<string, { mtime: number; row: JobRow }>()
|
|
88
|
+
let last = ''
|
|
89
|
+
let lastRunning = -1
|
|
90
|
+
let ticks = 0
|
|
91
|
+
let usage: Usage | undefined
|
|
92
|
+
let hasAutoOpened = false
|
|
93
|
+
let timer: { cancel: () => void } | undefined
|
|
94
|
+
|
|
95
|
+
const slash = (p: string) => p.replace(/\\/g, '/')
|
|
96
|
+
|
|
97
|
+
async function json<T>($: EngineInterface, path: string): Promise<T | undefined> {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(await $.fs.read(path)) as T
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function jobsDir($: EngineInterface): Promise<string | undefined> {
|
|
106
|
+
const custom = await $.env.get('CODEX_BRIDGE_HOME')
|
|
107
|
+
if (custom) return `${slash(custom)}/jobs`
|
|
108
|
+
const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
|
|
109
|
+
return user ? `${slash(user)}/.codex-bridge/jobs` : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readRow($: EngineInterface, dir: string, id: string): Promise<JobRow | undefined> {
|
|
113
|
+
const jobFile = `${dir}/${id}/job.json`
|
|
114
|
+
const stat = await $.fs.stat(jobFile).catch(() => undefined)
|
|
115
|
+
if (!stat) return undefined
|
|
116
|
+
const hit = cache.get(id)
|
|
117
|
+
if (hit && hit.mtime === stat.mtimeMs && isFinal(hit.row.status)) return hit.row
|
|
118
|
+
|
|
119
|
+
const job = await json<Job>($, jobFile)
|
|
120
|
+
if (!job) return undefined
|
|
121
|
+
const attempt = `${dir}/${id}/attempt-${job.attempts}`
|
|
122
|
+
const result = await json<Result>($, `${attempt}/result.json`)
|
|
123
|
+
|
|
124
|
+
let status: JobStatus
|
|
125
|
+
if (result) {
|
|
126
|
+
status = result.status
|
|
127
|
+
} else {
|
|
128
|
+
const now = await $.clock.now()
|
|
129
|
+
const beat = await $.fs.stat(`${attempt}/heartbeat`).catch(() => undefined)
|
|
130
|
+
const isAlive = beat
|
|
131
|
+
? now - beat.mtimeMs < BEAT_STALE_MS
|
|
132
|
+
: now - (job.attemptStarted ?? job.created) < LAUNCH_GRACE_MS
|
|
133
|
+
status = isAlive ? 'running' : 'interrupted'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let images: number | undefined
|
|
137
|
+
if (job.tool === 'codex_image') {
|
|
138
|
+
const copied = result?.images
|
|
139
|
+
? result.images.length
|
|
140
|
+
: Object.keys((await json<Record<string, string>>($, `${dir}/${id}/copied.json`)) ?? {}).length
|
|
141
|
+
images = copied
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const row: JobRow = {
|
|
145
|
+
id: job.id,
|
|
146
|
+
tool: job.tool,
|
|
147
|
+
status,
|
|
148
|
+
created: job.created,
|
|
149
|
+
finished: result?.finished,
|
|
150
|
+
label: (job.args?.request ?? job.args?.prompt ?? '').replace(/\s+/g, ' ').trim().slice(0, 160),
|
|
151
|
+
where: job.cwd ?? job.outDir ?? '',
|
|
152
|
+
attempts: job.attempts,
|
|
153
|
+
images,
|
|
154
|
+
count: job.args?.count,
|
|
155
|
+
}
|
|
156
|
+
cache.set(id, { mtime: stat.mtimeMs, row })
|
|
157
|
+
return row
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const names = async ($: EngineInterface, dir: string, kind: 'dir' | 'file') =>
|
|
161
|
+
((await $.fs.list(dir).catch(() => [])) ?? [])
|
|
162
|
+
.filter(entry => entry.kind === kind)
|
|
163
|
+
.map(entry => entry.name)
|
|
164
|
+
.sort()
|
|
165
|
+
.reverse()
|
|
166
|
+
|
|
167
|
+
const toWindow = (w: unknown): UsageWindow | undefined => {
|
|
168
|
+
const x = w as { used_percent?: number; window_minutes?: number; resets_at?: number } | null | undefined
|
|
169
|
+
if (typeof x?.used_percent !== 'number' || typeof x.window_minutes !== 'number' || typeof x.resets_at !== 'number') {
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
return { usedPercent: x.used_percent, windowMinutes: x.window_minutes, resetsAt: x.resets_at * 1000 }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Codex writes its usage windows into every session log (token_count events:
|
|
176
|
+
// payload.rate_limits.primary / .secondary). The newest log with a reading is
|
|
177
|
+
// the freshest one; the same meter covers text and image generation.
|
|
178
|
+
async function readUsage($: EngineInterface): Promise<Usage | undefined> {
|
|
179
|
+
const codexHome = await $.env.get('CODEX_HOME')
|
|
180
|
+
const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
|
|
181
|
+
const base = codexHome ?? (user ? `${user}/.codex` : undefined)
|
|
182
|
+
if (!base) return undefined
|
|
183
|
+
const root = `${slash(base)}/sessions`
|
|
184
|
+
const files: string[] = []
|
|
185
|
+
for (const year of await names($, root, 'dir')) {
|
|
186
|
+
for (const month of await names($, `${root}/${year}`, 'dir')) {
|
|
187
|
+
for (const day of await names($, `${root}/${year}/${month}`, 'dir')) {
|
|
188
|
+
const dayDir = `${root}/${year}/${month}/${day}`
|
|
189
|
+
for (const name of await names($, dayDir, 'file')) {
|
|
190
|
+
if (name.startsWith('rollout-') && name.endsWith('.jsonl')) files.push(`${dayDir}/${name}`)
|
|
191
|
+
if (files.length >= USAGE_FILES) break
|
|
192
|
+
}
|
|
193
|
+
if (files.length >= USAGE_FILES) break
|
|
194
|
+
}
|
|
195
|
+
if (files.length >= USAGE_FILES) break
|
|
196
|
+
}
|
|
197
|
+
if (files.length >= USAGE_FILES) break
|
|
198
|
+
}
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
// A log over 4 MiB cannot be read whole; the next one is tried.
|
|
201
|
+
const text = await $.fs.read(file).catch(() => undefined)
|
|
202
|
+
if (!text) continue
|
|
203
|
+
const lines = text.split('\n')
|
|
204
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
205
|
+
const line = lines[i]
|
|
206
|
+
if (!line?.includes('"rate_limits":{')) continue
|
|
207
|
+
try {
|
|
208
|
+
const event = JSON.parse(line) as { timestamp?: string; payload?: { rate_limits?: { primary?: unknown; secondary?: unknown } } }
|
|
209
|
+
const limits = event.payload?.rate_limits
|
|
210
|
+
const primary = toWindow(limits?.primary)
|
|
211
|
+
const secondary = toWindow(limits?.secondary)
|
|
212
|
+
if (primary || secondary) {
|
|
213
|
+
return { primary, secondary, asOf: event.timestamp ? Date.parse(event.timestamp) || 0 : 0 }
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// A half-written last line; the one before it is fine.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function tick($: EngineInterface): Promise<void> {
|
|
224
|
+
const dir = await jobsDir($)
|
|
225
|
+
const now = await $.clock.now()
|
|
226
|
+
let rows: JobRow[] = []
|
|
227
|
+
let problem: string | undefined
|
|
228
|
+
if (!dir) {
|
|
229
|
+
problem = 'Cannot work out where codex-bridge keeps its jobs (no home folder).'
|
|
230
|
+
} else {
|
|
231
|
+
// A missing folder just means codex-bridge has not run yet.
|
|
232
|
+
const ids = (await names($, dir, 'dir')).filter(name => name.startsWith('job-')).slice(0, READ)
|
|
233
|
+
for (const id of cache.keys()) if (!ids.includes(id)) cache.delete(id)
|
|
234
|
+
const loaded = await Promise.all(ids.map(id => readRow($, dir, id).catch(() => undefined)))
|
|
235
|
+
rows = loaded.filter((row): row is JobRow => row !== undefined)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Images the bridge saved in the last week, counted per usage window when drawn.
|
|
239
|
+
const images: ImageTally[] = rows
|
|
240
|
+
.filter(row => row.tool === 'codex_image' && (row.images ?? 0) > 0 && now - row.created < 7 * DAY_MS)
|
|
241
|
+
.map(row => ({ at: row.created, n: row.images ?? 0 }))
|
|
242
|
+
const isUsageTick = ticks++ % USAGE_EVERY === 0
|
|
243
|
+
if (isUsageTick) usage = await readUsage($).catch(() => usage)
|
|
244
|
+
rows = rows.slice(0, SHOWN)
|
|
245
|
+
|
|
246
|
+
const running = rows.filter(row => row.status === 'running').length
|
|
247
|
+
if (running !== lastRunning) {
|
|
248
|
+
lastRunning = running
|
|
249
|
+
$.ui.status(running > 0 ? `codex: ${running} running` : undefined)
|
|
250
|
+
}
|
|
251
|
+
if (running > 0 && !hasAutoOpened) {
|
|
252
|
+
hasAutoOpened = true
|
|
253
|
+
void $.ui.open({ id: PANE, title: 'Codex jobs' })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const key = JSON.stringify([rows, images, usage]) + (problem ?? '')
|
|
257
|
+
if (running > 0 || isUsageTick || key !== last) {
|
|
258
|
+
last = key
|
|
259
|
+
await update($, snapshot, () => ({ rows, images, usage, at: now, problem }))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Tells the person when a job they were watching ends.
|
|
264
|
+
const wasRunning = new Set<string>()
|
|
265
|
+
function announce($: EngineInterface, rows: JobRow[]) {
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
if (row.status === 'running') {
|
|
268
|
+
wasRunning.add(row.id)
|
|
269
|
+
} else if (wasRunning.delete(row.id) && isFinal(row.status)) {
|
|
270
|
+
const verb = row.status === 'done' ? 'finished' : row.status.replace('_', ' ')
|
|
271
|
+
$.ui.toast(`Codex ${row.tool.replace('codex_', '')} ${verb}: ${row.label.slice(0, 50)}`)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const register: Register = on => {
|
|
277
|
+
on('session.start', async ($, e, next) => {
|
|
278
|
+
await $.command.register({
|
|
279
|
+
name: 'codex-jobs',
|
|
280
|
+
description: 'Show your codex-bridge jobs in a pane',
|
|
281
|
+
})
|
|
282
|
+
timer?.cancel()
|
|
283
|
+
const run = () =>
|
|
284
|
+
tick($)
|
|
285
|
+
.then(async () => announce($, (await read($, snapshot)).rows))
|
|
286
|
+
.catch(() => {})
|
|
287
|
+
void run()
|
|
288
|
+
timer = $.clock.every(POLL_MS, () => void run())
|
|
289
|
+
|
|
290
|
+
return next(e)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
on('command.run', { command: 'codex-jobs' }, async $ => {
|
|
294
|
+
await tick($).catch(() => {})
|
|
295
|
+
await $.ui.open({ id: PANE, title: 'Codex jobs' })
|
|
296
|
+
|
|
297
|
+
return { text: 'Codex jobs pane opened.' }
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
301
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
302
|
+
const snap = await read($, snapshot)
|
|
303
|
+
const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 14) / 2))
|
|
304
|
+
const running = snap.rows.filter(row => row.status === 'running').length
|
|
305
|
+
const interrupted = snap.rows.filter(row => row.status === 'interrupted')
|
|
306
|
+
const windows = [
|
|
307
|
+
snap.usage?.primary && { label: '5-hour', ...snap.usage.primary },
|
|
308
|
+
snap.usage?.secondary && { label: 'Weekly', ...snap.usage.secondary },
|
|
309
|
+
].filter((w): w is UsageWindow & { label: string } => !!w)
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<Box flexDirection="column" gap={1}>
|
|
313
|
+
<Box gap={1}>
|
|
314
|
+
<Text bold>Codex jobs</Text>
|
|
315
|
+
<Text dimColor>
|
|
316
|
+
{running} running, {snap.rows.length} shown
|
|
317
|
+
</Text>
|
|
318
|
+
</Box>
|
|
319
|
+
<Box flexDirection="column">
|
|
320
|
+
<Text bold>Codex usage</Text>
|
|
321
|
+
{windows.length === 0 && <Text dimColor>No usage reading yet. Codex records one after its first run.</Text>}
|
|
322
|
+
{windows.map(w => {
|
|
323
|
+
const isReset = w.resetsAt <= snap.at
|
|
324
|
+
// Images are counted from the start of this window, by when each job began.
|
|
325
|
+
const since = w.resetsAt - w.windowMinutes * 60_000
|
|
326
|
+
const imagesNow = snap.images.filter(i => i.at >= since).reduce((sum, i) => sum + i.n, 0)
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Box flexDirection="column">
|
|
330
|
+
<Box gap={1}>
|
|
331
|
+
<Text>{w.label}</Text>
|
|
332
|
+
<Text color={w.usedPercent >= 90 ? 'red' : w.usedPercent >= 70 ? 'yellow' : 'green'}>
|
|
333
|
+
{bar(isReset ? 0 : w.usedPercent)} {isReset ? 0 : Math.round(w.usedPercent)}%
|
|
334
|
+
</Text>
|
|
335
|
+
<Text dimColor>{isReset ? 'window has reset' : `resets in ${until(w.resetsAt - snap.at)}`}</Text>
|
|
336
|
+
</Box>
|
|
337
|
+
<Text dimColor>
|
|
338
|
+
{' '}images this window: {isReset ? 0 : imagesNow}
|
|
339
|
+
</Text>
|
|
340
|
+
</Box>
|
|
341
|
+
)
|
|
342
|
+
})}
|
|
343
|
+
{windows.length > 0 && (
|
|
344
|
+
<Text dimColor>
|
|
345
|
+
Images share these limits; Codex has no separate image meter.
|
|
346
|
+
{snap.usage?.asOf ? ` Read ${ago(snap.at - snap.usage.asOf)} ago.` : ''}
|
|
347
|
+
</Text>
|
|
348
|
+
)}
|
|
349
|
+
</Box>
|
|
350
|
+
{snap.problem && <Text color="red">{snap.problem}</Text>}
|
|
351
|
+
{!snap.problem && snap.rows.length === 0 && (
|
|
352
|
+
<Text dimColor>
|
|
353
|
+
No Codex jobs yet. They appear here when Claude calls codex_plan, codex_code or codex_image.
|
|
354
|
+
</Text>
|
|
355
|
+
)}
|
|
356
|
+
{snap.rows.slice(0, room).map(row => {
|
|
357
|
+
const age = ago((row.finished ?? snap.at) - row.created)
|
|
358
|
+
const extra = [
|
|
359
|
+
row.tool === 'codex_image' ? `images ${row.images ?? 0}${row.count ? `/${row.count}` : ''}` : '',
|
|
360
|
+
row.attempts > 1 ? `attempt ${row.attempts}` : '',
|
|
361
|
+
]
|
|
362
|
+
.filter(Boolean)
|
|
363
|
+
.join(', ')
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<Box flexDirection="column">
|
|
367
|
+
<Box gap={1}>
|
|
368
|
+
<Text color={COLOR[row.status]}>{ICON[row.status]}</Text>
|
|
369
|
+
<Text bold>{row.tool.replace('codex_', '')}</Text>
|
|
370
|
+
<Text color={COLOR[row.status]}>{row.status.replace('_', ' ')}</Text>
|
|
371
|
+
<Text dimColor>{age}</Text>
|
|
372
|
+
{extra && <Text dimColor>{extra}</Text>}
|
|
373
|
+
</Box>
|
|
374
|
+
<Text wrap="truncate-end" dimColor>
|
|
375
|
+
{row.label || row.id}
|
|
376
|
+
</Text>
|
|
377
|
+
</Box>
|
|
378
|
+
)
|
|
379
|
+
})}
|
|
380
|
+
{snap.rows.length > room && <Text dimColor>+{snap.rows.length - room} older</Text>}
|
|
381
|
+
{interrupted.length > 0 && (
|
|
382
|
+
<Text dimColor>
|
|
383
|
+
Interrupted: ask Claude to run codex_resume with job_id {interrupted[0]?.id}
|
|
384
|
+
{interrupted.length > 1 ? ` (and ${interrupted.length - 1} more)` : ''}.
|
|
385
|
+
</Text>
|
|
386
|
+
)}
|
|
387
|
+
</Box>
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type JobStatus = 'running' | 'done' | 'failed' | 'interrupted' | 'cancelled' | 'timed_out'
|
|
2
|
+
|
|
3
|
+
export type JobRow = {
|
|
4
|
+
id: string
|
|
5
|
+
tool: string
|
|
6
|
+
status: JobStatus
|
|
7
|
+
// Milliseconds since the epoch.
|
|
8
|
+
created: number
|
|
9
|
+
// Set once the job has a result.
|
|
10
|
+
finished?: number
|
|
11
|
+
// The request or prompt, one line.
|
|
12
|
+
label: string
|
|
13
|
+
// The folder the job works in (cwd for plans and code, output_dir for images).
|
|
14
|
+
where: string
|
|
15
|
+
attempts: number
|
|
16
|
+
// Image jobs only: saved so far, and how many were asked for.
|
|
17
|
+
images?: number
|
|
18
|
+
count?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One of Codex's usage windows, as its last session log reported it.
|
|
22
|
+
export type UsageWindow = {
|
|
23
|
+
usedPercent: number
|
|
24
|
+
windowMinutes: number
|
|
25
|
+
// Milliseconds since the epoch.
|
|
26
|
+
resetsAt: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Usage = {
|
|
30
|
+
// The 5-hour window and the weekly one. Codex reports one shared meter: images draw on it too.
|
|
31
|
+
primary?: UsageWindow
|
|
32
|
+
secondary?: UsageWindow
|
|
33
|
+
// When Codex wrote the reading.
|
|
34
|
+
asOf: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Images the bridge saved, by when the job started; the pane counts them per usage window.
|
|
38
|
+
export type ImageTally = { at: number; n: number }
|
|
39
|
+
|
|
40
|
+
export type JobsSnapshot = {
|
|
41
|
+
rows: JobRow[]
|
|
42
|
+
images: ImageTally[]
|
|
43
|
+
usage?: Usage
|
|
44
|
+
// When the snapshot was read; the pane's elapsed times count up from it.
|
|
45
|
+
at: number
|
|
46
|
+
// Set when the jobs folder could not be read.
|
|
47
|
+
problem?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
declare module 'claude-code' {
|
|
51
|
+
interface PluginState {
|
|
52
|
+
'codex-jobs': { snapshot: Shaped<JobsSnapshot> }
|
|
53
|
+
}
|
|
54
|
+
}
|
package/runner.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs one attempt of a codex-bridge job, detached from Claude Code, so the job
|
|
3
|
+
// survives Claude closing or crashing. Usage: node runner.mjs <jobId> <attempt> [bridgeHome]
|
|
4
|
+
//
|
|
5
|
+
// Reads jobs/<id>/job.json, attempt-<n>/spec.json, attempt-<n>/prompt.txt
|
|
6
|
+
// Writes attempt-<n>/events.jsonl, stderr.txt, state.json, heartbeat, result.json
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const [jobId, n, bridgeHome] = process.argv.slice(2);
|
|
13
|
+
// On Windows the runner is started through WMI and does not inherit the server's environment,
|
|
14
|
+
// so the server passes its job storage folder as an argument. lib.mjs reads it at import time.
|
|
15
|
+
if (bridgeHome) process.env.CODEX_BRIDGE_HOME = bridgeHome;
|
|
16
|
+
const {
|
|
17
|
+
EventTail, attemptDir, collectImages, jobDir, jobImages, killTree, readJson, writeJson,
|
|
18
|
+
} = await import('./lib.mjs');
|
|
19
|
+
|
|
20
|
+
const dir = attemptDir(jobId, n);
|
|
21
|
+
const job = readJson(path.join(jobDir(jobId), 'job.json'));
|
|
22
|
+
const spec = readJson(path.join(dir, 'spec.json'));
|
|
23
|
+
const IDLE_TIMEOUT_MS = spec.idleTimeoutMs;
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
|
|
26
|
+
const eventsFile = path.join(dir, 'events.jsonl');
|
|
27
|
+
const stdin = fs.openSync(path.join(dir, 'prompt.txt'), 'r');
|
|
28
|
+
const stdout = fs.openSync(eventsFile, 'a');
|
|
29
|
+
const stderr = fs.openSync(path.join(dir, 'stderr.txt'), 'a');
|
|
30
|
+
|
|
31
|
+
const child = spawn(spec.bin, spec.args, {
|
|
32
|
+
cwd: spec.cwd,
|
|
33
|
+
env: { ...process.env, ...spec.env },
|
|
34
|
+
stdio: [stdin, stdout, stderr],
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
});
|
|
37
|
+
writeJson(path.join(dir, 'state.json'), { runnerPid: process.pid, codexPid: child.pid, started });
|
|
38
|
+
|
|
39
|
+
const beat = () => fs.writeFileSync(path.join(dir, 'heartbeat'), String(Date.now()));
|
|
40
|
+
beat();
|
|
41
|
+
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
let timedOut = false;
|
|
44
|
+
let lastSize = 0;
|
|
45
|
+
let lastGrowth = Date.now();
|
|
46
|
+
const tail = new EventTail(eventsFile);
|
|
47
|
+
|
|
48
|
+
const watchdog = setInterval(() => {
|
|
49
|
+
beat();
|
|
50
|
+
// Copy images out as they are generated, so a crash loses at most the one in flight.
|
|
51
|
+
if (job.tool === 'codex_image') {
|
|
52
|
+
try { collectImages(job, tail.poll().threadId); } catch {}
|
|
53
|
+
}
|
|
54
|
+
if (!cancelled && fs.existsSync(path.join(jobDir(jobId), 'cancel'))) {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
killTree(child.pid);
|
|
57
|
+
}
|
|
58
|
+
const size = fs.existsSync(eventsFile) ? fs.statSync(eventsFile).size : 0;
|
|
59
|
+
if (size !== lastSize) { lastSize = size; lastGrowth = Date.now(); }
|
|
60
|
+
if (!timedOut && Date.now() - lastGrowth > IDLE_TIMEOUT_MS) {
|
|
61
|
+
timedOut = true;
|
|
62
|
+
killTree(child.pid);
|
|
63
|
+
}
|
|
64
|
+
}, 2000);
|
|
65
|
+
|
|
66
|
+
function finish(exitCode, spawnError) {
|
|
67
|
+
clearInterval(watchdog);
|
|
68
|
+
const s = new EventTail(eventsFile).poll();
|
|
69
|
+
const status = cancelled ? 'cancelled'
|
|
70
|
+
: timedOut ? 'timed_out'
|
|
71
|
+
: spawnError ? 'failed'
|
|
72
|
+
: s.completed && !s.failed ? 'done'
|
|
73
|
+
: 'failed';
|
|
74
|
+
|
|
75
|
+
let images;
|
|
76
|
+
if (job.tool === 'codex_image') {
|
|
77
|
+
collectImages(job, s.threadId, { settled: false });
|
|
78
|
+
images = jobImages(job);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let stderrTail = '';
|
|
82
|
+
try { stderrTail = fs.readFileSync(path.join(dir, 'stderr.txt'), 'utf8').slice(-4000).trim(); } catch {}
|
|
83
|
+
|
|
84
|
+
writeJson(path.join(dir, 'result.json'), {
|
|
85
|
+
status,
|
|
86
|
+
exitCode,
|
|
87
|
+
threadId: s.threadId,
|
|
88
|
+
message: s.messages.at(-1) ?? null,
|
|
89
|
+
failed: s.failed,
|
|
90
|
+
errors: s.errors,
|
|
91
|
+
usage: s.usage,
|
|
92
|
+
images,
|
|
93
|
+
stderrTail: status === 'done' ? '' : stderrTail,
|
|
94
|
+
spawnError: spawnError?.message,
|
|
95
|
+
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
|
96
|
+
started,
|
|
97
|
+
finished: Date.now(),
|
|
98
|
+
});
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
child.on('error', err => finish(null, err));
|
|
103
|
+
child.on('exit', code => finish(code));
|