open-research-protocol 0.4.23 → 0.4.25

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,68 @@
1
+ # Agent Runtime Borrowing Notes
2
+
3
+ ORP should watch fast-moving personal-agent runtimes such as Hermes Agent and
4
+ OpenClaw as design references, not as replacements for the ORP state model.
5
+ Their strongest lesson is that users want one reachable assistant surface across
6
+ CLI, mobile, messaging, schedulers, and remote machines. ORP's role is to make
7
+ that assistant surface durable, governable, and recoverable.
8
+
9
+ The architectural boundary is:
10
+
11
+ ```text
12
+ Clawdad = entry point and operator surface
13
+ ORP = durable workspace state, routing ledger, agenda, governance, packets, and checkpoints
14
+ Agent runtimes = execution backends, gateways, sandboxes, schedulers, or transports
15
+ Project artifacts = evidence
16
+ ```
17
+
18
+ Hermes/OpenClaw-style systems can inspire ORP features, but they should not
19
+ become parallel ledgers. ORP remains the canonical place for workspace tabs,
20
+ linked sessions, runner state, operating agendas, opportunities, connections,
21
+ repo governance, checkpoints, and research packets.
22
+
23
+ ## Ideas Worth Borrowing
24
+
25
+ - Gateway ergonomics: simplify setup for phone, chat, and always-on entrypoints
26
+ while preserving ORP's local-first and hosted-linked records.
27
+ - Skills and capability packs: expose small, auditable ORP command groups that
28
+ agents can load for specific jobs instead of handing them the whole machine.
29
+ - Background process signals: let long-running builds, scans, and research jobs
30
+ notify the current agent/session when they finish or hit watched output.
31
+ - Model and provider routing: study runtime-level provider switching while
32
+ keeping ORP's routing records independent of any one model vendor.
33
+ - Subagent isolation: borrow fresh-context worker patterns, but record only the
34
+ resulting task state, evidence paths, and handoff summaries in ORP.
35
+ - Local dashboards: use dashboards as visibility layers over ORP state, not as
36
+ a second source of workspace truth.
37
+ - Backup/import flows: make ORP's machine state, linked sessions, and local
38
+ workspace ledgers easier to inspect, export, and restore.
39
+ - Security hardening: preserve strict boundaries for remote control, including
40
+ allowlists, sandboxed command execution, explicit secret scoping, and clear
41
+ approval points.
42
+
43
+ ## Design Guardrails
44
+
45
+ - ORP files are process-only and remain separate from evidence.
46
+ - Messaging platforms must not own the durable agenda or project ledger.
47
+ - Agent memories may summarize preferences or conversation context, but ORP owns
48
+ project routing, governance, and operational state.
49
+ - Any borrowed gateway or scheduler behavior should write back to ORP through
50
+ explicit commands, not mutate hidden state.
51
+ - A new surface is acceptable only if an operator can still recover the work
52
+ from ORP without knowing which agent runtime handled it.
53
+
54
+ ## First Useful Adapter
55
+
56
+ A good borrowing experiment is an ORP skill or bridge for an external agent
57
+ runtime with read-first commands:
58
+
59
+ - `orp home --json`
60
+ - `orp agenda focus`
61
+ - `orp workspace tabs main`
62
+ - `orp runner status --json`
63
+ - `orp link status --json`
64
+ - `orp youtube inspect <url> --json`
65
+
66
+ The next layer can add carefully scoped writes such as registering a session,
67
+ emitting a checkpoint, or dispatching through Clawdad, but only after the read
68
+ surface proves useful and safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
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>",
@@ -28,6 +28,7 @@
28
28
  "spec/",
29
29
  "templates/",
30
30
  "AGENT_INTEGRATION.md",
31
+ "CHANGELOG.md",
31
32
  "INSTALL.md",
32
33
  "LICENSE",
33
34
  "PROTOCOL.md",
@@ -44,10 +44,13 @@ export { runWorkspaceSlot } from "./slot.js";
44
44
  export { buildWorkspaceTabsReport, parseWorkspaceTabsArgs, runWorkspaceTabs, summarizeWorkspaceTabs } from "./tabs.js";
45
45
  export {
46
46
  buildWorkspaceManifestFromHostedWorkspacePayload,
47
+ createHostedWorkspaceForIdea,
47
48
  fetchHostedWorkspacePayload,
48
49
  fetchIdeaPayload,
49
50
  fetchIdeasPayload,
50
51
  fetchHostedWorkspacesPayload,
52
+ findHostedWorkspaceByLinkedIdea,
53
+ findHostedWorkspaceLinkedToIdea,
51
54
  loadWorkspaceSource,
52
55
  pushHostedWorkspaceState,
53
56
  chooseImplicitMainCandidate,
@@ -14,12 +14,12 @@ import {
14
14
  import { buildHostedWorkspaceState } from "./hosted-state.js";
15
15
  import {
16
16
  buildWorkspaceManifestFromHostedWorkspacePayload,
17
- fetchIdeaPayload,
17
+ createHostedWorkspaceForIdea,
18
18
  fetchHostedWorkspacePayload,
19
+ findHostedWorkspaceByLinkedIdea,
19
20
  loadWorkspaceSource,
20
21
  pushHostedWorkspaceState,
21
22
  resolveWorkspaceWatchTargets,
22
- updateIdeaPayload,
23
23
  } from "./orp.js";
24
24
  import {
25
25
  cacheManagedWorkspaceManifest,
@@ -28,7 +28,7 @@ import {
28
28
  registerWorkspaceManifest,
29
29
  setWorkspaceSlot,
30
30
  } from "./registry.js";
31
- import { buildWorkspaceSyncPreview, resolveWorkspaceSyncTargetIdeaId, validateWorkspaceTitle } from "./sync.js";
31
+ import { validateWorkspaceTitle } from "./sync.js";
32
32
 
33
33
  function normalizeOptionalString(value) {
34
34
  if (value == null) {
@@ -125,6 +125,103 @@ function materializeWorkspaceManifest(manifest) {
125
125
  );
126
126
  }
127
127
 
128
+ function getObjectValue(record, ...keys) {
129
+ for (const key of keys) {
130
+ const value = record?.[key];
131
+ if (value && typeof value === "object" && !Array.isArray(value)) {
132
+ return value;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function getHostedWorkspaceId(workspace) {
139
+ return normalizeOptionalString(workspace?.workspace_id ?? workspace?.workspaceId ?? workspace?.id);
140
+ }
141
+
142
+ function getHostedWorkspaceTitle(workspace) {
143
+ return normalizeOptionalString(workspace?.title) || getHostedWorkspaceId(workspace);
144
+ }
145
+
146
+ function buildHostedWorkspaceSlotAssignment(workspace) {
147
+ const workspaceId = getHostedWorkspaceId(workspace);
148
+ if (!workspaceId) {
149
+ return null;
150
+ }
151
+ const title = getHostedWorkspaceTitle(workspace);
152
+ return {
153
+ kind: "hosted-workspace",
154
+ selector: title || workspaceId,
155
+ workspaceId,
156
+ title: title || undefined,
157
+ hostedWorkspaceId: workspaceId,
158
+ };
159
+ }
160
+
161
+ function buildWorkspaceFileSlotAssignment(manifest, manifestPath) {
162
+ const workspaceId = normalizeOptionalString(manifest?.workspaceId);
163
+ const title = normalizeOptionalString(manifest?.title) || workspaceId || undefined;
164
+ return {
165
+ kind: "workspace-file",
166
+ selector: title || workspaceId || manifestPath,
167
+ workspaceId: workspaceId || undefined,
168
+ title,
169
+ manifestPath,
170
+ };
171
+ }
172
+
173
+ async function assignMatchingWorkspaceSlots(source, manifest, assignment, options = {}) {
174
+ if (!assignment) {
175
+ return {};
176
+ }
177
+
178
+ const slotNames = new Set();
179
+ if (source.resolvedSlotName) {
180
+ slotNames.add(source.resolvedSlotName);
181
+ }
182
+
183
+ const sourceWorkspaceIds = new Set(
184
+ [
185
+ manifest?.workspaceId,
186
+ source.workspaceManifest?.workspaceId,
187
+ getHostedWorkspaceId(source.hostedWorkspace),
188
+ source.hostedWorkspaceId,
189
+ ]
190
+ .map((value) => normalizeOptionalString(value))
191
+ .filter(Boolean),
192
+ );
193
+ const sourcePaths = new Set(
194
+ [source.sourcePath, assignment.manifestPath]
195
+ .map((value) => normalizeOptionalString(value))
196
+ .filter(Boolean)
197
+ .map((value) => path.resolve(value)),
198
+ );
199
+
200
+ const slotsResult = await loadWorkspaceSlots(options).catch(() => ({ slots: {} }));
201
+ for (const [slotName, slot] of Object.entries(slotsResult.slots || {})) {
202
+ if (!slot || typeof slot !== "object" || Array.isArray(slot)) {
203
+ continue;
204
+ }
205
+ const slotIds = [
206
+ slot.workspaceId,
207
+ slot.hostedWorkspaceId,
208
+ slot.selector,
209
+ ]
210
+ .map((value) => normalizeOptionalString(value))
211
+ .filter(Boolean);
212
+ const slotPath = normalizeOptionalString(slot.manifestPath);
213
+ if (slotIds.some((value) => sourceWorkspaceIds.has(value)) || (slotPath && sourcePaths.has(path.resolve(slotPath)))) {
214
+ slotNames.add(slotName);
215
+ }
216
+ }
217
+
218
+ const assignedSlots = {};
219
+ for (const slotName of slotNames) {
220
+ assignedSlots[slotName] = (await setWorkspaceSlot(slotName, assignment, options)).slot;
221
+ }
222
+ return assignedSlots;
223
+ }
224
+
128
225
  function normalizeEditableManifest(source, parsed) {
129
226
  const baseManifest = parsed.manifest
130
227
  ? {
@@ -179,6 +276,51 @@ function normalizeEditableManifest(source, parsed) {
179
276
  return normalizeWorkspaceManifest(baseManifest);
180
277
  }
181
278
 
279
+ async function findOrCreateHostedWorkspaceForIdea(ideaId, source, manifest, options = {}) {
280
+ const existingWorkspace = await findHostedWorkspaceByLinkedIdea(ideaId, options);
281
+ if (existingWorkspace) {
282
+ return {
283
+ workspace: existingWorkspace,
284
+ created: false,
285
+ };
286
+ }
287
+
288
+ const linkedIdea =
289
+ source.sourceType === "hosted-idea"
290
+ ? source.idea
291
+ : getObjectValue(source.hostedWorkspace, "linked_idea", "linkedIdea");
292
+ const title =
293
+ manifest.title ||
294
+ manifest.workspaceId ||
295
+ source.title ||
296
+ normalizeOptionalString(linkedIdea?.idea_title ?? linkedIdea?.ideaTitle) ||
297
+ normalizeOptionalString(linkedIdea?.title) ||
298
+ ideaId;
299
+ const created = await createHostedWorkspaceForIdea({ title, ideaId }, options);
300
+ return {
301
+ workspace: created.workspace,
302
+ created: true,
303
+ createdPayload: created,
304
+ };
305
+ }
306
+
307
+ async function persistIdeaBackedWorkspaceToLocalCache(ideaId, source, manifest, reason, options = {}) {
308
+ const managedCache = await cacheManagedWorkspaceManifest(manifest, options);
309
+ const assignment = buildWorkspaceFileSlotAssignment(manifest, managedCache.manifestPath);
310
+ const assignedSlots = await assignMatchingWorkspaceSlots(source, manifest, assignment, options);
311
+ return {
312
+ persistedTo: "workspace-file",
313
+ ideaId,
314
+ promotedFromIdeaId: ideaId,
315
+ hostedMigrationSkippedReason: reason instanceof Error ? reason.message : String(reason || "Hosted workspace API unavailable."),
316
+ manifestPath: managedCache.manifestPath,
317
+ registryPath: managedCache.registryPath,
318
+ assignedSlots,
319
+ managedCache,
320
+ manifest,
321
+ };
322
+ }
323
+
182
324
  function parseLedgerSelectorArgs(
183
325
  argv = [],
184
326
  { commandName, requirePath = false, requireSelector = true, allowAppend = false, allowHere = false, allowCurrentCodex = false } = {},
@@ -593,40 +735,46 @@ async function persistWorkspaceManifest(source, manifest, options = {}) {
593
735
  }
594
736
 
595
737
  if (watchTargets.syncIdeaSelector) {
596
- const targetSource = await loadWorkspaceSource({
597
- ...options,
598
- ideaId: watchTargets.syncIdeaSelector,
599
- });
600
- const targetIdeaId = resolveWorkspaceSyncTargetIdeaId(targetSource);
601
- if (!targetIdeaId) {
602
- throw new Error(`Workspace source does not resolve to a syncable hosted idea: ${watchTargets.syncIdeaSelector}`);
738
+ const ideaId = watchTargets.syncIdeaSelector;
739
+ let promoted;
740
+ try {
741
+ promoted = await findOrCreateHostedWorkspaceForIdea(ideaId, source, manifest, options);
742
+ } catch (error) {
743
+ return persistIdeaBackedWorkspaceToLocalCache(ideaId, source, manifest, error, options);
603
744
  }
604
- const targetPayload =
605
- targetSource.sourceType === "hosted-idea" && targetSource.idea?.id === targetIdeaId
606
- ? targetSource.payload
607
- : await fetchIdeaPayload(targetIdeaId, options);
608
- const liveSource = {
609
- sourceType: "workspace-file",
610
- sourceLabel: `edited-workspace:${watchTargets.syncIdeaSelector}`,
611
- title: manifest.title || manifest.workspaceId || source.title || watchTargets.syncIdeaSelector,
612
- workspaceManifest: manifest,
613
- notes: "",
614
- };
615
- const parsed = parseWorkspaceSource(liveSource);
616
- const preview = buildWorkspaceSyncPreview({
617
- source: liveSource,
618
- parsed,
619
- targetIdea: targetPayload.idea,
620
- workspaceTitle: manifest.title || manifest.workspaceId || undefined,
745
+ const workspaceId = getHostedWorkspaceId(promoted.workspace);
746
+ if (!workspaceId) {
747
+ throw new Error(`Hosted workspace for idea ${ideaId} did not include a workspace id.`);
748
+ }
749
+
750
+ const previousWorkspace = promoted.created
751
+ ? promoted.workspace
752
+ : (await fetchHostedWorkspacePayload(workspaceId, options)).workspace;
753
+ const state = buildHostedWorkspaceState(manifest, {
754
+ previousWorkspace,
755
+ capturedAt: manifest.capture?.capturedAt,
756
+ updatedAt: new Date().toISOString(),
621
757
  });
622
- const updatedIdea = await updateIdeaPayload(targetIdeaId, { notes: preview.nextNotes }, options);
623
- const managedCache = await cacheManagedWorkspaceManifest(preview.manifest, options);
758
+ const pushResult = await pushHostedWorkspaceState(workspaceId, state, options);
759
+ const cachedManifest = buildWorkspaceManifestFromHostedWorkspacePayload(pushResult);
760
+ const managedCache = await cacheManagedWorkspaceManifest(cachedManifest, options);
761
+ const workspaceForSlot = pushResult.workspace || promoted.workspace;
762
+ const assignedSlots = await assignMatchingWorkspaceSlots(
763
+ source,
764
+ cachedManifest,
765
+ buildHostedWorkspaceSlotAssignment(workspaceForSlot),
766
+ options,
767
+ );
624
768
  return {
625
- persistedTo: "hosted-idea",
626
- ideaId: targetIdeaId,
627
- updatedIdea,
769
+ persistedTo: "hosted-workspace",
770
+ ideaId,
771
+ promotedFromIdeaId: ideaId,
772
+ createdHostedWorkspace: promoted.created,
773
+ workspaceId,
774
+ pushResult,
775
+ assignedSlots,
628
776
  managedCache,
629
- manifest: preview.manifest,
777
+ manifest: cachedManifest,
630
778
  };
631
779
  }
632
780
 
@@ -780,8 +928,14 @@ function summarizeWorkspaceLedgerMutation(result) {
780
928
  lines.push(`Canonical source: ORP idea ${result.ideaId}`);
781
929
  } else if (result.persistedTo === "hosted-workspace") {
782
930
  lines.push(`Canonical source: hosted workspace ${result.workspaceId}`);
931
+ if (result.promotedFromIdeaId) {
932
+ lines.push(`Linked idea: ${result.promotedFromIdeaId}`);
933
+ }
783
934
  } else if (result.persistedTo === "workspace-file") {
784
935
  lines.push(`Saved file: ${result.manifestPath}`);
936
+ if (result.hostedMigrationSkippedReason) {
937
+ lines.push(`Hosted migration skipped: ${result.hostedMigrationSkippedReason}`);
938
+ }
785
939
  }
786
940
 
787
941
  if (result.managedCachePath) {
@@ -809,9 +963,14 @@ async function runWorkspaceLedgerMutation(options, mutate, action) {
809
963
  removedTabs: (mutated.removedTabs || []).map((tab) => buildWorkspaceResultTab(tab)),
810
964
  persistedTo: persisted.persistedTo,
811
965
  ideaId: persisted.ideaId || null,
966
+ promotedFromIdeaId: persisted.promotedFromIdeaId || null,
967
+ createdHostedWorkspace: persisted.createdHostedWorkspace || false,
968
+ hostedMigrationSkippedReason: persisted.hostedMigrationSkippedReason || null,
812
969
  workspaceSourceId: persisted.workspaceId || null,
813
970
  manifestPath: persisted.manifestPath || null,
814
971
  managedCachePath: persisted.managedCache?.manifestPath || null,
972
+ assignedSlot: persisted.assignedSlot || Object.values(persisted.assignedSlots || {})[0] || null,
973
+ assignedSlots: persisted.assignedSlots || null,
815
974
  manifest: finalManifest,
816
975
  };
817
976
 
@@ -197,6 +197,59 @@ export async function fetchHostedWorkspacesPayload(options = {}) {
197
197
  };
198
198
  }
199
199
 
200
+ export function findHostedWorkspaceLinkedToIdea(workspaces = [], ideaId) {
201
+ const targetIdeaId = normalizeOptionalString(ideaId);
202
+ if (!targetIdeaId || !Array.isArray(workspaces)) {
203
+ return null;
204
+ }
205
+
206
+ return (
207
+ workspaces.find((workspace) => {
208
+ if (!workspace || typeof workspace !== "object" || Array.isArray(workspace)) {
209
+ return false;
210
+ }
211
+ const sourceKind = normalizeOptionalString(workspace.source_kind ?? workspace.sourceKind) || "hosted";
212
+ if (sourceKind === "idea_bridge") {
213
+ return false;
214
+ }
215
+ const linkedIdea = getObjectValue(workspace, "linked_idea", "linkedIdea");
216
+ return getTextValue(linkedIdea, "idea_id", "ideaId") === targetIdeaId;
217
+ }) || null
218
+ );
219
+ }
220
+
221
+ export async function findHostedWorkspaceByLinkedIdea(ideaId, options = {}) {
222
+ const payload = await fetchHostedWorkspacesPayload(options);
223
+ return findHostedWorkspaceLinkedToIdea(payload.workspaces, ideaId);
224
+ }
225
+
226
+ export async function createHostedWorkspaceForIdea({ title, ideaId, description = null, visibility = null } = {}, options = {}) {
227
+ const workspaceTitle = slugify(title) || (ideaId ? `workspace-${String(ideaId).slice(0, 8).toLowerCase()}` : "workspace");
228
+ const invocation = resolveOrpInvocation(options);
229
+ const args = [...invocation.prefixArgs, "workspaces", "add", "--title", workspaceTitle];
230
+ if (description != null) {
231
+ args.push("--description", String(description));
232
+ }
233
+ if (visibility != null) {
234
+ args.push("--visibility", String(visibility));
235
+ }
236
+ const linkedIdeaId = normalizeOptionalString(ideaId);
237
+ if (linkedIdeaId) {
238
+ args.push("--idea-id", linkedIdeaId);
239
+ }
240
+ if (options.baseUrl) {
241
+ args.push("--base-url", options.baseUrl);
242
+ }
243
+ args.push("--json");
244
+
245
+ const result = await runCommand(invocation.command, args, options);
246
+ const payload = parseOrpJsonResult(result, "Failed to create ORP hosted workspace.");
247
+ if (!payload || payload.ok !== true || !payload.workspace) {
248
+ throw new Error("ORP returned an unexpected hosted workspace creation payload.");
249
+ }
250
+ return payload;
251
+ }
252
+
200
253
  function buildWorkspaceTitleFromIdea(idea, manifest) {
201
254
  return normalizeOptionalString(manifest?.title) || normalizeOptionalString(idea?.title) || null;
202
255
  }
@@ -857,12 +910,19 @@ export async function loadWorkspaceSource(options = {}) {
857
910
  const selector = options.ideaId;
858
911
  const slotTarget = await resolveWorkspaceSlotTarget(selector, options);
859
912
  if (slotTarget?.target) {
860
- return loadWorkspaceSource({
913
+ const resolvedSource = await loadWorkspaceSource({
861
914
  ...options,
862
915
  ideaId: slotTarget.target.ideaId,
863
916
  workspaceFile: slotTarget.target.workspaceFile,
864
917
  hostedWorkspaceId: slotTarget.target.hostedWorkspaceId,
865
918
  });
919
+ return {
920
+ ...resolvedSource,
921
+ resolvedSlotName: slotTarget.slotName,
922
+ resolvedSlotMode: slotTarget.mode,
923
+ resolvedSlot: slotTarget.slot || null,
924
+ resolvedSlotCandidate: slotTarget.candidate || null,
925
+ };
866
926
  }
867
927
  if (slotTarget?.slotName && slotTarget.mode === "unset") {
868
928
  throw new Error(
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import process from "node:process";
2
5
 
3
6
  import {
@@ -12,6 +15,144 @@ import {
12
15
  } from "./core-plan.js";
13
16
  import { loadWorkspaceSource } from "./orp.js";
14
17
 
18
+ const SESSION_ID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
19
+
20
+ function normalizeOptionalString(value) {
21
+ if (value == null) {
22
+ return null;
23
+ }
24
+ const trimmed = String(value).trim();
25
+ return trimmed.length > 0 ? trimmed : null;
26
+ }
27
+
28
+ function resolveCodexHome(options = {}) {
29
+ return (
30
+ normalizeOptionalString(options.codexHome) ||
31
+ normalizeOptionalString(process.env.CODEX_HOME) ||
32
+ path.join(os.homedir(), ".codex")
33
+ );
34
+ }
35
+
36
+ function collectCodexSessionIds(tabs = []) {
37
+ return new Set(
38
+ tabs
39
+ .filter((tab) => tab.resumeTool === "codex")
40
+ .map((tab) => normalizeOptionalString(tab.sessionId))
41
+ .map((sessionId) => sessionId?.toLowerCase())
42
+ .filter(Boolean),
43
+ );
44
+ }
45
+
46
+ function sessionIdsInFilename(filename, wantedSessionIds) {
47
+ const matches = new Set();
48
+ for (const match of filename.matchAll(SESSION_ID_PATTERN)) {
49
+ const sessionId = match[0].toLowerCase();
50
+ if (wantedSessionIds.has(sessionId)) {
51
+ matches.add(sessionId);
52
+ }
53
+ }
54
+
55
+ if (matches.size > 0) {
56
+ return matches;
57
+ }
58
+
59
+ for (const sessionId of wantedSessionIds) {
60
+ if (filename.includes(sessionId)) {
61
+ matches.add(sessionId);
62
+ }
63
+ }
64
+ return matches;
65
+ }
66
+
67
+ function rememberActivity(activityBySessionId, sessionId, filePath, stat) {
68
+ const current = activityBySessionId.get(sessionId);
69
+ if (!current || stat.mtimeMs > current.mtimeMs) {
70
+ activityBySessionId.set(sessionId, {
71
+ mtimeMs: stat.mtimeMs,
72
+ filePath,
73
+ });
74
+ }
75
+ }
76
+
77
+ function scanActivityDirectory(rootDir, wantedSessionIds, activityBySessionId) {
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
81
+ } catch {
82
+ return;
83
+ }
84
+
85
+ for (const entry of entries) {
86
+ const entryPath = path.join(rootDir, entry.name);
87
+ if (entry.isDirectory()) {
88
+ scanActivityDirectory(entryPath, wantedSessionIds, activityBySessionId);
89
+ continue;
90
+ }
91
+ if (!entry.isFile()) {
92
+ continue;
93
+ }
94
+
95
+ const matchingSessionIds = sessionIdsInFilename(entry.name, wantedSessionIds);
96
+ if (matchingSessionIds.size === 0) {
97
+ continue;
98
+ }
99
+
100
+ let stat;
101
+ try {
102
+ stat = fs.statSync(entryPath);
103
+ } catch {
104
+ continue;
105
+ }
106
+ for (const sessionId of matchingSessionIds) {
107
+ rememberActivity(activityBySessionId, sessionId, entryPath, stat);
108
+ }
109
+ }
110
+ }
111
+
112
+ function buildCodexActivityIndex(tabs = [], options = {}) {
113
+ const wantedSessionIds = collectCodexSessionIds(tabs);
114
+ if (wantedSessionIds.size === 0) {
115
+ return new Map();
116
+ }
117
+
118
+ const activityBySessionId = new Map();
119
+ const codexHome = resolveCodexHome(options);
120
+ scanActivityDirectory(path.join(codexHome, "sessions"), wantedSessionIds, activityBySessionId);
121
+ scanActivityDirectory(path.join(codexHome, "shell_snapshots"), wantedSessionIds, activityBySessionId);
122
+ return activityBySessionId;
123
+ }
124
+
125
+ function orderTabsByRecentActivity(tabs = [], options = {}) {
126
+ const activityBySessionId = buildCodexActivityIndex(tabs, options);
127
+ const rankedTabs = tabs.map((tab, originalIndex) => {
128
+ const sessionActivity =
129
+ tab.resumeTool === "codex" && tab.sessionId ? activityBySessionId.get(String(tab.sessionId).toLowerCase()) : null;
130
+ return {
131
+ tab,
132
+ originalIndex,
133
+ activityMs: sessionActivity?.mtimeMs || 0,
134
+ };
135
+ });
136
+
137
+ const projectActivity = new Map();
138
+ 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));
141
+ }
142
+
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 ||
149
+ right.activityMs - left.activityMs ||
150
+ left.originalIndex - right.originalIndex
151
+ );
152
+ })
153
+ .map((ranked) => ranked.tab);
154
+ }
155
+
15
156
  export function parseWorkspaceTabsArgs(argv = []) {
16
157
  const options = {
17
158
  json: false,
@@ -64,7 +205,8 @@ export function buildWorkspaceTabsReport(source, parsed, options = {}) {
64
205
  tmux: false,
65
206
  resume: true,
66
207
  });
67
- const projectGroups = buildWorkspaceProjectGroups(launchTabs);
208
+ const orderedLaunchTabs = orderTabsByRecentActivity(launchTabs, options);
209
+ const projectGroups = buildWorkspaceProjectGroups(orderedLaunchTabs);
68
210
 
69
211
  return {
70
212
  sourceType: source.sourceType,
@@ -73,7 +215,7 @@ export function buildWorkspaceTabsReport(source, parsed, options = {}) {
73
215
  workspaceId: deriveWorkspaceId(source, parsed),
74
216
  machine: parsed.manifest?.machine || null,
75
217
  parseMode: parsed.parseMode,
76
- tabCount: launchTabs.length,
218
+ tabCount: orderedLaunchTabs.length,
77
219
  projectCount: projectGroups.length,
78
220
  skippedCount: parsed.skipped.length,
79
221
  projects: projectGroups.map((project) => ({
@@ -92,7 +234,7 @@ export function buildWorkspaceTabsReport(source, parsed, options = {}) {
92
234
  ),
93
235
  })),
94
236
  })),
95
- tabs: launchTabs.map((tab, index) => ({
237
+ tabs: orderedLaunchTabs.map((tab, index) => ({
96
238
  index: index + 1,
97
239
  title: tab.title,
98
240
  path: tab.path,
@@ -191,7 +333,8 @@ Options:
191
333
  -h, --help Show this help text
192
334
 
193
335
  Notes:
194
- - This shows the saved tab order plus any stored local path, remote repo, bootstrap command, and \`codex resume ...\` / \`claude --resume ...\` metadata.
336
+ - This shows activity-ranked saved tabs plus any stored local path, remote repo, bootstrap command, and \`codex resume ...\` / \`claude --resume ...\` metadata.
337
+ - Codex-backed tabs are ranked by the latest matching local session or shell snapshot activity; ties keep the saved ledger order.
195
338
  - JSON output includes grouped \`projects[].sessions[]\` so duplicate project paths can be reviewed and sunset together.
196
339
  - The human-readable \`resume:\` line is already copyable and includes the saved \`cd ... && resume ...\` recovery command.
197
340
  - When a tab also has \`remote:\` or \`setup:\` lines, those are the portable cross-machine clues for cloning and preparing the repo on another rig.