@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.
@@ -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));