orionfold-relay 0.22.1 → 0.23.0
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 +3 -3
- package/dist/cli.js +83 -40
- package/drizzle.config.ts +2 -2
- package/package.json +1 -1
- package/src/app/api/license/route.ts +1 -1
- package/src/app/packs/page.tsx +254 -39
- package/src/components/notifications/inbox-list.tsx +41 -1
- package/src/components/notifications/unread-badge.tsx +28 -2
- package/src/instrumentation-node.ts +1 -1
- package/src/lib/agents/runtime/anthropic-direct.ts +72 -9
- package/src/lib/apps/registry.ts +1 -1
- package/src/lib/packs/format.ts +38 -2
- package/src/lib/packs/templates/relay-agency/pack.yaml +1 -0
- package/src/lib/packs/templates/relay-agency-pro/pack.yaml +11 -2
- package/src/lib/plugins/examples/echo-server/plugin.yaml +1 -1
- package/src/lib/plugins/examples/finance-pack/plugin.yaml +1 -1
- package/src/lib/plugins/examples/reading-radar/plugin.yaml +1 -1
- package/src/lib/plugins/registry.ts +1 -1
- package/src/lib/plugins/sdk/types.ts +1 -1
- package/src/lib/utils/migrate-to-ainative.ts +82 -41
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ The governance is *in* the workflow, not bolted on. A blueprint is a fixed step
|
|
|
104
104
|
## Why it stays trustworthy
|
|
105
105
|
|
|
106
106
|
- **Local-first** — SQLite database, no cloud dependency, `npx orionfold-relay` and go
|
|
107
|
-
- **
|
|
107
|
+
- **Relay never sends your data to Orionfold** — no telemetry, no update checks, no license server; the complete outbound-network inventory is documented and code-linked in [docs/trust/data-flow.md](docs/trust/data-flow.md)
|
|
108
108
|
- **Your rules, enforced** — tool permissions, inbox approvals, and audit trails for every agent action
|
|
109
109
|
- **Your AI team** — 21 specialist profiles ready to deploy, each with instructions, tool policies, and runtime tuning
|
|
110
110
|
- **Know what you spend** — usage metering, budgets, and per-provider/per-model spend visibility on governed runs
|
|
@@ -134,8 +134,8 @@ relay license remove <license-id> # forget a license
|
|
|
134
134
|
|
|
135
135
|
- **Verification is 100% offline** — an Ed25519 signature check against keys embedded in
|
|
136
136
|
this repo ([`src/lib/licensing/verify.ts`](src/lib/licensing/verify.ts)). Relay never
|
|
137
|
-
|
|
138
|
-
air-gapped.
|
|
137
|
+
sends your data to Orionfold: no activation server, no telemetry, no network call of
|
|
138
|
+
any kind. Works air-gapped.
|
|
139
139
|
- **Your packs are yours forever. Renewal gets you the year's new and updated packs +
|
|
140
140
|
priority support.** An expired or removed license never re-locks content you already
|
|
141
141
|
installed — it only gates new premium installs and updates.
|
package/dist/cli.js
CHANGED
|
@@ -1186,7 +1186,7 @@ var CURRENT_PLUGIN_API_VERSION, CAPABILITY_VALUES, ORIGIN_VALUES, PrimitivesBund
|
|
|
1186
1186
|
var init_types = __esm({
|
|
1187
1187
|
"src/lib/plugins/sdk/types.ts"() {
|
|
1188
1188
|
"use strict";
|
|
1189
|
-
CURRENT_PLUGIN_API_VERSION = "0.
|
|
1189
|
+
CURRENT_PLUGIN_API_VERSION = "0.23";
|
|
1190
1190
|
CAPABILITY_VALUES = ["fs", "net", "child_process", "env"];
|
|
1191
1191
|
ORIGIN_VALUES = ["ainative-internal", "third-party"];
|
|
1192
1192
|
PrimitivesBundleManifestSchema = z.object({
|
|
@@ -3739,9 +3739,25 @@ var init_format = __esm({
|
|
|
3739
3739
|
/**
|
|
3740
3740
|
* Premium display copy (D6). Offline strings rendered on the locked
|
|
3741
3741
|
* gallery card — the Website still owns actual pricing. Meaningful only
|
|
3742
|
-
* alongside `entitlement`; harmless on a free pack.
|
|
3742
|
+
* alongside `entitlement`; harmless on a free pack. Either a flat string
|
|
3743
|
+
* ("$499/year") or a two-phase offer ({ list, intro?, note? }) so a
|
|
3744
|
+
* founding/introductory price can render alongside the list price.
|
|
3745
|
+
* Render sites consume `packPrice()` — never branch on the raw shape.
|
|
3743
3746
|
*/
|
|
3744
|
-
price: z3.
|
|
3747
|
+
price: z3.union([
|
|
3748
|
+
z3.string().min(1),
|
|
3749
|
+
z3.object({
|
|
3750
|
+
list: z3.string().min(1),
|
|
3751
|
+
intro: z3.string().min(1).optional(),
|
|
3752
|
+
note: z3.string().min(1).optional()
|
|
3753
|
+
}).strict()
|
|
3754
|
+
]).optional(),
|
|
3755
|
+
/**
|
|
3756
|
+
* Card identity token — a lucide icon name rendered on the gallery card
|
|
3757
|
+
* (e.g. "briefcase"). Unknown tokens fall back to the default glyph;
|
|
3758
|
+
* never a remote asset.
|
|
3759
|
+
*/
|
|
3760
|
+
icon: z3.string().min(1).optional(),
|
|
3745
3761
|
/** Get-license CTA target on the locked card. */
|
|
3746
3762
|
purchaseUrl: z3.url().optional(),
|
|
3747
3763
|
/**
|
|
@@ -12929,7 +12945,7 @@ var init_registry6 = __esm({
|
|
|
12929
12945
|
init_registry5();
|
|
12930
12946
|
init_installer();
|
|
12931
12947
|
init_schedule_spec();
|
|
12932
|
-
SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.
|
|
12948
|
+
SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.22"]);
|
|
12933
12949
|
pluginCache = null;
|
|
12934
12950
|
lastLoadedPluginIds = /* @__PURE__ */ new Set();
|
|
12935
12951
|
PluginTableSchema = z16.object({
|
|
@@ -20746,6 +20762,24 @@ async function withAnthropicDirectMcpServers(profileServers, browserServers, ext
|
|
|
20746
20762
|
relay: relayServer
|
|
20747
20763
|
};
|
|
20748
20764
|
}
|
|
20765
|
+
function mcpServersToAnthropicConnectors(mergedServers) {
|
|
20766
|
+
const connectors = [];
|
|
20767
|
+
for (const [name, config] of Object.entries(mergedServers)) {
|
|
20768
|
+
if (name === "relay") continue;
|
|
20769
|
+
const cfg = config;
|
|
20770
|
+
const url = cfg.url ?? cfg.server_url;
|
|
20771
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
20772
|
+
continue;
|
|
20773
|
+
}
|
|
20774
|
+
const connector = { type: "url", url, name };
|
|
20775
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
20776
|
+
if (["url", "server_url", "command", "args", "env"].includes(key)) continue;
|
|
20777
|
+
if (typeof value === "string") connector[key] = value;
|
|
20778
|
+
}
|
|
20779
|
+
connectors.push(connector);
|
|
20780
|
+
}
|
|
20781
|
+
return connectors;
|
|
20782
|
+
}
|
|
20749
20783
|
async function getAnthropicSDK() {
|
|
20750
20784
|
if (!_sdk) {
|
|
20751
20785
|
_sdk = await import("@anthropic-ai/sdk");
|
|
@@ -20812,7 +20846,7 @@ async function callAnthropicModel(client, systemPrompt, messages, tools, signal,
|
|
|
20812
20846
|
budget_tokens: options.extendedThinking.budgetTokens ?? 1e4
|
|
20813
20847
|
};
|
|
20814
20848
|
}
|
|
20815
|
-
if (options.mcpServers &&
|
|
20849
|
+
if (options.mcpServers && options.mcpServers.length > 0) {
|
|
20816
20850
|
params.mcp_servers = options.mcpServers;
|
|
20817
20851
|
}
|
|
20818
20852
|
const stream = client.messages.stream(params, { signal });
|
|
@@ -20917,6 +20951,7 @@ ${outputInstructions}`;
|
|
|
20917
20951
|
pluginServers,
|
|
20918
20952
|
task.projectId
|
|
20919
20953
|
);
|
|
20954
|
+
const mcpConnectors = mcpServersToAnthropicConnectors(mergedMcpServers);
|
|
20920
20955
|
let initialMessages;
|
|
20921
20956
|
if (isResume) {
|
|
20922
20957
|
const snapshot = await loadSessionSnapshot(taskId);
|
|
@@ -20965,7 +21000,7 @@ ${outputInstructions}`;
|
|
|
20965
21000
|
enableCaching: true,
|
|
20966
21001
|
profileInstructions: profile?.skillMd,
|
|
20967
21002
|
extendedThinking: capOverrides?.extendedThinking,
|
|
20968
|
-
mcpServers:
|
|
21003
|
+
mcpServers: mcpConnectors
|
|
20969
21004
|
}
|
|
20970
21005
|
);
|
|
20971
21006
|
await saveSessionSnapshot(taskId, agentProfileId, [
|
|
@@ -25740,8 +25775,8 @@ import { execFileSync as execFileSync3 } from "child_process";
|
|
|
25740
25775
|
import yaml12 from "js-yaml";
|
|
25741
25776
|
import semver from "semver";
|
|
25742
25777
|
function relayCoreVersion() {
|
|
25743
|
-
if (semver.valid("0.
|
|
25744
|
-
return "0.
|
|
25778
|
+
if (semver.valid("0.23.0")) {
|
|
25779
|
+
return "0.23.0";
|
|
25745
25780
|
}
|
|
25746
25781
|
try {
|
|
25747
25782
|
const root = getAppRoot(import.meta.dirname, 3);
|
|
@@ -26984,6 +27019,10 @@ function hasSqliteHeader(path24) {
|
|
|
26984
27019
|
return false;
|
|
26985
27020
|
}
|
|
26986
27021
|
}
|
|
27022
|
+
var MIGRATION_CHAIN = [
|
|
27023
|
+
{ fromDir: ".stagent", toDir: ".ainative", fromDb: "stagent.db", toDb: "ainative.db" },
|
|
27024
|
+
{ fromDir: ".ainative", toDir: ".relay", fromDb: "ainative.db", toDb: "relay.db" }
|
|
27025
|
+
];
|
|
26987
27026
|
async function migrateLegacyData(options = {}) {
|
|
26988
27027
|
const home = options.home ?? homedir2();
|
|
26989
27028
|
const gitDir = options.gitDir ?? join6(process.cwd(), ".git");
|
|
@@ -26996,46 +27035,50 @@ async function migrateLegacyData(options = {}) {
|
|
|
26996
27035
|
keychainMigrated: false,
|
|
26997
27036
|
errors: []
|
|
26998
27037
|
};
|
|
26999
|
-
const
|
|
27000
|
-
const
|
|
27001
|
-
|
|
27002
|
-
|
|
27003
|
-
|
|
27004
|
-
|
|
27005
|
-
|
|
27006
|
-
|
|
27007
|
-
|
|
27008
|
-
|
|
27009
|
-
|
|
27010
|
-
|
|
27011
|
-
|
|
27012
|
-
|
|
27013
|
-
|
|
27014
|
-
|
|
27015
|
-
|
|
27038
|
+
const finalDir = join6(home, MIGRATION_CHAIN[MIGRATION_CHAIN.length - 1].toDir);
|
|
27039
|
+
const finalDbName = MIGRATION_CHAIN[MIGRATION_CHAIN.length - 1].toDb;
|
|
27040
|
+
for (const hop of MIGRATION_CHAIN) {
|
|
27041
|
+
const oldDir = join6(home, hop.fromDir);
|
|
27042
|
+
const newDir = join6(home, hop.toDir);
|
|
27043
|
+
if (existsSync4(oldDir) && !existsSync4(newDir)) {
|
|
27044
|
+
try {
|
|
27045
|
+
renameSync(oldDir, newDir);
|
|
27046
|
+
report.dirMigrated = true;
|
|
27047
|
+
log(`renamed ${oldDir} -> ${newDir}`);
|
|
27048
|
+
} catch (err2) {
|
|
27049
|
+
const e = err2;
|
|
27050
|
+
if (e.code === "EXDEV") {
|
|
27051
|
+
try {
|
|
27052
|
+
cpSync(oldDir, newDir, { recursive: true });
|
|
27053
|
+
rmSync2(oldDir, { recursive: true, force: true });
|
|
27054
|
+
report.dirMigrated = true;
|
|
27055
|
+
log(`copied ${oldDir} -> ${newDir} (cross-device fallback)`);
|
|
27056
|
+
} catch (copyErr) {
|
|
27057
|
+
report.errors.push(`dir copy failed: ${String(copyErr)}`);
|
|
27058
|
+
return report;
|
|
27059
|
+
}
|
|
27060
|
+
} else {
|
|
27061
|
+
report.errors.push(`dir rename failed: ${String(err2)}`);
|
|
27016
27062
|
return report;
|
|
27017
27063
|
}
|
|
27018
|
-
} else {
|
|
27019
|
-
report.errors.push(`dir rename failed: ${String(err2)}`);
|
|
27020
|
-
return report;
|
|
27021
27064
|
}
|
|
27022
27065
|
}
|
|
27023
|
-
|
|
27024
|
-
|
|
27025
|
-
|
|
27026
|
-
|
|
27027
|
-
|
|
27028
|
-
|
|
27029
|
-
|
|
27030
|
-
|
|
27031
|
-
|
|
27032
|
-
|
|
27033
|
-
|
|
27066
|
+
if (existsSync4(newDir)) {
|
|
27067
|
+
for (const suffix of ["", "-shm", "-wal"]) {
|
|
27068
|
+
const oldName = join6(newDir, `${hop.fromDb}${suffix}`);
|
|
27069
|
+
const newName = join6(newDir, `${hop.toDb}${suffix}`);
|
|
27070
|
+
if (existsSync4(oldName) && !existsSync4(newName)) {
|
|
27071
|
+
try {
|
|
27072
|
+
renameSync(oldName, newName);
|
|
27073
|
+
report.dbFilesRenamed++;
|
|
27074
|
+
} catch (err2) {
|
|
27075
|
+
report.errors.push(`db file rename failed (${suffix}): ${String(err2)}`);
|
|
27076
|
+
}
|
|
27034
27077
|
}
|
|
27035
27078
|
}
|
|
27036
27079
|
}
|
|
27037
27080
|
}
|
|
27038
|
-
const dbPath4 = join6(
|
|
27081
|
+
const dbPath4 = join6(finalDir, finalDbName);
|
|
27039
27082
|
if (existsSync4(dbPath4) && !hasSqliteHeader(dbPath4)) {
|
|
27040
27083
|
log(`skipping SQL migration \u2014 ${dbPath4} exists but lacks SQLite header`);
|
|
27041
27084
|
}
|
package/drizzle.config.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { defineConfig } from "drizzle-kit";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
|
-
const dataDir = process.env.
|
|
5
|
+
const dataDir = process.env.RELAY_DATA_DIR || join(homedir(), ".relay");
|
|
6
6
|
|
|
7
7
|
export default defineConfig({
|
|
8
8
|
schema: "./src/lib/db/schema.ts",
|
|
9
9
|
out: "./src/lib/db/migrations",
|
|
10
10
|
dialect: "sqlite",
|
|
11
11
|
dbCredentials: {
|
|
12
|
-
url: join(dataDir, "
|
|
12
|
+
url: join(dataDir, "relay.db"),
|
|
13
13
|
},
|
|
14
14
|
});
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ import type { SignedLicense } from "@/lib/licensing/verify";
|
|
|
14
14
|
* paste/upload activation path for UI-first users: the browser reads the
|
|
15
15
|
* fulfilment file client-side and ships the `{ payload, signature }` envelope
|
|
16
16
|
* as JSON. Verification is offline Ed25519 inside saveLicense; nothing here
|
|
17
|
-
*
|
|
17
|
+
* sends user data to Orionfold.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const BodySchema = z.object({
|
package/src/app/packs/page.tsx
CHANGED
|
@@ -2,9 +2,20 @@ import Link from "next/link";
|
|
|
2
2
|
import { PageShell } from "@/components/shared/page-shell";
|
|
3
3
|
import { Badge } from "@/components/ui/badge";
|
|
4
4
|
import { Card, CardContent } from "@/components/ui/card";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Boxes,
|
|
7
|
+
Briefcase,
|
|
8
|
+
Building2,
|
|
9
|
+
Check,
|
|
10
|
+
Lock,
|
|
11
|
+
Package,
|
|
12
|
+
TriangleAlert,
|
|
13
|
+
type LucideIcon,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
6
16
|
import { listApps } from "@/lib/apps/registry";
|
|
7
17
|
import { listPackTemplates, type PackTemplate } from "@/lib/packs/catalog";
|
|
18
|
+
import { packPrice, type PackPrice } from "@/lib/packs/format";
|
|
8
19
|
import { packUpdateAvailability } from "@/lib/packs/update";
|
|
9
20
|
import { changelogWindow } from "@/lib/licensing/recap";
|
|
10
21
|
import { PackInstallButton } from "@/components/packs/pack-install-button";
|
|
@@ -16,29 +27,234 @@ export const dynamic = "force-dynamic";
|
|
|
16
27
|
* Local-first bundled-pack browser (NOT a marketplace — feature-cut fence).
|
|
17
28
|
* Premium packs are visible-but-locked (D6): every user sees what exists,
|
|
18
29
|
* what it materializes, and what it costs; only the content install is gated.
|
|
30
|
+
* A locked premium pack renders as a full-width feature panel — the sales
|
|
31
|
+
* copy and the two-phase offer are the conversion surface (#20/#21).
|
|
19
32
|
*/
|
|
20
|
-
|
|
33
|
+
|
|
34
|
+
/** Bundled icon names (pack.yaml `icon:`) → lucide glyphs. Never remote. */
|
|
35
|
+
const PACK_ICONS: Record<string, LucideIcon> = {
|
|
36
|
+
briefcase: Briefcase,
|
|
37
|
+
"building-2": Building2,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function packIcon(template: PackTemplate): LucideIcon {
|
|
41
|
+
const iconName = template.meta?.icon;
|
|
42
|
+
if (iconName && PACK_ICONS[iconName]) return PACK_ICONS[iconName];
|
|
43
|
+
return template.meta?.entitlement ? Lock : Package;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type PackFilter = "all" | "free" | "premium";
|
|
47
|
+
|
|
48
|
+
function resolveFilter(raw: string | string[] | undefined): PackFilter {
|
|
49
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
50
|
+
return v === "free" || v === "premium" ? v : "all";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function PacksPage({
|
|
54
|
+
searchParams,
|
|
55
|
+
}: {
|
|
56
|
+
searchParams: Promise<{ filter?: string | string[] }>;
|
|
57
|
+
}) {
|
|
58
|
+
const params = await searchParams;
|
|
59
|
+
const filter = resolveFilter(params.filter);
|
|
21
60
|
const templates = listPackTemplates();
|
|
22
61
|
const installedIds = new Set(listApps().map((a) => a.id));
|
|
23
62
|
|
|
63
|
+
const isPremium = (t: PackTemplate) => Boolean(t.meta?.entitlement);
|
|
64
|
+
// Corrupt templates ignore the filter — a packaging bug must stay visible.
|
|
65
|
+
const visible = templates.filter(
|
|
66
|
+
(t) =>
|
|
67
|
+
t.error ||
|
|
68
|
+
filter === "all" ||
|
|
69
|
+
(filter === "premium" ? isPremium(t) : !isPremium(t))
|
|
70
|
+
);
|
|
71
|
+
const featured = visible.filter(
|
|
72
|
+
(t) => !t.error && isPremium(t) && !installedIds.has(t.id)
|
|
73
|
+
);
|
|
74
|
+
const standard = visible.filter((t) => !featured.includes(t));
|
|
75
|
+
const counts: Record<PackFilter, number> = {
|
|
76
|
+
all: templates.length,
|
|
77
|
+
free: templates.filter((t) => !t.error && !isPremium(t)).length,
|
|
78
|
+
premium: templates.filter((t) => !t.error && isPremium(t)).length,
|
|
79
|
+
};
|
|
80
|
+
|
|
24
81
|
return (
|
|
25
82
|
<PageShell
|
|
26
83
|
title="Packs"
|
|
27
84
|
description="Vertical content bundles — an app, profiles, blueprints, tables, and seed data installed in one step."
|
|
85
|
+
filters={
|
|
86
|
+
templates.length > 1 ? (
|
|
87
|
+
<FilterChips active={filter} counts={counts} />
|
|
88
|
+
) : undefined
|
|
89
|
+
}
|
|
28
90
|
>
|
|
29
91
|
{templates.length === 0 ? (
|
|
30
92
|
<EmptyHero />
|
|
93
|
+
) : visible.length === 0 ? (
|
|
94
|
+
<p className="text-sm text-muted-foreground">
|
|
95
|
+
No {filter} packs in this build.{" "}
|
|
96
|
+
<Link
|
|
97
|
+
href="/packs"
|
|
98
|
+
className="underline underline-offset-2 hover:text-foreground"
|
|
99
|
+
>
|
|
100
|
+
Show all packs
|
|
101
|
+
</Link>
|
|
102
|
+
</p>
|
|
31
103
|
) : (
|
|
32
|
-
<div className="
|
|
33
|
-
{
|
|
34
|
-
<
|
|
104
|
+
<div className="space-y-4">
|
|
105
|
+
{featured.map((t) => (
|
|
106
|
+
<FeaturedPackCard key={t.id} template={t} />
|
|
35
107
|
))}
|
|
108
|
+
{standard.length > 0 && (
|
|
109
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
110
|
+
{standard.map((t) => (
|
|
111
|
+
<PackCard
|
|
112
|
+
key={t.id}
|
|
113
|
+
template={t}
|
|
114
|
+
installed={installedIds.has(t.id)}
|
|
115
|
+
/>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
36
119
|
</div>
|
|
37
120
|
)}
|
|
38
121
|
</PageShell>
|
|
39
122
|
);
|
|
40
123
|
}
|
|
41
124
|
|
|
125
|
+
function FilterChips({
|
|
126
|
+
active,
|
|
127
|
+
counts,
|
|
128
|
+
}: {
|
|
129
|
+
active: PackFilter;
|
|
130
|
+
counts: Record<PackFilter, number>;
|
|
131
|
+
}) {
|
|
132
|
+
const chips: Array<{ value: PackFilter; label: string; href: string }> = [
|
|
133
|
+
{ value: "all", label: "All", href: "/packs" },
|
|
134
|
+
{ value: "free", label: "Free", href: "/packs?filter=free" },
|
|
135
|
+
{ value: "premium", label: "Premium", href: "/packs?filter=premium" },
|
|
136
|
+
];
|
|
137
|
+
return (
|
|
138
|
+
<nav aria-label="Filter packs" className="flex items-center gap-2">
|
|
139
|
+
{chips.map((c) => (
|
|
140
|
+
<Link
|
|
141
|
+
key={c.value}
|
|
142
|
+
href={c.href}
|
|
143
|
+
aria-current={active === c.value ? "page" : undefined}
|
|
144
|
+
className={cn(
|
|
145
|
+
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
|
|
146
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
147
|
+
active === c.value
|
|
148
|
+
? "border-transparent bg-primary text-primary-foreground"
|
|
149
|
+
: "surface-control text-muted-foreground hover:text-foreground"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
{c.label}
|
|
153
|
+
<span
|
|
154
|
+
className={cn(
|
|
155
|
+
"tabular-nums",
|
|
156
|
+
active === c.value
|
|
157
|
+
? "text-primary-foreground/70"
|
|
158
|
+
: "text-muted-foreground/70"
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
{counts[c.value]}
|
|
162
|
+
</span>
|
|
163
|
+
</Link>
|
|
164
|
+
))}
|
|
165
|
+
</nav>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Two-phase offer: founding intro leads, list price stays as the anchor. */
|
|
170
|
+
function OfferPrice({ price }: { price: PackPrice }) {
|
|
171
|
+
return (
|
|
172
|
+
<div>
|
|
173
|
+
<div className="flex items-baseline gap-2">
|
|
174
|
+
<span className="text-2xl font-bold tracking-tight">
|
|
175
|
+
{price.intro ?? price.list}
|
|
176
|
+
</span>
|
|
177
|
+
{price.intro && (
|
|
178
|
+
<span className="text-sm text-muted-foreground line-through">
|
|
179
|
+
{price.list}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
{price.note && (
|
|
184
|
+
<p className="mt-1 text-xs text-muted-foreground">{price.note}</p>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Locked premium pack — the conversion hero. Full sales copy at a readable
|
|
192
|
+
* measure (never clamped), offer rail with the two-phase price + CTAs.
|
|
193
|
+
*/
|
|
194
|
+
function FeaturedPackCard({ template }: { template: PackTemplate }) {
|
|
195
|
+
const meta = template.meta!;
|
|
196
|
+
const Icon = packIcon(template);
|
|
197
|
+
const price = packPrice(meta);
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Card className="relative hover:border-primary/50 transition-colors">
|
|
201
|
+
<CardContent className="p-4 sm:p-5">
|
|
202
|
+
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_260px]">
|
|
203
|
+
<div className="min-w-0 space-y-3">
|
|
204
|
+
<div className="flex items-center gap-3">
|
|
205
|
+
<div className="surface-card-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border">
|
|
206
|
+
<Icon className="h-6 w-6 text-primary" aria-hidden="true" />
|
|
207
|
+
</div>
|
|
208
|
+
<div className="min-w-0">
|
|
209
|
+
<h2 className="truncate text-base font-semibold">
|
|
210
|
+
{meta.name}
|
|
211
|
+
</h2>
|
|
212
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
|
213
|
+
<Badge variant="outline" className="gap-1">
|
|
214
|
+
<Lock className="h-3 w-3" aria-hidden="true" />
|
|
215
|
+
Premium
|
|
216
|
+
</Badge>
|
|
217
|
+
{template.primitivesSummary && (
|
|
218
|
+
<span className="text-[11px] text-muted-foreground/70">
|
|
219
|
+
{template.primitivesSummary}
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
{meta.description && (
|
|
226
|
+
<p className="max-w-[70ch] text-sm leading-relaxed text-muted-foreground">
|
|
227
|
+
{meta.description}
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div className="surface-card-muted flex flex-col gap-4 rounded-lg border p-4 lg:self-start">
|
|
233
|
+
{price && <OfferPrice price={price} />}
|
|
234
|
+
<div className="space-y-2">
|
|
235
|
+
<PackInstallButton
|
|
236
|
+
packId={template.id}
|
|
237
|
+
packName={meta.name}
|
|
238
|
+
premium
|
|
239
|
+
/>
|
|
240
|
+
{meta.purchaseUrl && (
|
|
241
|
+
<a
|
|
242
|
+
href={meta.purchaseUrl}
|
|
243
|
+
target="_blank"
|
|
244
|
+
rel="noopener noreferrer"
|
|
245
|
+
className="block text-xs font-medium text-foreground underline underline-offset-2 hover:text-primary"
|
|
246
|
+
>
|
|
247
|
+
Get license →
|
|
248
|
+
</a>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</CardContent>
|
|
254
|
+
</Card>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
42
258
|
function PackCard({
|
|
43
259
|
template,
|
|
44
260
|
installed,
|
|
@@ -68,34 +284,31 @@ function PackCard({
|
|
|
68
284
|
|
|
69
285
|
const meta = template.meta!;
|
|
70
286
|
const premium = Boolean(meta.entitlement);
|
|
287
|
+
const Icon = packIcon(template);
|
|
288
|
+
const price = packPrice(meta);
|
|
71
289
|
|
|
72
290
|
return (
|
|
73
291
|
<Card className="relative h-full hover:border-primary/50 transition-colors">
|
|
74
|
-
<CardContent className="
|
|
292
|
+
<CardContent className="flex h-full flex-col gap-2 p-3">
|
|
75
293
|
<div className="flex items-center justify-between gap-2">
|
|
76
|
-
<div className="flex items-center gap-2
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
className="h-
|
|
80
|
-
aria-hidden="true"
|
|
81
|
-
/>
|
|
82
|
-
) : (
|
|
83
|
-
<Package
|
|
84
|
-
className="h-4 w-4 text-muted-foreground shrink-0"
|
|
294
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
295
|
+
<div className="surface-card-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-md border">
|
|
296
|
+
<Icon
|
|
297
|
+
className="h-5 w-5 text-muted-foreground"
|
|
85
298
|
aria-hidden="true"
|
|
86
299
|
/>
|
|
87
|
-
|
|
300
|
+
</div>
|
|
88
301
|
<span className="text-sm font-medium truncate">{meta.name}</span>
|
|
89
302
|
</div>
|
|
90
303
|
{premium && !installed && (
|
|
91
304
|
<Badge variant="outline" className="shrink-0">
|
|
92
|
-
{
|
|
305
|
+
{price?.intro ?? price?.list ?? "Premium"}
|
|
93
306
|
</Badge>
|
|
94
307
|
)}
|
|
95
308
|
</div>
|
|
96
309
|
|
|
97
310
|
{meta.description && (
|
|
98
|
-
<p className="text-xs text-muted-foreground line-clamp-
|
|
311
|
+
<p className="text-xs text-muted-foreground line-clamp-3">
|
|
99
312
|
{meta.description}
|
|
100
313
|
</p>
|
|
101
314
|
)}
|
|
@@ -105,27 +318,29 @@ function PackCard({
|
|
|
105
318
|
</p>
|
|
106
319
|
)}
|
|
107
320
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
321
|
+
<div className="mt-auto">
|
|
322
|
+
{installed ? (
|
|
323
|
+
<InstalledActions template={template} />
|
|
324
|
+
) : (
|
|
325
|
+
<div className="flex items-end justify-between gap-2 pt-1">
|
|
326
|
+
<PackInstallButton
|
|
327
|
+
packId={template.id}
|
|
328
|
+
packName={meta.name}
|
|
329
|
+
premium={premium}
|
|
330
|
+
/>
|
|
331
|
+
{premium && meta.purchaseUrl && (
|
|
332
|
+
<a
|
|
333
|
+
href={meta.purchaseUrl}
|
|
334
|
+
target="_blank"
|
|
335
|
+
rel="noopener noreferrer"
|
|
336
|
+
className="text-xs font-medium text-foreground underline underline-offset-2 hover:text-primary shrink-0"
|
|
337
|
+
>
|
|
338
|
+
Get license →
|
|
339
|
+
</a>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
129
344
|
</CardContent>
|
|
130
345
|
</Card>
|
|
131
346
|
);
|
|
@@ -40,12 +40,52 @@ export function InboxList({
|
|
|
40
40
|
}
|
|
41
41
|
}, []);
|
|
42
42
|
|
|
43
|
-
// Poll every 10 seconds (consolidated from 3s inbox + 5s badge)
|
|
43
|
+
// Poll every 10 seconds (consolidated from 3s inbox + 5s badge). This is the
|
|
44
|
+
// safety net for non-time-critical notification types (task_completed,
|
|
45
|
+
// agent_message, …) that the approvals stream below does not carry.
|
|
44
46
|
useEffect(() => {
|
|
45
47
|
const interval = setInterval(refresh, 10_000);
|
|
46
48
|
return () => clearInterval(interval);
|
|
47
49
|
}, [refresh]);
|
|
48
50
|
|
|
51
|
+
// Real-time surfacing for workflow-blocking checkpoints
|
|
52
|
+
// (fix-inbox-checkpoint-realtime). A workflow stuck at a HITL checkpoint
|
|
53
|
+
// shouldn't wait on the 10s poll — the user must act, and the UI going quiet
|
|
54
|
+
// for ~15s undercuts the "governed, unattended" promise. We subscribe to the
|
|
55
|
+
// existing pending-approvals SSE (~750ms server-side tail, already includes
|
|
56
|
+
// WorkflowCheckpoint rows) and use each snapshot purely as an invalidation
|
|
57
|
+
// signal: re-pull the authoritative /api/notifications list so a new
|
|
58
|
+
// checkpoint (and the badge) surfaces within ~1-2s. On SSE failure we fall
|
|
59
|
+
// back to the 10s poll already running above. Mirrors PendingApprovalHost.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let cancelled = false;
|
|
62
|
+
let eventSource: EventSource | null = null;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
eventSource = new EventSource("/api/notifications/pending-approvals/stream");
|
|
66
|
+
eventSource.onmessage = () => {
|
|
67
|
+
// The snapshot content is not rendered here — its arrival means the
|
|
68
|
+
// pending-approval set changed, so re-fetch the full list immediately.
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
refresh().catch(() => {
|
|
71
|
+
// Trigger-driven refresh should fail quietly; the poll will retry.
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
eventSource.onerror = () => {
|
|
75
|
+
// Fall back to the 10s poll (still active) until the stream recovers.
|
|
76
|
+
eventSource?.close();
|
|
77
|
+
eventSource = null;
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
// EventSource unavailable — the 10s poll remains the delivery path.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
eventSource?.close();
|
|
86
|
+
};
|
|
87
|
+
}, [refresh]);
|
|
88
|
+
|
|
49
89
|
async function markAllRead() {
|
|
50
90
|
await fetch("/api/notifications/mark-all-read", { method: "PATCH" });
|
|
51
91
|
toast.success("All notifications marked as read");
|
|
@@ -7,12 +7,15 @@ export function UnreadBadge() {
|
|
|
7
7
|
const [count, setCount] = useState(0);
|
|
8
8
|
|
|
9
9
|
useEffect(() => {
|
|
10
|
+
let cancelled = false;
|
|
11
|
+
let eventSource: EventSource | null = null;
|
|
12
|
+
|
|
10
13
|
async function fetchCount() {
|
|
11
14
|
try {
|
|
12
15
|
const res = await fetch("/api/notifications?countOnly=true&unread=true");
|
|
13
16
|
if (res.ok) {
|
|
14
17
|
const data = await res.json();
|
|
15
|
-
setCount(data.count ?? 0);
|
|
18
|
+
if (!cancelled) setCount(data.count ?? 0);
|
|
16
19
|
}
|
|
17
20
|
} catch {
|
|
18
21
|
// Silently fail
|
|
@@ -22,7 +25,30 @@ export function UnreadBadge() {
|
|
|
22
25
|
fetchCount();
|
|
23
26
|
// Aligned with InboxList polling at 10s to reduce duplicate requests
|
|
24
27
|
const interval = setInterval(fetchCount, 10_000);
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
// Real-time badge updates for workflow checkpoints
|
|
30
|
+
// (fix-inbox-checkpoint-realtime): the badge must jump the instant a
|
|
31
|
+
// checkpoint is raised, not on the next 10s tick. Re-count immediately
|
|
32
|
+
// whenever the pending-approvals set changes (SSE trigger). Poll remains
|
|
33
|
+
// the fallback for other notification types and on SSE failure.
|
|
34
|
+
try {
|
|
35
|
+
eventSource = new EventSource("/api/notifications/pending-approvals/stream");
|
|
36
|
+
eventSource.onmessage = () => {
|
|
37
|
+
if (!cancelled) void fetchCount();
|
|
38
|
+
};
|
|
39
|
+
eventSource.onerror = () => {
|
|
40
|
+
eventSource?.close();
|
|
41
|
+
eventSource = null;
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
// EventSource unavailable — the 10s poll remains the delivery path.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
eventSource?.close();
|
|
51
|
+
};
|
|
26
52
|
}, []);
|
|
27
53
|
|
|
28
54
|
if (count === 0) return null;
|
|
@@ -41,7 +41,7 @@ export async function registerNodeInstrumentation() {
|
|
|
41
41
|
await runPendingMigrations();
|
|
42
42
|
|
|
43
43
|
// Plugin loader (Kind 5 only). Seeds dogfood examples on first boot,
|
|
44
|
-
// scans ~/.
|
|
44
|
+
// scans ~/.relay/plugins/, registers profiles + blueprints + tables + schedules.
|
|
45
45
|
// Failures are isolated per-plugin; boot continues regardless.
|
|
46
46
|
//
|
|
47
47
|
// ORDERING INVARIANTS (do not move this block without re-checking):
|
|
@@ -78,6 +78,62 @@ export async function withAnthropicDirectMcpServers(
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Anthropic Messages API `mcp_servers` connector entry.
|
|
83
|
+
* The API accepts ONLY remote URL connectors (`type: "url"`) — not
|
|
84
|
+
* in-process server instances or local stdio (`command`) servers.
|
|
85
|
+
*/
|
|
86
|
+
export type AnthropicMcpConnector = {
|
|
87
|
+
type: "url";
|
|
88
|
+
url: string;
|
|
89
|
+
name: string;
|
|
90
|
+
} & Record<string, string>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Project the five-source MCP merge down to the Anthropic Messages API
|
|
94
|
+
* `mcp_servers` shape (remote URL connectors only).
|
|
95
|
+
*
|
|
96
|
+
* The API's `mcp_servers` field is for REMOTE MCP connectors reached over
|
|
97
|
+
* HTTP; the SDK JSON-serializes the request body. The in-process `relay`
|
|
98
|
+
* server (an SDK MCP server object with a circular `root` transport
|
|
99
|
+
* back-reference) and local stdio (`command`) plugin servers CANNOT go here:
|
|
100
|
+
* - `relay` is not serializable → the SDK's `JSON.stringify(body)` throws
|
|
101
|
+
* "Converting circular structure to JSON … property 'root' closes the
|
|
102
|
+
* circle" and every anthropic-direct task fails before the model call.
|
|
103
|
+
* - stdio (`command`) servers are local subprocesses the remote API cannot
|
|
104
|
+
* reach; passing them is meaningless.
|
|
105
|
+
*
|
|
106
|
+
* Both are already available to the model as local function tools via
|
|
107
|
+
* `forProvider("anthropic")` + `executeHandler`, so dropping them here loses
|
|
108
|
+
* no capability. Only genuinely remote (URL-bearing) connectors are emitted;
|
|
109
|
+
* any remaining scalar config fields (e.g. a per-connector bearer token) are
|
|
110
|
+
* carried through opaquely. Mirrors openai-direct's `mcpServersToOpenAiTools`
|
|
111
|
+
* (which likewise skips `relay`).
|
|
112
|
+
*/
|
|
113
|
+
export function mcpServersToAnthropicConnectors(
|
|
114
|
+
mergedServers: Record<string, unknown>,
|
|
115
|
+
): AnthropicMcpConnector[] {
|
|
116
|
+
const connectors: AnthropicMcpConnector[] = [];
|
|
117
|
+
for (const [name, config] of Object.entries(mergedServers)) {
|
|
118
|
+
if (name === "relay") continue; // in-process tools handled via function-calling, not the remote MCP path
|
|
119
|
+
const cfg = config as Record<string, unknown>;
|
|
120
|
+
const url = cfg.url ?? cfg.server_url;
|
|
121
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
122
|
+
// stdio/command server or unserializable object — not a remote connector.
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const connector = { type: "url", url, name } as AnthropicMcpConnector;
|
|
126
|
+
// Carry through any remaining scalar config fields opaquely (e.g. the
|
|
127
|
+
// API's per-connector auth field, if a plugin config supplied one).
|
|
128
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
129
|
+
if (["url", "server_url", "command", "args", "env"].includes(key)) continue;
|
|
130
|
+
if (typeof value === "string") connector[key] = value;
|
|
131
|
+
}
|
|
132
|
+
connectors.push(connector);
|
|
133
|
+
}
|
|
134
|
+
return connectors;
|
|
135
|
+
}
|
|
136
|
+
|
|
81
137
|
// ── SDK lazy import ──────────────────────────────────────────────────
|
|
82
138
|
|
|
83
139
|
type AnthropicSDK = typeof import("@anthropic-ai/sdk");
|
|
@@ -128,13 +184,13 @@ interface AnthropicCallOptions {
|
|
|
128
184
|
/** Extended thinking config (Anthropic only). */
|
|
129
185
|
extendedThinking?: { enabled: boolean; budgetTokens?: number };
|
|
130
186
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* so
|
|
187
|
+
* Remote MCP connectors (TDR-035 §1), projected to the Anthropic Messages
|
|
188
|
+
* API `mcp_servers` shape by `mcpServersToAnthropicConnectors`. ONLY
|
|
189
|
+
* URL-based remote connectors — the in-process `relay` server and local
|
|
190
|
+
* stdio plugin servers are excluded upstream (they're served as local
|
|
191
|
+
* function tools instead), so this is always JSON-serializable.
|
|
136
192
|
*/
|
|
137
|
-
mcpServers?:
|
|
193
|
+
mcpServers?: AnthropicMcpConnector[];
|
|
138
194
|
}
|
|
139
195
|
|
|
140
196
|
/**
|
|
@@ -214,10 +270,13 @@ async function callAnthropicModel(
|
|
|
214
270
|
};
|
|
215
271
|
}
|
|
216
272
|
|
|
217
|
-
// Inject
|
|
273
|
+
// Inject remote MCP connectors (TDR-035 §1). Already projected to the
|
|
274
|
+
// serializable URL-connector shape by mcpServersToAnthropicConnectors —
|
|
275
|
+
// the in-process `relay` server and local stdio plugins are excluded
|
|
276
|
+
// upstream, so this can no longer make the request body circular.
|
|
218
277
|
// mcp_servers is a beta Anthropic Messages API field; params is typed as
|
|
219
278
|
// any above so no extra assertion is needed here.
|
|
220
|
-
if (options.mcpServers &&
|
|
279
|
+
if (options.mcpServers && options.mcpServers.length > 0) {
|
|
221
280
|
params.mcp_servers = options.mcpServers;
|
|
222
281
|
}
|
|
223
282
|
|
|
@@ -366,6 +425,10 @@ async function executeAnthropicDirectTask(taskId: string, isResume = false): Pro
|
|
|
366
425
|
pluginServers,
|
|
367
426
|
task.projectId,
|
|
368
427
|
);
|
|
428
|
+
// Project to the serializable remote-connector shape BEFORE it reaches the
|
|
429
|
+
// request body — the raw merge holds the in-process `relay` server (a
|
|
430
|
+
// circular SDK object) that would crash JSON.stringify (see the helper).
|
|
431
|
+
const mcpConnectors = mcpServersToAnthropicConnectors(mergedMcpServers);
|
|
369
432
|
|
|
370
433
|
// Build initial messages or restore from snapshot
|
|
371
434
|
let initialMessages: LoopMessage[];
|
|
@@ -429,7 +492,7 @@ async function executeAnthropicDirectTask(taskId: string, isResume = false): Pro
|
|
|
429
492
|
enableCaching: true,
|
|
430
493
|
profileInstructions: profile?.skillMd,
|
|
431
494
|
extendedThinking: capOverrides?.extendedThinking,
|
|
432
|
-
mcpServers:
|
|
495
|
+
mcpServers: mcpConnectors,
|
|
433
496
|
},
|
|
434
497
|
);
|
|
435
498
|
|
package/src/lib/apps/registry.ts
CHANGED
|
@@ -537,7 +537,7 @@ function sweepNamespacedBlueprints(blueprintsDir: string, appId: string): number
|
|
|
537
537
|
* Cascade-delete an app: removes its DB project (and all FK-dependent rows)
|
|
538
538
|
* via deleteProjectCascade, removes the manifest dir on disk, then sweeps
|
|
539
539
|
* `<appId>--*` profile dirs and `<appId>--*.yaml` blueprint files from the
|
|
540
|
-
* shared `~/.
|
|
540
|
+
* shared `~/.relay/profiles/` and `~/.relay/blueprints/` directories.
|
|
541
541
|
*
|
|
542
542
|
* All four halves are independent — a missing piece is not an error. The
|
|
543
543
|
* result reports which halves removed something.
|
package/src/lib/packs/format.ts
CHANGED
|
@@ -46,9 +46,29 @@ export const PackManifestSchema = z
|
|
|
46
46
|
/**
|
|
47
47
|
* Premium display copy (D6). Offline strings rendered on the locked
|
|
48
48
|
* gallery card — the Website still owns actual pricing. Meaningful only
|
|
49
|
-
* alongside `entitlement`; harmless on a free pack.
|
|
49
|
+
* alongside `entitlement`; harmless on a free pack. Either a flat string
|
|
50
|
+
* ("$499/year") or a two-phase offer ({ list, intro?, note? }) so a
|
|
51
|
+
* founding/introductory price can render alongside the list price.
|
|
52
|
+
* Render sites consume `packPrice()` — never branch on the raw shape.
|
|
50
53
|
*/
|
|
51
|
-
price: z
|
|
54
|
+
price: z
|
|
55
|
+
.union([
|
|
56
|
+
z.string().min(1),
|
|
57
|
+
z
|
|
58
|
+
.object({
|
|
59
|
+
list: z.string().min(1),
|
|
60
|
+
intro: z.string().min(1).optional(),
|
|
61
|
+
note: z.string().min(1).optional(),
|
|
62
|
+
})
|
|
63
|
+
.strict(),
|
|
64
|
+
])
|
|
65
|
+
.optional(),
|
|
66
|
+
/**
|
|
67
|
+
* Card identity token — a lucide icon name rendered on the gallery card
|
|
68
|
+
* (e.g. "briefcase"). Unknown tokens fall back to the default glyph;
|
|
69
|
+
* never a remote asset.
|
|
70
|
+
*/
|
|
71
|
+
icon: z.string().min(1).optional(),
|
|
52
72
|
/** Get-license CTA target on the locked card. */
|
|
53
73
|
purchaseUrl: z.url().optional(),
|
|
54
74
|
/**
|
|
@@ -65,6 +85,22 @@ export const PackManifestSchema = z
|
|
|
65
85
|
|
|
66
86
|
export type PackMeta = z.infer<typeof PackManifestSchema>;
|
|
67
87
|
|
|
88
|
+
/** Normalized two-phase offer; `list` is always present. */
|
|
89
|
+
export interface PackPrice {
|
|
90
|
+
list: string;
|
|
91
|
+
intro?: string;
|
|
92
|
+
note?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The single price shape every render site consumes (card, any future recap
|
|
97
|
+
* surface). Flat-string packs normalize to `{ list }`; free packs → null.
|
|
98
|
+
*/
|
|
99
|
+
export function packPrice(meta: PackMeta): PackPrice | null {
|
|
100
|
+
if (!meta.price) return null;
|
|
101
|
+
return typeof meta.price === "string" ? { list: meta.price } : meta.price;
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
// ── Pack + resolved-layer types ──────────────────────────────────────
|
|
69
105
|
|
|
70
106
|
export interface Pack {
|
|
@@ -4,6 +4,7 @@ name: Relay Agency
|
|
|
4
4
|
author: Orionfold
|
|
5
5
|
description: Agency operating layer — CRE + nonprofit verticals.
|
|
6
6
|
relayCore: ">=0.15.0"
|
|
7
|
+
icon: building-2
|
|
7
8
|
# Declarative metadata. The installer seeds customers from base/seed/customers.yaml
|
|
8
9
|
# (objects), not from this list — these slugs mirror that seed for at-a-glance review.
|
|
9
10
|
customers:
|
|
@@ -17,11 +17,20 @@ description: >
|
|
|
17
17
|
included update) — a grant pipeline that takes every opportunity from
|
|
18
18
|
fit-scored go/no-go through LOI, full application, and post-award
|
|
19
19
|
restricted-funds compliance with a reporting calendar. Everything installs
|
|
20
|
-
offline; your
|
|
20
|
+
offline; Relay never sends your data to Orionfold.
|
|
21
21
|
relayCore: ">=0.18.0"
|
|
22
22
|
entitlement: product:orionfold-relay
|
|
23
|
-
|
|
23
|
+
# Two-phase offer, hand-maintained to match orionfold.com/relay/ in this
|
|
24
|
+
# release. Reading the canonical Orionfold source to stay current is fine
|
|
25
|
+
# (the promise forbids SENDING user data, not reading public data — operator
|
|
26
|
+
# ruling 2026-07-02); until such a read ships, when the founding window
|
|
27
|
+
# closes on the Website, delete `intro` + `note` here in a normal release.
|
|
28
|
+
price:
|
|
29
|
+
intro: "$349/year"
|
|
30
|
+
list: "$499/year"
|
|
31
|
+
note: "Founding price for early buyers — locks in before it returns to list."
|
|
24
32
|
purchaseUrl: https://orionfold.com/relay/
|
|
33
|
+
icon: briefcase
|
|
25
34
|
# Per-version customer-voice recap — the single source every renewal surface
|
|
26
35
|
# reads (license status, the update refusal, the /packs card, the renewal
|
|
27
36
|
# email). Add a line here with EVERY version bump; the template test suite
|
|
@@ -53,7 +53,7 @@ import type { ScheduleSpec } from "@/lib/validators/schedule-spec";
|
|
|
53
53
|
// unfixed from 0.15.0 through 0.16.0 — treat the window test's failure as
|
|
54
54
|
// a release blocker, not noise). The 0.13→0.14 three-MINOR bridge is over;
|
|
55
55
|
// this is the standard 2-MINOR window now.
|
|
56
|
-
const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.
|
|
56
|
+
const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.22"]);
|
|
57
57
|
|
|
58
58
|
/** Test-helper export so the window-enforcement test can read state. */
|
|
59
59
|
export function isSupportedApiVersion(apiVersion: string): boolean {
|
|
@@ -6,7 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
// (a hardcoded copy there once drifted to "0.14" — scaffolded plugins would
|
|
7
7
|
// have been disabled on load the moment the window tightened). Bump on every
|
|
8
8
|
// MINOR release; api-version-window.test.ts fails if this goes stale.
|
|
9
|
-
export const CURRENT_PLUGIN_API_VERSION = "0.
|
|
9
|
+
export const CURRENT_PLUGIN_API_VERSION = "0.23";
|
|
10
10
|
|
|
11
11
|
// Shared capability tuple — single source of truth used by Zod schema and
|
|
12
12
|
// capability-check.ts hash derivation. Exported so consumers don't need a
|
|
@@ -37,7 +37,36 @@ function hasSqliteHeader(path: string): boolean {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* A single directory-rename hop in the brand-migration chain (`from` -> `to`),
|
|
41
|
+
* together with the db-file basename rename that lives inside that dir.
|
|
42
|
+
* `stagent` -> `ainative` -> `relay`.
|
|
43
|
+
*/
|
|
44
|
+
interface MigrationHop {
|
|
45
|
+
/** Legacy home-relative dir name, e.g. ".stagent". */
|
|
46
|
+
fromDir: string;
|
|
47
|
+
/** Next-brand home-relative dir name, e.g. ".ainative". */
|
|
48
|
+
toDir: string;
|
|
49
|
+
/** Legacy db basename (no suffix), e.g. "stagent.db". */
|
|
50
|
+
fromDb: string;
|
|
51
|
+
/** Next-brand db basename (no suffix), e.g. "ainative.db". */
|
|
52
|
+
toDb: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Ordered chain: an install may sit at ANY legacy brand. Running the hops in
|
|
56
|
+
// order converges `~/.stagent` (two behind) OR `~/.ainative` (one behind) onto
|
|
57
|
+
// the live `~/.relay` / relay.db that @/lib/config/env resolves. The SQL
|
|
58
|
+
// mcp__stagent__ -> mcp__ainative__ rewrite happens here (Step 3); the
|
|
59
|
+
// subsequent mcp__ainative__ -> mcp__relay__ rewrite runs against the live DB
|
|
60
|
+
// in instrumentation-node.ts via migrate-mcp-namespace.ts.
|
|
61
|
+
const MIGRATION_CHAIN: MigrationHop[] = [
|
|
62
|
+
{ fromDir: ".stagent", toDir: ".ainative", fromDb: "stagent.db", toDb: "ainative.db" },
|
|
63
|
+
{ fromDir: ".ainative", toDir: ".relay", fromDb: "ainative.db", toDb: "relay.db" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Idempotent migration of legacy data dirs onto the live `~/.relay`. Walks the
|
|
68
|
+
* `~/.stagent` -> `~/.ainative` -> `~/.relay` chain so an install at any prior
|
|
69
|
+
* brand converges on the current one. Safe to call on every boot.
|
|
41
70
|
* Never throws — errors are collected in report.errors.
|
|
42
71
|
*/
|
|
43
72
|
export async function migrateLegacyData(
|
|
@@ -55,57 +84,69 @@ export async function migrateLegacyData(
|
|
|
55
84
|
errors: [],
|
|
56
85
|
};
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
const
|
|
87
|
+
// The terminus of the chain — where the live app reads from.
|
|
88
|
+
const finalDir = join(home, MIGRATION_CHAIN[MIGRATION_CHAIN.length - 1].toDir);
|
|
89
|
+
const finalDbName = MIGRATION_CHAIN[MIGRATION_CHAIN.length - 1].toDb;
|
|
60
90
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
// Steps 1+2: walk each hop's dir rename + db-file rename. A `return` here on
|
|
92
|
+
// an unrecoverable dir error aborts the whole chain (matches prior behavior:
|
|
93
|
+
// the data is in an unknown state, so downstream steps must not run).
|
|
94
|
+
for (const hop of MIGRATION_CHAIN) {
|
|
95
|
+
const oldDir = join(home, hop.fromDir);
|
|
96
|
+
const newDir = join(home, hop.toDir);
|
|
97
|
+
|
|
98
|
+
// Step 1: rename directory if needed
|
|
99
|
+
if (existsSync(oldDir) && !existsSync(newDir)) {
|
|
100
|
+
try {
|
|
101
|
+
renameSync(oldDir, newDir);
|
|
102
|
+
report.dirMigrated = true;
|
|
103
|
+
log(`renamed ${oldDir} -> ${newDir}`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const e = err as NodeJS.ErrnoException;
|
|
106
|
+
if (e.code === "EXDEV") {
|
|
107
|
+
try {
|
|
108
|
+
cpSync(oldDir, newDir, { recursive: true });
|
|
109
|
+
rmSync(oldDir, { recursive: true, force: true });
|
|
110
|
+
report.dirMigrated = true;
|
|
111
|
+
log(`copied ${oldDir} -> ${newDir} (cross-device fallback)`);
|
|
112
|
+
} catch (copyErr) {
|
|
113
|
+
report.errors.push(`dir copy failed: ${String(copyErr)}`);
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
report.errors.push(`dir rename failed: ${String(err)}`);
|
|
77
118
|
return report;
|
|
78
119
|
}
|
|
79
|
-
} else {
|
|
80
|
-
report.errors.push(`dir rename failed: ${String(err)}`);
|
|
81
|
-
return report;
|
|
82
120
|
}
|
|
83
121
|
}
|
|
84
|
-
}
|
|
85
122
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
// Step 2: rename DB files inside the (now-current) dir. If newDir exists
|
|
124
|
+
// from a partial prior run (e.g., failed cpSync cross-device), iteration
|
|
125
|
+
// here is safe — neither old nor new DB files may exist and the step
|
|
126
|
+
// becomes a no-op.
|
|
127
|
+
if (existsSync(newDir)) {
|
|
128
|
+
for (const suffix of ["", "-shm", "-wal"]) {
|
|
129
|
+
const oldName = join(newDir, `${hop.fromDb}${suffix}`);
|
|
130
|
+
const newName = join(newDir, `${hop.toDb}${suffix}`);
|
|
131
|
+
if (existsSync(oldName) && !existsSync(newName)) {
|
|
132
|
+
try {
|
|
133
|
+
renameSync(oldName, newName);
|
|
134
|
+
report.dbFilesRenamed++;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
report.errors.push(`db file rename failed (${suffix}): ${String(err)}`);
|
|
137
|
+
}
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
}
|
|
102
141
|
}
|
|
103
142
|
|
|
104
|
-
// Step 3: SQL row migration. Only open the DB if it begins
|
|
105
|
-
// SQLite magic header. Opening a non-SQLite file (e.g., a test
|
|
106
|
-
// placeholder) would succeed initially, then fail on the first
|
|
107
|
-
// and the close() in finally would silently delete co-located
|
|
108
|
-
|
|
143
|
+
// Step 3: SQL row migration on the FINAL db. Only open the DB if it begins
|
|
144
|
+
// with the SQLite magic header. Opening a non-SQLite file (e.g., a test
|
|
145
|
+
// fixture placeholder) would succeed initially, then fail on the first
|
|
146
|
+
// prepare(), and the close() in finally would silently delete co-located
|
|
147
|
+
// -shm/-wal. This rewrites the stagent-era mcp prefix / sourceFormat; the
|
|
148
|
+
// ainative->relay mcp rewrite runs later against the live DB.
|
|
149
|
+
const dbPath = join(finalDir, finalDbName);
|
|
109
150
|
if (existsSync(dbPath) && !hasSqliteHeader(dbPath)) {
|
|
110
151
|
log(`skipping SQL migration — ${dbPath} exists but lacks SQLite header`);
|
|
111
152
|
}
|