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.
- package/CHANGELOG.md +426 -0
- package/README.md +17 -0
- package/cli/orp.py +909 -4
- package/docs/AGENT_RUNTIME_BORROWING_NOTES.md +68 -0
- package/package.json +2 -1
- package/packages/orp-workspace-launcher/src/index.js +3 -0
- package/packages/orp-workspace-launcher/src/ledger.js +192 -33
- package/packages/orp-workspace-launcher/src/orp.js +61 -1
- package/packages/orp-workspace-launcher/src/tabs.js +147 -4
- package/packages/orp-workspace-launcher/test/ledger.test.js +226 -0
- package/packages/orp-workspace-launcher/test/tabs.test.js +60 -0
|
@@ -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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
|
623
|
-
const
|
|
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-
|
|
626
|
-
ideaId
|
|
627
|
-
|
|
769
|
+
persistedTo: "hosted-workspace",
|
|
770
|
+
ideaId,
|
|
771
|
+
promotedFromIdeaId: ideaId,
|
|
772
|
+
createdHostedWorkspace: promoted.created,
|
|
773
|
+
workspaceId,
|
|
774
|
+
pushResult,
|
|
775
|
+
assignedSlots,
|
|
628
776
|
managedCache,
|
|
629
|
-
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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.
|