f0173440e4
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session — Prime Intellect's own harness, embedded as a Node library — answering from PIG's tools and, for the first time, able to put information into the CRM rather than only read it out. The harness is a coding agent, so the first job was taking the coding agent away from it. `noTools: 'all'` plus an explicit allowlist leaves the model with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That holds under attack: a hostile extension, a skill and a settings file planted in the agent's own directory, then `setActiveToolsByName` called with every built-in, still leaves ten tools, all ours. Both lines are load-bearing — `noTools` alone registers nothing, and the allowlist is what admits our own. Writing is gated rather than assumed. A change is proposed, not made: the tool returns a description, the transcript renders a diff card, and nothing reaches the database until someone presses Apply. Contracts, commitments, allocations and compliance always stop for a human whatever the mode. Every write runs through `executeMutation` as the calling user, so their capabilities and the audit trail apply exactly as they would to a human's. Four things about the SDK are wrong in its own documentation and cost a debugging cycle each: models.json does not resolve an env var name for `apiKey`, it sends the literal string; there is no built-in prime-inference provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you; and the stock system prompt is a coding-assistant prompt that must be replaced — but replacing it also silently removes the tool list, because the harness only renders that section when it owns the prompt. AGENTS.md records all four. The expensive one was thinking level. The harness defaults to `medium`, and nemotron spent an entire 4,096-token budget reasoning and returned an empty answer. `low` was worse; `off` omits the parameter so the endpoint's default wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn from 6,195 output tokens to 149. And a turn is now bounded. The harness loop is `while (true)` with no iteration cap; a runaway on a frontier model would have eaten the credit it is supposed to report on. Ceilings on model calls and tokens, enforced both through the harness hook and independently from the event stream, plus a per-user daily spend limit — and the ledger now records spend on turns that fail, which it previously discarded. Signing in lands on /piggy, which is a workspace: conversations down one side, the agent in the middle, what it did and what it cost beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
/**
|
|
* Application root: routing, data fetching, and the auth gate.
|
|
*/
|
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|
import { Link } from 'react-router-dom';
|
|
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
|
import { ThemeProvider } from '@/lib/theme';
|
|
import { IdentityProvider, useIdentityQuery } from '@/lib/identity';
|
|
import { LayoutProvider } from '@/lib/layout';
|
|
import { PiggyContextProvider } from '@/lib/piggy-context';
|
|
import { PlatformAudioProvider } from '@/lib/audio';
|
|
import { Shell } from '@/components/Shell';
|
|
import { SignIn } from '@/pages/SignIn';
|
|
import { CreateProfile } from '@/pages/CreateProfile';
|
|
import { Register } from '@/pages/Register';
|
|
import { PiggyMark } from '@/components/PiggyMark';
|
|
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Toaster } from '@/components/ui/sonner';
|
|
import { usePageTitle } from '@/lib/title';
|
|
|
|
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
|
const Capacity = lazy(() => import('@/pages/Capacity').then(({ Capacity }) => ({ default: Capacity })));
|
|
const DemandPipeline = lazy(() => import('@/pages/Pipeline').then(({ DemandPipeline }) => ({ default: DemandPipeline })));
|
|
const SupplyPipeline = lazy(() => import('@/pages/Pipeline').then(({ SupplyPipeline }) => ({ default: SupplyPipeline })));
|
|
const Settings = lazy(() => import('@/pages/Settings').then(({ Settings }) => ({ default: Settings })));
|
|
const Accounts = lazy(() => import('@/pages/Accounts').then(({ Accounts }) => ({ default: Accounts })));
|
|
const Account = lazy(() => import('@/pages/Account').then(({ Account }) => ({ default: Account })));
|
|
const Margin = lazy(() => import('@/pages/Margin').then(({ Margin }) => ({ default: Margin })));
|
|
const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) => ({ default: FactReview })));
|
|
const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts })));
|
|
const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports })));
|
|
const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy })));
|
|
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
|
|
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
|
|
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 30_000,
|
|
// Refetching every time a phone user switches apps and comes back is
|
|
// wasteful on cellular; the interval on the dashboard covers freshness.
|
|
refetchOnWindowFocus: false,
|
|
retry: (failureCount, error) => {
|
|
// Retrying an auth failure just produces the same failure slower.
|
|
if (error instanceof ApiError && (error.needsSignIn || error.needsProfile)) return false;
|
|
return failureCount < 2;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
export function App() {
|
|
const [config, setConfig] = useState<PublicConfig | null>(null);
|
|
const [configError, setConfigError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadPublicConfig()
|
|
.then(setConfig)
|
|
.catch((error: unknown) =>
|
|
setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'),
|
|
);
|
|
}, []);
|
|
|
|
if (configError) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Cannot reach PIG"
|
|
description={configError}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
if (!config) return <Splash />;
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider
|
|
onPersist={(prefs) => {
|
|
// Fire and forget: a failed preference save must never block the UI,
|
|
// and the local copy already applied the change.
|
|
void patch('/api/me/preferences', prefs).catch(() => {});
|
|
}}
|
|
>
|
|
{/*
|
|
Above the router, so the music survives navigation AND covers the
|
|
anonymous Learn page — a share-code visitor gets the same platform
|
|
character as a member. It never plays unbidden: the browser refuses
|
|
audio until the page has had a real gesture, so it begins when
|
|
someone actually starts using the page, and the header control mutes
|
|
it for good on that device.
|
|
*/}
|
|
<PlatformAudioProvider>
|
|
<BrowserRouter>
|
|
<AuthGate config={config} />
|
|
</BrowserRouter>
|
|
</PlatformAudioProvider>
|
|
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
|
<Toaster />
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The signed-out screens render outside Shell. Their shared AuthShell supplies
|
|
* the public header and its music control; keeping this boundary component
|
|
* means the auth gate does not need to know anything about that presentation.
|
|
*
|
|
* Learn is not wrapped: it brings its own chrome and already hosts one.
|
|
*/
|
|
function SignedOut({ children }: { children: React.ReactNode }) {
|
|
return children;
|
|
}
|
|
|
|
/**
|
|
* Decides what to show based on *why* a request failed.
|
|
*
|
|
* The distinction between "not signed in" and "signed in but not a member" is
|
|
* the one that matters: sending an invited-but-unprovisioned user back to a
|
|
* login screen they have already completed is an infuriating loop, and it is
|
|
* the default behaviour if both are treated as "auth error".
|
|
*/
|
|
function AuthGate({ config }: { config: PublicConfig }) {
|
|
// Which unauthenticated screen to show. Kept in state rather than a route so
|
|
// that a half-filled registration form is not lost to an accidental Back.
|
|
const [showRegister, setShowRegister] = useState(false);
|
|
|
|
const { data, isLoading, error, refetch } = useIdentityQuery();
|
|
|
|
// Adopt the server's stored appearance once we know who this is. Appearance
|
|
// only: sidebar-collapsed and dock-open are per-device and live in
|
|
// localStorage, deliberately (see lib/layout.tsx).
|
|
useEffect(() => {
|
|
if (!data) return;
|
|
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
|
|
.then((profile) => {
|
|
if (!profile) return;
|
|
const host = window as unknown as { __pigAdoptTheme?: (p: unknown) => void };
|
|
host.__pigAdoptTheme?.(profile);
|
|
})
|
|
.catch(() => {});
|
|
}, [data]);
|
|
|
|
// React to sign-in and sign-out without a page reload.
|
|
useEffect(() => {
|
|
const supabase = getSupabase();
|
|
if (!supabase) return;
|
|
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
|
|
void refetch();
|
|
});
|
|
return () => subscription.subscription.unsubscribe();
|
|
}, [refetch]);
|
|
|
|
if (isLoading) return <Splash />;
|
|
|
|
if (error instanceof ApiError) {
|
|
if (error.needsSignIn) {
|
|
/*
|
|
* Learn is the one route reachable without an account. It gates itself on
|
|
* a share code, and the API only ever serves it platform-track rows — so
|
|
* sending a code-holder to the sign-in screen would make the code
|
|
* unusable, which is the whole point of having one.
|
|
*
|
|
* Rendered outside Shell deliberately: the page uses no identity, layout
|
|
* or dock hook, and there is no member to build a workspace chrome for.
|
|
*/
|
|
if (window.location.pathname === '/learn') {
|
|
return (
|
|
<RoutePage>
|
|
<Learn />
|
|
</RoutePage>
|
|
);
|
|
}
|
|
return (
|
|
<SignedOut>
|
|
{showRegister ? (
|
|
<Register
|
|
config={config}
|
|
onBack={() => setShowRegister(false)}
|
|
onRegistered={() => {
|
|
setShowRegister(false);
|
|
void refetch();
|
|
}}
|
|
/>
|
|
) : (
|
|
<SignIn config={config} onCreateAccount={() => setShowRegister(true)} />
|
|
)}
|
|
</SignedOut>
|
|
);
|
|
}
|
|
// Authenticated but not a member. This is a step in the flow, not an
|
|
// error — sending them back to a login screen they have already completed
|
|
// would be a loop with no exit.
|
|
if (error.needsProfile) {
|
|
return (
|
|
<SignedOut>
|
|
<CreateProfile config={config} onCreated={() => void refetch()} />
|
|
</SignedOut>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<Centered>
|
|
<EmptyState
|
|
title="Something went wrong"
|
|
description={error instanceof Error ? error.message : 'Unknown error.'}
|
|
/>
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<IdentityProvider identity={data}>
|
|
<LayoutProvider>
|
|
<PiggyContextProvider>
|
|
<AppRoutes />
|
|
</PiggyContextProvider>
|
|
</LayoutProvider>
|
|
</IdentityProvider>
|
|
);
|
|
}
|
|
|
|
function AppRoutes() {
|
|
return (
|
|
<Routes>
|
|
<Route element={<Shell />}>
|
|
{/*
|
|
`/` is the front door, and the front door is Piggy.
|
|
--------------------------------------------------
|
|
Signing in does not navigate: the auth gate simply starts rendering
|
|
these routes at whatever address the browser is already on, which for
|
|
anyone arriving fresh is `/`. So "land on Piggy after sign-in" and
|
|
"`/` is Piggy" are the same sentence, and this is the only line that
|
|
decides it. A post-sign-in `navigate()` was rejected: it fires on one
|
|
path through the gate and not on a hard refresh, so the product would
|
|
open somewhere different depending on how you got there.
|
|
|
|
It is a redirect rather than Piggy mounted at the index, because the
|
|
workspace needs ONE address. Two paths rendering it would leave the
|
|
sidebar row unlit on `/`, the breadcrumb blank, and a shared link
|
|
ambiguous. `replace` keeps `/` out of history, so Back leaves the app
|
|
instead of bouncing between the two, and the logo — which points at
|
|
`/` and means "home" — lands on the same screen it always did, only
|
|
home is Piggy now.
|
|
|
|
Overview moves to `/overview` rather than being displaced: it is the
|
|
exec's page, it keeps its place at the top of Intelligence, it keeps
|
|
its tab on the phone, and it is one click from anywhere. What it
|
|
loses is being the thing you are shown before you have asked for
|
|
anything, which is the whole point of the change — a report is what
|
|
you open when you have a question about the business, and Piggy is
|
|
where you ask it.
|
|
|
|
Nothing else moves. Every other path is registered exactly as before,
|
|
so `/accounts/:id`, `/margin` and every bookmark and Piggy record link
|
|
into them still resolve directly, with no pass through here.
|
|
*/}
|
|
<Route index element={<Navigate to="/piggy" replace />} />
|
|
<Route path="overview" element={<RoutePage><Overview /></RoutePage>} />
|
|
<Route path="margin" element={<RoutePage><Margin /></RoutePage>} />
|
|
<Route path="growth" element={<RoutePage><Growth /></RoutePage>} />
|
|
<Route path="calendar" element={<RoutePage><Calendar /></RoutePage>} />
|
|
<Route path="learn" element={<RoutePage><Learn /></RoutePage>} />
|
|
<Route path="capacity" element={<RoutePage><Capacity /></RoutePage>} />
|
|
<Route path="demand" element={<RoutePage><DemandPipeline /></RoutePage>} />
|
|
<Route path="supply" element={<RoutePage><SupplyPipeline /></RoutePage>} />
|
|
<Route path="accounts" element={<RoutePage><Accounts /></RoutePage>} />
|
|
{/*
|
|
The first record route in the product. Registered after the list so
|
|
the list keeps `/accounts` exactly; react-router matches the more
|
|
specific path regardless of order, but keeping them adjacent is how
|
|
the next four record routes will read.
|
|
*/}
|
|
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
|
|
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
|
|
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
|
|
<Route path="piggy" element={<WorkspaceRoute><Piggy /></WorkspaceRoute>} />
|
|
<Route path="team" element={<Team />} />
|
|
<Route path="facts" element={<RoutePage><FactReview /></RoutePage>} />
|
|
<Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
|
|
<Route path="*" element={<Placeholder title="Not found" />} />
|
|
</Route>
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
function RoutePage({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<Suspense fallback={<RouteLoading />}>
|
|
{children}
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A route that FILLS the content pane instead of flowing down it.
|
|
*
|
|
* Shell puts every page inside `mx-auto max-w-7xl px-4 py-5 …`, which is right
|
|
* for a document and wrong for a workspace: an agent surface with a
|
|
* conversation list, a transcript and an activity panel wants the whole pane,
|
|
* a floor it can pin a composer to, and no page scrollbar behind the two
|
|
* panels that already scroll.
|
|
*
|
|
* `absolute inset-0` is how it gets that without a second shell. SidebarInset
|
|
* is `relative` (see ui/sidebar), so this box is laid out against the content
|
|
* pane itself — full width whatever the container capped, full height whatever
|
|
* the container did not stretch to — while the capped container stays exactly
|
|
* as it is for the twelve pages that want it. Taking it out of flow is also
|
|
* what makes `overflow-hidden` safe here: the page cannot grow, so the panels
|
|
* inside must own their own scrolling, which is the contract a workspace wants
|
|
* anyway.
|
|
*
|
|
* The bottom padding is the one thing that has to be restated. An absolutely
|
|
* positioned child is laid out against its ancestor's PADDING box, so the
|
|
* inset's own tab-bar clearance does not apply to it, and without this the
|
|
* composer would sit underneath the phone tab bar — the exact control a phone
|
|
* user came here to reach. `lg` matches where the tab bar gives way.
|
|
*/
|
|
function WorkspaceRoute({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="absolute inset-0 flex min-h-0 flex-col overflow-hidden pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0">
|
|
{/* `flex-1` on the fallback, or the spinner for a pane this tall sits up
|
|
against the header while the rest of it stays empty. */}
|
|
<Suspense fallback={<div className="flex flex-1 items-center justify-center"><RouteLoading /></div>}>
|
|
{children}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RouteLoading() {
|
|
return (
|
|
<div
|
|
className="flex min-h-[50dvh] items-center justify-center"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
|
<PiggyMark className="h-9 w-9 animate-pulse text-fg" aria-hidden />
|
|
<span>Loading view…</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Splash() {
|
|
return (
|
|
<Centered>
|
|
<PiggyMark className="h-12 w-12 animate-pulse text-fg" title="Loading pig" />
|
|
</Centered>
|
|
);
|
|
}
|
|
|
|
function Centered({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex min-h-dvh items-center justify-center bg-bg px-6">
|
|
<div className="w-full max-w-md">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Placeholder({ title }: { title: string }) {
|
|
usePageTitle(title);
|
|
return (
|
|
<EmptyState
|
|
title={title}
|
|
description="That page does not exist or may have moved. Use Search to return to a workspace."
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Team() {
|
|
usePageTitle('Team');
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['team'],
|
|
queryFn: () =>
|
|
get<
|
|
{
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
title: string | null;
|
|
teams: { team: string; role: string }[];
|
|
}[]
|
|
>('/api/team'),
|
|
});
|
|
|
|
const assignments = (data ?? []).reduce((total, person) => total + person.teams.length, 0);
|
|
const representedTeams = new Set((data ?? []).flatMap((person) => person.teams.map((team) => team.team))).size;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Access map</p>
|
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">Team</h1>
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
|
|
See who can operate each side of the compute business and where ownership is thin.
|
|
</p>
|
|
</div>
|
|
<Link
|
|
to="/settings"
|
|
className="tap inline-flex items-center self-start rounded-lg px-1 text-sm font-medium text-accent-fg underline-offset-4 hover:underline sm:self-auto"
|
|
>
|
|
Manage access in Settings
|
|
</Link>
|
|
</header>
|
|
|
|
<div className="grid grid-cols-3 gap-2 sm:max-w-xl sm:gap-3">
|
|
{[
|
|
['People', data?.length ?? 0],
|
|
['Teams', representedTeams],
|
|
['Assignments', assignments],
|
|
].map(([label, value]) => (
|
|
<Card key={label} className="p-3 sm:p-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted sm:text-xs">{label}</p>
|
|
<p className="nums mt-1 text-2xl font-semibold">{value}</p>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{error ? (
|
|
<Card>
|
|
<EmptyState title="Team unavailable" description={error instanceof Error ? error.message : 'Could not load team access.'} />
|
|
</Card>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{[0, 1, 2].map((key) => <Skeleton key={key} className="h-40 rounded-2xl" />)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length === 0 ? (
|
|
<Card>
|
|
<EmptyState title="No team members yet" description="Invite and assign the first operator from Settings." />
|
|
</Card>
|
|
) : null}
|
|
|
|
{!isLoading && !error && data?.length ? (
|
|
<section aria-labelledby="team-members-heading">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h2 id="team-members-heading" className="text-sm font-semibold">People and permissions</h2>
|
|
<span className="text-xs text-muted">Roles are enforced server-side</span>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{data.map((person) => {
|
|
const initials = person.name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join('');
|
|
return (
|
|
<Card key={person.id} className="p-4 sm:p-5">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Avatar className="size-11 border border-border">
|
|
<AvatarFallback className="bg-accent-subtle text-sm font-semibold text-accent-fg">
|
|
{initials || 'P'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-semibold">{person.name}</p>
|
|
<p className="truncate text-sm text-muted">{person.title || 'Team member'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
|
{person.teams.map((membership) => (
|
|
<Badge key={`${membership.team}:${membership.role}`} tone="accent">
|
|
{membership.team} · {membership.role}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{person.teams.length === 0 ? (
|
|
<p className="mt-4 text-sm text-warning">No operational team assigned</p>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|