@lyupro/codex-bridge 0.1.4 → 0.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -4
  3. package/bin/codex-bridge.mjs +37 -0
  4. package/cli/doctor.mjs +20 -2
  5. package/cli/install.mjs +18 -4
  6. package/cli/manifest.mjs +3 -1
  7. package/cli/projects.mjs +92 -0
  8. package/cli/prune-args.mjs +133 -0
  9. package/cli/prune-plan.mjs +205 -0
  10. package/cli/prune.mjs +278 -0
  11. package/cli/read.mjs +103 -0
  12. package/cli/run-lookup.mjs +59 -0
  13. package/cli/runs-inventory.mjs +173 -0
  14. package/cli/stop.mjs +4 -37
  15. package/cli/sweep.mjs +144 -0
  16. package/cli/table.mjs +110 -0
  17. package/package.json +1 -1
  18. package/src/agents/codex-build.md +7 -6
  19. package/src/agents/codex-review.md +8 -8
  20. package/src/agents/codex-scout.md +7 -6
  21. package/src/commands/usage.md +4 -3
  22. package/src/heartbeat.mjs +80 -0
  23. package/src/hook-definitions.mjs +11 -0
  24. package/src/hooks/live-runs.mjs +13 -6
  25. package/src/hooks/prune-guard.mjs +62 -0
  26. package/src/hooks/reply-guard.mjs +2 -2
  27. package/src/hooks/worktree-lock.mjs +8 -1
  28. package/src/meta/deadline.mjs +19 -0
  29. package/src/meta/events.mjs +166 -0
  30. package/src/meta/reason.mjs +29 -0
  31. package/src/meta/reply.mjs +40 -7
  32. package/src/meta/run-state.mjs +25 -2
  33. package/src/meta/startup.mjs +21 -0
  34. package/src/meta/transport.mjs +35 -0
  35. package/src/meta/verdict.mjs +40 -42
  36. package/src/no-self-execution.mjs +18 -0
  37. package/src/retention.mjs +120 -0
  38. package/src/run-codex.mjs +3 -3
  39. package/src/run-config.json +5 -1
  40. package/src/run-config.mjs +54 -1
  41. package/src/runner/attach.mjs +1 -1
  42. package/src/runner/codex-cmd.mjs +158 -31
  43. package/src/runner/launcher.mjs +11 -0
  44. package/src/runner/worker.mjs +14 -3
  45. package/src/write-meta.mjs +31 -22
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Everything about invoking the `codex` CLI: which flags a run gets, whether the CLI is
3
- * there at all, and how its output reaches raw.log.
3
+ * there at all, and how its JSONL stdout reaches events.jsonl while stderr stays separate.
4
4
  *
5
5
  * The flag sets below are the ones the three agent files carried before, verbatim.
6
6
  * Sandboxes especially must not drift: codex-scout is read-only through
@@ -11,10 +11,18 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { spawn, spawnSync } from 'node:child_process';
14
+ import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
14
15
  import { writeFailure } from '../write-meta.mjs';
15
16
  import { MAX_LOG } from './git-state.mjs';
16
17
  import { CLEAN_ENV, RUN_ENV } from './run-env.mjs';
17
18
 
19
+ // Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
20
+ // last, and that tail carries `item.completed` — the result the verdict is read from. A quarter
21
+ // of a second is enough to lose it on a loaded Windows host, turning a finished run into "the run
22
+ // left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
23
+ // run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
24
+ const STDIO_DRAIN_GRACE_MS = 30_000;
25
+
18
26
  /**
19
27
  * An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
20
28
  * expands variables even inside quotes. The launcher asks first, so the refusal costs
@@ -47,36 +55,81 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
47
55
  * shell. One command line through cmd.exe keeps a single code path; every argument is
48
56
  * quoted here, and unsafeForCmd() has already refused the ones that cannot be.
49
57
  *
50
- * The log is streamed, not collected: spawnSync kept every byte in memory and wrote the file
51
- * only on return, so a worker killed at minute twelve left raw.log missing entirely and the
52
- * run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said is
53
- * on disk, and the task text is pushed into stdin by hand since there is no `input` option
54
- * outside spawnSync. MAX_LOG stays the ceiling; past it the log is cut with a note rather
55
- * than the run being killed — a huge log is a diagnosis, not a reason to lose the work.
58
+ * Stdout is streamed, not collected: spawnSync kept every byte in memory and wrote the file
59
+ * only on return, so a worker killed at minute twelve left events.jsonl missing entirely and
60
+ * the run became unexplainable (2026-07-31, run 114736). Now whatever Codex has already said
61
+ * is on disk, and the task text is pushed into stdin by hand since there is no `input` option
62
+ * outside spawnSync. MAX_LOG stays the ceiling; past it the stream is cut rather than the run
63
+ * being killed — a huge stream is a diagnosis, not a reason to lose the work. stderr has its
64
+ * own bounded file because text outside the JSONL protocol must remain distinguishable from
65
+ * protocol events, and a line cut in half cannot be parsed back safely.
56
66
  */
57
- export function runCodex(args, taskText, logPath, budgetMinutes) {
67
+ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
58
68
  const onWindows = process.platform === 'win32';
59
69
  const deadlineMs =
60
70
  typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
61
71
  ? budgetMinutes * 60 * 1000
62
72
  : null;
63
- const log = fs.createWriteStream(logPath, { flags: 'a' });
64
- let written = 0;
65
- let cut = false;
66
- const append = (chunk) => {
67
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
68
- if (written >= MAX_LOG) {
69
- if (!cut) {
70
- cut = true;
71
- log.write(`\nrun-codex: log truncated at ${MAX_LOG} bytes; further output is not saved\n`);
73
+ let exited = false;
74
+ const heartbeat = createHeartbeat(path.dirname(eventsPath));
75
+ const stampHeartbeat = () => {
76
+ if (!exited) heartbeat.stamp();
77
+ };
78
+ const stderrLog = fs.createWriteStream(path.join(path.dirname(eventsPath), 'stderr.log'), { flags: 'a' });
79
+ const eventsLog = fs.createWriteStream(eventsPath, { flags: 'a' });
80
+ const appendTo = (stream, label) => {
81
+ let written = 0;
82
+ let cut = false;
83
+ return (chunk) => {
84
+ stampHeartbeat();
85
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
86
+ if (written >= MAX_LOG) {
87
+ if (!cut) {
88
+ cut = true;
89
+ stream.write(`\nrun-codex: ${label} truncated at ${MAX_LOG} bytes; further output is not saved\n`);
90
+ }
91
+ return;
72
92
  }
73
- return;
74
- }
75
- const room = MAX_LOG - written;
76
- const slice = buf.length > room ? buf.subarray(0, room) : buf;
77
- written += slice.length;
78
- log.write(slice);
93
+ const room = MAX_LOG - written;
94
+ const slice = buf.length > room ? buf.subarray(0, room) : buf;
95
+ written += slice.length;
96
+ stream.write(slice);
97
+ };
79
98
  };
99
+ const appendStderr = appendTo(stderrLog, 'stderr log');
100
+ const appendEvents = (() => {
101
+ let pending = Buffer.alloc(0);
102
+ let written = 0;
103
+ let cut = false;
104
+ const append = (chunk) => {
105
+ stampHeartbeat();
106
+ if (cut) return;
107
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
108
+ pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
109
+ let newline = pending.indexOf(0x0a);
110
+ while (newline !== -1) {
111
+ const line = pending.subarray(0, newline + 1);
112
+ pending = pending.subarray(newline + 1);
113
+ if (written + line.length > MAX_LOG) {
114
+ cut = true;
115
+ pending = Buffer.alloc(0);
116
+ return;
117
+ }
118
+ written += line.length;
119
+ eventsLog.write(line);
120
+ newline = pending.indexOf(0x0a);
121
+ }
122
+ if (pending.length > MAX_LOG - written) {
123
+ cut = true;
124
+ pending = Buffer.alloc(0);
125
+ }
126
+ };
127
+ const finish = () => {
128
+ if (!cut && pending.length && written + pending.length <= MAX_LOG) eventsLog.write(pending);
129
+ pending = Buffer.alloc(0);
130
+ };
131
+ return { append, finish };
132
+ })();
80
133
 
81
134
  let child;
82
135
  if (onWindows) {
@@ -97,12 +150,35 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
97
150
  return new Promise((resolve) => {
98
151
  let failed = false;
99
152
  let deadlineTimer;
153
+ let heartbeatTimer;
154
+ let stdioGraceTimer;
155
+ let stoppedOnDeadline = false;
156
+ let exitCode;
157
+ let settled = false;
158
+ let elapsedMs = 0;
100
159
  const startedAt = Date.now();
101
- child.stdout.on('data', append);
102
- child.stderr.on('data', append);
160
+ stampHeartbeat();
161
+ heartbeatTimer = setInterval(stampHeartbeat, HEARTBEAT_INTERVAL_MS);
162
+ child.stdout.on('data', appendEvents.append);
163
+ child.stdout.on('end', appendEvents.finish);
164
+ child.stderr.on('data', (chunk) => {
165
+ appendStderr(chunk);
166
+ });
167
+ // A log file that cannot be opened must not leave Codex spending quota unattended. Without
168
+ // a listener the stream's 'error' event throws inside the worker, and the crash handler
169
+ // closes the run folder while the CLI keeps running — the orphan of 2026-07-31 with an
170
+ // extra step. The run dies, but it dies with its child: killing first, recording second.
171
+ const onStreamError = (which) => (err) => {
172
+ failed = true;
173
+ const note = `\nrun-codex: cannot write ${which}: ${err.message}\n`;
174
+ if (which !== 'stderr.log' && stderrLog.writable) stderrLog.write(note);
175
+ stopCodex(child, onWindows);
176
+ };
177
+ stderrLog.on('error', onStreamError('stderr.log'));
178
+ eventsLog.on('error', onStreamError('events.jsonl'));
103
179
  child.on('error', (err) => {
104
180
  failed = true;
105
- append(`\nrun-codex: ${err.message}\n`);
181
+ appendStderr(`\nrun-codex: ${err.message}\n`);
106
182
  });
107
183
  // Codex that dies before reading the order leaves an EPIPE here; the exit code and the
108
184
  // log already say what happened, and an unhandled stream error would replace that with
@@ -112,16 +188,64 @@ export function runCodex(args, taskText, logPath, budgetMinutes) {
112
188
  if (deadlineMs !== null) {
113
189
  deadlineTimer = setTimeout(() => {
114
190
  const elapsed = Date.now() - startedAt;
115
- log.write(
116
- `\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
117
- );
191
+ // The tree is stopped either way — a grandchild still holding stdio is exactly what
192
+ // the budget exists to end — but only a process that was still alive was killed by
193
+ // the deadline, and only that run may say so.
194
+ if (!exited) {
195
+ stoppedOnDeadline = true;
196
+ elapsedMs = elapsed;
197
+ stderrLog.write(
198
+ `\nrun-codex: run stopped on its deadline after ${elapsed} ms (budget ${deadlineMs} ms)\n`,
199
+ );
200
+ }
118
201
  stopCodex(child, onWindows);
119
202
  }, deadlineMs);
120
203
  }
121
- child.on('close', (code) => {
204
+ // A stream that already failed never calls back from end(), so waiting on both callbacks in
205
+ // sequence hangs the worker until its own deadline — the run then costs a full budget and
206
+ // answers nothing. Closing is best-effort; the verdict is not.
207
+ const close = (stream) =>
208
+ new Promise((done) => {
209
+ if (stream.destroyed || stream.errored) {
210
+ stream.destroy();
211
+ done();
212
+ return;
213
+ }
214
+ stream.end(done);
215
+ });
216
+
217
+ // 'exit' fires when Codex is gone; 'close' waits for stdio, which the 2026-08-06 incident
218
+ // showed a grandchild can hold open forever. A bounded drain grace keeps normal logs intact,
219
+ // then closes the run with an explicit marker instead of waiting for an unbounded pipe.
220
+ const finish = async (code, stdioDrained) => {
221
+ if (settled) return;
222
+ settled = true;
223
+ if (deadlineTimer) clearTimeout(deadlineTimer);
224
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
225
+ if (stdioGraceTimer) clearTimeout(stdioGraceTimer);
226
+ if (!stoppedOnDeadline) elapsedMs = Date.now() - startedAt;
227
+ const exit = failed || code === null ? 1 : code;
228
+ appendEvents.finish();
229
+ if (!stdioDrained) {
230
+ try { child.stdout.destroy(); } catch {}
231
+ try { child.stderr.destroy(); } catch {}
232
+ }
233
+ await close(stderrLog);
234
+ await close(eventsLog);
235
+ resolve({ exit, stoppedOnDeadline, elapsedMs, stdioDrained });
236
+ };
237
+
238
+ child.on('exit', (code) => {
239
+ exited = true;
240
+ exitCode = code;
122
241
  if (deadlineTimer) clearTimeout(deadlineTimer);
123
- log.end(() => resolve(failed || code === null ? 1 : code));
242
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
243
+ // The grace is an argument only so tests can prove the fallback without waiting it out;
244
+ // a run never passes one, because a per-run grace is a second place to configure a
245
+ // contract that must be identical for every run.
246
+ stdioGraceTimer = setTimeout(() => finish(exitCode, false), graceMs);
124
247
  });
248
+ child.on('close', (code) => finish(code, true));
125
249
  });
126
250
  }
127
251
 
@@ -193,6 +317,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
193
317
  if (opts.agent === 'codex-scout') {
194
318
  return [
195
319
  'exec',
320
+ '--json',
196
321
  ...CLEAN_ENV,
197
322
  '--ignore-user-config',
198
323
  '-c',
@@ -216,6 +341,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
216
341
  // every edit is rejected ("writing is blocked by read-only sandbox").
217
342
  return [
218
343
  'exec',
344
+ '--json',
219
345
  ...CLEAN_ENV,
220
346
  '-c',
221
347
  effort,
@@ -246,6 +372,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
246
372
  // --ignore-user-config, same as scout: review never needs write access.
247
373
  return [
248
374
  'exec',
375
+ '--json',
249
376
  ...CLEAN_ENV,
250
377
  '--ignore-user-config',
251
378
  '-c',
@@ -36,6 +36,7 @@ import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-s
36
36
  import { codexArgs, requireCodex, runMode, unsafeForCmd } from './codex-cmd.mjs';
37
37
  import { runsRoot } from './runs-root.mjs';
38
38
  import { resolveProjectRunsDir } from './project-dir.mjs';
39
+ import { cleanupRetention } from '../retention.mjs';
39
40
 
40
41
  /**
41
42
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -166,6 +167,15 @@ export async function launcher() {
166
167
  // first one's edits, and an honest run gets failed for work it never did.
167
168
  const busy = opts.agent === 'codex-build' ? activeRun(projectRunsRoot, repoRoot) : null;
168
169
 
170
+ let retention = null;
171
+ try {
172
+ retention = cleanupRetention(projectRunsRoot, RUN_ENV?.retention);
173
+ } catch {
174
+ // Plan_17 step 4 makes retention advisory housekeeping: one broken filesystem call must never
175
+ // block a new run.
176
+ retention = null;
177
+ }
178
+
169
179
  const runDir = makeRunDir(path.join(projectRunsRoot, `${stamp()}_${opts.slug}`));
170
180
  setRun(runDir, opts.agent);
171
181
 
@@ -194,6 +204,7 @@ export async function launcher() {
194
204
  ...(chain.length ? { continues: chain[0] } : {}),
195
205
  // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
196
206
  ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
207
+ ...(retention ? { retention } : {}),
197
208
  });
198
209
 
199
210
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
+ import { writeStatus } from '../meta/run-state.mjs';
16
17
  import { setRun, emitReply } from './run-context.mjs';
17
18
  import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
18
19
  import { runCodex } from './codex-cmd.mjs';
@@ -26,12 +27,22 @@ export async function worker(runDir) {
26
27
  const repoRoot = cfg.repo;
27
28
  setRun(runDir, cfg.agent);
28
29
 
29
- const exit = await runCodex(
30
+ const run = await runCodex(
30
31
  cfg.args,
31
32
  fs.readFileSync(path.join(runDir, 'task.md'), 'utf8'),
32
- path.join(runDir, 'raw.log'),
33
+ path.join(runDir, 'events.jsonl'),
33
34
  cfg.budget_minutes,
34
35
  );
36
+ // This runner-owned fact cannot be forged through events.jsonl, which Codex can write.
37
+ // Written even when no deadline fired: `false` is the marker that a runner was watching,
38
+ // and an archived run with no field at all must stay distinguishable from a run that lived.
39
+ // `stdio_drained: false` separately records an exit that needed the grace fallback because
40
+ // a grandchild kept a pipe open, the distinction the 2026-08-06 incident made necessary.
41
+ writeStatus(runDir, {
42
+ stopped_on_deadline: run.stoppedOnDeadline,
43
+ elapsed_ms: run.elapsedMs,
44
+ stdio_drained: run.stdioDrained,
45
+ });
35
46
 
36
47
  if (cfg.agent === 'codex-build') {
37
48
  fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
@@ -56,7 +67,7 @@ export async function worker(runDir) {
56
67
 
57
68
  // collect() writes meta.json and closes status.json; reply.txt comes last, so the launcher
58
69
  // seeing a reply is proof the verdict behind it is already on disk.
59
- const { meta, reply } = collect(runDir, cfg.agent, exit);
70
+ const { meta, reply } = collect(runDir, cfg.agent, run.exit);
60
71
  emitReply(reply);
61
72
  process.exit(exitCodeFor(meta.status));
62
73
  }
@@ -3,9 +3,9 @@
3
3
  * Turns one Codex run directory into accounting plus a verdict:
4
4
  * node write-meta.mjs <runDir> <agent> <exitCode>
5
5
  *
6
- * This is the only entry point that reads a run's artifacts. It writes meta.json — quota
7
- * accounting that survives deletion of the gitignored raw.log — and derives the run
8
- * status from what is actually on disk: exit code, log size, result file, git state.
6
+ * This is the only entry point that reads a run's artifacts. It writes meta.json — accounting
7
+ * for the two transport files — and derives the run status from what is actually on disk:
8
+ * exit code, structured events, result file, git state.
9
9
  * A dispatcher agent therefore cannot report an outcome its own run does not support.
10
10
  *
11
11
  * The reply lines printed here ARE the reply. Agents forward this stdout verbatim
@@ -24,6 +24,7 @@ import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
26
26
  import { splitRunChanges } from './meta/environment.mjs';
27
+ import { readEvents } from './meta/events.mjs';
27
28
  import { writeStatus } from './meta/run-state.mjs';
28
29
  import { resolveStatus } from './meta/verdict.mjs';
29
30
  import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
@@ -49,24 +50,30 @@ export function collect(runDir, agent, exitCode) {
49
50
  const cfg = AGENTS[agent];
50
51
  if (!cfg) throw new Error(`unknown agent: ${agent} (expected one of ${Object.keys(AGENTS).join(', ')})`);
51
52
 
52
- const logPath = path.join(runDir, 'raw.log');
53
+ const eventsPath = path.join(runDir, 'events.jsonl');
54
+ const stderrPath = path.join(runDir, 'stderr.log');
53
55
  const resultPath = path.join(runDir, cfg.result);
54
- const log = readText(logPath);
55
- const logBytes = size(logPath);
56
+ const eventsBytes = size(eventsPath);
57
+ const stderrBytes = size(stderrPath);
56
58
  const result = readJson(resultPath);
57
59
  const resultOk = Boolean(result) && cfg.filled(result);
58
60
  const exit = exitCode === undefined || exitCode === null ? null : Number(exitCode);
61
+ const events = readEvents(runDir);
62
+ const worker = readJson(path.join(runDir, 'worker.json'));
59
63
 
60
- // "tokens used" is followed by the count on the next line, with a non-breaking
61
- // thousands separator — strip every non-digit rather than trusting the spacing.
62
- const usage = [...log.matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
63
-
64
- // `codex exec review` reports no usage at all. Null, never 0: a zero would silently
65
- // understate the total in /codex:usage.
66
- const tokens = usage ? parseInt(usage[1].replace(/\D/g, ''), 10) || null : null;
67
-
68
- const pick = (re) => (log.match(re) || [])[1] || null;
69
- const { status, reason, carried } = resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir });
64
+ const args = Array.isArray(worker?.args) ? worker.args : [];
65
+ const argValue = (flag) => {
66
+ const index = args.indexOf(flag);
67
+ return index >= 0 && typeof args[index + 1] === 'string' ? args[index + 1] : null;
68
+ };
69
+ const { status, reason, carried } = resolveStatus({
70
+ resultOk,
71
+ exit,
72
+ agent,
73
+ result,
74
+ runDir,
75
+ events,
76
+ });
70
77
 
71
78
  const meta = {
72
79
  agent,
@@ -88,14 +95,16 @@ export function collect(runDir, agent, exitCode) {
88
95
  changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
89
96
  ).environment,
90
97
  result_ok: resultOk,
91
- log_bytes: logBytes,
92
- tokens,
93
- tokens_reported: tokens !== null,
94
- model: pick(/^model:\s*(\S+)/m),
95
- sandbox: pick(/^sandbox:\s*(\S+)/m),
98
+ events_bytes: eventsBytes,
99
+ stderr_bytes: stderrBytes,
100
+ tokens: events.tokens,
101
+ tokens_reported: events.tokens !== null,
102
+ usage: events.usage,
103
+ model: argValue('-m'),
104
+ sandbox: argValue('--sandbox'),
96
105
  // Null for runs made before env.json existed: absence is not the same as "both off".
97
106
  env: readJson(path.join(runDir, 'env.json')),
98
- session_id: pick(/^session id:\s*(\S+)/m),
107
+ session_id: events.session_id,
99
108
  };
100
109
 
101
110
  fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);