open-research-protocol 0.4.29 → 0.4.31

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,32 @@ 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.31 - 2026-04-25
10
+
11
+ This release refreshes ORP's OpenAI-backed research lanes and tightens
12
+ workspace tab recency ranking for grouped project sessions.
13
+
14
+ ### Changed
15
+
16
+ - Updated built-in OpenAI research profiles to use `gpt-5.5` for high-reasoning,
17
+ web synthesis, and pro-research-style lanes, with Responses API `web_search`
18
+ and `xhigh` reasoning on deep research passes.
19
+ - Workspace tab reports now rank grouped Codex project tabs by the freshest
20
+ tracked session update time while keeping same-project sessions together.
21
+
22
+ ## v0.4.30 - 2026-04-25
23
+
24
+ This release tightens ORP-managed Codex session tracking so short-lived
25
+ `codex exec` runs, including Clawdad summary and planning work, do not replace
26
+ the saved interactive workspace thread.
27
+
28
+ ### Changed
29
+
30
+ - `orp codex status` and `orp codex reconcile` now ignore local Codex exec
31
+ sessions by default, alongside the existing delegated/subagent filtering.
32
+ - Added `--include-exec` to the ORP/Codex status and reconcile flows for
33
+ explicit diagnostics when exec sessions need to be inspected.
34
+
9
35
  ## v0.4.29 - 2026-04-25
10
36
 
11
37
  This release adds ORP-managed Codex session tracking so starting a Codex thread
package/README.md CHANGED
@@ -473,10 +473,11 @@ orp schedule add codex --name morning-summary --prompt "Summarize this repo" --j
473
473
  aligned with Codex sessions. `status` compares the current repo against the
474
474
  latest local Codex session metadata, `reconcile` refreshes stale saved sessions,
475
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:
476
+ session id. Codex exec and delegated/subagent sessions are ignored by default,
477
+ and artifact-output repos should be left untracked when a separate lab repo is
478
+ the source of truth. Use `--include-exec` only when diagnosing short-lived exec
479
+ sessions, and use `--` before Codex args that conflict with ORP wrapper options.
480
+ The manual fallback from inside Codex is still:
480
481
 
481
482
  ```bash
482
483
  orp workspace add-tab main --here --current-codex
package/cli/orp.py CHANGED
@@ -141,6 +141,8 @@ FRONTIER_TERMINAL_STATUSES = {"complete", "completed", "done", "skipped", "termi
141
141
  YOUTUBE_SOURCE_SCHEMA_VERSION = "1.0.0"
142
142
  EXCHANGE_REPORT_SCHEMA_VERSION = "1.0.0"
143
143
  RESEARCH_RUN_SCHEMA_VERSION = "1.0.0"
144
+ OPENAI_RESEARCH_MODEL = "gpt-5.5"
145
+ OPENAI_DEEP_RESEARCH_MODEL = OPENAI_RESEARCH_MODEL
144
146
  SECRET_SPEND_POLICY_SCHEMA_VERSION = "1.0.0"
145
147
  RESEARCH_SPEND_LEDGER_SCHEMA_VERSION = "1.0.0"
146
148
  PROJECT_CONTEXT_SCHEMA_VERSION = "1.0.0"
@@ -10661,23 +10663,23 @@ def _project_research_trigger_policy() -> dict[str, Any]:
10661
10663
  "moment_id": "thinking_reasoning_high",
10662
10664
  "calls_api": True,
10663
10665
  "lane": "openai_reasoning_high",
10664
- "model": "gpt-5.4",
10666
+ "model": OPENAI_RESEARCH_MODEL,
10665
10667
  "when": "Use when the directory has a decision gate, route choice, proof strategy, architecture tradeoff, or ambiguous next action.",
10666
10668
  },
10667
10669
  {
10668
10670
  "moment_id": "web_synthesis",
10669
10671
  "calls_api": True,
10670
10672
  "lane": "openai_web_synthesis",
10671
- "model": "gpt-5.4",
10673
+ "model": OPENAI_RESEARCH_MODEL,
10672
10674
  "when": "Use when the answer depends on current public facts, external docs, papers, project status, or citations.",
10673
10675
  },
10674
10676
  {
10675
10677
  "moment_id": "pro_deep_research",
10676
10678
  "calls_api": True,
10677
10679
  "lane": "openai_deep_research",
10678
- "model": "o3-deep-research-2025-06-26",
10680
+ "model": OPENAI_DEEP_RESEARCH_MODEL,
10679
10681
  "when": "Use only after reasoning/web lanes expose a research-heavy gap, disagreement, source-quality issue, or literature-scale synthesis need.",
10680
- "capability_note": "Requires an OpenAI organization verified for Deep Research model access.",
10682
+ "capability_note": "Runs GPT-5.5 with background mode, web search, and xhigh reasoning for a pro-research-style pass.",
10681
10683
  },
10682
10684
  ],
10683
10685
  "skip_research_when": [
@@ -17656,7 +17658,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17656
17658
  "calls_api": True,
17657
17659
  "secret_alias": "openai-primary",
17658
17660
  "env_var": "OPENAI_API_KEY",
17659
- "description": "Call GPT-5.4 with high reasoning to critique and compress the opening research.",
17661
+ "description": f"Call {OPENAI_RESEARCH_MODEL} with high reasoning to critique and compress the opening research.",
17660
17662
  },
17661
17663
  {
17662
17664
  "moment_id": "think_web_crosscheck",
@@ -17664,7 +17666,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17664
17666
  "calls_api": True,
17665
17667
  "secret_alias": "openai-primary",
17666
17668
  "env_var": "OPENAI_API_KEY",
17667
- "description": "Call GPT-5.4 with high reasoning and web search to verify recency-sensitive claims.",
17669
+ "description": f"Call {OPENAI_RESEARCH_MODEL} with high reasoning and web search to verify recency-sensitive claims.",
17668
17670
  },
17669
17671
  {
17670
17672
  "moment_id": "think_synthesis",
@@ -17672,7 +17674,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17672
17674
  "calls_api": True,
17673
17675
  "secret_alias": "openai-primary",
17674
17676
  "env_var": "OPENAI_API_KEY",
17675
- "description": "Call GPT-5.4 with high reasoning to resolve disagreements before final research.",
17677
+ "description": f"Call {OPENAI_RESEARCH_MODEL} with high reasoning to resolve disagreements before final research.",
17676
17678
  },
17677
17679
  {
17678
17680
  "moment_id": "final_deep_research",
@@ -17691,7 +17693,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17691
17693
  "call_moment": "opening_deep_research",
17692
17694
  "label": "Opening Deep Research",
17693
17695
  "provider": "openai",
17694
- "model": "o3-deep-research-2025-06-26",
17696
+ "model": OPENAI_DEEP_RESEARCH_MODEL,
17695
17697
  "adapter": "openai_responses",
17696
17698
  "role": (
17697
17699
  "Initial Deep Research scan. Map the landscape, source families, hard unknowns, "
@@ -17712,9 +17714,10 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17712
17714
  ],
17713
17715
  "env_var": "OPENAI_API_KEY",
17714
17716
  "secret_alias": "openai-primary",
17717
+ "reasoning_effort": "xhigh",
17715
17718
  "reasoning_summary": "auto",
17716
17719
  "web_search": True,
17717
- "web_search_tool": "web_search_preview",
17720
+ "web_search_tool": "web_search",
17718
17721
  "background": False,
17719
17722
  "spend_reserve_usd": 1.5,
17720
17723
  "max_tool_calls": 40,
@@ -17728,7 +17731,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17728
17731
  "call_moment": "think_after_deep",
17729
17732
  "label": "Think after Deep Research",
17730
17733
  "provider": "openai",
17731
- "model": "gpt-5.4",
17734
+ "model": OPENAI_RESEARCH_MODEL,
17732
17735
  "adapter": "openai_responses",
17733
17736
  "role": (
17734
17737
  "High-reasoning critique of the opening Deep Research output. Compress it into a sharper "
@@ -17761,7 +17764,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17761
17764
  "call_moment": "think_web_crosscheck",
17762
17765
  "label": "Think with web cross-check",
17763
17766
  "provider": "openai",
17764
- "model": "gpt-5.4",
17767
+ "model": OPENAI_RESEARCH_MODEL,
17765
17768
  "adapter": "openai_responses",
17766
17769
  "role": (
17767
17770
  "High-reasoning web-search pass. Verify current facts, citations, public claims, "
@@ -17799,7 +17802,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17799
17802
  "call_moment": "think_synthesis",
17800
17803
  "label": "Synthesis thinking pass",
17801
17804
  "provider": "openai",
17802
- "model": "gpt-5.4",
17805
+ "model": OPENAI_RESEARCH_MODEL,
17803
17806
  "adapter": "openai_responses",
17804
17807
  "role": (
17805
17808
  "High-reasoning synthesis pass. Reconcile the deep-research map, critique, and web cross-check "
@@ -17831,7 +17834,7 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17831
17834
  "call_moment": "final_deep_research",
17832
17835
  "label": "Final Deep Research",
17833
17836
  "provider": "openai",
17834
- "model": "o3-deep-research-2025-06-26",
17837
+ "model": OPENAI_DEEP_RESEARCH_MODEL,
17835
17838
  "adapter": "openai_responses",
17836
17839
  "role": (
17837
17840
  "Final Deep Research pass. Use all prior lane outputs to produce the decisive, source-grounded "
@@ -17851,9 +17854,10 @@ def _research_staged_deep_think_profile(profile_id: str = "deep-think-web-think-
17851
17854
  ],
17852
17855
  "env_var": "OPENAI_API_KEY",
17853
17856
  "secret_alias": "openai-primary",
17857
+ "reasoning_effort": "xhigh",
17854
17858
  "reasoning_summary": "auto",
17855
17859
  "web_search": True,
17856
- "web_search_tool": "web_search_preview",
17860
+ "web_search_tool": "web_search",
17857
17861
  "background": False,
17858
17862
  "spend_reserve_usd": 1.5,
17859
17863
  "max_tool_calls": 40,
@@ -17901,7 +17905,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17901
17905
  "calls_api": True,
17902
17906
  "secret_alias": "openai-primary",
17903
17907
  "env_var": "OPENAI_API_KEY",
17904
- "description": "Call GPT-5.4 with high reasoning for the deliberate thinking pass.",
17908
+ "description": f"Call {OPENAI_RESEARCH_MODEL} with high reasoning for the deliberate thinking pass.",
17905
17909
  },
17906
17910
  {
17907
17911
  "moment_id": "web_synthesis",
@@ -17909,7 +17913,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17909
17913
  "calls_api": True,
17910
17914
  "secret_alias": "openai-primary",
17911
17915
  "env_var": "OPENAI_API_KEY",
17912
- "description": "Call GPT-5.4 with web search for current public evidence and citations.",
17916
+ "description": f"Call {OPENAI_RESEARCH_MODEL} with web search for current public evidence and citations.",
17913
17917
  },
17914
17918
  {
17915
17919
  "moment_id": "pro_deep_research",
@@ -17926,7 +17930,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17926
17930
  "call_moment": "thinking_reasoning_high",
17927
17931
  "label": "OpenAI reasoning high",
17928
17932
  "provider": "openai",
17929
- "model": "gpt-5.4",
17933
+ "model": OPENAI_RESEARCH_MODEL,
17930
17934
  "adapter": "openai_responses",
17931
17935
  "role": "Deliberate high-reasoning pass from the provided context. Think hard, critique assumptions, and produce a decision-oriented answer.",
17932
17936
  "env_var": "OPENAI_API_KEY",
@@ -17941,7 +17945,7 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17941
17945
  "call_moment": "web_synthesis",
17942
17946
  "label": "OpenAI web synthesis",
17943
17947
  "provider": "openai",
17944
- "model": "gpt-5.4",
17948
+ "model": OPENAI_RESEARCH_MODEL,
17945
17949
  "adapter": "openai_responses",
17946
17950
  "role": "Recency-aware synthesis using OpenAI Responses web search with citations.",
17947
17951
  "env_var": "OPENAI_API_KEY",
@@ -17961,14 +17965,15 @@ def _research_default_profile(profile_id: str = "openai-council") -> dict[str, A
17961
17965
  "call_moment": "pro_deep_research",
17962
17966
  "label": "OpenAI Pro / Deep Research",
17963
17967
  "provider": "openai",
17964
- "model": "o3-deep-research-2025-06-26",
17968
+ "model": OPENAI_DEEP_RESEARCH_MODEL,
17965
17969
  "adapter": "openai_responses",
17966
17970
  "role": "Pro Research style long-form investigation. Produce a structured, citation-rich report grounded in public sources.",
17967
17971
  "env_var": "OPENAI_API_KEY",
17968
17972
  "secret_alias": "openai-primary",
17973
+ "reasoning_effort": "xhigh",
17969
17974
  "reasoning_summary": "auto",
17970
17975
  "web_search": True,
17971
- "web_search_tool": "web_search_preview",
17976
+ "web_search_tool": "web_search",
17972
17977
  "background": True,
17973
17978
  "spend_reserve_usd": 3.5,
17974
17979
  "max_tool_calls": 40,
@@ -18914,7 +18919,7 @@ def _research_run_openai_lane(
18914
18919
  }
18915
18920
 
18916
18921
  body: dict[str, Any] = {
18917
- "model": str(lane.get("model", "gpt-5.4")).strip() or "gpt-5.4",
18922
+ "model": str(lane.get("model", OPENAI_RESEARCH_MODEL)).strip() or OPENAI_RESEARCH_MODEL,
18918
18923
  "input": prompt,
18919
18924
  "background": bool(lane.get("background", False)),
18920
18925
  }
@@ -22,11 +22,11 @@ orp research ask "Where should this system live?" --execute --json
22
22
 
23
23
  The built-in `openai-council` profile defines three OpenAI API lanes:
24
24
 
25
- - `openai_reasoning_high`: `gpt-5.4` with `reasoning.effort=high` for the deliberate thinking pass.
26
- - `openai_web_synthesis`: `gpt-5.4` with high reasoning plus Responses API web search for current public evidence and citations.
27
- - `openai_deep_research`: `o3-deep-research-2025-06-26` with background execution and web search preview for Pro/Deep Research style investigation.
25
+ - `openai_reasoning_high`: `gpt-5.5` with `reasoning.effort=high` for the deliberate thinking pass.
26
+ - `openai_web_synthesis`: `gpt-5.5` with high reasoning plus Responses API web search for current public evidence and citations.
27
+ - `openai_deep_research`: `gpt-5.5` with `reasoning.effort=xhigh`, background execution, and Responses API web search for Pro/Deep Research style investigation.
28
28
 
29
- This follows OpenAI's current model guidance: `gpt-5.4` is the default for general-purpose, coding, reasoning, and agentic workflows; web search is enabled through the Responses API `tools` array when current information is needed; and Deep Research is available through the Responses endpoint with `o3-deep-research-2025-06-26`.
29
+ This follows OpenAI's current model guidance: `gpt-5.5` works best through the Responses API for reasoning and tool workflows; web search is enabled through the Responses API `tools` array when current information is needed; and deeper research-style work should use higher reasoning effort plus background mode.
30
30
 
31
31
  ## Staged Deep Research Template
32
32
 
@@ -119,7 +119,7 @@ Fixtures are useful when an OpenAI run happened outside ORP, when you are compar
119
119
 
120
120
  ORP uses the Responses API for these lanes. Useful knobs in profile JSON:
121
121
 
122
- - `model`: for example `gpt-5.4` or `o3-deep-research-2025-06-26`.
122
+ - `model`: for example `gpt-5.5`.
123
123
  - `call_moment`: the named research-loop moment when this lane may resolve a key.
124
124
  - `reasoning_effort`: `none`, `low`, `medium`, `high`, or `xhigh` for supported models.
125
125
  - `reasoning_summary`: `auto` or `detailed` for Deep Research reasoning summaries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
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>",
@@ -77,6 +77,22 @@ export function isDelegatedCodexSession(session) {
77
77
  return sourceText.includes("subagent") || sourceText.includes("delegate");
78
78
  }
79
79
 
80
+ export function isCodexExecSession(session) {
81
+ const originator = normalizeOptionalString(session?.originator)?.toLowerCase();
82
+ if (originator === "codex_exec" || originator === "codex-exec") {
83
+ return true;
84
+ }
85
+ const source = session?.source;
86
+ if (typeof source === "string") {
87
+ return source.trim().toLowerCase() === "exec";
88
+ }
89
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
90
+ return false;
91
+ }
92
+ const sourceKind = normalizeOptionalString(source.kind ?? source.type ?? source.source)?.toLowerCase();
93
+ return sourceKind === "exec";
94
+ }
95
+
80
96
  export function parseCodexSessionMetaLine(line, filePath, stat = {}) {
81
97
  let row;
82
98
  try {
@@ -97,6 +113,12 @@ export function parseCodexSessionMetaLine(line, filePath, stat = {}) {
97
113
 
98
114
  const timestamp = normalizeOptionalString(payload.timestamp) || normalizeOptionalString(row.timestamp);
99
115
  const timestampMs = timestamp ? Date.parse(timestamp) : 0;
116
+ const source =
117
+ typeof payload.source === "string"
118
+ ? normalizeOptionalString(payload.source)
119
+ : payload.source && typeof payload.source === "object" && !Array.isArray(payload.source)
120
+ ? payload.source
121
+ : null;
100
122
  return {
101
123
  sessionId,
102
124
  cwd: path.resolve(cwd),
@@ -106,7 +128,7 @@ export function parseCodexSessionMetaLine(line, filePath, stat = {}) {
106
128
  filePath,
107
129
  originator: normalizeOptionalString(payload.originator),
108
130
  cliVersion: normalizeOptionalString(payload.cli_version ?? payload.cliVersion),
109
- source: payload.source && typeof payload.source === "object" && !Array.isArray(payload.source) ? payload.source : null,
131
+ source,
110
132
  };
111
133
  }
112
134
 
@@ -171,6 +193,7 @@ export async function scanCodexSessions(options = {}) {
171
193
  const sessionsDir = path.join(codexHome, "sessions");
172
194
  const sinceMs = typeof options.sinceMs === "number" ? options.sinceMs : 0;
173
195
  const includeDelegated = Boolean(options.includeDelegated || options.includeSubagents);
196
+ const includeExec = Boolean(options.includeExec || options.includeCodexExec);
174
197
  const files = await walkSessionFiles(sessionsDir, options);
175
198
  const sessions = [];
176
199
 
@@ -192,6 +215,9 @@ export async function scanCodexSessions(options = {}) {
192
215
  if (!session) {
193
216
  continue;
194
217
  }
218
+ if (!includeExec && isCodexExecSession(session)) {
219
+ continue;
220
+ }
195
221
  if (!includeDelegated && isDelegatedCodexSession(session)) {
196
222
  continue;
197
223
  }
@@ -287,6 +313,7 @@ function sessionSummary(session) {
287
313
  updatedAt: session.updatedMs ? new Date(session.updatedMs).toISOString() : null,
288
314
  originator: session.originator || null,
289
315
  cliVersion: session.cliVersion || null,
316
+ source: session.source || null,
290
317
  filePath: session.filePath || null,
291
318
  };
292
319
  }
@@ -563,6 +590,10 @@ function parseCommonOptions(argv = [], defaults = {}, parseOptions = {}) {
563
590
  options.includeDelegated = true;
564
591
  continue;
565
592
  }
593
+ if (arg === "--include-exec") {
594
+ options.includeExec = true;
595
+ continue;
596
+ }
566
597
  if (arg === "--include-artifacts") {
567
598
  options.includeArtifactRepos = true;
568
599
  continue;
@@ -658,6 +689,7 @@ Commands:
658
689
  start Launch Codex in the repo root and save the new session when metadata appears
659
690
 
660
691
  Notes:
692
+ - Codex exec sessions are ignored by default.
661
693
  - Delegated/subagent sessions are ignored by default.
662
694
  - Broad roots and artifact-output repos are refused unless explicitly overridden.
663
695
  - Use -- before Codex args that conflict with ORP wrapper options.
@@ -669,7 +701,7 @@ function printStatusHelp() {
669
701
  console.log(`ORP Codex status
670
702
 
671
703
  Usage:
672
- orp codex status [--workspace main] [--path <repo-or-subdir>] [--codex-home <path>] [--json]
704
+ orp codex status [--workspace main] [--path <repo-or-subdir>] [--codex-home <path>] [--include-exec] [--json]
673
705
  `);
674
706
  }
675
707
 
@@ -677,7 +709,7 @@ function printReconcileHelp() {
677
709
  console.log(`ORP Codex reconcile
678
710
 
679
711
  Usage:
680
- orp codex reconcile [--workspace main] [--dry-run] [--add-missing] [--since-days <n>] [--json]
712
+ orp codex reconcile [--workspace main] [--dry-run] [--add-missing] [--since-days <n>] [--include-exec] [--json]
681
713
  `);
682
714
  }
683
715
 
@@ -122,9 +122,9 @@ function buildCodexActivityIndex(tabs = [], options = {}) {
122
122
  return activityBySessionId;
123
123
  }
124
124
 
125
- function orderTabsByRecentActivity(tabs = [], options = {}) {
125
+ function buildRankedTabs(tabs = [], options = {}) {
126
126
  const activityBySessionId = buildCodexActivityIndex(tabs, options);
127
- const rankedTabs = tabs.map((tab, originalIndex) => {
127
+ return tabs.map((tab, originalIndex) => {
128
128
  const sessionActivity =
129
129
  tab.resumeTool === "codex" && tab.sessionId ? activityBySessionId.get(String(tab.sessionId).toLowerCase()) : null;
130
130
  return {
@@ -133,24 +133,40 @@ function orderTabsByRecentActivity(tabs = [], options = {}) {
133
133
  activityMs: sessionActivity?.mtimeMs || 0,
134
134
  };
135
135
  });
136
+ }
137
+
138
+ function orderTabsByRecentActivity(tabs = [], options = {}) {
139
+ const rankedTabs = buildRankedTabs(tabs, options);
140
+ const projects = new Map();
136
141
 
137
- const projectActivity = new Map();
138
142
  for (const ranked of rankedTabs) {
139
- const current = projectActivity.get(ranked.tab.path) || 0;
140
- projectActivity.set(ranked.tab.path, Math.max(current, ranked.activityMs));
143
+ const projectPath = ranked.tab.path;
144
+ if (!projects.has(projectPath)) {
145
+ projects.set(projectPath, {
146
+ projectPath,
147
+ firstIndex: ranked.originalIndex,
148
+ activityMs: ranked.activityMs,
149
+ tabs: [],
150
+ });
151
+ }
152
+
153
+ const project = projects.get(projectPath);
154
+ project.firstIndex = Math.min(project.firstIndex, ranked.originalIndex);
155
+ project.activityMs = Math.max(project.activityMs, ranked.activityMs);
156
+ project.tabs.push(ranked);
141
157
  }
142
158
 
143
- return rankedTabs
144
- .sort((left, right) => {
145
- const leftProjectActivity = projectActivity.get(left.tab.path) || 0;
146
- const rightProjectActivity = projectActivity.get(right.tab.path) || 0;
147
- return (
148
- rightProjectActivity - leftProjectActivity ||
159
+ return [...projects.values()]
160
+ .sort(
161
+ (left, right) =>
149
162
  right.activityMs - left.activityMs ||
150
- left.originalIndex - right.originalIndex
151
- );
152
- })
153
- .map((ranked) => ranked.tab);
163
+ left.firstIndex - right.firstIndex,
164
+ )
165
+ .flatMap((project) =>
166
+ project.tabs
167
+ .sort((left, right) => right.activityMs - left.activityMs || left.originalIndex - right.originalIndex)
168
+ .map((ranked) => ranked.tab),
169
+ );
154
170
  }
155
171
 
156
172
  export function parseWorkspaceTabsArgs(argv = []) {
@@ -129,6 +129,38 @@ test("scanCodexSessions ignores delegated sessions by default", async () => {
129
129
  );
130
130
  });
131
131
 
132
+ test("scanCodexSessions ignores exec sessions by default", async () => {
133
+ const tempDir = await makeTempDir();
134
+ const codexHome = path.join(tempDir, "codex-home");
135
+ const repoRoot = path.join(tempDir, "repo");
136
+ await writeSession(
137
+ codexHome,
138
+ "019dc2cb-d435-7072-bbfd-4ae4280474d1",
139
+ repoRoot,
140
+ "2026-04-25T12:00:00Z",
141
+ );
142
+ await writeSession(
143
+ codexHome,
144
+ "019dc2cb-d435-7072-bbfd-4ae4280474d2",
145
+ repoRoot,
146
+ "2026-04-25T12:01:00Z",
147
+ { originator: "codex_exec", source: "exec" },
148
+ );
149
+
150
+ const defaultSessions = await scanCodexSessions({ codexHome, sinceMs: 0 });
151
+ assert.deepEqual(
152
+ defaultSessions.map((session) => session.sessionId),
153
+ ["019dc2cb-d435-7072-bbfd-4ae4280474d1"],
154
+ );
155
+
156
+ const withExecSessions = await scanCodexSessions({ codexHome, sinceMs: 0, includeExec: true });
157
+ assert.deepEqual(
158
+ withExecSessions.map((session) => session.sessionId),
159
+ ["019dc2cb-d435-7072-bbfd-4ae4280474d2", "019dc2cb-d435-7072-bbfd-4ae4280474d1"],
160
+ );
161
+ assert.equal(withExecSessions[0].source, "exec");
162
+ });
163
+
132
164
  test("scanCodexSessions finds session metadata near the start of a rollout file", async () => {
133
165
  const tempDir = await makeTempDir();
134
166
  const codexHome = path.join(tempDir, "codex-home");
@@ -74,12 +74,12 @@ test("buildWorkspaceCommandsReport exposes direct restart commands and exact sav
74
74
  assert.equal(report.commandCount, 3);
75
75
  assert.equal(report.tabs[0]?.resumeCommand, "codex resume abc-123");
76
76
  assert.equal(report.tabs[0]?.restartCommand, "cd '/Volumes/Code_2TB/code/collaboration' && codex resume abc-123");
77
- assert.equal(report.tabs[1]?.resumeCommand, "claude resume claude-456");
77
+ assert.equal(report.tabs[1]?.restartCommand, "cd '/Volumes/Code_2TB/code/collaboration'");
78
+ assert.equal(report.tabs[2]?.resumeCommand, "claude resume claude-456");
78
79
  assert.equal(
79
- report.tabs[1]?.restartCommand,
80
+ report.tabs[2]?.restartCommand,
80
81
  "cd '/Volumes/Code_2TB/code/anthropic-lab' && claude resume claude-456",
81
82
  );
82
- assert.equal(report.tabs[2]?.restartCommand, "cd '/Volumes/Code_2TB/code/collaboration'");
83
83
  });
84
84
 
85
85
  test("runWorkspaceCommands prints JSON with copyable commands", async () => {
@@ -111,16 +111,16 @@ test("buildWorkspaceTabsReport keeps duplicate titles unique and exposes generic
111
111
  "cd '/Volumes/Code_2TB/code/collaboration' && codex resume abc-123",
112
112
  );
113
113
  assert.equal(report.tabs[0]?.codexSessionId, "abc-123");
114
- assert.equal(report.tabs[1]?.title, "anthropic-lab");
115
- assert.equal(report.tabs[1]?.resumeCommand, "claude resume claude-456");
116
- assert.equal(report.tabs[1]?.remoteBranch, "main");
114
+ assert.equal(report.tabs[1]?.title, "collaboration (2)");
115
+ assert.equal(report.tabs[1]?.codexSessionId, null);
116
+ assert.equal(report.tabs[2]?.title, "anthropic-lab");
117
+ assert.equal(report.tabs[2]?.resumeCommand, "claude resume claude-456");
118
+ assert.equal(report.tabs[2]?.remoteBranch, "main");
117
119
  assert.equal(
118
- report.tabs[1]?.restartCommand,
120
+ report.tabs[2]?.restartCommand,
119
121
  "cd '/Volumes/Code_2TB/code/anthropic-lab' && claude resume claude-456",
120
122
  );
121
- assert.equal(report.tabs[1]?.claudeSessionId, "claude-456");
122
- assert.equal(report.tabs[2]?.title, "collaboration (2)");
123
- assert.equal(report.tabs[2]?.codexSessionId, null);
123
+ assert.equal(report.tabs[2]?.claudeSessionId, "claude-456");
124
124
  });
125
125
 
126
126
  test("buildWorkspaceTabsReport ranks Codex tabs by recent local session activity", async () => {
@@ -183,6 +183,143 @@ test("buildWorkspaceTabsReport ranks Codex tabs by recent local session activity
183
183
  assert.equal(report.projects[2]?.path, "/Volumes/Code_2TB/code/no-session-project");
184
184
  });
185
185
 
186
+ test("buildWorkspaceTabsReport ranks tracked Codex sessions by update time, not rollout creation time", async () => {
187
+ const tempDir = await makeTempDir();
188
+ const codexHome = path.join(tempDir, "codex-home");
189
+ const sessionsDir = path.join(codexHome, "sessions", "2026", "04", "15");
190
+ await fs.mkdir(sessionsDir, { recursive: true });
191
+
192
+ const oldRolloutUpdatedSessionId = "019d0000-0000-7000-8000-000000000021";
193
+ const newRolloutStaleSessionId = "019d0000-0000-7000-8000-000000000022";
194
+ const untrackedFreshSessionId = "019d0000-0000-7000-8000-000000000023";
195
+ const oldRolloutUpdatedPath = path.join(
196
+ sessionsDir,
197
+ `rollout-2026-04-15T01-00-00-${oldRolloutUpdatedSessionId}.jsonl`,
198
+ );
199
+ const newRolloutStalePath = path.join(
200
+ sessionsDir,
201
+ `rollout-2026-04-15T09-00-00-${newRolloutStaleSessionId}.jsonl`,
202
+ );
203
+ const untrackedFreshPath = path.join(sessionsDir, `rollout-2026-04-15T10-00-00-${untrackedFreshSessionId}.jsonl`);
204
+
205
+ await fs.writeFile(oldRolloutUpdatedPath, "{}\n", "utf8");
206
+ await fs.writeFile(newRolloutStalePath, "{}\n", "utf8");
207
+ await fs.writeFile(untrackedFreshPath, "{}\n", "utf8");
208
+ await fs.utimes(oldRolloutUpdatedPath, new Date("2026-04-15T11:00:00Z"), new Date("2026-04-15T11:00:00Z"));
209
+ await fs.utimes(newRolloutStalePath, new Date("2026-04-15T09:00:00Z"), new Date("2026-04-15T09:00:00Z"));
210
+ await fs.utimes(untrackedFreshPath, new Date("2026-04-15T12:00:00Z"), new Date("2026-04-15T12:00:00Z"));
211
+
212
+ const parsed = parseWorkspaceSource({
213
+ sourceType: "workspace-file",
214
+ sourceLabel: "/tmp/workspace.json",
215
+ title: "workspace",
216
+ workspaceManifest: {
217
+ version: "1",
218
+ workspaceId: "orp-main",
219
+ tabs: [
220
+ {
221
+ title: "new-rollout-stale",
222
+ path: "/Volumes/Code_2TB/code/new-rollout-stale",
223
+ resumeCommand: `codex resume ${newRolloutStaleSessionId}`,
224
+ },
225
+ {
226
+ title: "old-rollout-updated",
227
+ path: "/Volumes/Code_2TB/code/old-rollout-updated",
228
+ resumeCommand: `codex resume ${oldRolloutUpdatedSessionId}`,
229
+ },
230
+ ],
231
+ },
232
+ notes: "",
233
+ });
234
+
235
+ const report = buildWorkspaceTabsReport(
236
+ {
237
+ sourceType: "workspace-file",
238
+ sourceLabel: "/tmp/workspace.json",
239
+ title: "workspace",
240
+ },
241
+ parsed,
242
+ { codexHome },
243
+ );
244
+
245
+ assert.deepEqual(
246
+ report.tabs.map((tab) => tab.title),
247
+ ["old-rollout-updated", "new-rollout-stale"],
248
+ );
249
+ });
250
+
251
+ test("buildWorkspaceTabsReport bubbles a project when one attached Codex session is freshest", async () => {
252
+ const tempDir = await makeTempDir();
253
+ const codexHome = path.join(tempDir, "codex-home");
254
+ const sessionsDir = path.join(codexHome, "sessions", "2026", "04", "16");
255
+ await fs.mkdir(sessionsDir, { recursive: true });
256
+
257
+ const projectAOlderSessionId = "019d0000-0000-7000-8000-000000000011";
258
+ const projectANewerSessionId = "019d0000-0000-7000-8000-000000000012";
259
+ const projectBSessionId = "019d0000-0000-7000-8000-000000000013";
260
+
261
+ const projectAOlderPath = path.join(sessionsDir, `rollout-2026-04-16T01-00-00-${projectAOlderSessionId}.jsonl`);
262
+ const projectANewerPath = path.join(sessionsDir, `rollout-2026-04-16T03-00-00-${projectANewerSessionId}.jsonl`);
263
+ const projectBPath = path.join(sessionsDir, `rollout-2026-04-16T02-00-00-${projectBSessionId}.jsonl`);
264
+
265
+ await fs.writeFile(projectAOlderPath, "{}\n", "utf8");
266
+ await fs.writeFile(projectANewerPath, "{}\n", "utf8");
267
+ await fs.writeFile(projectBPath, "{}\n", "utf8");
268
+ await fs.utimes(projectAOlderPath, new Date("2026-04-16T01:00:00Z"), new Date("2026-04-16T01:00:00Z"));
269
+ await fs.utimes(projectANewerPath, new Date("2026-04-16T03:00:00Z"), new Date("2026-04-16T03:00:00Z"));
270
+ await fs.utimes(projectBPath, new Date("2026-04-16T02:00:00Z"), new Date("2026-04-16T02:00:00Z"));
271
+
272
+ const parsed = parseWorkspaceSource({
273
+ sourceType: "workspace-file",
274
+ sourceLabel: "/tmp/workspace.json",
275
+ title: "workspace",
276
+ workspaceManifest: {
277
+ version: "1",
278
+ workspaceId: "orp-main",
279
+ tabs: [
280
+ {
281
+ title: "project-b",
282
+ path: "/Volumes/Code_2TB/code/project-b",
283
+ resumeCommand: `codex resume ${projectBSessionId}`,
284
+ },
285
+ {
286
+ title: "project-a-old",
287
+ path: "/Volumes/Code_2TB/code/project-a",
288
+ resumeCommand: `codex resume ${projectAOlderSessionId}`,
289
+ },
290
+ {
291
+ title: "project-a-new",
292
+ path: "/Volumes/Code_2TB/code/project-a",
293
+ resumeCommand: `codex resume ${projectANewerSessionId}`,
294
+ },
295
+ ],
296
+ },
297
+ notes: "",
298
+ });
299
+
300
+ const report = buildWorkspaceTabsReport(
301
+ {
302
+ sourceType: "workspace-file",
303
+ sourceLabel: "/tmp/workspace.json",
304
+ title: "workspace",
305
+ },
306
+ parsed,
307
+ { codexHome },
308
+ );
309
+
310
+ assert.deepEqual(
311
+ report.tabs.map((tab) => tab.title),
312
+ ["project-a-new", "project-a-old", "project-b"],
313
+ );
314
+ assert.equal(report.projects[0]?.path, "/Volumes/Code_2TB/code/project-a");
315
+ assert.equal(report.projects[0]?.sessionCount, 2);
316
+ assert.deepEqual(
317
+ report.projects[0]?.sessions.map((session) => session.title),
318
+ ["project-a-new", "project-a-old"],
319
+ );
320
+ assert.equal(report.projects[1]?.path, "/Volumes/Code_2TB/code/project-b");
321
+ });
322
+
186
323
  test("runWorkspaceTabs prints JSON without launch commands", async () => {
187
324
  const tempDir = await makeTempDir();
188
325
  const manifestPath = path.join(tempDir, "workspace.json");
Binary file