open-research-protocol 0.4.14 → 0.4.15
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/AGENT_INTEGRATION.md +50 -0
- package/README.md +273 -144
- package/bin/orp.js +14 -1
- package/cli/orp.py +14846 -9925
- package/docs/AGENT_LOOP.md +13 -0
- package/docs/AGENT_MODES.md +79 -0
- package/docs/CANONICAL_CLI_BOUNDARY.md +15 -0
- package/docs/EXCHANGE.md +94 -0
- package/docs/LAUNCH_KIT.md +107 -0
- package/docs/ORP_HOSTED_WORKSPACE_CONTRACT.md +295 -0
- package/docs/ORP_PUBLIC_LAUNCH_CHECKLIST.md +5 -0
- package/docs/START_HERE.md +567 -0
- package/package.json +4 -2
- package/packages/lifeops-orp/README.md +67 -0
- package/packages/lifeops-orp/package.json +48 -0
- package/packages/lifeops-orp/src/index.d.ts +106 -0
- package/packages/lifeops-orp/src/index.js +7 -0
- package/packages/lifeops-orp/src/mapping.js +309 -0
- package/packages/lifeops-orp/src/workspace.js +108 -0
- package/packages/lifeops-orp/test/orp.test.js +187 -0
- package/packages/orp-workspace-launcher/README.md +82 -0
- package/packages/orp-workspace-launcher/package.json +39 -0
- package/packages/orp-workspace-launcher/src/commands.js +77 -0
- package/packages/orp-workspace-launcher/src/core-plan.js +506 -0
- package/packages/orp-workspace-launcher/src/hosted-state.js +208 -0
- package/packages/orp-workspace-launcher/src/index.js +82 -0
- package/packages/orp-workspace-launcher/src/ledger.js +745 -0
- package/packages/orp-workspace-launcher/src/list.js +488 -0
- package/packages/orp-workspace-launcher/src/orp-command.js +126 -0
- package/packages/orp-workspace-launcher/src/orp.js +912 -0
- package/packages/orp-workspace-launcher/src/registry.js +558 -0
- package/packages/orp-workspace-launcher/src/slot.js +188 -0
- package/packages/orp-workspace-launcher/src/sync.js +363 -0
- package/packages/orp-workspace-launcher/src/tabs.js +166 -0
- package/packages/orp-workspace-launcher/test/commands.test.js +164 -0
- package/packages/orp-workspace-launcher/test/core-plan.test.js +253 -0
- package/packages/orp-workspace-launcher/test/fixtures/smoke-notes.txt +2 -0
- package/packages/orp-workspace-launcher/test/fixtures/workspace-manifest.json +17 -0
- package/packages/orp-workspace-launcher/test/ledger.test.js +244 -0
- package/packages/orp-workspace-launcher/test/list.test.js +299 -0
- package/packages/orp-workspace-launcher/test/orp-command.test.js +44 -0
- package/packages/orp-workspace-launcher/test/orp.test.js +224 -0
- package/packages/orp-workspace-launcher/test/tabs.test.js +168 -0
- package/scripts/orp-kernel-agent-pilot.py +10 -1
- package/scripts/orp-kernel-agent-replication.py +10 -1
- package/scripts/orp-kernel-canonical-continuation.py +10 -1
- package/scripts/orp-kernel-continuation-pilot.py +10 -1
- package/scripts/render-terminal-demo.py +416 -0
- package/spec/v1/exchange-report.schema.json +105 -0
- package/spec/v1/hosted-workspace-event.schema.json +102 -0
- package/spec/v1/hosted-workspace.schema.json +332 -0
- package/spec/v1/workspace.schema.json +108 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# orp-workspace-launcher
|
|
2
|
+
|
|
3
|
+
Manage a durable ORP workspace ledger of project paths plus saved `codex resume ...` or `claude --resume ...` commands.
|
|
4
|
+
|
|
5
|
+
The package no longer automates iTerm or Terminal.app. The workspace ledger is the source of truth, and you use Terminal however you want.
|
|
6
|
+
|
|
7
|
+
## Core flow
|
|
8
|
+
|
|
9
|
+
Create a local workspace ledger with no hosted account required:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
orp workspace create main-cody-1
|
|
13
|
+
orp workspace create research-lab --path /Volumes/Code_2TB/code/research-lab --resume-tool claude --resume-session-id 469d99b2-2997-42bf-a8f5-3812c808ef29
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Inspect the saved ledger:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
orp workspace ledger main
|
|
20
|
+
orp workspace tabs main
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Print copyable recovery commands:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
orp workspace tabs main
|
|
27
|
+
orp workspace tabs main --json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Add a new saved tab manually:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
orp workspace ledger add main --path /Volumes/Code_2TB/code/frg-site --resume-command "codex resume 019d348d-5031-78e1-9840-a66deaac33ae"
|
|
34
|
+
orp workspace add-tab main --path /Volumes/Code_2TB/code/anthropic-lab --resume-tool claude --resume-session-id claude-456
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Remove a saved tab manually:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
orp workspace ledger remove main --path /Volumes/Code_2TB/code/frg-site
|
|
41
|
+
orp workspace remove-tab main --resume-session-id claude-456 --resume-tool claude
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Work directly with a local manifest file:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
orp workspace add-tab --workspace-file ./workspace.json --path /Volumes/Code_2TB/code/orp
|
|
48
|
+
orp workspace tabs --workspace-file ./workspace.json
|
|
49
|
+
orp workspace tabs --workspace-file ./workspace.json --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Sync a local manifest back to the hosted canonical workspace:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
orp workspace sync main --workspace-file ./workspace.json
|
|
56
|
+
orp workspace sync main --workspace-file ./workspace.json --dry-run
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
List saved workspaces:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
orp workspace list
|
|
63
|
+
orp workspace slot list
|
|
64
|
+
orp workspace slot set main main-cody-1
|
|
65
|
+
orp workspace slot set offhand research-lab
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
- `--json`: print agent-friendly JSON
|
|
71
|
+
- `--notes-file <path>`: read a local notes file instead of ORP
|
|
72
|
+
- `--hosted-workspace-id <id>`: read or update a first-class hosted workspace instead of an idea-backed bridge
|
|
73
|
+
- `--workspace-file <path>`: read or update a structured workspace manifest JSON file
|
|
74
|
+
- `--base-url <url>`: override the ORP hosted base URL
|
|
75
|
+
- `--orp-command <path-or-command>`: override the ORP CLI binary used to fetch hosted idea JSON
|
|
76
|
+
- `--path <absolute-path>`: add or match a saved project path
|
|
77
|
+
- `--title <text>`: set or match a saved tab title
|
|
78
|
+
- `--resume-command <text>`: save or match an exact `codex resume ...` or `claude --resume ...` command
|
|
79
|
+
- `--resume-tool <codex|claude>`: build or narrow the resume command by tool
|
|
80
|
+
- `--resume-session-id <id>`: build or match a specific session id
|
|
81
|
+
- `--index <n>`: remove a saved tab by 1-based index
|
|
82
|
+
- `--all`: remove every matching saved tab
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orp-workspace-launcher",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage ORP workspace ledgers with saved paths and Codex or Claude resume commands.",
|
|
5
|
+
"author": "Fractal Research Group <cody@frg.earth>",
|
|
6
|
+
"homepage": "https://github.com/SproutSeeds/orp/tree/main/packages/orp-workspace-launcher",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/SproutSeeds/orp.git",
|
|
10
|
+
"directory": "packages/orp-workspace-launcher"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SproutSeeds/orp/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"codex",
|
|
32
|
+
"open-research-protocol",
|
|
33
|
+
"orp",
|
|
34
|
+
"workspace",
|
|
35
|
+
"ledger",
|
|
36
|
+
"claude"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildWorkspaceTabsReport,
|
|
5
|
+
parseWorkspaceTabsArgs,
|
|
6
|
+
summarizeWorkspaceTabs,
|
|
7
|
+
} from "./tabs.js";
|
|
8
|
+
import { loadWorkspaceSource } from "./orp.js";
|
|
9
|
+
import { parseWorkspaceSource } from "./core-plan.js";
|
|
10
|
+
|
|
11
|
+
export function parseWorkspaceCommandsArgs(argv = []) {
|
|
12
|
+
return parseWorkspaceTabsArgs(argv);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildWorkspaceCommandsReport(source, parsed, options = {}) {
|
|
16
|
+
const report = buildWorkspaceTabsReport(source, parsed, options);
|
|
17
|
+
return {
|
|
18
|
+
...report,
|
|
19
|
+
commandCount: report.tabCount,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function summarizeWorkspaceCommands(report) {
|
|
24
|
+
return summarizeWorkspaceTabs({
|
|
25
|
+
...report,
|
|
26
|
+
tabCount: report.commandCount ?? report.tabCount,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printWorkspaceCommandsHelp() {
|
|
31
|
+
console.log(`ORP workspace commands
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
orp workspace commands <name-or-id> [--json]
|
|
35
|
+
orp workspace commands --hosted-workspace-id <workspace-id> [--json]
|
|
36
|
+
orp workspace commands --notes-file <path> [--json]
|
|
37
|
+
orp workspace commands --workspace-file <path> [--json]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--json Print saved recovery commands as JSON
|
|
41
|
+
--hosted-workspace-id <id> Read a first-class hosted workspace instead of an idea
|
|
42
|
+
--notes-file <path> Read a local notes file instead of ORP
|
|
43
|
+
--workspace-file <path> Read a structured workspace manifest JSON file
|
|
44
|
+
--base-url <url> Override the ORP hosted base URL
|
|
45
|
+
--orp-command <cmd> Override the ORP CLI executable used for hosted fetches
|
|
46
|
+
-h, --help Show this help text
|
|
47
|
+
|
|
48
|
+
Notes:
|
|
49
|
+
- This is now a compatibility alias for \`orp workspace tabs ...\`.
|
|
50
|
+
- Use \`orp workspace tabs ...\` as the main read command for saved paths plus copyable resume lines.
|
|
51
|
+
- The selector can be \`main\`, \`offhand\`, a hosted idea id, a hosted workspace id, a local workspace id, or a saved workspace title/slug.
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runWorkspaceCommands(argv = process.argv.slice(2)) {
|
|
56
|
+
const options = parseWorkspaceCommandsArgs(argv);
|
|
57
|
+
if (options.help) {
|
|
58
|
+
printWorkspaceCommandsHelp();
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const source = await loadWorkspaceSource(options);
|
|
63
|
+
const parsed = parseWorkspaceSource(source);
|
|
64
|
+
const report = buildWorkspaceCommandsReport(source, parsed, options);
|
|
65
|
+
|
|
66
|
+
if (report.commandCount === 0) {
|
|
67
|
+
throw new Error("No saved workspace commands were found in the provided workspace source.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (options.json) {
|
|
71
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.stdout.write(`${summarizeWorkspaceCommands(report)}\n`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const WORKSPACE_SCHEMA_VERSION = "1";
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_RESUME_TOOLS = new Set(["codex", "claude"]);
|
|
6
|
+
const CODEX_RESUME_PATTERN = /^\s*codex\s+resume\s+([^\s]+)(?:\s+.*)?$/i;
|
|
7
|
+
const CLAUDE_LEGACY_RESUME_PATTERN = /^\s*claude\s+resume\s+([^\s]+)(?:\s+.*)?$/i;
|
|
8
|
+
const CLAUDE_FLAG_RESUME_PATTERN = /^\s*claude\s+(?:--resume|-r)(?:=|\s+)([^\s]+)(?:\s+.*)?$/i;
|
|
9
|
+
const STRUCTURED_WORKSPACE_PATTERN = /```orp-workspace\s*([\s\S]*?)```/i;
|
|
10
|
+
|
|
11
|
+
function partitionOnColon(value) {
|
|
12
|
+
const index = value.indexOf(":");
|
|
13
|
+
if (index === -1) {
|
|
14
|
+
return [value, ""];
|
|
15
|
+
}
|
|
16
|
+
return [value.slice(0, index), value.slice(index + 1)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shellQuote(value) {
|
|
20
|
+
const text = String(value);
|
|
21
|
+
if (text.length === 0) {
|
|
22
|
+
return "''";
|
|
23
|
+
}
|
|
24
|
+
return `'${text.replace(/'/g, `'\"'\"'`)}'`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeDisplayPath(value) {
|
|
28
|
+
const trimmed = String(value).trim();
|
|
29
|
+
if (trimmed === "/") {
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
return trimmed.replace(/\/+$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hashText(value) {
|
|
36
|
+
let hash = 0;
|
|
37
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
38
|
+
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
39
|
+
}
|
|
40
|
+
return hash.toString(36);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function slugify(value) {
|
|
44
|
+
const normalized = String(value || "")
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
48
|
+
.replace(/^-+|-+$/g, "");
|
|
49
|
+
return normalized || "workspace";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateAbsolutePath(value, label) {
|
|
53
|
+
if (typeof value !== "string" || !value.trim().startsWith("/")) {
|
|
54
|
+
throw new Error(`${label} must be an absolute path`);
|
|
55
|
+
}
|
|
56
|
+
return value.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeOptionalString(value) {
|
|
60
|
+
if (value == null) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const trimmed = String(value).trim();
|
|
64
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeResumeTool(value) {
|
|
68
|
+
const trimmed = normalizeOptionalString(value)?.toLowerCase() || null;
|
|
69
|
+
return trimmed && SUPPORTED_RESUME_TOOLS.has(trimmed) ? trimmed : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildCanonicalResumeCommand(tool, sessionId) {
|
|
73
|
+
const resumeTool = normalizeResumeTool(tool);
|
|
74
|
+
const resumeSessionId = normalizeOptionalString(sessionId);
|
|
75
|
+
if (!resumeTool || !resumeSessionId) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (resumeTool === "claude") {
|
|
79
|
+
return `claude --resume ${resumeSessionId}`;
|
|
80
|
+
}
|
|
81
|
+
return `${resumeTool} resume ${resumeSessionId}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseResumeCommandText(value) {
|
|
85
|
+
const trimmed = normalizeOptionalString(value);
|
|
86
|
+
if (!trimmed) {
|
|
87
|
+
return {
|
|
88
|
+
resumeCommand: null,
|
|
89
|
+
resumeTool: null,
|
|
90
|
+
resumeSessionId: null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const codexMatch = trimmed.match(CODEX_RESUME_PATTERN);
|
|
95
|
+
if (codexMatch) {
|
|
96
|
+
return {
|
|
97
|
+
resumeCommand: trimmed,
|
|
98
|
+
resumeTool: "codex",
|
|
99
|
+
resumeSessionId: normalizeOptionalString(codexMatch[1]),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const claudeFlagMatch = trimmed.match(CLAUDE_FLAG_RESUME_PATTERN);
|
|
104
|
+
if (claudeFlagMatch) {
|
|
105
|
+
return {
|
|
106
|
+
resumeCommand: trimmed,
|
|
107
|
+
resumeTool: "claude",
|
|
108
|
+
resumeSessionId: normalizeOptionalString(claudeFlagMatch[1]),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const claudeLegacyMatch = trimmed.match(CLAUDE_LEGACY_RESUME_PATTERN);
|
|
113
|
+
if (!claudeLegacyMatch) {
|
|
114
|
+
return {
|
|
115
|
+
resumeCommand: null,
|
|
116
|
+
resumeTool: null,
|
|
117
|
+
resumeSessionId: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
resumeCommand: trimmed,
|
|
123
|
+
resumeTool: "claude",
|
|
124
|
+
resumeSessionId: normalizeOptionalString(claudeLegacyMatch[1]),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveResumeMetadata(raw = {}) {
|
|
129
|
+
const parsedCommand = parseResumeCommandText(raw.resumeCommand ?? raw.remainder);
|
|
130
|
+
const explicitTool = normalizeResumeTool(raw.resumeTool);
|
|
131
|
+
const explicitResumeSessionId = normalizeOptionalString(raw.resumeSessionId ?? raw.sessionId);
|
|
132
|
+
const legacyCodexSessionId = normalizeOptionalString(raw.codexSessionId);
|
|
133
|
+
const legacyClaudeSessionId = normalizeOptionalString(raw.claudeSessionId);
|
|
134
|
+
|
|
135
|
+
const resumeTool =
|
|
136
|
+
parsedCommand.resumeTool ||
|
|
137
|
+
explicitTool ||
|
|
138
|
+
(explicitResumeSessionId ? "codex" : null) ||
|
|
139
|
+
(legacyCodexSessionId ? "codex" : null) ||
|
|
140
|
+
(legacyClaudeSessionId ? "claude" : null);
|
|
141
|
+
|
|
142
|
+
const resumeSessionId =
|
|
143
|
+
parsedCommand.resumeSessionId ||
|
|
144
|
+
explicitResumeSessionId ||
|
|
145
|
+
(resumeTool === "codex" ? legacyCodexSessionId : null) ||
|
|
146
|
+
(resumeTool === "claude" ? legacyClaudeSessionId : null) ||
|
|
147
|
+
legacyCodexSessionId ||
|
|
148
|
+
legacyClaudeSessionId ||
|
|
149
|
+
null;
|
|
150
|
+
|
|
151
|
+
const resumeCommand =
|
|
152
|
+
parsedCommand.resumeCommand ||
|
|
153
|
+
buildCanonicalResumeCommand(resumeTool, resumeSessionId);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
resumeCommand,
|
|
157
|
+
resumeTool,
|
|
158
|
+
resumeSessionId,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getResumeCommand(entry) {
|
|
163
|
+
return resolveResumeMetadata(entry).resumeCommand;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeOptionalPositiveInteger(value, label) {
|
|
167
|
+
if (value == null || value === "") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
171
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
172
|
+
throw new Error(`${label} must be a positive integer`);
|
|
173
|
+
}
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeOptionalNonNegativeInteger(value, label) {
|
|
178
|
+
if (value == null || value === "") {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
182
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
183
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
184
|
+
}
|
|
185
|
+
return parsed;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeOptionalPositiveNumber(value, label) {
|
|
189
|
+
if (value == null || value === "") {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const parsed = Number(String(value));
|
|
193
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
194
|
+
throw new Error(`${label} must be a positive number`);
|
|
195
|
+
}
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeCaptureMetadata(rawCapture) {
|
|
200
|
+
if (rawCapture == null) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (!rawCapture || typeof rawCapture !== "object" || Array.isArray(rawCapture)) {
|
|
204
|
+
throw new Error("workspace manifest capture metadata must be an object");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const capture = Object.fromEntries(
|
|
208
|
+
Object.entries({
|
|
209
|
+
sourceApp: normalizeOptionalString(rawCapture.sourceApp),
|
|
210
|
+
mode: normalizeOptionalString(rawCapture.mode),
|
|
211
|
+
host: normalizeOptionalString(rawCapture.host),
|
|
212
|
+
windowId: normalizeOptionalPositiveInteger(rawCapture.windowId, "workspace manifest capture.windowId"),
|
|
213
|
+
windowIndex: normalizeOptionalPositiveInteger(rawCapture.windowIndex, "workspace manifest capture.windowIndex"),
|
|
214
|
+
tabCount: normalizeOptionalNonNegativeInteger(rawCapture.tabCount, "workspace manifest capture.tabCount"),
|
|
215
|
+
capturedAt: normalizeOptionalString(rawCapture.capturedAt),
|
|
216
|
+
trackingStartedAt: normalizeOptionalString(rawCapture.trackingStartedAt),
|
|
217
|
+
pollSeconds: normalizeOptionalPositiveNumber(rawCapture.pollSeconds, "workspace manifest capture.pollSeconds"),
|
|
218
|
+
}).filter(([, value]) => value != null),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return Object.keys(capture).length > 0 ? capture : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeStructuredTab(rawTab, index) {
|
|
225
|
+
if (!rawTab || typeof rawTab !== "object" || Array.isArray(rawTab)) {
|
|
226
|
+
throw new Error(`workspace tab ${index + 1} must be an object`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pathValue = validateAbsolutePath(rawTab.path, `workspace tab ${index + 1} path`);
|
|
230
|
+
const title = normalizeOptionalString(rawTab.title);
|
|
231
|
+
const resume = resolveResumeMetadata(rawTab);
|
|
232
|
+
const tmuxSessionName = normalizeOptionalString(rawTab.tmuxSessionName);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
lineNumber: index + 1,
|
|
236
|
+
rawLine: JSON.stringify(rawTab),
|
|
237
|
+
path: pathValue,
|
|
238
|
+
remainder: resume.resumeCommand || "",
|
|
239
|
+
sessionId: resume.resumeSessionId,
|
|
240
|
+
resumeCommand: resume.resumeCommand,
|
|
241
|
+
resumeTool: resume.resumeTool,
|
|
242
|
+
title,
|
|
243
|
+
tmuxSessionName,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function normalizeWorkspaceManifest(rawManifest) {
|
|
248
|
+
if (!rawManifest || typeof rawManifest !== "object" || Array.isArray(rawManifest)) {
|
|
249
|
+
throw new Error("workspace manifest must be a JSON object");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const version = String(rawManifest.version ?? WORKSPACE_SCHEMA_VERSION);
|
|
253
|
+
if (version !== WORKSPACE_SCHEMA_VERSION) {
|
|
254
|
+
throw new Error(`unsupported workspace manifest version: ${version}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!Array.isArray(rawManifest.tabs) || rawManifest.tabs.length === 0) {
|
|
258
|
+
throw new Error("workspace manifest must include a non-empty tabs array");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tabs = rawManifest.tabs.map((tab, index) => normalizeStructuredTab(tab, index));
|
|
262
|
+
return {
|
|
263
|
+
version,
|
|
264
|
+
workspaceId: normalizeOptionalString(rawManifest.workspaceId),
|
|
265
|
+
title: normalizeOptionalString(rawManifest.title),
|
|
266
|
+
tmuxPrefix: normalizeOptionalString(rawManifest.tmuxPrefix),
|
|
267
|
+
capture: normalizeCaptureMetadata(rawManifest.capture),
|
|
268
|
+
tabs,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function extractStructuredWorkspaceFromNotes(notes) {
|
|
273
|
+
const match = String(notes || "").match(STRUCTURED_WORKSPACE_PATTERN);
|
|
274
|
+
if (!match) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let parsed;
|
|
279
|
+
try {
|
|
280
|
+
parsed = JSON.parse(match[1]);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`failed to parse \`\`\`orp-workspace\`\`\` JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return normalizeWorkspaceManifest(parsed);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function deriveBaseTitle(entry) {
|
|
291
|
+
if (entry.title && String(entry.title).trim().length > 0) {
|
|
292
|
+
return String(entry.title).trim();
|
|
293
|
+
}
|
|
294
|
+
const normalized = normalizeDisplayPath(entry.path);
|
|
295
|
+
return path.basename(normalized) || normalized;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function deriveTmuxSessionName(entry, options = {}) {
|
|
299
|
+
if (entry.tmuxSessionName && String(entry.tmuxSessionName).trim().length > 0) {
|
|
300
|
+
return String(entry.tmuxSessionName).trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const prefix = (options.tmuxPrefix || "orp").trim() || "orp";
|
|
304
|
+
const base = deriveBaseTitle(entry)
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
307
|
+
.replace(/^-+|-+$/g, "") || "workspace";
|
|
308
|
+
const entropy = hashText(`${entry.path}:${getResumeCommand(entry) || entry.sessionId || ""}:${entry.lineNumber || 0}`).slice(
|
|
309
|
+
0,
|
|
310
|
+
6,
|
|
311
|
+
);
|
|
312
|
+
return `${prefix}-${base}-${entropy}`.slice(0, 64);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function deriveWorkspaceId(source, parsed) {
|
|
316
|
+
if (parsed.manifest?.workspaceId) {
|
|
317
|
+
return parsed.manifest.workspaceId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (source.sourceType === "hosted-idea" && source.idea?.id) {
|
|
321
|
+
return `idea-${source.idea.id}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (source.sourceType === "workspace-file" && source.sourcePath) {
|
|
325
|
+
return `file-${slugify(path.basename(source.sourcePath, path.extname(source.sourcePath)))}-${hashText(source.sourcePath).slice(0, 6)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (source.sourcePath) {
|
|
329
|
+
return `file-${hashText(source.sourcePath).slice(0, 8)}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return `workspace-${hashText(`${source.sourceType}:${source.sourceLabel}`).slice(0, 8)}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function parseCorePlanNotes(notes) {
|
|
336
|
+
const entries = [];
|
|
337
|
+
const skipped = [];
|
|
338
|
+
|
|
339
|
+
for (const [index, rawLine] of String(notes || "").split(/\r?\n/).entries()) {
|
|
340
|
+
const lineNumber = index + 1;
|
|
341
|
+
const line = rawLine.trim();
|
|
342
|
+
if (!line) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (!line.startsWith("/")) {
|
|
346
|
+
skipped.push({ lineNumber, rawLine });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const [rawPath, rawRemainder] = partitionOnColon(line);
|
|
351
|
+
const workspacePath = rawPath.trim();
|
|
352
|
+
const remainder = rawRemainder.trim();
|
|
353
|
+
const resume = resolveResumeMetadata({ resumeCommand: remainder });
|
|
354
|
+
|
|
355
|
+
entries.push({
|
|
356
|
+
lineNumber,
|
|
357
|
+
rawLine,
|
|
358
|
+
path: workspacePath,
|
|
359
|
+
remainder,
|
|
360
|
+
sessionId: resume.resumeSessionId,
|
|
361
|
+
resumeCommand: resume.resumeCommand,
|
|
362
|
+
resumeTool: resume.resumeTool,
|
|
363
|
+
title: null,
|
|
364
|
+
tmuxSessionName: null,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { entries, skipped, manifest: null, parseMode: "notes" };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function parseWorkspaceSource(source) {
|
|
372
|
+
if (source.workspaceManifest) {
|
|
373
|
+
const manifest = normalizeWorkspaceManifest(source.workspaceManifest);
|
|
374
|
+
return {
|
|
375
|
+
entries: manifest.tabs,
|
|
376
|
+
skipped: [],
|
|
377
|
+
manifest,
|
|
378
|
+
parseMode: "manifest",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const structuredManifest = extractStructuredWorkspaceFromNotes(source.notes || "");
|
|
383
|
+
if (structuredManifest) {
|
|
384
|
+
return {
|
|
385
|
+
entries: structuredManifest.tabs,
|
|
386
|
+
skipped: [],
|
|
387
|
+
manifest: structuredManifest,
|
|
388
|
+
parseMode: "manifest",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return parseCorePlanNotes(source.notes || "");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function buildDirectCommand(entry, options = {}) {
|
|
396
|
+
const commands = [`cd ${shellQuote(entry.path)}`];
|
|
397
|
+
const resumeCommand = options.resume !== false ? getResumeCommand(entry) : null;
|
|
398
|
+
if (resumeCommand) {
|
|
399
|
+
commands.push(resumeCommand);
|
|
400
|
+
}
|
|
401
|
+
return commands.join(" && ");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function buildTmuxPresentationCommands(entry, options = {}) {
|
|
405
|
+
const sessionName = options.sessionName || deriveTmuxSessionName(entry, options);
|
|
406
|
+
const quotedSession = shellQuote(sessionName);
|
|
407
|
+
const targetWindow = `${quotedSession}:0`;
|
|
408
|
+
const title = options.displayTitle || entry.displayTitle || deriveBaseTitle(entry);
|
|
409
|
+
|
|
410
|
+
return [
|
|
411
|
+
`tmux set-option -t ${quotedSession} set-titles on`,
|
|
412
|
+
`tmux set-option -t ${quotedSession} set-titles-string ${shellQuote(title)}`,
|
|
413
|
+
`tmux set-window-option -t ${targetWindow} automatic-rename off`,
|
|
414
|
+
`tmux set-window-option -t ${targetWindow} allow-rename off`,
|
|
415
|
+
`tmux rename-window -t ${targetWindow} ${shellQuote(title)}`,
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function buildTmuxCommand(entry, options = {}) {
|
|
420
|
+
const sessionName = options.sessionName || deriveTmuxSessionName(entry, options);
|
|
421
|
+
const sessionNameQuoted = shellQuote(sessionName);
|
|
422
|
+
const loginShell = options.loginShell || process.env.SHELL || "/bin/zsh";
|
|
423
|
+
const bootstrapCommand = `cd ${shellQuote(entry.path)} && exec ${shellQuote(loginShell)} -l`;
|
|
424
|
+
const presentationCommands = buildTmuxPresentationCommands(entry, { ...options, sessionName });
|
|
425
|
+
const createParts = [
|
|
426
|
+
`tmux new-session -d -s ${sessionNameQuoted} ${shellQuote(bootstrapCommand)}`,
|
|
427
|
+
...presentationCommands,
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const resumeCommand = options.resume !== false ? getResumeCommand(entry) : null;
|
|
431
|
+
if (resumeCommand) {
|
|
432
|
+
createParts.push(
|
|
433
|
+
`tmux send-keys -t ${sessionNameQuoted} ${shellQuote(resumeCommand)} C-m`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return [
|
|
438
|
+
`if tmux has-session -t ${sessionNameQuoted} 2>/dev/null; then`,
|
|
439
|
+
`${presentationCommands.join(" && ")} && tmux attach-session -t ${sessionNameQuoted};`,
|
|
440
|
+
"else",
|
|
441
|
+
`${createParts.join(" && ")} && tmux attach-session -t ${sessionNameQuoted};`,
|
|
442
|
+
"fi",
|
|
443
|
+
].join(" ");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function buildLaunchPlan(entries, options = {}) {
|
|
447
|
+
const titleCounts = new Map();
|
|
448
|
+
|
|
449
|
+
return entries.map((entry) => {
|
|
450
|
+
const baseTitle = deriveBaseTitle(entry);
|
|
451
|
+
const occurrence = (titleCounts.get(baseTitle) || 0) + 1;
|
|
452
|
+
titleCounts.set(baseTitle, occurrence);
|
|
453
|
+
|
|
454
|
+
const title = occurrence === 1 ? baseTitle : `${baseTitle} (${occurrence})`;
|
|
455
|
+
const sessionName = options.tmux ? deriveTmuxSessionName(entry, options) : null;
|
|
456
|
+
// iTerm already appends "(tmux)" when a tab is backed by a tmux session,
|
|
457
|
+
// so the title we push into tmux should stay clean.
|
|
458
|
+
const displayTitle = title;
|
|
459
|
+
const command = options.tmux
|
|
460
|
+
? buildTmuxCommand(entry, { ...options, sessionName, displayTitle })
|
|
461
|
+
: buildDirectCommand(entry, options);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
...entry,
|
|
465
|
+
title,
|
|
466
|
+
displayTitle,
|
|
467
|
+
sessionName,
|
|
468
|
+
command,
|
|
469
|
+
mode: options.tmux ? "tmux" : "direct",
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function summarizeLaunchPlan(plan) {
|
|
475
|
+
const terminalLabel = plan.terminalApp === "terminal" ? "Terminal.app" : "iTerm";
|
|
476
|
+
const lines = [
|
|
477
|
+
`Source: ${plan.sourceLabel}`,
|
|
478
|
+
`Workspace ID: ${plan.workspaceId}`,
|
|
479
|
+
`Tabs: ${plan.tabs.length}`,
|
|
480
|
+
`Mode: ${plan.tmux ? `${terminalLabel} + tmux` : `${terminalLabel} direct`}`,
|
|
481
|
+
`Parse mode: ${plan.parseMode}`,
|
|
482
|
+
"",
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
for (const [index, tab] of plan.tabs.entries()) {
|
|
486
|
+
lines.push(`${String(index + 1).padStart(2, "0")}. ${tab.title}`);
|
|
487
|
+
lines.push(` path: ${tab.path}`);
|
|
488
|
+
if (tab.resumeCommand) {
|
|
489
|
+
lines.push(` resume: ${tab.resumeCommand}`);
|
|
490
|
+
}
|
|
491
|
+
if (tab.sessionName) {
|
|
492
|
+
lines.push(` tmux: ${tab.sessionName}`);
|
|
493
|
+
}
|
|
494
|
+
lines.push(` command: ${tab.command}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (plan.skipped.length > 0) {
|
|
498
|
+
lines.push("");
|
|
499
|
+
lines.push("Skipped lines:");
|
|
500
|
+
for (const skipped of plan.skipped) {
|
|
501
|
+
lines.push(` line ${skipped.lineNumber}: ${skipped.rawLine}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return lines.join("\n");
|
|
506
|
+
}
|