open-research-protocol 0.4.27 → 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.
@@ -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
+ }