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 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
- - **Never phones home** — 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)
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
- phones home: no activation server, no telemetry, no network call of any kind. Works
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.22";
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.string().min(1).optional(),
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.21"]);
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 && Object.keys(options.mcpServers).length > 0) {
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: mergedMcpServers
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.22.1")) {
25744
- return "0.22.1";
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 oldDir = join6(home, ".stagent");
27000
- const newDir = join6(home, ".ainative");
27001
- if (existsSync4(oldDir) && !existsSync4(newDir)) {
27002
- try {
27003
- renameSync(oldDir, newDir);
27004
- report.dirMigrated = true;
27005
- log(`renamed ${oldDir} -> ${newDir}`);
27006
- } catch (err2) {
27007
- const e = err2;
27008
- if (e.code === "EXDEV") {
27009
- try {
27010
- cpSync(oldDir, newDir, { recursive: true });
27011
- rmSync2(oldDir, { recursive: true, force: true });
27012
- report.dirMigrated = true;
27013
- log(`copied ${oldDir} -> ${newDir} (cross-device fallback)`);
27014
- } catch (copyErr) {
27015
- report.errors.push(`dir copy failed: ${String(copyErr)}`);
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
- if (existsSync4(newDir)) {
27025
- for (const suffix of ["", "-shm", "-wal"]) {
27026
- const oldName = join6(newDir, `stagent.db${suffix}`);
27027
- const newName = join6(newDir, `ainative.db${suffix}`);
27028
- if (existsSync4(oldName) && !existsSync4(newName)) {
27029
- try {
27030
- renameSync(oldName, newName);
27031
- report.dbFilesRenamed++;
27032
- } catch (err2) {
27033
- report.errors.push(`db file rename failed (${suffix}): ${String(err2)}`);
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(newDir, "ainative.db");
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.AINATIVE_DATA_DIR || join(homedir(), ".ainative");
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, "ainative.db"),
12
+ url: join(dataDir, "relay.db"),
13
13
  },
14
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
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",
@@ -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
- * phones home.
17
+ * sends user data to Orionfold.
18
18
  */
19
19
 
20
20
  const BodySchema = z.object({
@@ -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 { Boxes, Check, Lock, Package, TriangleAlert } from "lucide-react";
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
- export default function PacksPage() {
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="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
33
- {templates.map((t) => (
34
- <PackCard key={t.id} template={t} installed={installedIds.has(t.id)} />
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="p-3 space-y-2">
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 min-w-0">
77
- {premium ? (
78
- <Lock
79
- className="h-4 w-4 text-muted-foreground shrink-0"
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
- {meta.price ?? "Premium"}
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-2">
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
- {installed ? (
109
- <InstalledActions template={template} />
110
- ) : (
111
- <div className="flex items-end justify-between gap-2 pt-1">
112
- <PackInstallButton
113
- packId={template.id}
114
- packName={meta.name}
115
- premium={premium}
116
- />
117
- {premium && meta.purchaseUrl && (
118
- <a
119
- href={meta.purchaseUrl}
120
- target="_blank"
121
- rel="noopener noreferrer"
122
- className="text-xs font-medium text-foreground underline underline-offset-2 hover:text-primary shrink-0"
123
- >
124
- Get license →
125
- </a>
126
- )}
127
- </div>
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
- return () => clearInterval(interval);
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 ~/.ainative/plugins/, registers profiles + blueprints + tables + schedules.
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
- * Five-source merged MCP servers (TDR-035 §1).
132
- * Passed as `mcp_servers` in the Anthropic Messages API request body.
133
- * The Anthropic SDK mcp_servers field is in beta; typed as unknown to
134
- * avoid SDK version skew. The `params` object is already typed as `any`
135
- * so no ts-expect-error is needed.
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?: Record<string, unknown>;
193
+ mcpServers?: AnthropicMcpConnector[];
138
194
  }
139
195
 
140
196
  /**
@@ -214,10 +270,13 @@ async function callAnthropicModel(
214
270
  };
215
271
  }
216
272
 
217
- // Inject five-source merged MCP servers (TDR-035 §1).
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 && Object.keys(options.mcpServers).length > 0) {
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: mergedMcpServers,
495
+ mcpServers: mcpConnectors,
433
496
  },
434
497
  );
435
498
 
@@ -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 `~/.ainative/profiles/` and `~/.ainative/blueprints/` directories.
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.
@@ -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.string().min(1).optional(),
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 license never phones home.
20
+ offline; Relay never sends your data to Orionfold.
21
21
  relayCore: ">=0.18.0"
22
22
  entitlement: product:orionfold-relay
23
- price: "$499/year"
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
@@ -1,6 +1,6 @@
1
1
  id: echo-server
2
2
  version: 0.1.0
3
- apiVersion: "0.22"
3
+ apiVersion: "0.23"
4
4
  kind: chat-tools
5
5
  name: Echo Server
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: finance-pack
2
2
  version: 0.1.0
3
- apiVersion: "0.22"
3
+ apiVersion: "0.23"
4
4
  kind: primitives-bundle
5
5
  name: Finance Pack
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: reading-radar
2
2
  version: 0.1.0
3
- apiVersion: "0.22"
3
+ apiVersion: "0.23"
4
4
  kind: primitives-bundle
5
5
  name: Reading Radar
6
6
  description: |
@@ -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.21"]);
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.22";
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
- * Idempotent migration from ~/.stagent/ to ~/.ainative/. Safe to call on every boot.
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
- const oldDir = join(home, ".stagent");
59
- const newDir = join(home, ".ainative");
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
- // Step 1: rename directory if needed
62
- if (existsSync(oldDir) && !existsSync(newDir)) {
63
- try {
64
- renameSync(oldDir, newDir);
65
- report.dirMigrated = true;
66
- log(`renamed ${oldDir} -> ${newDir}`);
67
- } catch (err) {
68
- const e = err as NodeJS.ErrnoException;
69
- if (e.code === "EXDEV") {
70
- try {
71
- cpSync(oldDir, newDir, { recursive: true });
72
- rmSync(oldDir, { recursive: true, force: true });
73
- report.dirMigrated = true;
74
- log(`copied ${oldDir} -> ${newDir} (cross-device fallback)`);
75
- } catch (copyErr) {
76
- report.errors.push(`dir copy failed: ${String(copyErr)}`);
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
- // Step 2: rename DB files inside the new dir. If newDir exists from a
87
- // partial prior run (e.g., failed cpSync cross-device), iteration here is
88
- // safe — neither old nor new DB files may exist and the step becomes a no-op.
89
- if (existsSync(newDir)) {
90
- for (const suffix of ["", "-shm", "-wal"]) {
91
- const oldName = join(newDir, `stagent.db${suffix}`);
92
- const newName = join(newDir, `ainative.db${suffix}`);
93
- if (existsSync(oldName) && !existsSync(newName)) {
94
- try {
95
- renameSync(oldName, newName);
96
- report.dbFilesRenamed++;
97
- } catch (err) {
98
- report.errors.push(`db file rename failed (${suffix}): ${String(err)}`);
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 with the
105
- // SQLite magic header. Opening a non-SQLite file (e.g., a test fixture
106
- // placeholder) would succeed initially, then fail on the first prepare(),
107
- // and the close() in finally would silently delete co-located -shm/-wal.
108
- const dbPath = join(newDir, "ainative.db");
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
  }