open-research-protocol 0.4.28 → 0.4.29

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/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ There was no prior in-repo changelog file, so the first formal entry starts
6
6
  with the currently shipped `v0.4.4` release and summarizes the full release
7
7
  delta reflected in this repo.
8
8
 
9
+ ## v0.4.29 - 2026-04-25
10
+
11
+ This release adds ORP-managed Codex session tracking so starting a Codex thread
12
+ from a repo can automatically refresh the saved workspace session for that
13
+ directory.
14
+
15
+ ### Added
16
+
17
+ - Added `orp codex` as the default tracked Codex launcher: it resolves the repo
18
+ root, starts Codex there, watches local session metadata, and saves the new
19
+ Codex resume id into workspace `main`.
20
+ - Added `orp codex status` and `orp codex reconcile` for checking stale saved
21
+ Codex sessions and refreshing tracked workspace tabs from recent local Codex
22
+ session metadata.
23
+ - Added safeguards that ignore delegated/subagent sessions by default and refuse
24
+ broad roots or artifact-output repos unless explicitly overridden.
25
+
26
+ ### Changed
27
+
28
+ - Documented the ORP/Codex compatibility workflow and exposed the new wrapper
29
+ command in the npm CLI help surface.
30
+
9
31
  ## v0.4.28 - 2026-04-22
10
32
 
11
33
  This release tightens the ORP project-startup and research-spend loops, then
package/README.md CHANGED
@@ -459,6 +459,9 @@ orp workspace add-tab main --path /absolute/path/to/project --remote-url git@git
459
459
  orp workspace add-tab main --path /absolute/path/to/project --title "second active thread" --resume-tool claude --resume-session-id <id> --append
460
460
  orp workspace remove-tab main --path /absolute/path/to/project
461
461
  orp workspace sync main
462
+ orp codex status
463
+ orp codex reconcile --dry-run
464
+ orp codex --search
462
465
  orp secrets list --json
463
466
  orp secrets ensure --alias openai-primary --provider openai --current-project --json
464
467
  orp secrets keychain-add --alias openai-primary --provider openai --env-var-name OPENAI_API_KEY --value-stdin --json
@@ -466,6 +469,19 @@ orp secrets sync-keychain openai-primary --json
466
469
  orp schedule add codex --name morning-summary --prompt "Summarize this repo" --json
467
470
  ```
468
471
 
472
+ `orp codex` is a local compatibility layer for keeping workspace `main`
473
+ aligned with Codex sessions. `status` compares the current repo against the
474
+ latest local Codex session metadata, `reconcile` refreshes stale saved sessions,
475
+ and bare `orp codex` launches Codex from the repo root while watching for the new
476
+ session id. Delegated/subagent sessions are ignored by default, and
477
+ artifact-output repos should be left untracked when a separate lab repo is the
478
+ source of truth. Use `--` before Codex args that conflict with ORP wrapper
479
+ options. The manual fallback from inside Codex is still:
480
+
481
+ ```bash
482
+ orp workspace add-tab main --here --current-codex
483
+ ```
484
+
469
485
  For secrets, the simplest plain-English rule is:
470
486
 
471
487
  - `orp secrets ensure ...` = use the saved key if it already exists, or ask for it and create it if it does not
package/bin/orp.js CHANGED
@@ -9,6 +9,9 @@ const computeCliUrl = pathToFileURL(path.resolve(__dirname, "orp-compute.mjs")).
9
9
  const workspaceCliUrl = pathToFileURL(
10
10
  path.resolve(__dirname, "..", "packages", "orp-workspace-launcher", "src", "orp-command.js"),
11
11
  ).href;
12
+ const codexCliUrl = pathToFileURL(
13
+ path.resolve(__dirname, "..", "packages", "orp-workspace-launcher", "src", "codex.js"),
14
+ ).href;
12
15
  const argv = process.argv.slice(2);
13
16
 
14
17
  const candidates = [];
@@ -36,6 +39,12 @@ async function runWorkspace(args) {
36
39
  process.exit(code == null ? 0 : code);
37
40
  }
38
41
 
42
+ async function runCodex(args) {
43
+ const mod = await import(codexCliUrl);
44
+ const code = await mod.runOrpCodexCommand(args);
45
+ process.exit(code == null ? 0 : code);
46
+ }
47
+
39
48
  function runPythonCli(args, { captureOutput }) {
40
49
  let lastErr = null;
41
50
 
@@ -58,7 +67,7 @@ function runPythonCli(args, { captureOutput }) {
58
67
  process.stderr.write(result.stderr);
59
68
  }
60
69
  if (result.status === 0) {
61
- process.stdout.write("\nAdditional wrapper surface:\n orp compute -h\n orp workspace tabs -h\n orp workspace hygiene --json\n");
70
+ process.stdout.write("\nAdditional wrapper surface:\n orp compute -h\n orp workspace tabs -h\n orp workspace hygiene --json\n orp codex status -h\n");
62
71
  }
63
72
  }
64
73
  process.exit(result.status == null ? 1 : result.status);
@@ -91,6 +100,10 @@ async function main() {
91
100
  await runWorkspace(argv.slice(1));
92
101
  return;
93
102
  }
103
+ if (argv[0] === "codex") {
104
+ await runCodex(argv.slice(1));
105
+ return;
106
+ }
94
107
 
95
108
  runPythonCli(argv, { captureOutput: isTopLevelHelp(argv) });
96
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "ORP CLI (Open Research Protocol): workspace ledgers, secrets, scheduling, governed execution, and agent-friendly research workflows.",
5
5
  "license": "MIT",
6
6
  "author": "Fractal Research Group <cody@frg.earth>",
@@ -0,0 +1,822 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+
7
+ import { buildLaunchPlan, getResumeCommand, parseWorkspaceSource } from "./core-plan.js";
8
+ import { applyWorkspaceAddTabOptions } from "./ledger.js";
9
+ import { loadWorkspaceSource } from "./orp.js";
10
+
11
+ const DEFAULT_WORKSPACE = "main";
12
+ const DEFAULT_SCAN_DAYS = 30;
13
+ const SESSION_READ_BYTES = 64 * 1024;
14
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+
16
+ function normalizeOptionalString(value) {
17
+ if (value == null) {
18
+ return null;
19
+ }
20
+ const trimmed = String(value).trim();
21
+ return trimmed.length > 0 ? trimmed : null;
22
+ }
23
+
24
+ function normalizePath(value) {
25
+ const normalized = normalizeOptionalString(value);
26
+ return normalized ? path.resolve(normalized) : null;
27
+ }
28
+
29
+ function pathContains(parent, child) {
30
+ const normalizedParent = normalizePath(parent);
31
+ const normalizedChild = normalizePath(child);
32
+ if (!normalizedParent || !normalizedChild) {
33
+ return false;
34
+ }
35
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
36
+ }
37
+
38
+ function resolveCodexHome(options = {}) {
39
+ return (
40
+ normalizeOptionalString(options.codexHome) ||
41
+ normalizeOptionalString(process.env.CODEX_HOME) ||
42
+ path.join(os.homedir(), ".codex")
43
+ );
44
+ }
45
+
46
+ function defaultForbiddenRoots() {
47
+ return new Set(
48
+ [
49
+ path.parse(process.cwd()).root,
50
+ os.homedir(),
51
+ "/Volumes/Code_2TB/code",
52
+ normalizeOptionalString(process.env.ORP_CODE_ROOT),
53
+ ]
54
+ .map((entry) => (entry ? path.resolve(entry) : null))
55
+ .filter(Boolean),
56
+ );
57
+ }
58
+
59
+ function isArtifactOutputRepo(repoRoot) {
60
+ const base = path.basename(String(repoRoot || ""));
61
+ return /(^|-)artifacts?$/i.test(base) || /-artifacts?-/i.test(base);
62
+ }
63
+
64
+ export function isDelegatedCodexSession(session) {
65
+ const originator = normalizeOptionalString(session?.originator)?.toLowerCase();
66
+ if (originator === "clawdad" || originator?.includes("delegate")) {
67
+ return true;
68
+ }
69
+ const source = session?.source;
70
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
71
+ return false;
72
+ }
73
+ if (source.subagent || source.delegate || source.delegated) {
74
+ return true;
75
+ }
76
+ const sourceText = JSON.stringify(source).toLowerCase();
77
+ return sourceText.includes("subagent") || sourceText.includes("delegate");
78
+ }
79
+
80
+ export function parseCodexSessionMetaLine(line, filePath, stat = {}) {
81
+ let row;
82
+ try {
83
+ row = JSON.parse(line);
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (!row || row.type !== "session_meta" || !row.payload || typeof row.payload !== "object") {
88
+ return null;
89
+ }
90
+
91
+ const payload = row.payload;
92
+ const sessionId = normalizeOptionalString(payload.id);
93
+ const cwd = normalizeOptionalString(payload.cwd);
94
+ if (!sessionId || !SESSION_ID_PATTERN.test(sessionId) || !cwd) {
95
+ return null;
96
+ }
97
+
98
+ const timestamp = normalizeOptionalString(payload.timestamp) || normalizeOptionalString(row.timestamp);
99
+ const timestampMs = timestamp ? Date.parse(timestamp) : 0;
100
+ return {
101
+ sessionId,
102
+ cwd: path.resolve(cwd),
103
+ timestamp,
104
+ timestampMs: Number.isFinite(timestampMs) ? timestampMs : 0,
105
+ updatedMs: typeof stat.mtimeMs === "number" ? stat.mtimeMs : 0,
106
+ filePath,
107
+ originator: normalizeOptionalString(payload.originator),
108
+ cliVersion: normalizeOptionalString(payload.cli_version ?? payload.cliVersion),
109
+ source: payload.source && typeof payload.source === "object" && !Array.isArray(payload.source) ? payload.source : null,
110
+ };
111
+ }
112
+
113
+ async function readFirstSessionMetaLine(filePath) {
114
+ let handle;
115
+ try {
116
+ handle = await fs.open(filePath, "r");
117
+ const buffer = Buffer.alloc(SESSION_READ_BYTES);
118
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
119
+ if (bytesRead <= 0) {
120
+ return "";
121
+ }
122
+ const chunk = buffer.subarray(0, bytesRead).toString("utf8");
123
+ for (const line of chunk.split("\n")) {
124
+ if (!line.trim()) {
125
+ continue;
126
+ }
127
+ let row;
128
+ try {
129
+ row = JSON.parse(line);
130
+ } catch {
131
+ continue;
132
+ }
133
+ if (row?.type === "session_meta") {
134
+ return line;
135
+ }
136
+ }
137
+ return "";
138
+ } catch {
139
+ return "";
140
+ } finally {
141
+ if (handle) {
142
+ await handle.close().catch(() => {});
143
+ }
144
+ }
145
+ }
146
+
147
+ async function walkSessionFiles(rootDir, options = {}, files = []) {
148
+ let entries;
149
+ try {
150
+ entries = await fs.readdir(rootDir, { withFileTypes: true });
151
+ } catch {
152
+ return files;
153
+ }
154
+
155
+ for (const entry of entries) {
156
+ const entryPath = path.join(rootDir, entry.name);
157
+ if (entry.isDirectory()) {
158
+ await walkSessionFiles(entryPath, options, files);
159
+ continue;
160
+ }
161
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl") || !entry.name.startsWith("rollout-")) {
162
+ continue;
163
+ }
164
+ files.push(entryPath);
165
+ }
166
+ return files;
167
+ }
168
+
169
+ export async function scanCodexSessions(options = {}) {
170
+ const codexHome = resolveCodexHome(options);
171
+ const sessionsDir = path.join(codexHome, "sessions");
172
+ const sinceMs = typeof options.sinceMs === "number" ? options.sinceMs : 0;
173
+ const includeDelegated = Boolean(options.includeDelegated || options.includeSubagents);
174
+ const files = await walkSessionFiles(sessionsDir, options);
175
+ const sessions = [];
176
+
177
+ for (const filePath of files) {
178
+ let stat;
179
+ try {
180
+ stat = await fs.stat(filePath);
181
+ } catch {
182
+ continue;
183
+ }
184
+ if (sinceMs && stat.mtimeMs < sinceMs) {
185
+ continue;
186
+ }
187
+ const metaLine = await readFirstSessionMetaLine(filePath);
188
+ if (!metaLine) {
189
+ continue;
190
+ }
191
+ const session = parseCodexSessionMetaLine(metaLine, filePath, stat);
192
+ if (!session) {
193
+ continue;
194
+ }
195
+ if (!includeDelegated && isDelegatedCodexSession(session)) {
196
+ continue;
197
+ }
198
+ sessions.push(session);
199
+ }
200
+
201
+ return sessions.sort((left, right) => latestSessionMs(right) - latestSessionMs(left));
202
+ }
203
+
204
+ function latestSessionMs(session) {
205
+ return Math.max(Number(session?.updatedMs || 0), Number(session?.timestampMs || 0));
206
+ }
207
+
208
+ export function resolveRepoRoot(startPath = process.cwd(), options = {}) {
209
+ const cwd = path.resolve(startPath);
210
+ const git = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
211
+ encoding: "utf8",
212
+ });
213
+ const repoRoot = git.status === 0 && git.stdout.trim() ? path.resolve(git.stdout.trim()) : cwd;
214
+ const forbiddenRoots = options.forbiddenRoots instanceof Set ? options.forbiddenRoots : defaultForbiddenRoots();
215
+ const isForbidden = forbiddenRoots.has(repoRoot);
216
+ const isArtifact = !options.includeArtifactRepos && isArtifactOutputRepo(repoRoot);
217
+
218
+ return {
219
+ inputPath: cwd,
220
+ repoRoot,
221
+ ok: !isForbidden && !isArtifact,
222
+ reason: isForbidden ? "broad_root" : isArtifact ? "artifact_output_repo" : null,
223
+ };
224
+ }
225
+
226
+ function latestSessionForPath(sessions, targetPath) {
227
+ const target = path.resolve(targetPath);
228
+ return (
229
+ sessions
230
+ .filter((session) => pathContains(target, session.cwd))
231
+ .sort((left, right) => latestSessionMs(right) - latestSessionMs(left))[0] || null
232
+ );
233
+ }
234
+
235
+ async function loadWorkspaceTabs(options = {}) {
236
+ const source = await loadWorkspaceSource({
237
+ ideaId: options.workspace || DEFAULT_WORKSPACE,
238
+ workspaceFile: options.workspaceFile,
239
+ hostedWorkspaceId: options.hostedWorkspaceId,
240
+ baseUrl: options.baseUrl,
241
+ orpCommand: options.orpCommand,
242
+ });
243
+ const parsed = parseWorkspaceSource(source);
244
+ const tabs = buildLaunchPlan(parsed.entries, {
245
+ tmux: false,
246
+ resume: true,
247
+ });
248
+ return {
249
+ source,
250
+ parsed,
251
+ tabs,
252
+ };
253
+ }
254
+
255
+ function codexTabsForPath(tabs, repoRoot) {
256
+ const target = path.resolve(repoRoot);
257
+ return tabs.filter((tab) => path.resolve(tab.path) === target && tab.resumeTool === "codex");
258
+ }
259
+
260
+ function anyTabsForPath(tabs, repoRoot) {
261
+ const target = path.resolve(repoRoot);
262
+ return tabs.filter((tab) => path.resolve(tab.path) === target);
263
+ }
264
+
265
+ function tabSummary(tab) {
266
+ if (!tab) {
267
+ return null;
268
+ }
269
+ return {
270
+ title: tab.title || null,
271
+ path: tab.path,
272
+ resumeCommand: getResumeCommand(tab),
273
+ resumeTool: tab.resumeTool || null,
274
+ resumeSessionId: tab.sessionId || null,
275
+ codexSessionId: tab.resumeTool === "codex" ? tab.sessionId || null : null,
276
+ };
277
+ }
278
+
279
+ function sessionSummary(session) {
280
+ if (!session) {
281
+ return null;
282
+ }
283
+ return {
284
+ sessionId: session.sessionId,
285
+ cwd: session.cwd,
286
+ timestamp: session.timestamp || null,
287
+ updatedAt: session.updatedMs ? new Date(session.updatedMs).toISOString() : null,
288
+ originator: session.originator || null,
289
+ cliVersion: session.cliVersion || null,
290
+ filePath: session.filePath || null,
291
+ };
292
+ }
293
+
294
+ function sinceMsFromOptions(options = {}) {
295
+ if (typeof options.sinceMs === "number") {
296
+ return options.sinceMs;
297
+ }
298
+ const sinceDays = Number.isFinite(options.sinceDays) ? options.sinceDays : DEFAULT_SCAN_DAYS;
299
+ return sinceDays > 0 ? Date.now() - sinceDays * 24 * 60 * 60 * 1000 : 0;
300
+ }
301
+
302
+ export async function buildCodexStatusReport(options = {}) {
303
+ const repo = resolveRepoRoot(options.path || process.cwd(), options);
304
+ const workspace = options.workspace || DEFAULT_WORKSPACE;
305
+ const [workspaceData, sessions] = await Promise.all([
306
+ loadWorkspaceTabs(options),
307
+ scanCodexSessions({
308
+ ...options,
309
+ sinceMs: sinceMsFromOptions(options),
310
+ }),
311
+ ]);
312
+ const trackedCodexTabs = repo.ok ? codexTabsForPath(workspaceData.tabs, repo.repoRoot) : [];
313
+ const trackedTabs = repo.ok ? anyTabsForPath(workspaceData.tabs, repo.repoRoot) : [];
314
+ const latestSession = repo.ok ? latestSessionForPath(sessions, repo.repoRoot) : null;
315
+ const primaryTab = trackedCodexTabs.length === 1 ? trackedCodexTabs[0] : trackedCodexTabs[0] || null;
316
+ const trackedSessionId = primaryTab?.sessionId || null;
317
+
318
+ let status = "unknown";
319
+ if (!repo.ok) {
320
+ status = repo.reason || "invalid_repo";
321
+ } else if (trackedCodexTabs.length > 1) {
322
+ status = "ambiguous";
323
+ } else if (trackedTabs.length === 0) {
324
+ status = "untracked";
325
+ } else if (!latestSession) {
326
+ status = "no_local_codex_session";
327
+ } else if (trackedSessionId === latestSession.sessionId) {
328
+ status = "current";
329
+ } else {
330
+ status = "stale";
331
+ }
332
+
333
+ return {
334
+ workspace,
335
+ sourceLabel: workspaceData.source.sourceLabel,
336
+ repoRoot: repo.repoRoot,
337
+ repoOk: repo.ok,
338
+ repoReason: repo.reason,
339
+ status,
340
+ stale: status === "stale",
341
+ trackedTab: tabSummary(primaryTab),
342
+ trackedTabs: trackedCodexTabs.map((tab) => tabSummary(tab)),
343
+ latestCodexSession: sessionSummary(latestSession),
344
+ updateCommand:
345
+ repo.ok && latestSession
346
+ ? `orp workspace add-tab ${workspace} --path '${repo.repoRoot}' --resume-tool codex --resume-session-id ${latestSession.sessionId}`
347
+ : null,
348
+ };
349
+ }
350
+
351
+ export function summarizeCodexStatus(report) {
352
+ const lines = [
353
+ `Workspace: ${report.workspace}`,
354
+ `Repo: ${report.repoRoot}`,
355
+ `Status: ${report.status}`,
356
+ ];
357
+ if (report.trackedTab) {
358
+ lines.push(`Tracked: ${report.trackedTab.resumeCommand || "path-only"}`);
359
+ }
360
+ if (report.latestCodexSession) {
361
+ lines.push(`Latest local Codex: codex resume ${report.latestCodexSession.sessionId}`);
362
+ lines.push(`Latest cwd: ${report.latestCodexSession.cwd}`);
363
+ }
364
+ if (report.updateCommand && report.stale) {
365
+ lines.push(`Refresh: ${report.updateCommand}`);
366
+ }
367
+ return lines.join("\n");
368
+ }
369
+
370
+ function buildWorkspaceOptions(options = {}) {
371
+ return Object.fromEntries(
372
+ Object.entries({
373
+ ideaId: options.workspace || DEFAULT_WORKSPACE,
374
+ workspaceFile: options.workspaceFile,
375
+ hostedWorkspaceId: options.hostedWorkspaceId,
376
+ baseUrl: options.baseUrl,
377
+ orpCommand: options.orpCommand,
378
+ }).filter(([, value]) => value != null),
379
+ );
380
+ }
381
+
382
+ function plannedMutationForTrackedPath(tabs, repoRoot, latestSession) {
383
+ const trackedTabs = anyTabsForPath(tabs, repoRoot);
384
+ if (trackedTabs.length === 0) {
385
+ return null;
386
+ }
387
+ const codexTabs = codexTabsForPath(tabs, repoRoot);
388
+ if (codexTabs.length > 1) {
389
+ return {
390
+ action: "skip",
391
+ reason: "ambiguous_codex_tabs",
392
+ repoRoot,
393
+ latestCodexSession: sessionSummary(latestSession),
394
+ trackedTabs: codexTabs.map((tab) => tabSummary(tab)),
395
+ };
396
+ }
397
+ const current = codexTabs[0] || null;
398
+ if (!latestSession) {
399
+ return {
400
+ action: "skip",
401
+ reason: "no_local_codex_session",
402
+ repoRoot,
403
+ trackedTab: tabSummary(current || trackedTabs[0]),
404
+ };
405
+ }
406
+ if (current?.sessionId === latestSession.sessionId) {
407
+ return {
408
+ action: "unchanged",
409
+ repoRoot,
410
+ trackedTab: tabSummary(current),
411
+ latestCodexSession: sessionSummary(latestSession),
412
+ };
413
+ }
414
+ return {
415
+ action: current ? "update" : "attach",
416
+ repoRoot,
417
+ title: current?.title || trackedTabs[0]?.title || undefined,
418
+ trackedTab: tabSummary(current || trackedTabs[0]),
419
+ latestCodexSession: sessionSummary(latestSession),
420
+ };
421
+ }
422
+
423
+ export async function buildCodexReconcilePlan(options = {}) {
424
+ const workspace = options.workspace || DEFAULT_WORKSPACE;
425
+ const [workspaceData, sessions] = await Promise.all([
426
+ loadWorkspaceTabs(options),
427
+ scanCodexSessions({
428
+ ...options,
429
+ sinceMs: sinceMsFromOptions(options),
430
+ }),
431
+ ]);
432
+ const trackedPaths = [...new Set(workspaceData.tabs.map((tab) => path.resolve(tab.path)))];
433
+ const actions = [];
434
+
435
+ for (const repoRoot of trackedPaths) {
436
+ if (!resolveRepoRoot(repoRoot, options).ok) {
437
+ actions.push({ action: "skip", reason: "refused_repo_root", repoRoot });
438
+ continue;
439
+ }
440
+ actions.push(plannedMutationForTrackedPath(workspaceData.tabs, repoRoot, latestSessionForPath(sessions, repoRoot)));
441
+ }
442
+
443
+ const trackedPathSet = new Set(trackedPaths);
444
+ const missingByRepo = new Map();
445
+ if (options.addMissing) {
446
+ for (const session of sessions) {
447
+ const repo = resolveRepoRoot(session.cwd, options);
448
+ if (!repo.ok || trackedPathSet.has(repo.repoRoot)) {
449
+ continue;
450
+ }
451
+ const current = missingByRepo.get(repo.repoRoot);
452
+ if (!current || latestSessionMs(session) > latestSessionMs(current)) {
453
+ missingByRepo.set(repo.repoRoot, session);
454
+ }
455
+ }
456
+ for (const [repoRoot, session] of missingByRepo.entries()) {
457
+ actions.push({
458
+ action: "add",
459
+ repoRoot,
460
+ title: path.basename(repoRoot),
461
+ latestCodexSession: sessionSummary(session),
462
+ });
463
+ }
464
+ }
465
+
466
+ return {
467
+ workspace,
468
+ sourceLabel: workspaceData.source.sourceLabel,
469
+ dryRun: Boolean(options.dryRun),
470
+ addMissing: Boolean(options.addMissing),
471
+ actionCount: actions.filter((action) => ["update", "attach", "add"].includes(action?.action)).length,
472
+ actions: actions.filter(Boolean),
473
+ };
474
+ }
475
+
476
+ async function applyReconcileAction(action, options = {}) {
477
+ if (!["update", "attach", "add"].includes(action.action)) {
478
+ return { ...action, applied: false };
479
+ }
480
+ const latest = action.latestCodexSession;
481
+ const result = await applyWorkspaceAddTabOptions({
482
+ ...buildWorkspaceOptions(options),
483
+ path: action.repoRoot,
484
+ title: action.action === "add" ? action.title : undefined,
485
+ resumeTool: "codex",
486
+ resumeSessionId: latest.sessionId,
487
+ append: Boolean(options.append),
488
+ });
489
+ return {
490
+ ...action,
491
+ applied: true,
492
+ mutation: result.mutation,
493
+ tab: result.tab,
494
+ };
495
+ }
496
+
497
+ export async function applyCodexReconcilePlan(plan, options = {}) {
498
+ const appliedActions = [];
499
+ for (const action of plan.actions) {
500
+ if (options.dryRun || !["update", "attach", "add"].includes(action.action)) {
501
+ appliedActions.push({ ...action, applied: false });
502
+ continue;
503
+ }
504
+ appliedActions.push(await applyReconcileAction(action, options));
505
+ }
506
+ return {
507
+ ...plan,
508
+ dryRun: Boolean(options.dryRun),
509
+ actions: appliedActions,
510
+ };
511
+ }
512
+
513
+ export function summarizeCodexReconcile(report) {
514
+ const lines = [
515
+ `Workspace: ${report.workspace}`,
516
+ `Source: ${report.sourceLabel}`,
517
+ `Mode: ${report.dryRun ? "dry-run" : "apply"}`,
518
+ `Actionable: ${report.actionCount}`,
519
+ "",
520
+ ];
521
+ for (const action of report.actions) {
522
+ const prefix = action.applied ? "applied" : action.action;
523
+ lines.push(`${prefix}: ${action.repoRoot}${action.reason ? ` (${action.reason})` : ""}`);
524
+ if (action.latestCodexSession) {
525
+ lines.push(` latest: codex resume ${action.latestCodexSession.sessionId}`);
526
+ }
527
+ }
528
+ return lines.join("\n").trimEnd();
529
+ }
530
+
531
+ function parseCommonOptions(argv = [], defaults = {}, parseOptions = {}) {
532
+ const options = {
533
+ workspace: DEFAULT_WORKSPACE,
534
+ json: false,
535
+ sinceDays: DEFAULT_SCAN_DAYS,
536
+ ...defaults,
537
+ };
538
+ const rest = [];
539
+ for (let index = 0; index < argv.length; index += 1) {
540
+ const arg = argv[index];
541
+ if (arg === "-h" || arg === "--help") {
542
+ options.help = true;
543
+ continue;
544
+ }
545
+ if (arg === "--json") {
546
+ options.json = true;
547
+ continue;
548
+ }
549
+ if (arg === "--dry-run") {
550
+ options.dryRun = true;
551
+ continue;
552
+ }
553
+ if (arg === "--add-missing") {
554
+ options.addMissing = true;
555
+ continue;
556
+ }
557
+ if (arg === "--append") {
558
+ options.append = true;
559
+ continue;
560
+ }
561
+ if (arg === "--include-subagents" || arg === "--include-delegated") {
562
+ options.includeSubagents = true;
563
+ options.includeDelegated = true;
564
+ continue;
565
+ }
566
+ if (arg === "--include-artifacts") {
567
+ options.includeArtifactRepos = true;
568
+ continue;
569
+ }
570
+ if (arg === "--") {
571
+ rest.push(...argv.slice(index + 1));
572
+ break;
573
+ }
574
+ if (arg.startsWith("--")) {
575
+ const next = argv[index + 1];
576
+ if (arg === "--workspace") {
577
+ if (next == null || next.startsWith("--")) {
578
+ throw new Error(`missing value for ${arg}`);
579
+ }
580
+ options.workspace = next;
581
+ } else if (arg === "--workspace-file") {
582
+ if (next == null || next.startsWith("--")) {
583
+ throw new Error(`missing value for ${arg}`);
584
+ }
585
+ options.workspaceFile = next;
586
+ } else if (arg === "--hosted-workspace-id") {
587
+ if (next == null || next.startsWith("--")) {
588
+ throw new Error(`missing value for ${arg}`);
589
+ }
590
+ options.hostedWorkspaceId = next;
591
+ } else if (arg === "--base-url") {
592
+ if (next == null || next.startsWith("--")) {
593
+ throw new Error(`missing value for ${arg}`);
594
+ }
595
+ options.baseUrl = next;
596
+ } else if (arg === "--orp-command") {
597
+ if (next == null || next.startsWith("--")) {
598
+ throw new Error(`missing value for ${arg}`);
599
+ }
600
+ options.orpCommand = next;
601
+ } else if (arg === "--codex-home") {
602
+ if (next == null || next.startsWith("--")) {
603
+ throw new Error(`missing value for ${arg}`);
604
+ }
605
+ options.codexHome = next;
606
+ } else if (arg === "--path") {
607
+ if (next == null || next.startsWith("--")) {
608
+ throw new Error(`missing value for ${arg}`);
609
+ }
610
+ options.path = next;
611
+ } else if (arg === "--since-days") {
612
+ if (next == null || next.startsWith("--")) {
613
+ throw new Error(`missing value for ${arg}`);
614
+ }
615
+ options.sinceDays = Number(next);
616
+ } else if (arg === "--title") {
617
+ if (next == null || next.startsWith("--")) {
618
+ throw new Error(`missing value for ${arg}`);
619
+ }
620
+ options.title = next;
621
+ } else if (arg === "--codex-bin") {
622
+ if (next == null || next.startsWith("--")) {
623
+ throw new Error(`missing value for ${arg}`);
624
+ }
625
+ options.codexBin = next;
626
+ } else if (arg === "--watch-timeout-ms") {
627
+ if (next == null || next.startsWith("--")) {
628
+ throw new Error(`missing value for ${arg}`);
629
+ }
630
+ options.watchTimeoutMs = Number(next);
631
+ } else {
632
+ if (parseOptions.passUnknownOptions) {
633
+ rest.push(...argv.slice(index));
634
+ break;
635
+ }
636
+ throw new Error(`unknown option: ${arg}`);
637
+ }
638
+ index += 1;
639
+ continue;
640
+ }
641
+ rest.push(arg);
642
+ }
643
+ return { options, rest };
644
+ }
645
+
646
+ function printCodexHelp() {
647
+ console.log(`ORP Codex session tracking
648
+
649
+ Usage:
650
+ orp codex [--workspace main] [--append] [--title <title>] [codex args...]
651
+ orp codex status [--workspace main] [--json]
652
+ orp codex reconcile [--workspace main] [--dry-run] [--add-missing] [--json]
653
+ orp codex start [--workspace main] [--append] [--title <title>] [-- <codex args...>]
654
+
655
+ Commands:
656
+ status Compare this repo's ORP tab with the latest local Codex session
657
+ reconcile Scan recent local Codex sessions and refresh stale ORP workspace tabs
658
+ start Launch Codex in the repo root and save the new session when metadata appears
659
+
660
+ Notes:
661
+ - Delegated/subagent sessions are ignored by default.
662
+ - Broad roots and artifact-output repos are refused unless explicitly overridden.
663
+ - Use -- before Codex args that conflict with ORP wrapper options.
664
+ - Manual fallback inside Codex remains: orp workspace add-tab main --here --current-codex
665
+ `);
666
+ }
667
+
668
+ function printStatusHelp() {
669
+ console.log(`ORP Codex status
670
+
671
+ Usage:
672
+ orp codex status [--workspace main] [--path <repo-or-subdir>] [--codex-home <path>] [--json]
673
+ `);
674
+ }
675
+
676
+ function printReconcileHelp() {
677
+ console.log(`ORP Codex reconcile
678
+
679
+ Usage:
680
+ orp codex reconcile [--workspace main] [--dry-run] [--add-missing] [--since-days <n>] [--json]
681
+ `);
682
+ }
683
+
684
+ function printStartHelp() {
685
+ console.log(`ORP Codex start
686
+
687
+ Usage:
688
+ orp codex [--workspace main] [--append] [--title <title>] [codex args...]
689
+ orp codex start [--workspace main] [--append] [--title <title>] [--codex-bin <bin>] [--watch-timeout-ms <ms>] [-- <codex args...>]
690
+ `);
691
+ }
692
+
693
+ async function runCodexStatus(argv) {
694
+ const { options } = parseCommonOptions(argv);
695
+ if (options.help) {
696
+ printStatusHelp();
697
+ return 0;
698
+ }
699
+ const report = await buildCodexStatusReport(options);
700
+ process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : `${summarizeCodexStatus(report)}\n`);
701
+ return 0;
702
+ }
703
+
704
+ async function runCodexReconcile(argv) {
705
+ const { options } = parseCommonOptions(argv);
706
+ if (options.help) {
707
+ printReconcileHelp();
708
+ return 0;
709
+ }
710
+ const plan = await buildCodexReconcilePlan(options);
711
+ const report = await applyCodexReconcilePlan(plan, options);
712
+ process.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : `${summarizeCodexReconcile(report)}\n`);
713
+ return 0;
714
+ }
715
+
716
+ async function waitForMatchingSession(repoRoot, options = {}) {
717
+ const startedAtMs = options.startedAtMs || Date.now();
718
+ const timeoutMs = Number.isFinite(options.watchTimeoutMs) ? options.watchTimeoutMs : 30000;
719
+ const deadline = Date.now() + timeoutMs;
720
+ let latest = null;
721
+ while (Date.now() <= deadline) {
722
+ const sessions = await scanCodexSessions({
723
+ ...options,
724
+ sinceMs: startedAtMs - 1000,
725
+ });
726
+ latest = latestSessionForPath(sessions, repoRoot);
727
+ if (latest && latestSessionMs(latest) >= startedAtMs - 1000) {
728
+ return latest;
729
+ }
730
+ const remainingMs = deadline - Date.now();
731
+ if (remainingMs <= 0) {
732
+ break;
733
+ }
734
+ await new Promise((resolve) => setTimeout(resolve, Math.min(500, remainingMs)));
735
+ }
736
+ return latest;
737
+ }
738
+
739
+ async function runCodexStart(argv) {
740
+ const { options, rest: codexArgs } = parseCommonOptions(argv, {
741
+ watchTimeoutMs: 30000,
742
+ }, { passUnknownOptions: true });
743
+ if (options.help) {
744
+ printStartHelp();
745
+ return 0;
746
+ }
747
+ const repo = resolveRepoRoot(options.path || process.cwd(), options);
748
+ if (!repo.ok) {
749
+ throw new Error(`Refusing to start Codex for ${repo.repoRoot}: ${repo.reason}`);
750
+ }
751
+
752
+ const startedAtMs = Date.now();
753
+ const codexBin = normalizeOptionalString(options.codexBin) || "codex";
754
+ const args = ["-C", repo.repoRoot, ...codexArgs];
755
+ const watcher = waitForMatchingSession(repo.repoRoot, {
756
+ ...options,
757
+ startedAtMs,
758
+ })
759
+ .then(async (session) => {
760
+ if (!session) {
761
+ return null;
762
+ }
763
+ const result = await applyWorkspaceAddTabOptions({
764
+ ...buildWorkspaceOptions(options),
765
+ path: repo.repoRoot,
766
+ title: options.append ? options.title : undefined,
767
+ resumeTool: "codex",
768
+ resumeSessionId: session.sessionId,
769
+ append: Boolean(options.append),
770
+ });
771
+ return { session, result };
772
+ })
773
+ .catch((error) => ({ error }));
774
+
775
+ const child = spawn(codexBin, args, {
776
+ cwd: repo.repoRoot,
777
+ stdio: "inherit",
778
+ env: process.env,
779
+ });
780
+
781
+ const childExit = await new Promise((resolve, reject) => {
782
+ child.on("error", reject);
783
+ child.on("close", (code) => resolve(code == null ? 1 : code));
784
+ });
785
+ const watched = await watcher;
786
+ if (!watched || watched.error) {
787
+ process.stderr.write(
788
+ `ORP could not automatically save the Codex session. Fallback: orp workspace add-tab ${options.workspace || DEFAULT_WORKSPACE} --here --current-codex\n`,
789
+ );
790
+ if (watched?.error) {
791
+ process.stderr.write(`${watched.error instanceof Error ? watched.error.message : String(watched.error)}\n`);
792
+ }
793
+ } else if (options.json) {
794
+ process.stdout.write(`${JSON.stringify({ repoRoot: repo.repoRoot, session: sessionSummary(watched.session), mutation: watched.result.mutation }, null, 2)}\n`);
795
+ } else {
796
+ process.stderr.write(
797
+ `ORP saved Codex session for ${repo.repoRoot}: codex resume ${watched.session.sessionId}\n`,
798
+ );
799
+ }
800
+ return childExit;
801
+ }
802
+
803
+ export async function runOrpCodexCommand(argv = []) {
804
+ const [subcommand, ...rest] = argv;
805
+ if (subcommand === "-h" || subcommand === "--help" || subcommand === "help") {
806
+ printCodexHelp();
807
+ return 0;
808
+ }
809
+ if (!subcommand) {
810
+ return runCodexStart([]);
811
+ }
812
+ if (subcommand === "status") {
813
+ return runCodexStatus(rest);
814
+ }
815
+ if (subcommand === "reconcile") {
816
+ return runCodexReconcile(rest);
817
+ }
818
+ if (subcommand === "start") {
819
+ return runCodexStart(rest);
820
+ }
821
+ return runCodexStart(argv);
822
+ }
@@ -1,3 +1,13 @@
1
+ export {
2
+ applyCodexReconcilePlan,
3
+ buildCodexReconcilePlan,
4
+ buildCodexStatusReport,
5
+ parseCodexSessionMetaLine,
6
+ runOrpCodexCommand,
7
+ scanCodexSessions,
8
+ summarizeCodexReconcile,
9
+ summarizeCodexStatus,
10
+ } from "./codex.js";
1
11
  export {
2
12
  buildCloneCommand,
3
13
  buildDirectCommand,
@@ -945,7 +945,7 @@ function summarizeWorkspaceLedgerMutation(result) {
945
945
  return lines.join("\n");
946
946
  }
947
947
 
948
- async function runWorkspaceLedgerMutation(options, mutate, action) {
948
+ async function applyWorkspaceLedgerMutation(options, mutate, action) {
949
949
  const source = await loadWorkspaceSource(options);
950
950
  const parsed = parseWorkspaceSource(source);
951
951
  const manifest = normalizeEditableManifest(source, parsed);
@@ -974,6 +974,12 @@ async function runWorkspaceLedgerMutation(options, mutate, action) {
974
974
  manifest: finalManifest,
975
975
  };
976
976
 
977
+ return result;
978
+ }
979
+
980
+ async function runWorkspaceLedgerMutation(options, mutate, action) {
981
+ const result = await applyWorkspaceLedgerMutation(options, mutate, action);
982
+
977
983
  if (options.json) {
978
984
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
979
985
  return 0;
@@ -983,6 +989,10 @@ async function runWorkspaceLedgerMutation(options, mutate, action) {
983
989
  return 0;
984
990
  }
985
991
 
992
+ export async function applyWorkspaceAddTabOptions(options = {}) {
993
+ return applyWorkspaceLedgerMutation(options, addTabToManifest, "add-tab");
994
+ }
995
+
986
996
  export async function runWorkspaceAddTab(argv = process.argv.slice(2)) {
987
997
  const options = parseWorkspaceAddTabArgs(argv);
988
998
  if (options.help) {
@@ -0,0 +1,309 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import {
8
+ applyCodexReconcilePlan,
9
+ buildCodexReconcilePlan,
10
+ buildCodexStatusReport,
11
+ parseCodexSessionMetaLine,
12
+ runOrpCodexCommand,
13
+ scanCodexSessions,
14
+ } from "../src/index.js";
15
+
16
+ async function makeTempDir() {
17
+ return fs.mkdtemp(path.join(os.tmpdir(), "orp-codex-"));
18
+ }
19
+
20
+ async function writeSession(codexHome, sessionId, cwd, timestamp, extraPayload = {}) {
21
+ const day = timestamp.slice(0, 10).split("-");
22
+ const sessionsDir = path.join(codexHome, "sessions", day[0], day[1], day[2]);
23
+ await fs.mkdir(sessionsDir, { recursive: true });
24
+ const filePath = path.join(sessionsDir, `rollout-${timestamp.replaceAll(":", "-")}-${sessionId}.jsonl`);
25
+ const row = {
26
+ timestamp,
27
+ type: "session_meta",
28
+ payload: {
29
+ id: sessionId,
30
+ timestamp,
31
+ cwd,
32
+ originator: "codex-tui",
33
+ cli_version: "0.125.0",
34
+ ...extraPayload,
35
+ },
36
+ };
37
+ await fs.writeFile(filePath, `${JSON.stringify(row)}\n`, "utf8");
38
+ const mtime = new Date(timestamp);
39
+ await fs.utimes(filePath, mtime, mtime);
40
+ return filePath;
41
+ }
42
+
43
+ async function writeSessionWithPrefix(codexHome, sessionId, cwd, timestamp) {
44
+ const filePath = await writeSession(codexHome, sessionId, cwd, timestamp);
45
+ const original = await fs.readFile(filePath, "utf8");
46
+ await fs.writeFile(filePath, `${JSON.stringify({ type: "response_item", payload: {} })}\n${original}`, "utf8");
47
+ const mtime = new Date(timestamp);
48
+ await fs.utimes(filePath, mtime, mtime);
49
+ return filePath;
50
+ }
51
+
52
+ async function writeWorkspaceManifest(filePath, tabs) {
53
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
54
+ await fs.writeFile(
55
+ filePath,
56
+ `${JSON.stringify(
57
+ {
58
+ version: "1",
59
+ workspaceId: "orp-main",
60
+ title: "ORP Main",
61
+ tabs,
62
+ },
63
+ null,
64
+ 2,
65
+ )}\n`,
66
+ "utf8",
67
+ );
68
+ }
69
+
70
+ test("parseCodexSessionMetaLine reads stable session metadata", () => {
71
+ const row = JSON.stringify({
72
+ timestamp: "2026-04-25T12:00:00Z",
73
+ type: "session_meta",
74
+ payload: {
75
+ id: "019dc2cb-d435-7072-bbfd-4ae4280474dd",
76
+ timestamp: "2026-04-25T12:00:00Z",
77
+ cwd: "/tmp/example",
78
+ originator: "codex-tui",
79
+ cli_version: "0.125.0",
80
+ },
81
+ });
82
+
83
+ const parsed = parseCodexSessionMetaLine(row, "/tmp/session.jsonl", { mtimeMs: 123 });
84
+ assert.equal(parsed.sessionId, "019dc2cb-d435-7072-bbfd-4ae4280474dd");
85
+ assert.equal(parsed.cwd, "/tmp/example");
86
+ assert.equal(parsed.cliVersion, "0.125.0");
87
+ assert.equal(parsed.updatedMs, 123);
88
+ assert.equal(parseCodexSessionMetaLine("not json", "/tmp/session.jsonl"), null);
89
+ });
90
+
91
+ test("scanCodexSessions ignores delegated sessions by default", async () => {
92
+ const tempDir = await makeTempDir();
93
+ const codexHome = path.join(tempDir, "codex-home");
94
+ await writeSession(
95
+ codexHome,
96
+ "019dc2cb-d435-7072-bbfd-4ae4280474aa",
97
+ path.join(tempDir, "repo"),
98
+ "2026-04-25T12:00:00Z",
99
+ );
100
+ await writeSession(
101
+ codexHome,
102
+ "019dc2cb-d435-7072-bbfd-4ae4280474bb",
103
+ path.join(tempDir, "repo"),
104
+ "2026-04-25T12:01:00Z",
105
+ { source: { subagent: { other: "guardian" } } },
106
+ );
107
+ await writeSession(
108
+ codexHome,
109
+ "019dc2cb-d435-7072-bbfd-4ae4280474cc",
110
+ path.join(tempDir, "repo"),
111
+ "2026-04-25T12:02:00Z",
112
+ { originator: "clawdad" },
113
+ );
114
+
115
+ const defaultSessions = await scanCodexSessions({ codexHome, sinceMs: 0 });
116
+ assert.deepEqual(
117
+ defaultSessions.map((session) => session.sessionId),
118
+ ["019dc2cb-d435-7072-bbfd-4ae4280474aa"],
119
+ );
120
+
121
+ const allSessions = await scanCodexSessions({ codexHome, sinceMs: 0, includeSubagents: true });
122
+ assert.deepEqual(
123
+ allSessions.map((session) => session.sessionId),
124
+ [
125
+ "019dc2cb-d435-7072-bbfd-4ae4280474cc",
126
+ "019dc2cb-d435-7072-bbfd-4ae4280474bb",
127
+ "019dc2cb-d435-7072-bbfd-4ae4280474aa",
128
+ ],
129
+ );
130
+ });
131
+
132
+ test("scanCodexSessions finds session metadata near the start of a rollout file", async () => {
133
+ const tempDir = await makeTempDir();
134
+ const codexHome = path.join(tempDir, "codex-home");
135
+ const repoRoot = path.join(tempDir, "repo");
136
+ await writeSessionWithPrefix(
137
+ codexHome,
138
+ "019dc2cb-d435-7072-bbfd-4ae4280474dd",
139
+ repoRoot,
140
+ "2026-04-25T12:00:00Z",
141
+ );
142
+
143
+ const sessions = await scanCodexSessions({ codexHome, sinceMs: 0 });
144
+ assert.equal(sessions.length, 1);
145
+ assert.equal(sessions[0].sessionId, "019dc2cb-d435-7072-bbfd-4ae4280474dd");
146
+ });
147
+
148
+ test("buildCodexStatusReport marks a tracked repo stale when local Codex metadata is newer", async () => {
149
+ const tempDir = await makeTempDir();
150
+ const repoRoot = path.join(tempDir, "repo");
151
+ const codexHome = path.join(tempDir, "codex-home");
152
+ const workspaceFile = path.join(tempDir, "workspace.json");
153
+ await fs.mkdir(repoRoot, { recursive: true });
154
+ await writeWorkspaceManifest(workspaceFile, [
155
+ {
156
+ title: "repo",
157
+ path: repoRoot,
158
+ resumeTool: "codex",
159
+ resumeSessionId: "019dc2cb-d435-7072-bbfd-4ae428047401",
160
+ },
161
+ ]);
162
+ await writeSession(codexHome, "019dc2cb-d435-7072-bbfd-4ae428047402", repoRoot, "2026-04-25T12:00:00Z");
163
+
164
+ const report = await buildCodexStatusReport({
165
+ workspaceFile,
166
+ codexHome,
167
+ path: repoRoot,
168
+ sinceDays: 0,
169
+ });
170
+
171
+ assert.equal(report.status, "stale");
172
+ assert.equal(report.trackedTab.codexSessionId, "019dc2cb-d435-7072-bbfd-4ae428047401");
173
+ assert.equal(report.latestCodexSession.sessionId, "019dc2cb-d435-7072-bbfd-4ae428047402");
174
+ });
175
+
176
+ test("Codex reconcile updates tracked workspace tabs without appending by default", async () => {
177
+ const tempDir = await makeTempDir();
178
+ const repoRoot = path.join(tempDir, "repo");
179
+ const codexHome = path.join(tempDir, "codex-home");
180
+ const workspaceFile = path.join(tempDir, "workspace.json");
181
+ await fs.mkdir(repoRoot, { recursive: true });
182
+ await writeWorkspaceManifest(workspaceFile, [
183
+ {
184
+ title: "repo",
185
+ path: repoRoot,
186
+ resumeTool: "codex",
187
+ resumeSessionId: "019dc2cb-d435-7072-bbfd-4ae428047411",
188
+ },
189
+ ]);
190
+ await writeSession(codexHome, "019dc2cb-d435-7072-bbfd-4ae428047412", repoRoot, "2026-04-25T12:00:00Z");
191
+
192
+ const plan = await buildCodexReconcilePlan({
193
+ workspaceFile,
194
+ codexHome,
195
+ sinceDays: 0,
196
+ });
197
+ assert.equal(plan.actionCount, 1);
198
+ assert.equal(plan.actions[0].action, "update");
199
+
200
+ const applied = await applyCodexReconcilePlan(plan, {
201
+ workspaceFile,
202
+ codexHome,
203
+ });
204
+ assert.equal(applied.actions[0].applied, true);
205
+
206
+ const manifest = JSON.parse(await fs.readFile(workspaceFile, "utf8"));
207
+ assert.equal(manifest.tabs.length, 1);
208
+ assert.equal(manifest.tabs[0].resumeSessionId, "019dc2cb-d435-7072-bbfd-4ae428047412");
209
+ assert.equal(manifest.tabs[0].codexSessionId, "019dc2cb-d435-7072-bbfd-4ae428047412");
210
+ });
211
+
212
+ test("bare orp codex routes to start", async () => {
213
+ const tempDir = await makeTempDir();
214
+ const codexHome = path.join(tempDir, "codex-home");
215
+ const fakeCodex = path.join(tempDir, "fake-codex");
216
+ await fs.writeFile(fakeCodex, "#!/bin/sh\nexit 0\n", "utf8");
217
+ await fs.chmod(fakeCodex, 0o755);
218
+
219
+ const originalWrite = process.stderr.write;
220
+ let stderr = "";
221
+ process.stderr.write = (chunk) => {
222
+ stderr += String(chunk);
223
+ return true;
224
+ };
225
+ try {
226
+ const code = await runOrpCodexCommand([
227
+ "--path",
228
+ tempDir,
229
+ "--codex-home",
230
+ codexHome,
231
+ "--codex-bin",
232
+ fakeCodex,
233
+ "--watch-timeout-ms",
234
+ "1",
235
+ "--search",
236
+ ]);
237
+ assert.equal(code, 0);
238
+ assert.match(stderr, /Fallback: orp workspace add-tab main --here --current-codex/);
239
+ } finally {
240
+ process.stderr.write = originalWrite;
241
+ }
242
+ });
243
+
244
+ test("bare orp codex saves the new session when Codex writes metadata", async () => {
245
+ const tempDir = await makeTempDir();
246
+ const repoRoot = path.join(tempDir, "repo");
247
+ const codexHome = path.join(tempDir, "codex-home");
248
+ const workspaceFile = path.join(tempDir, "workspace.json");
249
+ const fakeCodex = path.join(tempDir, "fake-codex.js");
250
+ const sessionId = "019dc2cb-d435-7072-bbfd-4ae4280474ee";
251
+ await fs.mkdir(repoRoot, { recursive: true });
252
+ await writeWorkspaceManifest(workspaceFile, []);
253
+ await fs.writeFile(
254
+ fakeCodex,
255
+ `#!/usr/bin/env node
256
+ const fs = require("node:fs");
257
+ const path = require("node:path");
258
+ if (!process.argv.includes("--search")) process.exit(7);
259
+ const codexHome = ${JSON.stringify(codexHome)};
260
+ const repoRoot = ${JSON.stringify(repoRoot)};
261
+ const sessionId = ${JSON.stringify(sessionId)};
262
+ const timestamp = new Date().toISOString();
263
+ const dir = path.join(codexHome, "sessions", "2026", "04", "25");
264
+ fs.mkdirSync(dir, { recursive: true });
265
+ fs.writeFileSync(
266
+ path.join(dir, "rollout-2026-04-25T12-00-00Z-" + sessionId + ".jsonl"),
267
+ JSON.stringify({
268
+ timestamp,
269
+ type: "session_meta",
270
+ payload: { id: sessionId, timestamp, cwd: repoRoot, originator: "codex-tui" },
271
+ }) + "\\n",
272
+ );
273
+ `,
274
+ "utf8",
275
+ );
276
+ await fs.chmod(fakeCodex, 0o755);
277
+
278
+ const originalWrite = process.stderr.write;
279
+ let stderr = "";
280
+ process.stderr.write = (chunk) => {
281
+ stderr += String(chunk);
282
+ return true;
283
+ };
284
+ try {
285
+ const code = await runOrpCodexCommand([
286
+ "--path",
287
+ repoRoot,
288
+ "--workspace-file",
289
+ workspaceFile,
290
+ "--codex-home",
291
+ codexHome,
292
+ "--codex-bin",
293
+ fakeCodex,
294
+ "--watch-timeout-ms",
295
+ "2000",
296
+ "--search",
297
+ ]);
298
+ assert.equal(code, 0);
299
+ assert.match(stderr, new RegExp(`ORP saved Codex session for .*: codex resume ${sessionId}`));
300
+ } finally {
301
+ process.stderr.write = originalWrite;
302
+ }
303
+
304
+ const manifest = JSON.parse(await fs.readFile(workspaceFile, "utf8"));
305
+ assert.equal(manifest.tabs.length, 1);
306
+ assert.equal(manifest.tabs[0].path, repoRoot);
307
+ assert.equal(manifest.tabs[0].resumeTool, "codex");
308
+ assert.equal(manifest.tabs[0].resumeSessionId, sessionId);
309
+ });