orionfold-relay 0.15.0 → 0.15.2

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/README.md CHANGED
@@ -194,20 +194,6 @@ See `features/roadmap.md` for the full inventory and `features/stats/snapshot.js
194
194
 
195
195
  ---
196
196
 
197
- ## About the author
198
-
199
- ### Manav Sehgal
200
-
201
- Solutions Leader, AWS Frontier AI. Author, *AI Native Business* open book. 2M+ Kaggle views. Ex Amazon AGI.
202
-
203
- Orionfold Relay is a personal research project exploring what an AI-native operating system looks like — built over weekends, on a personal laptop, with capped AI plans. The *AI Native Business* open book is its 14-chapter playbook for building autonomous business systems with AI agents.
204
-
205
- Manav is a solutions leader at AWS Frontier AI, collaborating with Anthropic, NVIDIA, and Disney on production AI and agentic systems. His 25-year arc spans Xerox PARC (1996), HCL's digital practice, Daily Mail, Amazon AGI, and AWS, where he has delivered scale AI programs for Amazon Retail and Alexa. He led the AWS pandemic response honored by a President of India award for the customer. He holds credentials from Harvard (Disruptive Strategy), MIT Sloan (Design Thinking), and Berkeley Haas (Leading Innovative Change).
206
-
207
- > Orionfold Relay and *AI Native Business* are personal works created on Manav's own time and resources. While they may refer to AWS technologies, the opinions expressed are the author's personal opinions and not those of the employer.
208
-
209
- ---
210
-
211
197
  ## License
212
198
 
213
199
  Licensed under the [Apache License 2.0](LICENSE).
package/dist/cli.js CHANGED
@@ -27,18 +27,37 @@ var __copyProps = (to, from, except, desc16) => {
27
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
28
 
29
29
  // src/lib/utils/app-root.ts
30
- import { existsSync as existsSync2 } from "fs";
31
- import { join as join2 } from "path";
30
+ import { existsSync as existsSync2, readFileSync } from "fs";
31
+ import { join as join2, dirname as dirname2 } from "path";
32
32
  function getAppRoot(metaDirname, depth) {
33
33
  if (metaDirname) {
34
34
  const candidate = join2(metaDirname, ...Array(depth).fill(".."));
35
- if (existsSync2(join2(candidate, "package.json"))) return candidate;
35
+ if (isPackageRoot(candidate)) return candidate;
36
+ let dir = metaDirname;
37
+ while (true) {
38
+ if (isPackageRoot(dir)) return dir;
39
+ const parent = dirname2(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
36
43
  }
37
44
  return process.cwd();
38
45
  }
46
+ function isPackageRoot(dir) {
47
+ const pkgPath = join2(dir, "package.json");
48
+ if (!existsSync2(pkgPath)) return false;
49
+ try {
50
+ const pkg2 = JSON.parse(readFileSync(pkgPath, "utf-8"));
51
+ return pkg2.name === PKG_NAME;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+ var PKG_NAME;
39
57
  var init_app_root = __esm({
40
58
  "src/lib/utils/app-root.ts"() {
41
59
  "use strict";
60
+ PKG_NAME = "orionfold-relay";
42
61
  }
43
62
  });
44
63
 
@@ -1203,7 +1222,7 @@ var init_types = __esm({
1203
1222
 
1204
1223
  // src/lib/environment/parsers/utils.ts
1205
1224
  import { createHash } from "crypto";
1206
- import { readFileSync as readFileSync2, statSync } from "fs";
1225
+ import { readFileSync as readFileSync3, statSync } from "fs";
1207
1226
  function computeHash(content) {
1208
1227
  const truncated = content.slice(0, MAX_HASH_BYTES);
1209
1228
  return createHash("sha256").update(truncated).digest("hex");
@@ -1220,7 +1239,7 @@ function safeStat(path19) {
1220
1239
  }
1221
1240
  function safeReadFile(path19) {
1222
1241
  try {
1223
- return readFileSync2(path19, "utf-8");
1242
+ return readFileSync3(path19, "utf-8");
1224
1243
  } catch {
1225
1244
  return null;
1226
1245
  }
@@ -5744,7 +5763,7 @@ __export(crypto_exports, {
5744
5763
  getOrCreateKeyfile: () => getOrCreateKeyfile
5745
5764
  });
5746
5765
  import { randomBytes, createCipheriv, createDecipheriv } from "crypto";
5747
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, chmodSync } from "fs";
5766
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync5, mkdirSync as mkdirSync2, chmodSync } from "fs";
5748
5767
  import { join as join7 } from "path";
5749
5768
  function getKeyfilePath() {
5750
5769
  return join7(dataDir(), ".keyfile");
@@ -5752,7 +5771,7 @@ function getKeyfilePath() {
5752
5771
  function getOrCreateKeyfile() {
5753
5772
  const keyfilePath = getKeyfilePath();
5754
5773
  if (existsSync5(keyfilePath)) {
5755
- const key2 = readFileSync3(keyfilePath);
5774
+ const key2 = readFileSync4(keyfilePath);
5756
5775
  if (key2.length !== KEY_LENGTH) {
5757
5776
  throw new Error(`Invalid keyfile: expected ${KEY_LENGTH} bytes, got ${key2.length}`);
5758
5777
  }
@@ -6718,7 +6737,7 @@ __export(workspace_context_exports, {
6718
6737
  getLaunchCwd: () => getLaunchCwd,
6719
6738
  getWorkspaceContext: () => getWorkspaceContext
6720
6739
  });
6721
- import { basename, dirname as dirname2 } from "path";
6740
+ import { basename, dirname as dirname3 } from "path";
6722
6741
  import { homedir as homedir4 } from "os";
6723
6742
  import { execFileSync as execFileSync2 } from "child_process";
6724
6743
  import { statSync as statSync2 } from "fs";
@@ -6730,7 +6749,7 @@ function getWorkspaceContext() {
6730
6749
  const cwd = getLaunchCwd();
6731
6750
  const home = homedir4();
6732
6751
  const folderName = basename(cwd);
6733
- const parent = dirname2(cwd);
6752
+ const parent = dirname3(cwd);
6734
6753
  const parentPath = parent.startsWith(home) ? "~" + parent.slice(home.length) : parent;
6735
6754
  let gitBranch = null;
6736
6755
  try {
@@ -12811,7 +12830,7 @@ var list_fused_profiles_exports = {};
12811
12830
  __export(list_fused_profiles_exports, {
12812
12831
  listFusedProfiles: () => listFusedProfiles
12813
12832
  });
12814
- import { readdirSync, readFileSync as readFileSync4, statSync as statSync3, existsSync as existsSync6 } from "fs";
12833
+ import { readdirSync, readFileSync as readFileSync5, statSync as statSync3, existsSync as existsSync6 } from "fs";
12815
12834
  import { join as join11 } from "path";
12816
12835
  import { homedir as homedir5 } from "os";
12817
12836
  function parseFrontmatter2(content) {
@@ -12836,7 +12855,7 @@ function loadFilesystemSkills(skillsDir, origin, projectRootDir) {
12836
12855
  if (!statSync3(skillPath).isDirectory()) continue;
12837
12856
  const skillMdPath = join11(skillPath, "SKILL.md");
12838
12857
  if (!existsSync6(skillMdPath)) continue;
12839
- const content = readFileSync4(skillMdPath, "utf8");
12858
+ const content = readFileSync5(skillMdPath, "utf8");
12840
12859
  const fm = parseFrontmatter2(content);
12841
12860
  if (!fm || !fm.name) {
12842
12861
  console.warn(
@@ -15552,7 +15571,7 @@ var init_active_skills = __esm({
15552
15571
  });
15553
15572
 
15554
15573
  // src/lib/environment/parsers/skill.ts
15555
- import { readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
15574
+ import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
15556
15575
  import { join as join12, basename as basename3 } from "path";
15557
15576
  function parseSkillDir(dirPath, tool, scope, baseDir) {
15558
15577
  const stat2 = safeStat(dirPath);
@@ -15566,7 +15585,7 @@ function parseSkillDir(dirPath, tool, scope, baseDir) {
15566
15585
  const skillFile = files.find((f) => f === "SKILL.md") || files.find((f) => f.endsWith(".md")) || files[0];
15567
15586
  if (skillFile) {
15568
15587
  mainFile = join12(dirPath, skillFile);
15569
- content = readFileSync5(mainFile, "utf-8");
15588
+ content = readFileSync6(mainFile, "utf-8");
15570
15589
  }
15571
15590
  } catch {
15572
15591
  return null;
@@ -16202,7 +16221,7 @@ __export(list_skills_exports, {
16202
16221
  listSkills: () => listSkills,
16203
16222
  listSkillsEnriched: () => listSkillsEnriched
16204
16223
  });
16205
- import { readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
16224
+ import { readFileSync as readFileSync7, readdirSync as readdirSync6, statSync as statSync4 } from "fs";
16206
16225
  import { join as join17 } from "path";
16207
16226
  function listSkills(options = {}) {
16208
16227
  const projectDir = options.projectDir ?? getLaunchCwd();
@@ -16227,7 +16246,7 @@ function getSkill(id, options = {}) {
16227
16246
  const filePath = resolveSkillFile(hit.absPath);
16228
16247
  if (!filePath) return null;
16229
16248
  try {
16230
- const content = readFileSync6(filePath, "utf8");
16249
+ const content = readFileSync7(filePath, "utf8");
16231
16250
  return { ...hit, content };
16232
16251
  } catch {
16233
16252
  return null;
@@ -19151,7 +19170,7 @@ var init_codex_app_server_client = __esm({
19151
19170
 
19152
19171
  // src/lib/agents/runtime/openai-codex-auth.ts
19153
19172
  import { mkdir as mkdir2, readFile as readFile7, rm, writeFile } from "fs/promises";
19154
- import { dirname as dirname3 } from "path";
19173
+ import { dirname as dirname4 } from "path";
19155
19174
  function parseRateLimitWindow(value) {
19156
19175
  if (!value || typeof value !== "object") return null;
19157
19176
  return {
@@ -19211,7 +19230,7 @@ async function ensureCodexHomeConfig() {
19211
19230
  const codexDir = getAinativeCodexDir();
19212
19231
  const configPath = getAinativeCodexConfigPath();
19213
19232
  await mkdir2(codexDir, { recursive: true });
19214
- await mkdir2(dirname3(configPath), { recursive: true });
19233
+ await mkdir2(dirname4(configPath), { recursive: true });
19215
19234
  let current = "";
19216
19235
  try {
19217
19236
  current = await readFile7(configPath, "utf8");
@@ -25183,6 +25202,9 @@ import { execFileSync as execFileSync3 } from "child_process";
25183
25202
  import yaml12 from "js-yaml";
25184
25203
  import semver from "semver";
25185
25204
  function relayCoreVersion() {
25205
+ if (semver.valid("0.15.2")) {
25206
+ return "0.15.2";
25207
+ }
25186
25208
  try {
25187
25209
  const root = getAppRoot(import.meta.dirname, 3);
25188
25210
  const pkg2 = JSON.parse(
@@ -25551,13 +25573,13 @@ var init_cli = __esm({
25551
25573
 
25552
25574
  // bin/cli.ts
25553
25575
  import { program } from "commander";
25554
- import { basename as basename5, dirname as dirname4, join as join20 } from "path";
25576
+ import { basename as basename5, dirname as dirname5, join as join20 } from "path";
25555
25577
  import { homedir as homedir8 } from "os";
25556
25578
  import { fileURLToPath as fileURLToPath3 } from "url";
25557
25579
  import {
25558
25580
  mkdirSync as mkdirSync5,
25559
25581
  existsSync as existsSync12,
25560
- readFileSync as readFileSync7,
25582
+ readFileSync as readFileSync8,
25561
25583
  writeFileSync as writeFileSync5,
25562
25584
  cpSync as cpSync2,
25563
25585
  unlinkSync as unlinkSync2
@@ -25629,14 +25651,14 @@ init_ainative_paths();
25629
25651
  init_bootstrap();
25630
25652
 
25631
25653
  // src/lib/utils/migrate-to-ainative.ts
25632
- import { existsSync as existsSync3, renameSync, cpSync, rmSync, readFileSync } from "fs";
25654
+ import { existsSync as existsSync3, renameSync, cpSync, rmSync, readFileSync as readFileSync2 } from "fs";
25633
25655
  import { join as join5 } from "path";
25634
25656
  import { homedir as homedir2 } from "os";
25635
25657
  import Database from "better-sqlite3";
25636
25658
  function hasSqliteHeader(path19) {
25637
25659
  const SQLITE_MAGIC = "SQLite format 3\0";
25638
25660
  try {
25639
- const header = readFileSync(path19, { encoding: null });
25661
+ const header = readFileSync2(path19, { encoding: null });
25640
25662
  return header.length >= 16 && header.subarray(0, 16).toString("binary") === SQLITE_MAGIC;
25641
25663
  } catch {
25642
25664
  return false;
@@ -25758,7 +25780,7 @@ async function migrateLegacyData(options = {}) {
25758
25780
 
25759
25781
  // bin/cli.ts
25760
25782
  init_detect();
25761
- var __dirname = dirname4(fileURLToPath3(import.meta.url));
25783
+ var __dirname = dirname5(fileURLToPath3(import.meta.url));
25762
25784
  var appDir = join20(__dirname, "..");
25763
25785
  var launchCwd2 = process.cwd();
25764
25786
  var _envLocalPath = join20(launchCwd2, ".env.local");
@@ -25766,18 +25788,31 @@ var _firstRunNeedsEnv = !existsSync12(_envLocalPath) && !process.env.RELAY_DATA_
25766
25788
  if (_firstRunNeedsEnv) {
25767
25789
  const folderName = basename5(launchCwd2);
25768
25790
  const autoDataDir = join20(homedir8(), `.${folderName}`);
25769
- writeFileSync5(
25770
- _envLocalPath,
25771
- `# Auto-created by orionfold-relay on first run.
25791
+ try {
25792
+ writeFileSync5(
25793
+ _envLocalPath,
25794
+ `# Auto-created by orionfold-relay on first run.
25772
25795
  # Points this folder's install at an isolated data directory.
25773
25796
  RELAY_DATA_DIR=${autoDataDir}
25774
25797
  `,
25775
- "utf-8"
25776
- );
25777
- console.log(`First run \u2014 wrote ${_envLocalPath} (RELAY_DATA_DIR=${autoDataDir}).`);
25798
+ "utf-8"
25799
+ );
25800
+ console.log(`First run \u2014 wrote ${_envLocalPath} (RELAY_DATA_DIR=${autoDataDir}).`);
25801
+ } catch (e) {
25802
+ const reason = e instanceof Error ? e.message : String(e);
25803
+ console.warn(
25804
+ `Warning: could not write ${_envLocalPath} (${reason}).
25805
+ Continuing with the default data directory (~/.relay). This folder will not get an isolated database.`
25806
+ );
25807
+ if (/^([A-Za-z]:)?[\\/]Windows([\\/]|$)/.test(launchCwd2)) {
25808
+ console.warn(
25809
+ `It looks like you launched from a Windows UNC path under WSL, so the working directory defaulted to "${launchCwd2}". Run relay from your Linux filesystem instead \u2014 e.g. \`cd ~\` first, or run it from a WSL home directory rather than a \\\\wsl.localhost\\... path.`
25810
+ );
25811
+ }
25812
+ }
25778
25813
  }
25779
25814
  if (existsSync12(_envLocalPath)) {
25780
- for (const line of readFileSync7(_envLocalPath, "utf-8").split("\n")) {
25815
+ for (const line of readFileSync8(_envLocalPath, "utf-8").split("\n")) {
25781
25816
  const trimmed = line.trim();
25782
25817
  if (!trimmed || trimmed.startsWith("#")) continue;
25783
25818
  const eqIdx = trimmed.indexOf("=");
@@ -25789,7 +25824,7 @@ if (existsSync12(_envLocalPath)) {
25789
25824
  }
25790
25825
  }
25791
25826
  }
25792
- var pkg = JSON.parse(readFileSync7(join20(appDir, "package.json"), "utf-8"));
25827
+ var pkg = JSON.parse(readFileSync8(join20(appDir, "package.json"), "utf-8"));
25793
25828
  function getHelpText() {
25794
25829
  const dir = getAinativeDataDir();
25795
25830
  const db3 = getAinativeDbPath();
@@ -25910,8 +25945,8 @@ async function main() {
25910
25945
  let effectiveCwd = appDir;
25911
25946
  const localNm = join20(appDir, "node_modules");
25912
25947
  if (!existsSync12(join20(localNm, "next", "package.json"))) {
25913
- let searchDir = dirname4(appDir);
25914
- while (searchDir !== dirname4(searchDir)) {
25948
+ let searchDir = dirname5(appDir);
25949
+ while (searchDir !== dirname5(searchDir)) {
25915
25950
  const candidate = join20(searchDir, "node_modules", "next", "package.json");
25916
25951
  if (existsSync12(candidate)) {
25917
25952
  const hoistedRoot = searchDir;
@@ -25933,13 +25968,13 @@ async function main() {
25933
25968
  const dest = join20(hoistedRoot, name);
25934
25969
  const src = join20(appDir, name);
25935
25970
  if (!existsSync12(dest) && existsSync12(src)) {
25936
- writeFileSync5(dest, readFileSync7(src));
25971
+ writeFileSync5(dest, readFileSync8(src));
25937
25972
  }
25938
25973
  }
25939
25974
  effectiveCwd = hoistedRoot;
25940
25975
  break;
25941
25976
  }
25942
- searchDir = dirname4(searchDir);
25977
+ searchDir = dirname5(searchDir);
25943
25978
  }
25944
25979
  }
25945
25980
  const nextEntrypoint = resolveNextEntrypoint(effectiveCwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -52,12 +52,12 @@
52
52
  ],
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "https://github.com/manavsehgal/ainative.git"
55
+ "url": "https://github.com/orionfold/relay.git"
56
56
  },
57
57
  "bugs": {
58
- "url": "https://github.com/manavsehgal/ainative/issues"
58
+ "url": "https://github.com/orionfold/relay/issues"
59
59
  },
60
- "homepage": "https://ainative.business",
60
+ "homepage": "https://orionfold.com/relay/",
61
61
  "scripts": {
62
62
  "dev": "next dev --turbopack",
63
63
  "build": "next build",
@@ -13,6 +13,7 @@ export async function GET() {
13
13
  name: projects.name,
14
14
  description: projects.description,
15
15
  workingDirectory: projects.workingDirectory,
16
+ customerId: projects.customerId,
16
17
  status: projects.status,
17
18
  createdAt: projects.createdAt,
18
19
  updatedAt: projects.updatedAt,
@@ -42,6 +43,7 @@ export async function POST(req: NextRequest) {
42
43
  name: parsed.data.name,
43
44
  description: parsed.data.description ?? null,
44
45
  workingDirectory: parsed.data.workingDirectory ?? null,
46
+ customerId: parsed.data.customerId ?? null,
45
47
  status: "active",
46
48
  createdAt: now,
47
49
  updatedAt: now,
@@ -13,6 +13,7 @@ export default async function ProjectsPage() {
13
13
  name: projects.name,
14
14
  description: projects.description,
15
15
  workingDirectory: projects.workingDirectory,
16
+ customerId: projects.customerId,
16
17
  status: projects.status,
17
18
  createdAt: projects.createdAt,
18
19
  updatedAt: projects.updatedAt,
@@ -19,7 +19,7 @@ import {
19
19
  SelectTrigger,
20
20
  SelectValue,
21
21
  } from "@/components/ui/select";
22
- import { FolderOpen, AlignLeft, FolderCode, Trash2, Paperclip, Plus, X } from "lucide-react";
22
+ import { FolderOpen, AlignLeft, FolderCode, Trash2, Paperclip, Plus, X, Building2 } from "lucide-react";
23
23
  import { toast } from "sonner";
24
24
  import { Badge } from "@/components/ui/badge";
25
25
  import { ConfirmDialog } from "@/components/shared/confirm-dialog";
@@ -31,9 +31,18 @@ interface Project {
31
31
  name: string;
32
32
  description: string | null;
33
33
  workingDirectory: string | null;
34
+ customerId: string | null;
34
35
  status: string;
35
36
  }
36
37
 
38
+ interface CustomerOption {
39
+ id: string;
40
+ name: string;
41
+ }
42
+
43
+ // Radix Select cannot use an empty-string value; this sentinel maps to null.
44
+ const NO_CUSTOMER = "__none__";
45
+
37
46
  interface ProjectFormSheetProps {
38
47
  mode: "create" | "edit";
39
48
  project?: Project | null;
@@ -53,6 +62,8 @@ export function ProjectFormSheet({
53
62
  const [description, setDescription] = useState("");
54
63
  const [workingDirectory, setWorkingDirectory] = useState("");
55
64
  const [status, setStatus] = useState("active");
65
+ const [customerId, setCustomerId] = useState<string>(NO_CUSTOMER);
66
+ const [customers, setCustomers] = useState<CustomerOption[]>([]);
56
67
  const [loading, setLoading] = useState(false);
57
68
  const [error, setError] = useState<string | null>(null);
58
69
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -60,6 +71,17 @@ export function ProjectFormSheet({
60
71
  const [selectedDocs, setSelectedDocs] = useState<Array<{ id: string; originalName: string; mimeType: string; size: number }>>([]);
61
72
  const [pickerOpen, setPickerOpen] = useState(false);
62
73
 
74
+ // Load selectable customers whenever the sheet opens
75
+ useEffect(() => {
76
+ if (!open) return;
77
+ fetch("/api/customers")
78
+ .then((r) => r.json())
79
+ .then((rows: Array<{ id: string; name: string }>) => {
80
+ setCustomers(rows.map((c) => ({ id: c.id, name: c.name })));
81
+ })
82
+ .catch(() => setCustomers([]));
83
+ }, [open]);
84
+
63
85
  // Pre-fill form in edit mode
64
86
  useEffect(() => {
65
87
  if (mode === "edit" && project) {
@@ -67,6 +89,7 @@ export function ProjectFormSheet({
67
89
  setDescription(project.description ?? "");
68
90
  setWorkingDirectory(project.workingDirectory ?? "");
69
91
  setStatus(project.status);
92
+ setCustomerId(project.customerId ?? NO_CUSTOMER);
70
93
  // Load existing default documents
71
94
  fetch(`/api/projects/${project.id}/documents`)
72
95
  .then((r) => r.json())
@@ -91,6 +114,7 @@ export function ProjectFormSheet({
91
114
  setDescription("");
92
115
  setWorkingDirectory("");
93
116
  setStatus("active");
117
+ setCustomerId(NO_CUSTOMER);
94
118
  setSelectedDocIds(new Set());
95
119
  setSelectedDocs([]);
96
120
  }
@@ -120,6 +144,7 @@ export function ProjectFormSheet({
120
144
  name: name.trim(),
121
145
  description: description.trim() || undefined,
122
146
  workingDirectory: workingDirectory.trim() || undefined,
147
+ customerId: customerId === NO_CUSTOMER ? null : customerId,
123
148
  documentIds: selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
124
149
  }),
125
150
  });
@@ -140,6 +165,7 @@ export function ProjectFormSheet({
140
165
  description: description.trim() || undefined,
141
166
  workingDirectory: workingDirectory.trim() || undefined,
142
167
  status,
168
+ customerId: customerId === NO_CUSTOMER ? null : customerId,
143
169
  documentIds: [...selectedDocIds],
144
170
  }),
145
171
  });
@@ -241,6 +267,33 @@ export function ProjectFormSheet({
241
267
  </p>
242
268
  </div>
243
269
 
270
+ <div className="space-y-2">
271
+ <Label htmlFor="proj-customer" className="flex items-center gap-1.5">
272
+ <Building2 className="h-3.5 w-3.5 text-muted-foreground" />
273
+ Customer
274
+ </Label>
275
+ <Select value={customerId} onValueChange={setCustomerId}>
276
+ <SelectTrigger id="proj-customer">
277
+ <SelectValue placeholder="No customer" />
278
+ </SelectTrigger>
279
+ <SelectContent>
280
+ <SelectItem value={NO_CUSTOMER}>
281
+ <span className="text-muted-foreground">No customer</span>
282
+ </SelectItem>
283
+ {customers.map((c) => (
284
+ <SelectItem key={c.id} value={c.id}>
285
+ {c.name}
286
+ </SelectItem>
287
+ ))}
288
+ </SelectContent>
289
+ </Select>
290
+ <p className="text-xs text-muted-foreground">
291
+ {customers.length === 0
292
+ ? "No customers yet — create one to attribute this project's AI spend."
293
+ : "Link to a customer so this project's AI spend rolls up per customer."}
294
+ </p>
295
+ </div>
296
+
244
297
  {isEdit && (
245
298
  <div className="space-y-2">
246
299
  <Label htmlFor="proj-status">Status</Label>
@@ -14,6 +14,7 @@ interface Project {
14
14
  name: string;
15
15
  description: string | null;
16
16
  workingDirectory: string | null;
17
+ customerId: string | null;
17
18
  status: string;
18
19
  taskCount: number;
19
20
  docCount: number;
@@ -3,6 +3,22 @@ export async function registerNodeInstrumentation() {
3
3
  const { migrateLegacyData } = await import("@/lib/utils/migrate-to-ainative");
4
4
  await migrateLegacyData();
5
5
 
6
+ // ainative→relay hop: the runtime now publishes chat/compose tools as
7
+ // mcp__relay__* (engine.ts server key), so previously-saved allow-lists and
8
+ // "Always Allow" records — matched by exact string — must be rewritten too.
9
+ // Runs against the live DB; idempotent, never throws.
10
+ const { migrateMcpNamespace } = await import("@/lib/utils/migrate-mcp-namespace");
11
+ const { sqlite } = await import("@/lib/db");
12
+ const nsReport = migrateMcpNamespace(sqlite);
13
+ if (nsReport.profilesUpdated > 0 || nsReport.permissionsUpdated > 0) {
14
+ console.log(
15
+ `[migrate] mcp namespace ainative→relay: ${nsReport.profilesUpdated} profile(s), ${nsReport.permissionsUpdated} permission set(s)`,
16
+ );
17
+ }
18
+ for (const err of nsReport.errors) {
19
+ console.error(`[migrate] mcp namespace: ${err}`);
20
+ }
21
+
6
22
  // Instance bootstrap — creates local branch, handles dev-mode gates, consent flow.
7
23
  // Runs BEFORE other startup so instance config is available downstream.
8
24
  // Safe in the canonical Relay dev repo thanks to RELAY_DEV_MODE=true
@@ -505,7 +505,10 @@ export async function* sendMessage(
505
505
  // Keep only last 50 chunks to avoid unbounded memory
506
506
  if (stderrChunks.length > 50) stderrChunks.shift();
507
507
  },
508
- mcpServers: { ainative: ainativeServer, ...browserServers, ...externalServers },
508
+ // Server key = tool namespace: the Agent SDK derives `mcp__<key>__*`
509
+ // from THIS map key. Must be `relay` so published tools match the
510
+ // allow-list (:510) and the auto-allow gate (:533) below.
511
+ mcpServers: { relay: ainativeServer, ...browserServers, ...externalServers },
509
512
  allowedTools: [
510
513
  "mcp__relay__*",
511
514
  ...browserToolPatterns,
@@ -19,14 +19,28 @@ import {
19
19
  type ResolvedPackFile,
20
20
  } from "./format";
21
21
 
22
- // The current relay-core version. Sourced from package.json at module load.
23
- // Kept here (not inlined) so the compat gate has a single point of truth.
22
+ // Compile-time core version, embedded by tsup's `define` (see tsup.config.ts).
23
+ // Present ONLY in the bundled CLI; `undefined` in dev/test/Next.js builds,
24
+ // where the runtime lookup below takes over. Declared as a global so the
25
+ // reference type-checks in the non-bundled builds where it doesn't exist.
26
+ declare const __RELAY_CORE_VERSION__: string | undefined;
27
+
28
+ // The current relay-core version. Kept here (not inlined) so the compat gate
29
+ // has a single point of truth.
24
30
  function relayCoreVersion(): string {
25
- // Resolve the app root via getAppRoot (NOT process.cwd() under npx that is
26
- // the user's launch dir, not the app, which would read the wrong
27
- // package.json; see npx-process-cwd.test.ts). install.ts lives at
28
- // src/lib/packs/, so the app root is 3 levels up from this file's dir
29
- // same depth ainative-paths.ts uses.
31
+ // 1. Bundle path: the version baked in at build time. This eliminates the
32
+ // runtime package.json lookup entirely in the shipped CLI the source of
33
+ // the "0.0.0" bug, where the flattened dist/ layout broke the depth-based
34
+ // getAppRoot resolution and fell back to the user's launch dir.
35
+ if (typeof __RELAY_CORE_VERSION__ === "string" && semver.valid(__RELAY_CORE_VERSION__)) {
36
+ return __RELAY_CORE_VERSION__;
37
+ }
38
+
39
+ // 2. Dev/test/Next.js path: resolve the app root via getAppRoot (NOT
40
+ // process.cwd() — under npx that is the user's launch dir; see
41
+ // npx-process-cwd.test.ts) and read package.json. getAppRoot is now
42
+ // bundle-aware (walks up to the orionfold-relay package.json), so this
43
+ // path is also correct in the bundle even if the define is ever dropped.
30
44
  try {
31
45
  const root = getAppRoot(import.meta.dirname, 3);
32
46
  const pkg = JSON.parse(
@@ -1,11 +1,30 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+
4
+ const PKG_NAME = "orionfold-relay";
3
5
 
4
6
  /**
5
7
  * Resolve the app root directory.
6
8
  * - import.meta.dirname works under npx (real path to installed package)
7
9
  * - Turbopack compiles it to /ROOT/... (virtual, doesn't exist) → fall back to process.cwd()
8
10
  *
11
+ * Two layouts must resolve correctly:
12
+ * 1. Source tree — the `depth` a caller passes lands exactly on the repo
13
+ * root (e.g. src/lib/packs/ → depth 3). This is the fast path and stays
14
+ * byte-identical to the original behavior.
15
+ * 2. Bundled dist/cli.js — tsup flattens every module into one file, so
16
+ * `import.meta.dirname` is `dist/` for ALL callers regardless of the
17
+ * depth they pass (a source-tree assumption). Depth then overshoots and
18
+ * the depth candidate misses. Rather than fix depth math at every call
19
+ * site (fragile — 5 sites, different depths), we walk UP from the caller
20
+ * until we find the `orionfold-relay` package.json. This makes the passed
21
+ * `depth` a hint, not a hard requirement, so all call sites resolve in the
22
+ * bundle without per-site changes.
23
+ *
24
+ * The upward walk verifies `name === "orionfold-relay"` (not just any
25
+ * package.json) so that under npx it anchors to OUR package, never a foreign
26
+ * package.json in the user's launch dir.
27
+ *
9
28
  * Uses static `node:` built-in imports. These resolve natively in every
10
29
  * server context that consumes this helper — Next.js server modules, the
11
30
  * instrumentation hook, and the tsup ESM CLI bundle. (An earlier version used
@@ -17,8 +36,30 @@ import { join } from "node:path";
17
36
  */
18
37
  export function getAppRoot(metaDirname: string | undefined, depth: number): string {
19
38
  if (metaDirname) {
39
+ // Fast path — source tree: the passed depth lands on the package root.
20
40
  const candidate = join(metaDirname, ...Array(depth).fill(".."));
21
- if (existsSync(join(candidate, "package.json"))) return candidate;
41
+ if (isPackageRoot(candidate)) return candidate;
42
+
43
+ // Bundle fallback: walk up until we hit the orionfold-relay package root.
44
+ let dir = metaDirname;
45
+ while (true) {
46
+ if (isPackageRoot(dir)) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) break; // reached filesystem root
49
+ dir = parent;
50
+ }
22
51
  }
23
52
  return process.cwd();
24
53
  }
54
+
55
+ /** True if `dir` holds the orionfold-relay package.json. */
56
+ function isPackageRoot(dir: string): boolean {
57
+ const pkgPath = join(dir, "package.json");
58
+ if (!existsSync(pkgPath)) return false;
59
+ try {
60
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string };
61
+ return pkg.name === PKG_NAME;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
@@ -0,0 +1,94 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ const OLD_PREFIX = "mcp__ainative__";
4
+ const NEW_PREFIX = "mcp__relay__";
5
+
6
+ export interface McpNamespaceMigrationReport {
7
+ /** Rows in agent_profiles whose allowed_tools were rewritten. */
8
+ profilesUpdated: number;
9
+ /** 1 if the permissions.allow settings row was rewritten, else 0. */
10
+ permissionsUpdated: number;
11
+ errors: string[];
12
+ }
13
+
14
+ /**
15
+ * Rewrites the chat/compose MCP tool namespace from the legacy `mcp__ainative__`
16
+ * prefix to `mcp__relay__` in persisted data. Companion to the `engine.ts`
17
+ * server-key flip: that change makes the runtime *publish* tools as
18
+ * `mcp__relay__*`; this migration makes previously-saved allow-lists and
19
+ * "Always Allow" records match the new names, which are compared by exact
20
+ * string (see `permissions.ts:matchesPermission`).
21
+ *
22
+ * Backing stores rewritten:
23
+ * 1. `settings` row `key='permissions.allow'` — a JSON array stored as a
24
+ * string. This is the store that actually carries the namespace today
25
+ * (saved "Always Allow" records). A string-level REPLACE is safe: it
26
+ * rewrites the substring inside the serialized array without parsing it.
27
+ * 2. `agent_profiles.allowed_tools` — a JSON array column. NOTE: in current
28
+ * Relay, profiles are file-based (profile.yaml on disk) and there is no
29
+ * `agent_profiles` table, so this branch is a defensive no-op (guarded by
30
+ * a table-exists check). It is kept so that if a DB-backed profile store
31
+ * is ever reintroduced, the namespace hop is already covered. Shipped
32
+ * profile files carry no `mcp__ainative__` strings (verified), so no file
33
+ * rewrite is needed.
34
+ *
35
+ * Idempotent (a second run matches nothing) and never throws — a missing
36
+ * table (fresh/partial schema) is an expected no-op, not an error. Any real
37
+ * SQL failure is collected in `report.errors` so boot continues visibly.
38
+ *
39
+ * Takes the DB handle explicitly so it can be unit-tested against an in-memory
40
+ * database; production passes the live `sqlite` export.
41
+ */
42
+ export function migrateMcpNamespace(
43
+ db: Database.Database,
44
+ ): McpNamespaceMigrationReport {
45
+ const report: McpNamespaceMigrationReport = {
46
+ profilesUpdated: 0,
47
+ permissionsUpdated: 0,
48
+ errors: [],
49
+ };
50
+
51
+ if (!tableExists(db, "agent_profiles")) {
52
+ // Fresh install — nothing to migrate.
53
+ } else {
54
+ try {
55
+ const r = db
56
+ .prepare(
57
+ `UPDATE agent_profiles
58
+ SET allowed_tools = REPLACE(allowed_tools, ?, ?)
59
+ WHERE allowed_tools LIKE '%' || ? || '%'`,
60
+ )
61
+ .run(OLD_PREFIX, NEW_PREFIX, OLD_PREFIX);
62
+ report.profilesUpdated = r.changes;
63
+ } catch (err) {
64
+ // allowed_tools column may be absent in an older schema — record, don't crash.
65
+ report.errors.push(`agent_profiles rewrite failed: ${String(err)}`);
66
+ }
67
+ }
68
+
69
+ if (!tableExists(db, "settings")) {
70
+ // Fresh install — no saved permissions yet.
71
+ } else {
72
+ try {
73
+ const r = db
74
+ .prepare(
75
+ `UPDATE settings
76
+ SET value = REPLACE(value, ?, ?)
77
+ WHERE key = 'permissions.allow' AND value LIKE '%' || ? || '%'`,
78
+ )
79
+ .run(OLD_PREFIX, NEW_PREFIX, OLD_PREFIX);
80
+ report.permissionsUpdated = r.changes;
81
+ } catch (err) {
82
+ report.errors.push(`settings permissions.allow rewrite failed: ${String(err)}`);
83
+ }
84
+ }
85
+
86
+ return report;
87
+ }
88
+
89
+ function tableExists(db: Database.Database, name: string): boolean {
90
+ const row = db
91
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
92
+ .get(name);
93
+ return row !== undefined;
94
+ }
@@ -4,6 +4,8 @@ export const createProjectSchema = z.object({
4
4
  name: z.string().min(1, "Name is required").max(100),
5
5
  description: z.string().max(500).optional(),
6
6
  workingDirectory: z.string().max(500).optional(),
7
+ // FK to customers.id; null/absent = unlinked. Attributes AI spend to a customer.
8
+ customerId: z.string().min(1).nullish(),
7
9
  });
8
10
 
9
11
  export const updateProjectSchema = z.object({
@@ -11,6 +13,8 @@ export const updateProjectSchema = z.object({
11
13
  description: z.string().max(500).optional(),
12
14
  workingDirectory: z.string().max(500).optional(),
13
15
  status: z.enum(["active", "paused", "completed"]).optional(),
16
+ // FK to customers.id; null clears the link, absent leaves it unchanged.
17
+ customerId: z.string().min(1).nullish(),
14
18
  });
15
19
 
16
20
  export type CreateProjectInput = z.infer<typeof createProjectSchema>;