Skip to content

Commit

The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.

syntaqxcommitted Parent42cc5b2Browse files
45 files+3568−3370/45 viewed
+4−2
5555 | Spend: where the money went by agent, person, channel, model and product, every budget, a receipt for each task, and your spend in the top bar | Preview |
5656 | Spend by extension | Coming |
5757 | Memory, sessions with live steps and cost, routines on a schedule | Live |
58−| An agent catalog of specialists, and foundational skills (documents, research, data, code, communication, files) | Coming |
58+| An agent catalog in the Marketplace: roles to add an agent into, which members can ask owners for | Live |
59+| More catalog specialists, and foundational skills (documents, research, data, code, communication, files) | Coming |
5960 | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming |
6061 | Agents answering in Slack and Teams | Coming |
6162
8384 | Integrations: the GitHub App, Sentry, Datadog, Jira, Linear, alerts | Live |
8485 | REST API, OpenAPI, MCP server, webhooks, an event bus | Live |
8586 | Usage billing with no seats, a public price book, spend limits, itemised invoices | Live |
86−| Marketplace of extensions, shared from public repositories; Drive, Gmail, Calendar and Slack integrations; Mail | Coming |
87+| The Marketplace: agents from the catalog and integrations, added by owners, with install requests from members | Live |
88+| Extensions shared from public repositories (Support, Recruiting, Mail and On-call are listed); Drive, Gmail, Calendar and Slack integrations | Coming |
8789 | Single sign-on (SAML, OIDC) and SCIM per workspace | Coming |
8890 | Storage: databases and buckets per team, with copies of production for anything unreviewed | Coming |
8991 | Self-hosting: the core forge in Docker Compose (agents, deployments and context search off) | Preview |
+1−0
144144 {
145145 label: 'Connect your tools',
146146 items: [
147+ { label: 'Marketplace', slug: 'guides/marketplace' },
147148 { label: 'Integrations', slug: 'guides/integrations' },
148149 { label: 'Model providers', slug: 'guides/models' },
149150 { label: 'AI Gateway', slug: 'guides/ai-gateway' },
+3−1
6565 <Steps>
6666
6767 1. Open **Agents** in the dock, then choose **New agent**, or go to
68− `g1t.sh/<workspace>/-/agents/new`.
68+ `g1t.sh/<workspace>/-/agents/new`. Or start from the
69+ [agent catalog](/guides/marketplace/#the-agent-catalog) in the
70+ Marketplace, where members who aren't owners can ask for a role.
6971 2. **Pick a role** from the templates, grouped by department, or choose
7072 **Start from nothing**. A role fills in every field below, and you can
7173 change any of them. See [role templates](#role-templates).
+160−0
1+---
2+title: Marketplace
3+description: Add agents, integrations and extensions to a workspace. Anyone browses the Marketplace; owners add things, and everyone else asks an owner. What each listing may do, where its data goes, and how installs, requests and the kill switch work.
4+---
5+
6+The Marketplace is where a workspace adds what isn't built in. Code,
7+Deployments, Chat, Agents and Artifacts come with every workspace; the
8+Marketplace lists what you can add to them:
9+
10+| Kind | What it adds | Status |
11+| --- | --- | --- |
12+| **Agents** | An agent hired into a role from the agent catalog. | Live |
13+| **Integrations** | A connection to a tool you already use, which gives agents new abilities without adding pages. | Live for the integrations marked available; the rest are coming |
14+| **Extensions** | Pages, data, cards and agent roles for what your team does besides code. | Coming: g1t's own are listed, and install from their first release |
15+
16+Open it from **Apps** at the foot of the dock (the launcher's
17+**Marketplace** link, or **Browse the Marketplace** on the Apps page), from
18+**More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and
19+*Marketplace*. Its address is `g1t.sh/<workspace>/-/marketplace`. The
20+Marketplace isn't in the dock itself.
21+
22+## Who can add things
23+
24+Every member of a workspace can browse the Marketplace. Only its
25+**owners** add agents, connect integrations and install extensions,
26+because each of them can spend the workspace's money or reach its data.
27+Everyone else sees **Request** where an owner sees **Add**, and asks.
28+
29+## The Marketplace's pages
30+
31+| Tab | What it shows |
32+| --- | --- |
33+| **Discover** | What every workspace has, a few roles from the agent catalog with `@g1t` first, integrations with the connected ones first, and extensions. |
34+| **Agents** | The agent catalog: every role, by department, with who in the workspace has the job. |
35+| **Integrations** | Connected integrations, the ones you can connect now, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. |
36+| **Extensions** | Every extension listed, published ones first, and how extensions are shared. |
37+| **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. |
38+
39+## The agent catalog
40+
41+Each role in the catalog is a starting point for an agent: a name it
42+suggests, a title, responsibilities, a voice, model limits and the helpers
43+it works with. [Role templates](/guides/agents/#role-templates) lists them.
44+Choose a role to see all of it, including the instructions it starts from
45+and who in the workspace was already hired into it.
46+
47+To add an agent from the catalog as an owner:
48+
49+1. Open **Marketplace → Agents**, or `g1t.sh/<workspace>/-/marketplace/agents`.
50+2. Choose **Add to workspace** on the role (**Add another** when someone
51+ already has the job).
52+3. Finish the new-agent form, which opens with the role chosen, and choose
53+ **Create agent**. See [Hire an agent](/guides/agents/#hire-an-agent).
54+
55+Adding an agent from a role answers every open request for that role: each
56+person who asked hears that it was added.
57+
58+## Integrations
59+
60+The Marketplace lists every integration in the
61+[integrations directory](/guides/integrations/) that a workspace can
62+connect, with whether yours has. **Connect** opens its setup page;
63+**Manage** opens it once it is connected. **Needs attention** means a
64+connection's last check failed: hover it to see why.
65+
66+Integrations each person connects for themselves, such as a calendar or a
67+mailbox, are listed among the coming ones; they are set up in your own
68+[settings](/guides/integrations/#workspace-and-personal) once available.
69+
70+## Extensions
71+
72+An extension adds pages to its own sidebar, data, cards in chat, tools for
73+agents and agent roles. g1t's own are listed now, marked **Soon**, so you
74+can see what each will add and what it will be able to do: **Support**,
75+**Recruiting**, **Mail** and **On-call**. None can be installed before its
76+first release.
77+
78+### Who stands behind a listing
79+
80+| Tier | Who | |
81+| --- | --- | --- |
82+| **Official** | g1t | Built and supported by g1t. |
83+| **Verified** | A reviewed publisher | Code and permissions are checked before it is listed. |
84+| **Community** | Anyone | Shared from a public repository. Pages run sandboxed; server code runs on the publisher's own servers until hosted code has an isolated sandbox. |
85+| **Internal** | Your workspace | Built by your own people and agents, seen only by your workspace. |
86+
87+### What an extension's page tells you
88+
89+Before anything is installed, its page lists:
90+
91+- **What it adds**: its pages, the agent roles it brings, the tools agents
92+ get, and the notifications it sends.
93+- **It will be able to**: what it may do, in plain words, and the scopes
94+ its token asks for, each described.
95+- **Where it runs, and where data goes**: on g1t, or on its publisher's
96+ servers, and every domain outside g1t its data goes to, as *Data leaves
97+ g1t to api.example.com*. *Its data stays in g1t* means it sends nothing
98+ elsewhere.
99+- **Version** and **Source**: the release it installs, and the repository
100+ and tag it was published from.
101+- **Price**: every listing is free. What its agents do is billed at what
102+ it costs, like any agent's work.
103+
104+### How extensions are shared
105+
106+An extension is a public repository on g1t with a manifest,
107+`.g1t/extension.json`. Tagging a release (`v1.2.0`) publishes that version.
108+An install keeps the version it was installed at; updates are offered to
109+owners, never forced.
110+
111+| Manifest field | What it says |
112+| --- | --- |
113+| `id`, `name`, `tagline`, `description`, `category` | What it is called and what it does. |
114+| `publisher` | Who publishes it, and its tier. |
115+| `runtime` | `hosted` (runs on g1t) or `connected` (runs on the publisher's servers). |
116+| `scopes` | The [scopes](/guides/authentication/#scopes) its token asks for. |
117+| `permissions` | What it may do, in plain words, for the install screen. |
118+| `domains` | Every host outside g1t its data goes to. A connected extension must name the ones it runs on. |
119+| `ui.entry` | Its page, loaded in a sandboxed frame from the user-content domain, which reaches g1t only through what it was allowed. |
120+| `adds` | Its `pages`, `agent_roles`, `tools` and `notifications`. |
121+| `pricing` | `null`: listings are free. |
122+
123+### Installed extensions
124+
125+Once installed, an owner can, from the extension's page:
126+
127+- **Switch off**: the kill switch. Its token stops working and its page
128+ stops loading at once, until it is switched on again.
129+- Cap what it spends in a month. Without a cap, the workspace's
130+ [spend limit](/guides/usage-and-billing/) applies.
131+- **Uninstall** it. The record of who installed and removed it stays.
132+
133+Installing, switching on or off, setting a budget and uninstalling are in
134+the [audit log](/guides/audit-log/).
135+
136+## Ask an owner to add something
137+
138+1. Choose **Request** on an agent, an integration or an extension.
139+2. Say why you want it, if you like, and choose **Send request**.
140+
141+Every owner is notified. The button then reads **Requested**, and **Your
142+requests** lists it until an owner answers; you are notified when one does.
143+You can't ask twice for the same thing while a request is waiting, and you
144+can have 20 waiting at once.
145+
146+To answer requests as an owner, open **Marketplace → Requests**:
147+
148+| | |
149+| --- | --- |
150+| **Add**, **Connect** or **Install** | Opens where it is added. Adding an agent from its role answers every request for that role by itself. |
151+| **Mark added** | After you connected the integration, or added it another way. |
152+| **Turn down** | The person who asked is told. They can ask again. |
153+
154+Answered requests stay listed for 30 days.
155+
156+## Apps
157+
158+What the workspace added from the Marketplace that you can use is under
159+**Apps**: today, the integrations it connected. Pin the ones you want to
160+your dock. See [Apps](/guides/workspaces/#apps).
+19−11
596596
597597 ### Apps
598598
599−Every other app in the workspace is a page you can pin to your dock:
600−**Projects**, **Packages**, **Security**, **Context** and **Memory** (for
601−members with Code access), **Teams**, **Usage**, **AI Gateway**,
602−**Integrations** and **Audit log**. A pinned app shows as its icon under
603−the built-in ones, in the order you pinned them.
599+Apps are what the workspace added from the [Marketplace](/guides/marketplace/)
600+that you can use: today, the integrations it connected, such as Sentry or
601+GitHub, each opening its own page; installed extensions join them once
602+extensions are published. An app you can't use shows **Request access**.
603+The built-in apps' own pages are in their sidebars, not in Apps: Code's has
604+**Projects**, **Security** and **Packages**; Agents' has **Context** and
605+**Memory**; People's has **Teams**; Workspace's has **Usage**,
606+**AI Gateway**, **Integrations** and the **Audit log**.
604607
605−To pin an app:
608+A pinned app shows as its mark under the built-in ones, in the order you
609+pinned them. To pin one:
606610
607611 1. Select **Apps** near the foot of the dock, or open the Apps page at
608612 `g1t.sh/<workspace>/-/apps`.
609613 2. Find the app, by name if you like.
610614 3. Select the pin on its tile. Select it again to unpin it.
611615
616+When the workspace hasn't added anything yet, the launcher and the Apps
617+page say so and lead to the Marketplace. The launcher's **Marketplace**
618+link always does.
619+
612620 Your pins are yours alone and each workspace has its own. They are saved
613621 to your account, so your dock is the same on every browser and device you
614−sign in on, in the order you pinned. There is no marketplace yet: it
615−shows as coming.
622+sign in on, in the order you pinned.
616623
617624 ### The sidebar
618625
634641 the sidebar already there.
635642
636643 Workspace's sidebar lists the workspace's **Overview**; under **Money**,
637−[**Spend**](/guides/spend/), **Usage**,
638−**Billing and plans**, **Integrations**, its security policies, the
644+[**Spend**](/guides/spend/), **Usage**, **AI Gateway**,
645+**Billing and plans**; for owners, **Runners** under **Compute**;
646+**Integrations**, its security policies, the
639647 **Audit log** and **Settings**, which slides over to how the workspace is
640648 set up:
641649
689697 | | What it does |
690698 | --- | --- |
691699 | **The bar along the bottom** | **Today**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. |
692−| **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. |
700+| **More** | A panel above the bar with **All apps**, the [Marketplace](/guides/marketplace/), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. |
693701 | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. |
694702 | **The tab you are already on** | Tap it again to open that app's sidebar too. |
695703 | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. |
+4−2
8383 | [Spend](/guides/spend/) | Where the money went, by agent, person, channel, model and product; every budget with what happens at 100%; a receipt for each task; your own spend in the top bar. | <Status is="preview" /> |
8484 | [Acting for the asker](/guides/agent-access/) | Agents act with the access of the person who asked, and answer with only what their audience may see. | <Status is="live" /> |
8585 | [Memory](/guides/agent-memory/), [sessions](/guides/agent-sessions/) and [routines](/guides/agent-routines/) | What an agent keeps, every run with its steps and cost, and work on a schedule. | <Status is="live" /> |
86−| Agent catalog | Specialists to hire from a catalog, each with a role, skills, tools and the runner it prefers. | <Status is="coming" /> |
86+| [Agent catalog](/guides/marketplace/#the-agent-catalog) | Roles to add an agent into, from the Marketplace, each with responsibilities, a voice and the helpers it works with. | <Status is="live" /> |
87+| More specialists | Catalog roles that bring their own skills, tools and the runner they prefer. | <Status is="coming" /> |
8788 | Foundational skills | Documents, research on the web, data and charts, code, communication, and files, in every agent. | <Status is="coming" /> |
8889 | Agents on runners | Every agent's machine is a runner: g1t's, one of yours, or your desktop, with sessions that last between tasks. | <Status is="coming" /> |
8990 | [Agents in your chat app](/guides/chat-app/) | The same agents, answering in the chat app your team already uses. | <Status is="coming" /> |
112113 | --- | --- | --- |
113114 | [Integrations](/guides/integrations/) | The GitHub App, Sentry, Datadog, Jira, Linear and alerts. | <Status is="live" /> |
114115 | [API](/reference/api/), [MCP](/reference/mcp/) and [webhooks](/guides/webhooks/) | Everything as a REST endpoint and an MCP tool, and a webhook for every event. | <Status is="live" /> |
115−| Marketplace | Extensions that add pages, cards, tools and agent roles, installed by an owner after reading their permissions. | <Status is="coming" /> |
116+| [Marketplace](/guides/marketplace/) | Agents from the catalog and integrations, added by owners; members ask for what they need. | <Status is="live" /> |
117+| Extensions | Pages, cards, tools and agent roles shared from public repositories, installed by an owner after reading their permissions and where their data goes. Support, Recruiting, Mail and On-call are listed. | <Status is="coming" /> |
116118 | More integrations | Drive, Gmail, Calendar, Slack and Microsoft 365, each listing what it lets agents do. | <Status is="coming" /> |
117119 | Mail | Email on your own domain, with shared inboxes that agents work in. | <Status is="coming" /> |
118120
+127−106
1−import {
2− BarChart3,
3− Bell,
4− Boxes,
5− Brain,
6− Building2,
7− Code2,
8− History,
9− LayoutGrid,
10− MessagesSquare,
11− Network,
12− Package,
13− Pin,
14− Plug,
15− Search,
16− Shapes,
17− ShieldCheck,
18− Sparkles,
19− Store,
20− Sun,
21− Users,
22− UsersRound,
23− Waypoints,
24−} from "lucide-react";
25−import { type ReactNode, useMemo, useState } from "react";
1+import { Bell, Building2, Code2, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, Sparkles, Store, Sun, Users } from "lucide-react";
2+import { type ReactNode, useEffect, useMemo, useState } from "react";
263 import { Link, useFetcher, useFetchers } from "react-router";
274
28−import { type AppInfo, type AppKey, type PinnableApp, appPinFromForm, appsFor, withPin } from "../lib/apps";
5+import { ConnectorMark } from "./connectors";
6+import { Skeleton } from "./ui/skeleton";
7+import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps";
298 import { cn } from "../lib/cn";
309 import { Hint } from "./ui/hint";
3110
32−/** Each app's icon, at the size asked for. */
33−export function appIcon(key: AppKey, size = 18): ReactNode {
34− const icons: Record<AppKey, ReactNode> = {
11+/** Each built-in app's icon, at the size asked for. */
12+export function appIcon(key: BuiltinApp, size = 18): ReactNode {
13+ const icons: Record<BuiltinApp, ReactNode> = {
3514 today: <Sun size={size} />,
3615 chat: <MessagesSquare size={size} />,
3716 notifications: <Bell size={size} />,
4019 artifacts: <Shapes size={size} />,
4120 people: <Users size={size} />,
4221 workspace: <Building2 size={size} />,
43− projects: <Boxes size={size} />,
44− packages: <Package size={size} />,
45− security: <ShieldCheck size={size} />,
46− context: <Network size={size} />,
47− memory: <Brain size={size} />,
48− teams: <UsersRound size={size} />,
49− usage: <BarChart3 size={size} />,
50− gateway: <Waypoints size={size} />,
51− integrations: <Plug size={size} />,
52− audit: <History size={size} />,
5322 };
5423 return icons[key];
5524 }
5625
26+/** An installed app's mark: its connector's, until extensions bring their own. */
27+export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) {
28+ return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />;
29+}
30+
31+/** A pinned app as the dock draws it, from its key alone; null when the key names nothing installable. */
32+export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null {
33+ const app = installedAppOf(key);
34+ if (!app) return null;
35+ return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> };
36+}
37+
5738 /** Every pin form shares this key, so the dock, the launcher and the Apps page see one change. */
5839 const PIN_FETCHER = "app-pin";
5940
8465 }
8566
8667 /**
87− * One app: its icon and name, opening it; and, for an app people pin, a
88− * pin that shows on hover or focus and stays lit while it is pinned.
68+ * One installed app: its mark and name, opening it; and a pin that shows
69+ * on hover or focus and stays lit while it is pinned. An app the person
70+ * can't use says Request access instead, and has no pin.
8971 */
9072 export function AppTile({
9173 app,
92− slug,
9374 pinned,
9475 onToggle,
9576 onOpen,
9677 card = false,
9778 }: {
98− app: AppInfo;
99− slug: string;
79+ app: InstalledAppData;
10080 pinned: boolean;
10181 onToggle: (app: PinnableApp) => void;
10282 onOpen?: () => void;
10383 /** On the Apps page: a card with what the app is. */
10484 card?: boolean;
10585 }) {
86+ const body = (
87+ <>
88+ <span className="relative flex shrink-0">
89+ <InstalledMark app={app} size={40} />
90+ {!app.usable && (
91+ <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-surface text-faint ring-1 ring-line">
92+ <Lock size={9} />
93+ </span>
94+ )}
95+ </span>
96+ {card ? (
97+ <span className="min-w-0">
98+ <span className="block truncate text-sm font-medium text-fg">{app.name}</span>
99+ <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span>
100+ </span>
101+ ) : (
102+ <span className="w-full min-w-0">
103+ <span className="block truncate text-xs font-medium text-fg-soft">{app.name}</span>
104+ {!app.usable && <span className="block truncate text-[0.625rem] text-accent">Request access</span>}
105+ </span>
106+ )}
107+ </>
108+ );
109+ const linkClass = cn(
110+ "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent",
111+ card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5",
112+ );
106113 return (
107114 <div
108115 className={cn(
110117 card ? "border border-line bg-surface hover:border-line-strong" : "hover:bg-raised focus-within:bg-raised",
111118 )}
112119 >
113− <Link
114− to={app.path(slug)}
115− prefetch="intent"
116− onClick={onOpen}
117− className={cn(
118− "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent",
119− card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5",
120− )}
121− >
122− <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-raised text-fg ring-1 ring-line group-hover/tile:ring-line-strong">
123− {appIcon(app.key, 19)}
124− </span>
125− {card ? (
126− <span className="min-w-0">
127− <span className="block truncate text-sm font-medium text-fg">{app.name}</span>
128− <span className="block truncate text-xs text-muted">{app.about}</span>
129− </span>
130− ) : (
131− <span className="w-full truncate text-xs font-medium text-fg-soft">{app.name}</span>
132− )}
120+ {/* An app the person can't use leads (`href`) to where they ask for it. */}
121+ <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}>
122+ {body}
133123 </Link>
134− {!app.builtin && (
124+ {app.usable && (
135125 <Hint label={pinned ? "Unpin from your dock" : "Pin to your dock"}>
136126 <button
137127 type="button"
138128 aria-label={pinned ? `Unpin ${app.name} from your dock` : `Pin ${app.name} to your dock`}
139129 aria-pressed={pinned}
140− onClick={() => onToggle(app.key as PinnableApp)}
130+ onClick={() => onToggle(app.key)}
141131 className={cn(
142132 "absolute flex size-6 items-center justify-center rounded-md outline-none transition-opacity hover:bg-line focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-accent [@media(hover:none)]:opacity-100",
143133 card ? "top-1/2 right-2.5 -translate-y-1/2" : "top-1 right-1",
152142 );
153143 }
154144
145+/** When nothing is installed: what Apps are, and the way to the Marketplace. */
146+export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) {
147+ return (
148+ <div className={cn("flex flex-col items-center text-center", compact ? "px-4 py-6" : "rounded-xl border border-dashed border-line px-6 py-12")}>
149+ <span className="flex size-10 items-center justify-center rounded-xl bg-raised text-muted ring-1 ring-line">
150+ <Store size={18} />
151+ </span>
152+ <p className="mt-3 text-sm font-medium">No apps installed yet</p>
153+ <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from the Marketplace: integrations your workspace connects, and extensions later. Each one you can use shows here, to pin to your dock.</p>
154+ <Link to={`/${slug}/-/marketplace`} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover">
155+ <Store size={14} />
156+ Browse the Marketplace
157+ </Link>
158+ </div>
159+ );
160+}
161+
155162 /**
156− * The Apps launcher's body: a search, every app in the workspace the
157− * person can use, and the way to all of them and to the Marketplace.
163+ * The Apps launcher's body: a search, every app the workspace installed
164+ * that the person can use (read from the Apps page as it opens), and the
165+ * way to all of them and to the Marketplace.
158166 */
159−export function AppsLauncher({
160− slug,
161− code,
162− pins,
163− onToggle,
164− onClose,
165−}: {
166− slug: string;
167− code: boolean;
168− pins: PinnableApp[];
169− onToggle: (app: PinnableApp) => void;
170− onClose: () => void;
171−}) {
167+export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) {
172168 const [query, setQuery] = useState("");
169+ const loader = useFetcher<{ apps: InstalledAppData[] | null }>();
170+ useEffect(() => {
171+ if (loader.state === "idle" && !loader.data) loader.load(`/${slug}/-/apps`);
172+ // Once per opening: the launcher's body mounts when it opens.
173+ // eslint-disable-next-line react-hooks/exhaustive-deps
174+ }, [slug]);
175+ const installed = loader.data?.apps;
173176 const wanted = query.trim().toLowerCase();
174− const apps = appsFor(code).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted));
177+ const apps = (installed ?? []).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted));
175178 return (
176179 <div className="flex flex-col gap-2">
177− <label className="flex h-9 items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-sm focus-within:border-line-strong">
178− <Search size={15} className="shrink-0 text-faint" />
179− <input
180− value={query}
181− onChange={(event) => setQuery(event.target.value)}
182− placeholder="Find an app"
183− aria-label="Find an app"
184− autoComplete="off"
185− className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
186− />
187− </label>
188− {apps.length === 0 ? (
189− <p className="px-2 py-6 text-center text-sm text-muted">No app matches.</p>
190− ) : (
191− <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3">
192− {apps.map((app) => (
193− <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={onToggle} onOpen={onClose} />
180+ {installed == null && loader.data ? (
181+ <p className="px-2 py-6 text-center text-sm text-muted">Apps can't be listed right now. Try again in a moment.</p>
182+ ) : installed == null ? (
183+ <div className="grid grid-cols-4 gap-1 p-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps">
184+ {Array.from({ length: 4 }, (_, n) => (
185+ <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5">
186+ <Skeleton className="size-10 rounded-xl" />
187+ <Skeleton className="h-2.5 w-12" />
188+ </div>
194189 ))}
195190 </div>
191+ ) : installed.length === 0 ? (
192+ <NoApps slug={slug} onOpen={onClose} compact />
193+ ) : (
194+ <>
195+ <label className="flex h-9 items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-sm focus-within:border-line-strong">
196+ <Search size={15} className="shrink-0 text-faint" />
197+ <input
198+ value={query}
199+ onChange={(event) => setQuery(event.target.value)}
200+ placeholder="Find an app"
201+ aria-label="Find an app"
202+ autoComplete="off"
203+ className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
204+ />
205+ </label>
206+ {apps.length === 0 ? (
207+ <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p>
208+ ) : (
209+ <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3">
210+ {apps.map((app) => (
211+ <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} />
212+ ))}
213+ </div>
214+ )}
215+ </>
196216 )}
197217 <div className="flex items-center justify-between border-t border-line pt-2">
198218 <Link
203223 <LayoutGrid size={14} />
204224 All apps
205225 </Link>
206− <Hint label="There is no marketplace yet." disabled>
207− <button type="button" disabled className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-faint">
208− <Store size={14} />
209− Marketplace
210− <span className="rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">Coming</span>
211− </button>
212− </Hint>
226+ <Link
227+ to={`/${slug}/-/marketplace`}
228+ onClick={onClose}
229+ className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg"
230+ >
231+ <Store size={14} />
232+ Marketplace
233+ </Link>
213234 </div>
214235 </div>
215236 );
+2−1
7474 borderRadius: size * 0.28,
7575 fontSize: size * (letters.length > 1 ? 0.34 : 0.42),
7676 background: `color-mix(in srgb, ${color} 16%, transparent)`,
77− color: `color-mix(in srgb, ${color} 72%, white)`,
77+ // Toward the text colour, so the letters read in both themes.
78+ color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`,
7879 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
7980 }}
8081 >
+11−12
44
55 import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
66
7−import { AppsLauncher, appIcon, useAppPins } from "./apps";
7+import { AppsLauncher, appIcon, pinnedApp, useAppPins } from "./apps";
88 import { Mark } from "./logo";
99 import { Avatar } from "./ui";
1010 import { Hint } from "./ui/hint";
1818 DropdownMenuTrigger,
1919 } from "./ui/dropdown-menu";
2020 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
21−import { type AppKey, type PinnableApp, appOf } from "../lib/apps";
21+import { type BuiltinApp, type PinnableApp } from "../lib/apps";
2222 import { cn } from "../lib/cn";
2323 import { paletteKeyLabel } from "../lib/shortcut";
2424 import { type ModeKey, modeHome, modeOf, todayPath } from "../lib/workspace-nav";
224224 }
225225
226226 /** The Apps button at the foot of the dock's apps, and the launcher it opens. */
227−function AppsButton({ slug, code, pins, onToggle, current }: { slug: string; code: boolean; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) {
227+function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) {
228228 const [open, setOpen] = useState(false);
229229 return (
230230 <Popover open={open} onOpenChange={setOpen}>
236236 <DockLabel current={current || open}>Apps</DockLabel>
237237 </PopoverTrigger>
238238 <PopoverContent side="right" align="end" sideOffset={14} aria-label="Apps" className="w-[23rem] bg-dock p-2.5">
239− <AppsLauncher slug={slug} code={code} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
239+ <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
240240 </PopoverContent>
241241 </Popover>
242242 );
281281 ...(code ? [{ key: "code" as const, label: "Code" }] : []),
282282 { key: "artifacts", label: "Artifacts" },
283283 ];
284− const shown = pins.filter((key) => code || !appOf(key).code);
284+ const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
285285 // A pinned app is current on its own page and the pages under it.
286286 const at = (to: string) => path === to || path.startsWith(`${to}/`);
287287 return (
302302 key={item.key}
303303 to={modeHome(item.key, slug)}
304304 label={item.label}
305− icon={appIcon(item.key as AppKey, 19)}
306− current={here === item.key && !shown.some((key) => at(appOf(key).path(slug)))}
305+ icon={appIcon(item.key as BuiltinApp, 19)}
306+ current={here === item.key && !shown.some((app) => at(app.to))}
307307 badge={item.badge}
308308 />
309309 ))}
310310 </div>
311311 {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
312312 <div className="flex min-h-0 w-full grow flex-col items-center gap-1 overflow-y-auto border-t border-line px-1.5 py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]">
313− {shown.map((key) => {
314− const app = appOf(key);
315− return <DockLink key={key} to={app.path(slug)} label={app.name} icon={appIcon(key, 18)} current={at(app.path(slug))} compact />;
316− })}
313+ {shown.map((app) => (
314+ <DockLink key={app.key} to={app.to} label={app.name} icon={app.icon(22)} current={at(app.to)} compact />
315+ ))}
317316 </div>
318317 <div className="flex w-full shrink-0 flex-col items-center px-1.5 pb-1.5">
319− <AppsButton slug={slug} code={code} pins={pins} onToggle={toggle} current={here === "apps"} />
318+ <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} />
320319 </div>
321320 <div className="flex w-full shrink-0 flex-col items-center gap-1 border-t border-line px-1.5 pt-2 pb-2.5">
322321 <DockLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 19)} current={here === "people"} />
+390−0
1+/**
2+ * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3+ * agents and integrations with what the workspace has of each, the button
4+ * that adds one (owners) or asks an owner to (everyone else), and a
5+ * request as owners and askers see it.
6+ */
7+import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react";
8+import { type ReactNode, useEffect, useState } from "react";
9+import { Link, useFetcher } from "react-router";
10+
11+import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts";
12+
13+import { PixelCreature } from "./agent-avatar";
14+import { ConnectorMark } from "./connectors";
15+import { Badge } from "./ui/badge";
16+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
17+import { Hint } from "./ui/hint";
18+import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
19+import { type AgentListing, type ExtensionListing, type IntegrationListing, TIERS, catalogAgentPath, extensionPath, hirePath, marketplacePath } from "../lib/marketplace";
20+import { cn } from "../lib/cn";
21+
22+const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors";
23+export const ACTION = {
24+ primary: `${SMALL} bg-fg text-bg hover:bg-fg-hover`,
25+ quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`,
26+};
27+
28+/** Who stands behind a listing: a small badge, with the sentence on hover. */
29+export function TierBadge({ tier }: { tier: ListingTier }) {
30+ return (
31+ <Hint label={TIERS[tier].about}>
32+ <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
33+ <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge>
34+ </span>
35+ </Hint>
36+ );
37+}
38+
39+/** "Coming", on what is planned and not built. */
40+export function ComingBadge() {
41+ return <Badge tone="neutral">Coming</Badge>;
42+}
43+
44+/** A section's heading, with a count or a link at its end. */
45+export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) {
46+ return (
47+ <div className="mb-3">
48+ <div className="flex items-baseline justify-between gap-3">
49+ <h2 id={id} className="text-base font-semibold tracking-tight">
50+ {title}
51+ </h2>
52+ {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
53+ </div>
54+ {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
55+ </div>
56+ );
57+}
58+
59+/** A link at the end of a section's heading. */
60+export function SeeAll({ to, children }: { to: string; children: ReactNode }) {
61+ return (
62+ <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg">
63+ {children}
64+ <ArrowRight size={13} />
65+ </Link>
66+ );
67+}
68+
69+/**
70+ * Asking the workspace's owners to add something: a dialog with an
71+ * optional note, sent to the requests route. Once sent, it says so.
72+ */
73+export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) {
74+ const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
75+ const [open, setOpen] = useState(false);
76+ const sent = fetcher.state === "idle" && fetcher.data?.error === null;
77+ useEffect(() => {
78+ if (sent) setOpen(false);
79+ }, [sent]);
80+ if (requested || sent) {
81+ return (
82+ <Hint label="The workspace's owners have your request. You'll hear when they answer.">
83+ <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
84+ <Clock size={13} />
85+ Requested
86+ </span>
87+ </Hint>
88+ );
89+ }
90+ return (
91+ <Dialog open={open} onOpenChange={setOpen}>
92+ <DialogTrigger asChild>
93+ <button type="button" className={cn(ACTION.quiet, className)}>
94+ <Send size={13} />
95+ Request
96+ </button>
97+ </DialogTrigger>
98+ <DialogContent>
99+ <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
100+ <DialogHeader>
101+ <DialogTitle>Ask to add {name}</DialogTitle>
102+ <DialogDescription>Only the workspace's owners add agents and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
103+ </DialogHeader>
104+ <input type="hidden" name="intent" value="request" />
105+ <input type="hidden" name="listing" value={listing} />
106+ <label className="grid gap-1.5 text-sm">
107+ <span className="font-medium">
108+ Why you want it <span className="font-normal text-faint">(optional)</span>
109+ </span>
110+ <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
111+ </label>
112+ {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
113+ <DialogFooter>
114+ <SubmitButton fetcher={fetcher} pending="Sending…">
115+ <Send size={14} />
116+ Send request
117+ </SubmitButton>
118+ </DialogFooter>
119+ </fetcher.Form>
120+ </DialogContent>
121+ </Dialog>
122+ );
123+}
124+
125+/** "3 waiting": open requests for a listing, for owners. */
126+function Waiting({ slug, count }: { slug: string; count: number }) {
127+ if (count === 0) return null;
128+ return (
129+ <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
130+ {count} {count === 1 ? "request" : "requests"}
131+ </Link>
132+ );
133+}
134+
135+/** What an owner or a member can do with a role in the catalog. */
136+export function AgentAction({ listing, slug, owner, className }: { listing: AgentListing; slug: string; owner: boolean; className?: string }) {
137+ if (owner) {
138+ return (
139+ <Link to={hirePath(slug, listing.template.id)} className={cn(listing.hired.length > 0 ? ACTION.quiet : ACTION.primary, className)}>
140+ <Plus size={14} />
141+ {listing.hired.length > 0 ? "Add another" : "Add to workspace"}
142+ </Link>
143+ );
144+ }
145+ return <RequestButton slug={slug} listing={listing.ref} name={`${listing.template.display_name}, ${listing.template.title}`} requested={listing.requested} className={className} />;
146+}
147+
148+/** A role in the agent catalog, as a card: its face, the name it suggests, what it does, and what the workspace has of it. */
149+export function AgentCard({ listing, slug, owner }: { listing: AgentListing; slug: string; owner: boolean }) {
150+ const { template, hired } = listing;
151+ return (
152+ <article className="group relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
153+ <div className="flex items-start gap-3">
154+ <PixelCreature seed={template.handle} size={44} />
155+ <div className="min-w-0 grow">
156+ <h3 className="truncate text-sm font-semibold">
157+ <Link to={catalogAgentPath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
158+ {template.display_name}
159+ </Link>
160+ </h3>
161+ <p className="truncate text-xs text-muted">
162+ {template.title}
163+ </p>
164+ </div>
165+ <TierBadge tier="official" />
166+ </div>
167+ <ul className="mt-3 grow space-y-1">
168+ {template.responsibilities.slice(0, 3).map((duty) => (
169+ <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted">
170+ <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" />
171+ <span className="line-clamp-1">{duty}</span>
172+ </li>
173+ ))}
174+ </ul>
175+ <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
176+ <span className="min-w-0 truncate text-xs text-faint">
177+ {/* What waits on an owner says more than what isn't there. */}
178+ {owner && listing.waiting > 0 && hired.length === 0 ? (
179+ <Waiting slug={slug} count={listing.waiting} />
180+ ) : hired.length === 0 ? (
181+ "Not added"
182+ ) : hired.length === 1 ? (
183+ `@${hired[0]!.handle} works here`
184+ ) : (
185+ `${hired.length} work here`
186+ )}
187+ </span>
188+ <AgentAction listing={listing} slug={slug} owner={owner} />
189+ </div>
190+ </article>
191+ );
192+}
193+
194+/** What an owner or a member can do with an integration. */
195+export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
196+ if (listing.connected) {
197+ return listing.href ? (
198+ <Link to={listing.href} className={ACTION.quiet}>
199+ Manage
200+ </Link>
201+ ) : null;
202+ }
203+ if (owner) {
204+ return listing.href ? (
205+ <Link to={listing.href} className={ACTION.primary}>
206+ <Plus size={14} />
207+ Connect
208+ </Link>
209+ ) : null;
210+ }
211+ return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />;
212+}
213+
214+/** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */
215+export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
216+ const { view, connected } = listing;
217+ return (
218+ <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
219+ <div className="flex items-start gap-3">
220+ <ConnectorMark view={view} size={40} />
221+ <div className="min-w-0 grow">
222+ <h3 className="truncate text-sm font-semibold">{view.name}</h3>
223+ <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
224+ </div>
225+ </div>
226+ {view.capabilities.length > 0 && (
227+ <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does">
228+ {view.capabilities.map((capability) => (
229+ <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted">
230+ {capability}
231+ </li>
232+ ))}
233+ </ul>
234+ )}
235+ <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
236+ <span className="flex min-w-0 items-center gap-1.5 text-xs">
237+ {connected ? (
238+ connected.problem ? (
239+ <Hint label={connected.problem}>
240+ <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none">
241+ <CircleAlert size={13} className="shrink-0" />
242+ <span className="truncate">Needs attention</span>
243+ </span>
244+ </Hint>
245+ ) : (
246+ <span className="inline-flex min-w-0 items-center gap-1 text-success">
247+ <Check size={13} className="shrink-0" />
248+ <span className="truncate">Connected</span>
249+ </span>
250+ )
251+ ) : (
252+ <span className="truncate text-faint">Not connected</span>
253+ )}
254+ {owner && listing.waiting > 0 && (
255+ <>
256+ <span className="text-faint">·</span>
257+ <Waiting slug={slug} count={listing.waiting} />
258+ </>
259+ )}
260+ </span>
261+ <IntegrationAction listing={listing} slug={slug} owner={owner} />
262+ </div>
263+ </article>
264+ );
265+}
266+
267+/** An extension's mark: its initials on a steady colour, until listings bring their own icon. */
268+export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) {
269+ return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
270+}
271+
272+/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */
273+export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
274+ const fetcher = useFetcher<{ error: string | null }>();
275+ const { manifest, install } = listing;
276+ if (manifest.status !== "available") {
277+ return (
278+ <Hint label="Not published yet. It can be installed from its first release.">
279+ <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
280+ Soon
281+ </span>
282+ </Hint>
283+ );
284+ }
285+ if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>;
286+ if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />;
287+ return (
288+ <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
289+ <input type="hidden" name="intent" value="install" />
290+ <input type="hidden" name="extension" value={manifest.id} />
291+ <SubmitButton fetcher={fetcher} className={cn(ACTION.primary, className)} pending="Installing…">
292+ <Plus size={14} />
293+ Install
294+ </SubmitButton>
295+ </fetcher.Form>
296+ );
297+}
298+
299+/** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */
300+export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
301+ const { manifest } = listing;
302+ return (
303+ <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
304+ <div className="flex items-start gap-3">
305+ <ExtensionMark manifest={manifest} />
306+ <div className="min-w-0 grow">
307+ <h3 className="truncate text-sm font-semibold">
308+ <Link to={extensionPath(slug, manifest.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
309+ {manifest.name}
310+ </Link>
311+ </h3>
312+ <p className="truncate text-xs text-muted">
313+ {manifest.publisher.name} · {manifest.category}
314+ </p>
315+ </div>
316+ <span className="relative">
317+ <TierBadge tier={manifest.publisher.tier} />
318+ </span>
319+ </div>
320+ <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
321+ <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
322+ <span className="min-w-0 truncate text-xs text-faint">{manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`}</span>
323+ <ExtensionAction listing={listing} slug={slug} owner={owner} />
324+ </div>
325+ </article>
326+ );
327+}
328+
329+/** A request's status, in a word. */
330+export function RequestStatus({ request }: { request: InstallRequest }) {
331+ if (request.status === "open") return <Badge tone="warn">Waiting</Badge>;
332+ if (request.status === "done") return <Badge tone="success">Added</Badge>;
333+ return <Badge tone="neutral">Turned down</Badge>;
334+}
335+
336+/**
337+ * One request as a row: what was asked for, by whom, why and when; for an
338+ * owner, while it waits, the way to add it, mark it added, or turn it down.
339+ */
340+export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
341+ const fetcher = useFetcher<{ error: string | null }>();
342+ const action = marketplacePath(slug, "requests");
343+ return (
344+ <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
345+ <div className="min-w-0 grow">
346+ <p className="flex flex-wrap items-center gap-2 text-sm">
347+ <span className="font-medium">{request.name}</span>
348+ <span className="text-xs text-faint">{request.kind === "agent" ? "Agent" : request.kind === "extension" ? "Extension" : "Integration"}</span>
349+ <RequestStatus request={request} />
350+ </p>
351+ <p className="mt-0.5 text-xs text-muted">
352+ {owner ? `@${request.requested_by} asked ` : "You asked "}
353+ <TimeAgo at={request.requested_at} />
354+ {request.resolved_by && request.status !== "open" && (
355+ <>
356+ {" · "}
357+ {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
358+ </>
359+ )}
360+ </p>
361+ {request.note && <p className="mt-1.5 max-w-xl rounded-md border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
362+ {fetcher.data?.error && (
363+ <div className="mt-2">
364+ <ErrorText>{fetcher.data.error}</ErrorText>
365+ </div>
366+ )}
367+ </div>
368+ {owner && request.status === "open" && (
369+ <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
370+ <input type="hidden" name="intent" value="resolve" />
371+ <input type="hidden" name="id" value={request.id} />
372+ {addTo && (
373+ <Link to={addTo} className={ACTION.primary}>
374+ <Plus size={14} />
375+ {request.kind === "agent" ? "Add" : request.kind === "extension" ? "Install" : "Connect"}
376+ </Link>
377+ )}
378+ <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…">
379+ <Check size={14} />
380+ Mark added
381+ </SubmitButton>
382+ <SubmitButton fetcher={fetcher} name="status" value="declined" className={ACTION.quiet} pending="Saving…">
383+ <X size={14} />
384+ Turn down
385+ </SubmitButton>
386+ </fetcher.Form>
387+ )}
388+ </li>
389+ );
390+}
+9−10
2121
2222 import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
2323
24−import { appIcon } from "./apps";
24+import { appIcon, pinnedApp } from "./apps";
2525 import { CountBadge, ShortcutsDialog } from "./dock";
2626 import { Mark } from "./logo";
2727 import { Avatar } from "./ui";
2828 import { TabStrip } from "./ui/tab-strip";
2929 import { StatusDialog } from "./presence";
3030 import { ThemeSwitch } from "./theme-switch";
31−import { type AppKey, type PinnableApp, appOf } from "../lib/apps";
31+import { type BuiltinApp, type PinnableApp, appOf } from "../lib/apps";
3232 import { setPresence, useOwnPresence } from "../lib/notify-client";
3333 import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
3434 import { STATUS_URL } from "../lib/status";
184184 >
185185 {current && <TopBar />}
186186 <BarIcon current={current}>
187− {appIcon(tab.key as AppKey, 20)}
187+ {appIcon(tab.key as BuiltinApp, 20)}
188188 {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />}
189189 </BarIcon>
190190 <span className={`w-full truncate text-[0.625rem] leading-tight font-medium max-[380px]:text-[0.5625rem] max-[380px]:tracking-[-0.02em] ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span>
249249
250250 /**
251251 * More, from the bottom bar: a rounded panel just above it, with g1t's
252− * mark at its top and a grid of big tiles: all apps, the Marketplace
253− * (coming), your pinned apps, Artifacts, People, Workspace, and "You and
252+ * mark at its top and a grid of big tiles: all apps, the Marketplace,
253+ * your pinned apps, Artifacts, People, Workspace, and "You and
254254 * help", which opens your account's sheet.
255255 */
256256 function MoreSheet({ open, onOpenChange, user, workspace, pins }: { open: boolean; onOpenChange: (open: boolean) => void; user: User; workspace: Membership; pins: PinnableApp[] }) {
257257 const slug = workspace.slug;
258− const code = hasCodeAccess(workspace);
259258 const [account, setAccount] = useState(false);
260− const shown = pins.filter((key) => code || !appOf(key).code);
259+ const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
261260 return (
262261 <>
263262 <Primitive.Root open={open} onOpenChange={onOpenChange}>
276275 </div>
277276 <div className="grid grid-cols-4 gap-1.5">
278277 <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" />
279− <MoreTile icon={<Store size={20} />} label="Marketplace" disabled note="Coming" />
280− {shown.map((key) => (
281− <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} />
278+ <MoreTile to={`/${slug}/-/marketplace`} icon={<Store size={20} />} label="Marketplace" />
279+ {shown.map((app) => (
280+ <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} />
282281 ))}
283282 <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" />
284283 <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" />
+2−2
176176 borderRadius: size * 0.28,
177177 fontSize: size * (String(content).length > 1 ? 0.34 : 0.42),
178178 background: `color-mix(in srgb, ${color} 16%, transparent)`,
179− // Lifted toward white, so the small letters read on the dark tile.
180− color: `color-mix(in srgb, ${color} 72%, white)`,
179+ // Toward the text colour, so the letters read on the tile in both themes.
180+ color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`,
181181 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
182182 }}
183183 >
+15−3
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
11711171 <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}>
11721172 Spend
11731173 </SidebarLink>
1174− <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}>
1174+ <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage")}>
11751175 Usage
11761176 </SidebarLink>
1177+ <SidebarLink to={`/${slug}/-/gateway`} icon={<Network size={15} />} current={at("gateway")}>
1178+ AI Gateway
1179+ </SidebarLink>
11771180 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}>
11781181 Billing and plans
11791182 </SidebarLink>
14091412 artifacts: "Artifacts",
14101413 today: "Today",
14111414 apps: "Apps",
1415+ marketplace: "Marketplace",
14121416 "code-access": "Code access",
14131417 overview: "Overview",
14141418 workspace: "Workspace",
14701474 if (third && SETTINGS_PAGE.test(page)) {
14711475 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
14721476 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1473− } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
1477+ } else if (third) {
1478+ trail.push({ label: SECTIONS[third] ?? third, to: page });
1479+ // The Marketplace names its tab: Marketplace / Agents.
1480+ const tabs: Record<string, string> = { agents: "Agents", integrations: "Integrations", extensions: "Extensions", requests: "Requests" };
1481+ const tab = third === "marketplace" && fourth ? tabs[fourth] : undefined;
1482+ if (tab) trail.push({ label: tab, to: `${page}/${fourth}` });
1483+ }
14741484 } else if (second) {
14751485 const repo = `/${owner}/${second}`;
14761486 trail.push({ label: second, to: repo, mono: true });
15661576 ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
15671577 { label: "Artifacts", to: `/${ws.slug}/-/artifacts`, icon: <Shapes size={15} /> },
15681578 { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> },
1579+ { label: "Marketplace", hint: "Agents and integrations to add", to: `/${ws.slug}/-/marketplace`, icon: <Store size={15} /> },
1580+ { label: "Agent catalog", hint: "Roles to add an agent into", to: `/${ws.slug}/-/marketplace/agents`, icon: <Bot size={15} /> },
15691581 { label: "Workspace", hint: "Billing, policies, settings", to: `/${ws.slug}/-/workspace`, icon: <Building2 size={15} /> },
15701582 ]
15711583 : []),
+64−27
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsFrom, pinsIn, pinsToShow, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts";
4+import {
5+ BUILTINS,
6+ MAX_APP_PINS,
7+ appData,
8+ appPinFromForm,
9+ dockCookie,
10+ installedAppOf,
11+ installedApps,
12+ integrationAppKey,
13+ isPinnable,
14+ pinsFrom,
15+ pinsIn,
16+ pinsToShow,
17+ readDock,
18+ sidebarClosed,
19+ sidebarCookie,
20+ withPin,
21+ writeDock,
22+} from "./apps.ts";
523
6−test("built-in apps are never pinned; the rest are", () => {
7− for (const app of APPS) assert.equal(isPinnable(app.key), !app.builtin, app.key);
8− assert.equal(isPinnable("nonsense"), false);
24+test("only apps installed from the Marketplace are pinned; built-in apps and their pages are not", () => {
25+ for (const app of BUILTINS) assert.equal(isPinnable(app.key), false, app.key);
26+ for (const page of ["projects", "packages", "security", "context", "memory", "teams", "usage", "gateway", "integrations", "audit"]) {
27+ assert.equal(isPinnable(page), false, page);
28+ }
29+ assert.equal(isPinnable("int-sentry"), true);
30+ assert.equal(isPinnable("int-slack"), false, "Slack can't be connected yet");
31+ assert.equal(isPinnable("int-mcp"), false, "MCP clients are each person's, not the workspace's");
32+ assert.equal(isPinnable("ext-support"), false, "no extensions yet");
33+ assert.equal(isPinnable("int-Sentry"), false);
934 });
1035
11−test("Code's apps are only for members with Code access", () => {
12− const without = appsFor(false).map((app) => app.key);
13− for (const key of ["code", "projects", "packages", "security", "context", "memory"]) assert.ok(!without.includes(key as never), key);
14− for (const key of ["today", "chat", "notifications", "agents", "artifacts", "people", "workspace", "teams", "usage"]) assert.ok(without.includes(key as never), key);
15− assert.equal(appsFor(true).length, APPS.length);
36+test("an integration's app opens its page", () => {
37+ assert.equal(installedAppOf("int-sentry")?.path("acme"), "/acme/-/integrations/alerts");
38+ assert.equal(installedAppOf("int-linear")?.path("acme"), "/acme/-/integrations/trackers");
39+ assert.equal(installedAppOf("int-anthropic")?.path("acme"), "/acme/-/integrations/models");
40+ assert.equal(installedAppOf("int-webhooks")?.path("acme"), "/acme/-/webhooks");
41+ assert.equal(installedAppOf(integrationAppKey("github"))?.listing, "integration:github");
42+ // Every key fits what identity keeps (MAX_DOCK_APP_KEY, 32).
43+ assert.ok(integrationAppKey("anthropic-endpoint").length <= 32);
1644 });
1745
46+test("installed apps are what the workspace connected, opening where each is managed", () => {
47+ const apps = installedApps({ sentry: { manage: "/acme/-/integrations/alerts" }, github: { manage: null }, nonsense: { manage: null } });
48+ assert.deepEqual(apps.map((app) => app.key), ["int-github", "int-sentry"]);
49+ assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme");
50+ assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/integrations/alerts");
51+ assert.deepEqual(installedApps({}), []);
52+});
53+
1854 test("pins are kept per workspace, in the order they were pinned", () => {
19− const value = writeDock(writeDock(null, "acme", ["projects", "usage"]), "Beta", ["teams"]);
20− assert.equal(value, "beta:teams,acme:projects.usage");
21− assert.deepEqual(pinsIn(value, "acme"), ["projects", "usage"]);
22− assert.deepEqual(pinsIn(value, "BETA"), ["teams"]);
55+ const value = writeDock(writeDock(null, "acme", ["int-sentry", "int-linear"]), "Beta", ["int-jira"]);
56+ assert.equal(value, "beta:int-jira,acme:int-sentry.int-linear");
57+ assert.deepEqual(pinsIn(value, "acme"), ["int-sentry", "int-linear"]);
58+ assert.deepEqual(pinsIn(value, "BETA"), ["int-jira"]);
2359 assert.deepEqual(pinsIn(value, "other"), []);
2460 // Unpinning everything keeps the workspace, with no pins.
2561 assert.deepEqual(pinsIn(writeDock(value, "acme", []), "acme"), []);
2662 });
2763
2864 test("a cookie someone wrote by hand reads as far as it makes sense", () => {
29− assert.deepEqual(readDock("acme:projects.today.nonsense.projects,BAD SLUG:usage,beta"), { acme: ["projects"] });
65+ assert.deepEqual(readDock("acme:int-sentry.today.nonsense.int-sentry,BAD SLUG:int-jira,beta"), { acme: ["int-sentry"] });
3066 assert.deepEqual(readDock(""), {});
31− const many = Array.from({ length: 30 }, () => "usage").join(".");
67+ const many = Array.from({ length: 30 }, () => "int-sentry").join(".");
3268 assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS);
3369 });
3470
35−test("pins kept with the account keep their order and only real apps", () => {
36− assert.deepEqual(pinsFrom(["usage", "projects", "today", "retired-app", "usage", "teams"]), ["usage", "projects", "teams"]);
71+test("pins of built-in pages from before Apps were the Marketplace's are dropped", () => {
72+ assert.deepEqual(pinsFrom(["usage", "int-sentry", "projects", "today", "int-sentry", "teams", "int-linear"]), ["int-sentry", "int-linear"]);
73+ assert.deepEqual(pinsIn("acme:projects.usage.teams", "acme"), []);
3774 assert.deepEqual(pinsFrom([]), []);
38− assert.ok(pinsFrom(Array.from({ length: 30 }, (_, n) => APPS[8 + (n % 10)]!.key)).length <= MAX_APP_PINS);
3975 });
4076
4177 test("the account's pins win over the cookie, which is the fallback", () => {
42− const cookie = writeDock(null, "acme", ["projects", "usage"]);
78+ const cookie = writeDock(null, "acme", ["int-sentry", "int-linear"]);
4379 // Saved with the account, even with nothing pinned: the account's.
44− assert.deepEqual(pinsToShow(["teams", "audit"], cookie, "acme"), ["teams", "audit"]);
80+ assert.deepEqual(pinsToShow(["int-jira"], cookie, "acme"), ["int-jira"]);
4581 assert.deepEqual(pinsToShow([], cookie, "acme"), []);
4682 // Never saved, or identity could not be asked: this device's cookie.
47− assert.deepEqual(pinsToShow(null, cookie, "acme"), ["projects", "usage"]);
83+ assert.deepEqual(pinsToShow(null, cookie, "acme"), ["int-sentry", "int-linear"]);
4884 assert.deepEqual(pinsToShow(undefined, null, "acme"), []);
4985 });
5086
5490 for (const [name, value] of Object.entries(fields)) data.set(name, value);
5591 return data;
5692 };
57− assert.deepEqual(appPinFromForm(form({ intent: "pin", app: "teams" })), { app: "teams", pinned: true });
58− assert.deepEqual(appPinFromForm(form({ intent: "unpin", app: "teams" })), { app: "teams", pinned: false });
93+ assert.deepEqual(appPinFromForm(form({ intent: "pin", app: "int-sentry" })), { app: "int-sentry", pinned: true });
94+ assert.deepEqual(appPinFromForm(form({ intent: "unpin", app: "int-sentry" })), { app: "int-sentry", pinned: false });
5995 assert.equal(appPinFromForm(form({ intent: "pin", app: "chat" })), null);
60− assert.equal(appPinFromForm(form({ intent: "drop", app: "teams" })), null);
61− assert.deepEqual(withPin(["projects", "teams"], "projects", true), ["teams", "projects"]);
62− assert.deepEqual(withPin(["projects", "teams"], "projects", false), ["teams"]);
96+ assert.equal(appPinFromForm(form({ intent: "pin", app: "teams" })), null);
97+ assert.equal(appPinFromForm(form({ intent: "drop", app: "int-sentry" })), null);
98+ assert.deepEqual(withPin(["int-sentry", "int-jira"], "int-sentry", true), ["int-jira", "int-sentry"]);
99+ assert.deepEqual(withPin(["int-sentry", "int-jira"], "int-sentry", false), ["int-jira"]);
63100 });
64101
65102 test("the cookies last a year and are secure when the site is", () => {
66− assert.match(dockCookie("acme:teams", true), /^g1t_dock=acme%3Ateams; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/);
103+ assert.match(dockCookie("acme:int-sentry", true), /^g1t_dock=acme%3Aint-sentry; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/);
67104 assert.equal(sidebarCookie(true, false), "g1t_sidebar=closed; Path=/; Max-Age=31536000; SameSite=Lax");
68105 assert.equal(sidebarClosed("closed"), true);
69106 assert.equal(sidebarClosed("open"), false);
+130−56
11 /**
2− * The apps a workspace has, and the ones each person pins to their dock.
2+ * The dock's built-in apps, the apps a workspace installed from the
3+ * Marketplace, and the ones each person pins to their dock.
34 *
45 * Built-in apps (Today, Chat, Notifications, Agents, Code, Artifacts,
5− * People and Workspace) are always in the dock. Every other app is a page
6− * of the workspace that people use on its own: Projects, Packages,
7− * Security, Context, Memory, Teams, Usage, the AI Gateway, Integrations
8− * and the audit log. Each person pins the ones they want, and nobody sees
9− * an app they cannot use: Code's apps are for members with Code access.
6+ * People and Workspace) are always in the dock; their own pages (Projects,
7+ * Usage, Teams and the rest) are in their sidebars. Apps are what the
8+ * workspace added from the Marketplace: today, the integrations it
9+ * connected, each opening its page. Each person pins the ones they want.
10+ *
11+ * An installed app's key says what it is: `int-<connector>` for an
12+ * integration (@g1t/contracts/connectors), and later `ext-<extension>`.
13+ * A key that names nothing installable (a built-in page pinned before
14+ * Apps were the Marketplace's) is dropped wherever pins are read.
1015 *
1116 * Pins are each person's own, per workspace, in the order they set, and
1217 * kept with their account by the identity service (`dock_pins`), so the
1621 * with the account carry over from it until the first change.
1722 * No Workers or React imports, so it can be tested under Node.
1823 */
24+import type { ExtensionInstall } from "@g1t/contracts";
25+import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors";
26+import { extensionById } from "@g1t/contracts/marketplace";
1927
2028 /** The apps that are always in the dock. */
2129 export type BuiltinApp = "today" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace";
2230
23−/** The apps people pin. */
24−export type PinnableApp =
25− | "projects"
26− | "packages"
27− | "security"
28− | "context"
29− | "memory"
30− | "teams"
31− | "usage"
32− | "gateway"
33− | "integrations"
34− | "audit";
35−
36−export type AppKey = BuiltinApp | PinnableApp;
37−
38−export type AppInfo = {
39− key: AppKey;
31+export type BuiltinInfo = {
32+ key: BuiltinApp;
4033 name: string;
41− /** What it is, in a few words, for the Apps page. */
42− about: string;
4334 /** Its page in the workspace `slug`. */
4435 path: (slug: string) => string;
45− /** Always in the dock: it cannot be pinned or unpinned. */
46− builtin: boolean;
4736 /** Part of Code: only for members with Code access. */
4837 code?: boolean;
4938 };
5039
5140 const under = (page: string) => (slug: string) => `/${slug}/-/${page}`;
5241
53−/** Every app, built-in first, in the order the launcher shows them. */
54−export const APPS: AppInfo[] = [
55− { key: "today", name: "Today", about: "What needs you, across the workspace", path: under("today"), builtin: true },
56− { key: "chat", name: "Chat", about: "Channels and messages with people and agents", path: under("chat"), builtin: true },
57− { key: "notifications", name: "Notifications", about: "Reviews, mentions and what agents wait on", path: () => "/notifications", builtin: true },
58− { key: "agents", name: "Agents", about: "Your workspace's agents and their sessions", path: under("agents"), builtin: true },
59− { key: "code", name: "Code", about: "Projects, pull requests and checks", path: under("overview"), builtin: true, code: true },
60− { key: "artifacts", name: "Artifacts", about: "Documents, decks and pages", path: under("artifacts"), builtin: true },
61− { key: "people", name: "People", about: "Who belongs to the workspace", path: under("people"), builtin: true },
62− { key: "workspace", name: "Workspace", about: "Billing, policies and settings", path: under("workspace"), builtin: true },
63− { key: "projects", name: "Projects", about: "Every project in the workspace", path: under("projects"), builtin: false, code: true },
64− { key: "packages", name: "Packages", about: "Packages published from its projects", path: under("packages"), builtin: false, code: true },
65− { key: "security", name: "Security", about: "Alerts across its projects", path: under("security"), builtin: false, code: true },
66− { key: "context", name: "Context", about: "What agents can look up", path: under("context"), builtin: false, code: true },
67− { key: "memory", name: "Memory", about: "What agents remember about the workspace", path: under("memory"), builtin: false, code: true },
68− { key: "teams", name: "Teams", about: "Groups of people to mention and grant access", path: under("teams"), builtin: false },
69− { key: "usage", name: "Usage", about: "What the workspace has used this month", path: under("usage"), builtin: false },
70− { key: "gateway", name: "AI Gateway", about: "Every model request, with its cost", path: under("gateway"), builtin: false },
71− { key: "integrations", name: "Integrations", about: "Model providers, alerts and trackers", path: under("integrations"), builtin: false },
72− { key: "audit", name: "Audit log", about: "Who did what, and when", path: under("audit"), builtin: false },
42+/** The dock's built-in apps, in its order. */
43+export const BUILTINS: BuiltinInfo[] = [
44+ { key: "today", name: "Today", path: under("today") },
45+ { key: "chat", name: "Chat", path: under("chat") },
46+ { key: "notifications", name: "Notifications", path: () => "/notifications" },
47+ { key: "agents", name: "Agents", path: under("agents") },
48+ { key: "code", name: "Code", path: under("overview"), code: true },
49+ { key: "artifacts", name: "Artifacts", path: under("artifacts") },
50+ { key: "people", name: "People", path: under("people") },
51+ { key: "workspace", name: "Workspace", path: under("workspace") },
7352 ];
7453
75−const PINNABLE = new Set<string>(APPS.filter((app) => !app.builtin).map((app) => app.key));
54+/** The built-in app `key`. */
55+export function appOf(key: BuiltinApp): BuiltinInfo {
56+ return BUILTINS.find((app) => app.key === key)!;
57+}
58+
59+/** An installed app's key, as pins and forms carry it: `int-sentry`. */
60+export type PinnableApp = string;
61+
62+/** An app installed from the Marketplace. */
63+export type InstalledApp = {
64+ key: PinnableApp;
65+ name: string;
66+ /** What it is, in a few words. */
67+ about: string;
68+ /** The Marketplace listing it was installed from: `integration:sentry`. */
69+ listing: string;
70+ /** The connector behind it, for its mark. */
71+ connector: Pick<Connector, "id" | "name" | "provider">;
72+ /** Its page in the workspace `slug`. */
73+ path: (slug: string) => string;
74+ /**
75+ * Whether the person can open it. Every member can open an integration's
76+ * page; an app limited to some people shows Request access to the rest.
77+ */
78+ usable: boolean;
79+};
80+
81+/** Where an integration is looked after: its setup page, by kind, or its own page. */
82+const SECTION_BY_CATEGORY: Partial<Record<Connector["category"], string>> = { ai: "models", monitoring: "alerts", issues: "trackers" };
83+
84+function integrationPath(connector: Connector): (slug: string) => string {
85+ const section = connector.provider ? SECTION_BY_CATEGORY[connector.category] : undefined;
86+ if (section) return under(`integrations/${section}`);
87+ const href = connector.href?.workspace;
88+ return href ? (slug) => connectorPath(href, slug) : under("integrations");
89+}
90+
91+/** Identity keeps keys of at most this many characters (MAX_DOCK_APP_KEY). */
92+const MAX_KEY = 32;
7693
77−/** Whether `key` is an app people pin. */
94+/** The installed-app key for an integration: `int-sentry`. */
95+export function integrationAppKey(connectorId: string): PinnableApp {
96+ return `int-${connectorId}`;
97+}
98+
99+/**
100+ * The app a key names, from the catalog alone (no service is asked), or
101+ * null when it names nothing that can be installed: a retired key, a
102+ * built-in page, or a connector that is not available for a workspace.
103+ */
104+export function installedAppOf(key: string): InstalledApp | null {
105+ if (key.length > MAX_KEY) return null;
106+ if (/^ext-[a-z][a-z0-9-]*$/.test(key)) {
107+ const extension = extensionById(key.slice(4));
108+ if (!extension || extension.status !== "available") return null;
109+ return {
110+ key,
111+ name: extension.name,
112+ about: extension.tagline,
113+ listing: `extension:${extension.id}`,
114+ connector: { id: extension.id, name: extension.name, provider: undefined },
115+ // Its own page, in a sandboxed frame, is the extension host's to serve; until then, its listing.
116+ path: (slug) => `/${slug}/-/marketplace/extensions/${extension.id}`,
117+ usable: true,
118+ };
119+ }
120+ if (!/^int-[a-z0-9][a-z0-9-]*$/.test(key)) return null;
121+ const connector = CONNECTORS.find((c) => c.id === key.slice(4));
122+ if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null;
123+ return {
124+ key,
125+ name: connector.name,
126+ about: connector.description,
127+ listing: `integration:${connector.id}`,
128+ connector: { id: connector.id, name: connector.name, provider: connector.provider },
129+ path: integrationPath(connector),
130+ usable: true,
131+ };
132+}
133+
134+/** Whether `key` names an app that can be pinned. */
78135 export function isPinnable(key: string): key is PinnableApp {
79− return PINNABLE.has(key);
136+ return installedAppOf(key) != null;
80137 }
81138
82−/** The apps someone can use in a workspace: Code's only with Code access. */
83−export function appsFor(code: boolean): AppInfo[] {
84− return APPS.filter((app) => code || !app.code);
139+/**
140+ * The apps the workspace installed, from what it has connected (by
141+ * connector id, lib/connected.server.ts), in catalog order. `manage`, when
142+ * a connection has one, is where its app opens.
143+ */
144+export function installedApps(
145+ connected: Record<string, { manage: string | null }>,
146+ installs: readonly Pick<ExtensionInstall, "listing" | "enabled">[] = [],
147+): InstalledApp[] {
148+ const integrations = CONNECTORS.filter((connector) => connected[connector.id]).map((connector) => {
149+ const app = installedAppOf(integrationAppKey(connector.id));
150+ const manage = connected[connector.id]?.manage;
151+ return app && manage ? { ...app, path: () => manage } : app;
152+ });
153+ // Extensions switched off (the kill switch) aren't apps until they're on again.
154+ const extensions = installs.filter((install) => install.enabled).map((install) => installedAppOf(`ext-${install.listing.replace(/^extension:/, "")}`));
155+ return [...extensions, ...integrations].filter((app): app is InstalledApp => app != null);
85156 }
86157
87−/** The app `key`. */
88−export function appOf(key: AppKey): AppInfo {
89− return APPS.find((app) => app.key === key)!;
158+/** An app as a loader sends it: everything but its path, which is worked out for the workspace. */
159+export type InstalledAppData = Omit<InstalledApp, "path"> & { href: string };
160+
161+export function appData(app: InstalledApp, slug: string): InstalledAppData {
162+ const { path, ...rest } = app;
163+ return { ...rest, href: path(slug) };
90164 }
91165
92166 /** The cookie that keeps this device's copy of each person's pins, per workspace. */
+56−0
1+/**
2+ * What a workspace has connected, by connector id (@g1t/contracts/connectors):
3+ * its provider connections from the integrations service, the GitHub App's
4+ * installations and its own webhooks, each with how it is doing. The
5+ * Integrations directory and the Marketplace both read it.
6+ */
7+import type { User } from "@g1t/contracts";
8+import { PROVIDERS } from "@g1t/contracts";
9+import { CONNECTORS } from "@g1t/contracts/connectors";
10+
11+import type { ConnectedState } from "./connectors";
12+import { githubApp } from "./github.server";
13+import { integrationsSection } from "./integration-sections";
14+import { integrations, webhooks } from "./services.server";
15+
16+/** What the workspace `slug` has connected, as `viewer` may see it. Each source that does not answer counts as nothing connected. */
17+export async function connectedStates(slug: string, viewer: User): Promise<Record<string, ConnectedState>> {
18+ const [connections, github, hooks] = await Promise.all([
19+ integrations.list(slug, viewer).catch(() => null),
20+ githubApp.status(viewer, slug).catch(() => null),
21+ webhooks.list(viewer, { workspace: slug }).catch(() => null),
22+ ]);
23+
24+ const connected: Record<string, ConnectedState> = {};
25+ if (connections?.ok) {
26+ for (const connector of CONNECTORS) {
27+ if (!connector.provider) continue;
28+ const mine = connections.value.filter((connection) => connection.provider === connector.provider);
29+ if (mine.length === 0) continue;
30+ const broken = mine.find((connection) => connection.lastError);
31+ connected[connector.id] = {
32+ detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`,
33+ problem: broken ? `${broken.name}: ${broken.lastError}` : null,
34+ manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`,
35+ };
36+ }
37+ }
38+ if (github?.ok && github.value.installations.length > 0) {
39+ const suspended = github.value.installations.find((installation) => installation.suspended);
40+ connected.github = {
41+ detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`,
42+ problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null,
43+ manage: null,
44+ };
45+ }
46+ const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : [];
47+ if (ours.length > 0) {
48+ const failing = ours.filter((hook) => hook.lastStatus === "failed");
49+ connected.webhooks = {
50+ detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`,
51+ problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null,
52+ manage: null,
53+ };
54+ }
55+ return connected;
56+}
+28−0
1+/**
2+ * What the Marketplace's pages read: the agent catalog's roles with the
3+ * workspace's agents, and what the workspace has connected. Each part that
4+ * does not answer comes back null, and the page says so.
5+ */
6+import type { AgentTemplate, User, WorkspaceAgent } from "@g1t/contracts";
7+
8+import type { ConnectedState } from "./connectors";
9+import { connectedStates } from "./connected.server";
10+import { workspaceAgents } from "./services.server";
11+
12+/** An agent as the Marketplace needs it: who it is and which role it was hired into. */
13+export type HiredAgent = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">;
14+
15+export async function loadCatalog(slug: string, viewer: User): Promise<{ templates: AgentTemplate[] | null; agents: HiredAgent[] | null }> {
16+ const [templates, agents] = await Promise.all([
17+ workspaceAgents.templates().catch(() => null),
18+ workspaceAgents
19+ .list(slug, viewer)
20+ .then((result) => (result.ok ? result.value.map(({ id, handle, display_name, template, archived_at }) => ({ id, handle, display_name, template, archived_at })) : null))
21+ .catch(() => null),
22+ ]);
23+ return { templates, agents };
24+}
25+
26+export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> {
27+ return connectedStates(slug, viewer);
28+}
+140−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentTemplate, InstallRequest } from "@g1t/contracts";
5+import { connectorsFor } from "@g1t/contracts/connectors";
6+
7+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
8+
9+import { addPath, agentListings, comingIntegrations, extensionListings, integrationListings, integrationMatches, marketplaceForm, routingWords } from "./marketplace.ts";
10+import { modeOf } from "./workspace-nav.ts";
11+
12+const template = (id: string, title: string): AgentTemplate => ({
13+ id,
14+ display_name: title.split(" ")[0]!,
15+ handle: id,
16+ name_ideas: [],
17+ role: title,
18+ title,
19+ department: "Engineering",
20+ responsibilities: ["Do the work"],
21+ subagents: [],
22+ instructions: "",
23+ personality_preset: "crisp",
24+ routing: { floor: null, ceiling: null, providers: [], pinned: null },
25+});
26+
27+const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({
28+ id: `ins_${listing}_${by}`,
29+ listing,
30+ kind: listing.startsWith("agent:") ? "agent" : "integration",
31+ name: listing,
32+ note: null,
33+ requested_by: by,
34+ requested_at: "2026-10-10T12:00:00Z",
35+ status,
36+ resolved_by: null,
37+ resolved_at: null,
38+});
39+
40+test("a role counts the agents hired into it, not archived ones", () => {
41+ const [eng, qa] = agentListings(
42+ [template("engineering", "Software Engineer"), template("qa", "QA Engineer")],
43+ [
44+ { id: "a1", handle: "otto", display_name: "Otto", template: "engineering", archived_at: null },
45+ { id: "a2", handle: "bolt", display_name: "Bolt", template: "engineering", archived_at: "2026-10-01T00:00:00Z" },
46+ { id: "a3", handle: "g1t", display_name: "g1t", template: null, archived_at: null },
47+ ],
48+ [request("agent:qa", "ana"), request("agent:qa", "bo"), request("agent:engineering", "ana", "declined")],
49+ "Ana",
50+ );
51+ assert.deepEqual(eng!.hired.map((a) => a.handle), ["otto"]);
52+ assert.equal(eng!.requested, false, "a turned-down request isn't waiting");
53+ assert.equal(qa!.hired.length, 0);
54+ assert.equal(qa!.requested, true, "usernames match whatever their case");
55+ assert.equal(qa!.waiting, 2);
56+});
57+
58+test("without the agents service, nobody counts as hired", () => {
59+ const [eng] = agentListings([template("engineering", "Software Engineer")], null, [], "ana");
60+ assert.deepEqual(eng!.hired, []);
61+});
62+
63+test("only integrations a workspace can connect today are listed, connected first", () => {
64+ const views = connectorsFor("workspace");
65+ const listings = integrationListings(views, { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } }, [], "ana", "acme");
66+ assert.ok(listings.every((l) => l.view.status === "available"));
67+ assert.equal(listings[0]!.view.id, "sentry");
68+ assert.equal(listings[0]!.href, "/acme/-/integrations/alerts", "managed where it is managed");
69+ const linear = listings.find((l) => l.view.id === "linear")!;
70+ assert.equal(linear.href, "/acme/-/integrations/trackers?add=linear#add", "connected on its setup page");
71+ assert.ok(!listings.some((l) => l.view.id === "slack"));
72+ assert.ok(comingIntegrations(views).some((v) => v.id === "slack"));
73+ assert.ok(integrationMatches(linear, "tracker"));
74+ assert.ok(!integrationMatches(linear, "deploys"));
75+});
76+
77+test("an owner adds what a request asks for where it is added", () => {
78+ const views = connectorsFor("workspace");
79+ assert.equal(addPath({ listing: "agent:qa" }, "acme", views), "/acme/-/agents/new?template=qa");
80+ assert.equal(addPath({ listing: "integration:jira" }, "acme", views), "/acme/-/integrations/trackers?add=jira#add");
81+ assert.equal(addPath({ listing: "nonsense" }, "acme", views), null);
82+});
83+
84+test("the Marketplace's forms are read strictly", () => {
85+ const form = (fields: Record<string, string>) => {
86+ const data = new FormData();
87+ for (const [k, v] of Object.entries(fields)) data.set(k, v);
88+ return data;
89+ };
90+ assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa", note: " flaky " })), { intent: "request", listing: "agent:qa", note: "flaky" });
91+ assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa" })), { intent: "request", listing: "agent:qa", note: null });
92+ assert.equal(marketplaceForm(form({ intent: "request", listing: "app:qa" })), null);
93+ assert.deepEqual(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "declined" })), { intent: "resolve", id: "ins_1", status: "declined" });
94+ assert.equal(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "open" })), null);
95+ assert.equal(marketplaceForm(form({ intent: "install" })), null);
96+});
97+
98+test("extensions list published ones first, each with its install", () => {
99+ const published = { ...FIRST_PARTY_EXTENSIONS[1]!, id: "standup", status: "available" as const, source: { repo: "flagon-io/standup", tag: "v1.0.0" }, version: "1.0.0" };
100+ const install = { id: "ins_1", listing: "extension:standup", version: "1.0.0", plan: "free", installed_by: "chase", installed_at: "2026-10-10T00:00:00Z", enabled: true, disabled_by: null, disabled_at: null, budget_monthly_micros: null };
101+ const listings = extensionListings([...FIRST_PARTY_EXTENSIONS, published], [install], [request("extension:support", "ana")], "ana");
102+ assert.equal(listings[0]!.manifest.id, "standup");
103+ assert.equal(listings[0]!.install?.version, "1.0.0");
104+ assert.equal(listings.find((l) => l.manifest.id === "support")!.requested, true);
105+ assert.equal(listings.find((l) => l.manifest.id === "mail")!.install, null);
106+});
107+
108+test("coming integrations include the ones each person connects, once", () => {
109+ const workspace = connectorsFor("workspace");
110+ const personal = connectorsFor("personal");
111+ const coming = comingIntegrations(workspace, personal).map((v) => v.id);
112+ assert.ok(coming.includes("slack"));
113+ assert.ok(coming.includes("gmail"), "Gmail is each person's");
114+ assert.ok(coming.includes("google-calendar"));
115+ assert.equal(new Set(coming).size, coming.length);
116+});
117+
118+test("installing, switching and removing an extension are read from forms", () => {
119+ const form = (fields: Record<string, string>) => {
120+ const data = new FormData();
121+ for (const [k, v] of Object.entries(fields)) data.set(k, v);
122+ return data;
123+ };
124+ assert.deepEqual(marketplaceForm(form({ intent: "install", extension: "support" })), { intent: "install", extension: "support" });
125+ assert.deepEqual(marketplaceForm(form({ intent: "switch", listing: "extension:support", enabled: "off" })), { intent: "switch", listing: "extension:support", enabled: false });
126+ assert.equal(marketplaceForm(form({ intent: "switch", listing: "integration:sentry", enabled: "off" })), null);
127+ assert.deepEqual(marketplaceForm(form({ intent: "uninstall", listing: "extension:support" })), { intent: "uninstall", listing: "extension:support" });
128+ assert.equal(addPath({ listing: "extension:mail" }, "acme", []), "/acme/-/marketplace/extensions/mail");
129+});
130+
131+test("routing limits read as words", () => {
132+ assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs");
133+ assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better");
134+ assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models");
135+});
136+
137+test("the Marketplace is part of Apps, not a mode of its own", () => {
138+ assert.equal(modeOf("/acme/-/marketplace", "acme"), "apps");
139+ assert.equal(modeOf("/acme/-/marketplace/agents/qa", "acme"), "apps");
140+});
+248−0
1+/**
2+ * The Marketplace (routes/workspace/marketplace/): what a workspace can
3+ * add today, read against what it has and what its people asked for.
4+ *
5+ * Two kinds of listing can be added now: an agent from the catalog (the
6+ * agents service's role templates) and an integration the connector
7+ * catalog marks available for a workspace. Owners add them; anyone else
8+ * asks, and the agents service keeps the request (`install_requests`).
9+ * Extensions have no listings yet: their tiers are described, not filled.
10+ *
11+ * No Workers or React imports, so it can be tested under Node.
12+ */
13+import type { AgentTemplate, ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier, WorkspaceAgent } from "@g1t/contracts";
14+import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
15+import { listingRef, parseListing } from "@g1t/contracts/marketplace";
16+
17+import type { ConnectedState } from "./connectors";
18+
19+/** The Marketplace's pages, under `/<workspace>/-/marketplace`. */
20+export function marketplacePath(slug: string, page: "" | "agents" | "integrations" | "extensions" | "requests" = ""): string {
21+ return `/${slug}/-/marketplace${page ? `/${page}` : ""}`;
22+}
23+
24+/** One role's page in the agent catalog. */
25+export function catalogAgentPath(slug: string, template: string): string {
26+ return `/${slug}/-/marketplace/agents/${template}`;
27+}
28+
29+/** Where an owner hires an agent into a role: the new-agent form, with the role chosen. */
30+export function hirePath(slug: string, template: string): string {
31+ return `/${slug}/-/agents/new?template=${encodeURIComponent(template)}`;
32+}
33+
34+/** Who stands behind a listing, in a word and a sentence. */
35+export const TIERS: Record<ListingTier, { label: string; about: string }> = {
36+ official: { label: "Official", about: "Built and supported by g1t." },
37+ verified: { label: "Verified", about: "From a reviewed publisher, whose code and permissions are checked before it is listed." },
38+ community: { label: "Community", about: "From anyone, shared from a public repository. Pages run sandboxed; server code waits for an isolated sandbox." },
39+ internal: { label: "Internal", about: "Built by your own people and agents, and seen only by your workspace." },
40+};
41+
42+/** Whether `request` is `username`'s and still waiting. */
43+function openFor(request: InstallRequest, ref: string, username: string): boolean {
44+ return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase();
45+}
46+
47+/** Open requests for `ref`. */
48+function openCount(requests: InstallRequest[], ref: string): number {
49+ return requests.filter((request) => request.status === "open" && request.listing === ref).length;
50+}
51+
52+/** A role in the agent catalog, with what the workspace has of it. */
53+export type AgentListing = {
54+ ref: string;
55+ template: AgentTemplate;
56+ /** The workspace's agents hired into this role, not archived. */
57+ hired: Pick<WorkspaceAgent, "id" | "handle" | "display_name">[];
58+ /** The viewer asked for it, and an owner hasn't answered. */
59+ requested: boolean;
60+ /** Open requests for it: everyone's for an owner, the viewer's own otherwise. */
61+ waiting: number;
62+};
63+
64+/**
65+ * Every role in the catalog, in the order the agents service gives them,
66+ * with who has been hired into each. `agents` null: the agents service
67+ * could not say, so nobody counts as hired.
68+ */
69+export function agentListings(
70+ templates: AgentTemplate[],
71+ agents: Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">[] | null,
72+ requests: InstallRequest[],
73+ username: string,
74+): AgentListing[] {
75+ return templates.map((template) => {
76+ const ref = listingRef("agent", template.id);
77+ return {
78+ ref,
79+ template,
80+ hired: (agents ?? [])
81+ .filter((agent) => agent.template === template.id && !agent.archived_at)
82+ .map(({ id, handle, display_name }) => ({ id, handle, display_name })),
83+ requested: requests.some((request) => openFor(request, ref, username)),
84+ waiting: openCount(requests, ref),
85+ };
86+ });
87+}
88+
89+/** An integration a workspace can connect, with whether it has. */
90+export type IntegrationListing = {
91+ ref: string;
92+ view: ConnectorView;
93+ /** How it is doing, when it is connected. */
94+ connected: ConnectedState | null;
95+ /** Where an owner connects it, or manages it once connected. */
96+ href: string | null;
97+ requested: boolean;
98+ waiting: number;
99+};
100+
101+/**
102+ * The integrations a workspace can connect today, connected ones first,
103+ * each in catalog order. `connected` comes from the integrations service
104+ * and the GitHub App (lib/connected.server.ts).
105+ */
106+export function integrationListings(
107+ views: ConnectorView[],
108+ connected: Record<string, ConnectedState>,
109+ requests: InstallRequest[],
110+ username: string,
111+ slug: string,
112+): IntegrationListing[] {
113+ const listings = views
114+ .filter((view) => view.status === "available")
115+ .map((view) => {
116+ const ref = listingRef("integration", view.id);
117+ const state = connected[view.id] ?? null;
118+ const setup = view.href ? connectorPath(view.href, slug) : null;
119+ return {
120+ ref,
121+ view,
122+ connected: state,
123+ href: state?.manage ?? setup,
124+ requested: requests.some((request) => openFor(request, ref, username)),
125+ waiting: openCount(requests, ref),
126+ };
127+ });
128+ return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)];
129+}
130+
131+/**
132+ * The integrations the catalog lists as coming: a workspace's, then those
133+ * each person connects for themselves (a calendar, a mailbox), once each.
134+ */
135+export function comingIntegrations(views: ConnectorView[], personal: ConnectorView[] = []): ConnectorView[] {
136+ const workspace = views.filter((view) => view.status === "soon");
137+ const seen = new Set(workspace.map((view) => view.id));
138+ const own = personal.filter((view) => view.status === "soon" && !seen.has(view.id) && !views.some((v) => v.id === view.id));
139+ return [...workspace, ...own];
140+}
141+
142+/** An extension in the Marketplace, with whether the workspace has it. */
143+export type ExtensionListing = {
144+ ref: string;
145+ manifest: ExtensionManifest;
146+ install: ExtensionInstall | null;
147+ requested: boolean;
148+ waiting: number;
149+};
150+
151+/** Every extension listed, published ones first, each with its install. */
152+export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] {
153+ const listings = manifests.map((manifest) => {
154+ const ref = listingRef("extension", manifest.id);
155+ return {
156+ ref,
157+ manifest,
158+ install: installs.find((install) => install.listing === ref) ?? null,
159+ requested: requests.some((request) => openFor(request, ref, username)),
160+ waiting: openCount(requests, ref),
161+ };
162+ });
163+ return [...listings.filter((l) => l.manifest.status === "available"), ...listings.filter((l) => l.manifest.status !== "available")];
164+}
165+
166+/** One extension's page in the Marketplace. */
167+export function extensionPath(slug: string, id: string): string {
168+ return `/${slug}/-/marketplace/extensions/${id}`;
169+}
170+
171+/** Where an extension runs, in words. */
172+export function runtimeWords(manifest: Pick<ExtensionManifest, "runtime" | "publisher">): string {
173+ return manifest.runtime === "hosted" ? `Runs on g1t` : `Runs on ${manifest.publisher.name}'s servers`;
174+}
175+
176+/** Whether an integration listing matches what someone typed: its name, category words, capabilities or keywords. */
177+export function integrationMatches(listing: IntegrationListing, query: string): boolean {
178+ const wanted = query.trim().toLowerCase();
179+ if (!wanted) return true;
180+ const { view } = listing;
181+ return [view.name, view.description, view.category, ...view.capabilities, ...view.keywords].some((word) => word.toLowerCase().includes(wanted));
182+}
183+
184+/** The requests still waiting on an owner. */
185+export function openRequests(requests: InstallRequest[]): InstallRequest[] {
186+ return requests.filter((request) => request.status === "open");
187+}
188+
189+/** Where an owner goes to add what a request asks for: the hire form, or the integration's setup page. */
190+export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null {
191+ const parsed = parseListing(request.listing);
192+ if (!parsed) return null;
193+ if (parsed.kind === "agent") return hirePath(slug, parsed.id);
194+ if (parsed.kind === "extension") return extensionPath(slug, parsed.id);
195+ const view = views.find((v) => v.id === parsed.id);
196+ return view?.href ? connectorPath(view.href, slug) : null;
197+}
198+
199+/** What the Marketplace's forms ask for, read from one. */
200+export type MarketplaceForm =
201+ | { intent: "request"; listing: string; note: string | null }
202+ | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> }
203+ | { intent: "install"; extension: string }
204+ | { intent: "switch"; listing: string; enabled: boolean }
205+ | { intent: "uninstall"; listing: string };
206+
207+/** A Marketplace form's request; null when it asks for nothing that makes sense. */
208+export function marketplaceForm(form: FormData): MarketplaceForm | null {
209+ const intent = form.get("intent");
210+ if (intent === "request") {
211+ const listing = String(form.get("listing") ?? "");
212+ if (!parseListing(listing)) return null;
213+ const note = String(form.get("note") ?? "").trim();
214+ return { intent, listing, note: note || null };
215+ }
216+ if (intent === "resolve") {
217+ const id = String(form.get("id") ?? "");
218+ const status = form.get("status");
219+ if (!id || (status !== "done" && status !== "declined")) return null;
220+ return { intent, id, status };
221+ }
222+ const listing = String(form.get("listing") ?? "");
223+ const extension = parseListing(listing)?.kind === "extension" ? listing : null;
224+ if (intent === "install") {
225+ const id = String(form.get("extension") ?? "");
226+ return /^[a-z][a-z0-9-]{1,31}$/.test(id) ? { intent, extension: id } : null;
227+ }
228+ if (intent === "switch" && extension) {
229+ const enabled = form.get("enabled");
230+ return enabled === "on" || enabled === "off" ? { intent, listing: extension, enabled: enabled === "on" } : null;
231+ }
232+ if (intent === "uninstall" && extension) return { intent, listing: extension };
233+ return null;
234+}
235+
236+/** "Software Engineer, Engineering": a role as one line. */
237+export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string {
238+ return template.department ? `${template.title} · ${template.department}` : template.title;
239+}
240+
241+/** How a routing limit reads: "Any model", "At least large", "Up to large", "Large to frontier". */
242+export function routingWords(routing: Pick<AgentTemplate["routing"], "floor" | "ceiling">): string {
243+ const name = (tier: string) => tier.charAt(0).toUpperCase() + tier.slice(1);
244+ if (routing.floor && routing.ceiling) return `${name(routing.floor)} to ${routing.ceiling} models`;
245+ if (routing.floor) return `${name(routing.floor)} models or better`;
246+ if (routing.ceiling) return `Up to ${routing.ceiling} models`;
247+ return "Any model the work needs";
248+}
+1−1
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121− "stargazers starred commit_checks shortcuts spend person_budgets usage_report"
121+ "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs"
122122 ).split(" "),
123123 );
124124
+1−0
146146 context: "agents",
147147 memory: "agents",
148148 apps: "apps",
149+ marketplace: "apps",
149150 people: "people",
150151 members: "people",
151152 teams: "people",
+11−0
202202 route("-/today", "routes/workspace/home.tsx"),
203203 route("-/home", "routes/workspace/moved.ts", { id: "routes/workspace/moved-home" }),
204204 route("-/apps", "routes/workspace/apps.tsx"),
205+ // The Marketplace: anyone browses it; owners add agents and integrations,
206+ // and everyone else asks (`requests` takes every form it posts).
207+ route("-/marketplace", "routes/workspace/marketplace/layout.tsx", [
208+ index("routes/workspace/marketplace/discover.tsx"),
209+ route("agents", "routes/workspace/marketplace/agents.tsx"),
210+ route("agents/:template", "routes/workspace/marketplace/agent.tsx"),
211+ route("integrations", "routes/workspace/marketplace/integrations.tsx"),
212+ route("extensions", "routes/workspace/marketplace/extensions.tsx"),
213+ route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"),
214+ route("requests", "routes/workspace/marketplace/requests.tsx"),
215+ ]),
205216 // What Code's pages say to a member without Code.
206217 route("-/code-access", "routes/workspace/code-access.tsx"),
207218 route("-/memory", "routes/workspace/memory.tsx"),
+2−2
249249 <h2 id="roster" className="text-sm font-medium">
250250 Agents <span className="text-faint">{agents.length}</span>
251251 </h2>
252− <Link to={`/${slug}/-/agents/new`} className="text-xs text-muted hover:text-fg">
253− Hire from a template
252+ <Link to={`/${slug}/-/marketplace/agents`} className="text-xs text-muted hover:text-fg">
253+ Add from the agent catalog
254254 </Link>
255255 </div>
256256 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
+75−53
11 /**
2− * Apps: every app in the workspace that you can use, the ones pinned to
3− * your dock first. Built-in apps are always in the dock; the rest you pin
4− * or unpin here, from the launcher, or on a phone from More. Pins are
5− * yours alone, per workspace, kept with your account so every device
6− * shows the same dock (lib/dock.server.ts), with a copy in a cookie for
7− * when the account's cannot be read (lib/apps.ts). The pin buttons post
8− * here.
2+ * Apps: what the workspace installed from the Marketplace that you can
3+ * use, the ones pinned to your dock first. Built-in apps are always in the
4+ * dock, and their own pages are in their sidebars; Apps are only what was
5+ * added. Pins are yours alone, per workspace, kept with your account so
6+ * every device shows the same dock (lib/dock.server.ts), with a copy in a
7+ * cookie for when the account's cannot be read (lib/apps.ts). The pin
8+ * buttons post here, and the launcher reads its list from here.
99 */
1010 import { Store } from "lucide-react";
11−import { data } from "react-router";
11+import { Link, data } from "react-router";
1212
13−import { hasCodeAccess } from "@g1t/contracts";
14−
1513 import type { Route } from "./+types/apps";
16−import { AppTile, useAppPins } from "../../components/apps";
17−import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsToShow, withPin, writeDock } from "../../lib/apps";
14+import { AppTile, NoApps, useAppPins } from "../../components/apps";
15+import { DOCK_COOKIE, type InstalledAppData, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps";
16+import { connectedStates } from "../../lib/connected.server";
1817 import { savePins, savedPins } from "../../lib/dock.server";
18+import { workspaceAgents } from "../../lib/services.server";
1919 import { page } from "../../lib/meta";
2020 import { readCookie } from "../../lib/mission";
2121 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
2929 const slug = params.owner.toLowerCase();
3030 const membership = viewer?.workspaces?.find((m) => m.slug === slug);
3131 if (!viewer || !membership) throw data(null, { status: 404 });
32+ const [connected, installs, saved] = await Promise.all([
33+ connectedStates(slug, viewer).catch(() => null),
34+ workspaceAgents
35+ .extensionInstalls(slug, viewer)
36+ .then((result) => (result.ok ? result.value : []))
37+ .catch(() => []),
38+ savedPins(viewer, slug),
39+ ]);
3240 return {
3341 slug,
3442 name: membership.name?.trim() || slug,
35− code: hasCodeAccess(membership),
36− pins: pinsToShow(await savedPins(viewer, slug), readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
43+ /** Null when what is installed could not be read. */
44+ apps: connected ? installedApps(connected, installs).map((app) => appData(app, slug)) : (null as InstalledAppData[] | null),
45+ pins: pinsToShow(saved, readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
3746 };
3847 }
3948
5665 }
5766
5867 export default function Apps({ loaderData }: Route.ComponentProps) {
59− const { slug, name, code } = loaderData;
68+ const { slug, name, apps } = loaderData;
6069 const { pins, toggle } = useAppPins(slug, loaderData.pins);
61− const apps = appsFor(code);
62− const pinned = pins.map((key) => apps.find((app) => app.key === key)).filter((app) => app != null);
70+ const usable = (apps ?? []).filter((app) => app.usable);
71+ const pinned = pins.map((key) => usable.find((app) => app.key === key)).filter((app) => app != null);
6372 return (
6473 <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10">
6574 <div className="flex flex-wrap items-end justify-between gap-4">
6675 <div>
6776 <h1 className="text-2xl font-semibold tracking-tight">Apps</h1>
6877 <p className="mt-1 max-w-xl text-sm text-muted">
69− Everything in {name} that you can use. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins.
78+ What {name} added from the Marketplace. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins.
7079 </p>
7180 </div>
72− <span className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
81+ <Link
82+ to={`/${slug}/-/marketplace`}
83+ prefetch="intent"
84+ className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-fg/85 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg"
85+ >
7386 <Store size={15} />
74− Marketplace
75− <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium text-muted">Coming</span>
76− </span>
87+ Browse the Marketplace
88+ </Link>
7789 </div>
7890
79− <section aria-labelledby="pinned" className="mt-8">
80− <div className="flex items-baseline justify-between gap-3">
81− <h2 id="pinned" className="text-sm font-semibold">
82− Pinned to your dock
83− </h2>
84− <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span>
91+ {apps == null ? (
92+ <p className="mt-8 rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">What is installed can't be read right now. Reload in a minute.</p>
93+ ) : apps.length === 0 ? (
94+ <div className="mt-8">
95+ <NoApps slug={slug} />
8596 </div>
86− {pinned.length === 0 ? (
87− <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
88− Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones.
89− </p>
90− ) : (
91− <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
92− {pinned.map((app) => (
93− <AppTile key={app.key} app={app} slug={slug} pinned onToggle={toggle} card />
94− ))}
95− </div>
96− )}
97− </section>
97+ ) : (
98+ <>
99+ <section aria-labelledby="pinned" className="mt-8">
100+ <div className="flex items-baseline justify-between gap-3">
101+ <h2 id="pinned" className="text-sm font-semibold">
102+ Pinned to your dock
103+ </h2>
104+ <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span>
105+ </div>
106+ {pinned.length === 0 ? (
107+ <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
108+ Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones.
109+ </p>
110+ ) : (
111+ <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
112+ {pinned.map((app) => (
113+ <AppTile key={app.key} app={app} pinned onToggle={toggle} card />
114+ ))}
115+ </div>
116+ )}
117+ </section>
98118
99− <section aria-labelledby="yours" className="mt-10">
100− <div className="flex items-baseline justify-between gap-3">
101− <h2 id="yours" className="text-sm font-semibold">
102− Your apps
103− </h2>
104− <span className="text-xs text-faint">Built-in apps are always in your dock</span>
105− </div>
106− <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
107− {apps.map((app) => (
108− <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={toggle} card />
109− ))}
110− </div>
111− </section>
119+ <section aria-labelledby="installed" className="mt-10">
120+ <div className="flex items-baseline justify-between gap-3">
121+ <h2 id="installed" className="text-sm font-semibold">
122+ Installed
123+ </h2>
124+ <span className="text-xs text-faint">{apps.length}</span>
125+ </div>
126+ <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
127+ {apps.map((app) => (
128+ <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={toggle} card />
129+ ))}
130+ </div>
131+ </section>
132+ </>
133+ )}
112134 </main>
113135 );
114136 }
+3−43
11 import { redirect } from "react-router";
22
33 import { type Provider, PROVIDERS } from "@g1t/contracts";
4−import { CONNECTORS, connectorsFor } from "@g1t/contracts/connectors";
4+import { connectorsFor } from "@g1t/contracts/connectors";
55
66 import type { Route } from "./+types/integrations-directory";
77 import { ConnectorDirectory, ScopeNote } from "../../components/connectors";
8−import type { ConnectedState } from "../../lib/connectors";
9−import { githubApp } from "../../lib/github.server";
8+import { connectedStates } from "../../lib/connected.server";
109 import { integrationsSection } from "../../lib/integration-sections";
1110 import { page } from "../../lib/meta";
12−import { integrations, webhooks } from "../../lib/services.server";
1311 import { requireUser, roleIn } from "../../lib/session.server";
1412
1513 export function meta({ params, ...args }: Route.MetaArgs) {
3432 if (add && add in PROVIDERS) {
3533 throw redirect(`/${slug}/-/integrations/${integrationsSection(PROVIDERS[add as Provider].kind)}?add=${add}#add`);
3634 }
37−
38− const [connections, github, hooks] = await Promise.all([
39− integrations.list(slug, viewer),
40− githubApp.status(viewer, slug).catch(() => null),
41− webhooks.list(viewer, { workspace: slug }).catch(() => null),
42− ]);
4335
44− const connected: Record<string, ConnectedState> = {};
45− if (connections.ok) {
46− for (const connector of CONNECTORS) {
47− if (!connector.provider) continue;
48− const mine = connections.value.filter((connection) => connection.provider === connector.provider);
49− if (mine.length === 0) continue;
50− const broken = mine.find((connection) => connection.lastError);
51− connected[connector.id] = {
52− detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`,
53− problem: broken ? `${broken.name}: ${broken.lastError}` : null,
54− manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`,
55− };
56− }
57− }
58− if (github?.ok && github.value.installations.length > 0) {
59− const suspended = github.value.installations.find((installation) => installation.suspended);
60− connected.github = {
61− detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`,
62− problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null,
63− manage: null,
64− };
65− }
66− const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : [];
67− if (ours.length > 0) {
68− const failing = ours.filter((hook) => hook.lastStatus === "failed");
69− connected.webhooks = {
70− detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`,
71− problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null,
72− manage: null,
73− };
74− }
75−
76− return { slug, owner: role === "owner", connected };
36+ return { slug, owner: role === "owner", connected: await connectedStates(slug, viewer) };
7737 }
7838
7939 export default function WorkspaceIntegrationsDirectory({ loaderData }: Route.ComponentProps) {
+156−0
1+/**
2+ * One role in the agent catalog: what an agent hired into it is
3+ * responsible for, the helpers it works with, how it talks, which models
4+ * it runs on, the instructions it starts from, and who in the workspace
5+ * already has the job.
6+ */
7+import { ArrowLeft } from "lucide-react";
8+import { Link, data } from "react-router";
9+
10+import type { Route } from "./+types/agent";
11+import { PixelCreature } from "../../../components/agent-avatar";
12+import { AgentAction, TierBadge } from "../../../components/marketplace";
13+import { PRESETS } from "../../../lib/agent-form";
14+import { agentListings, marketplacePath, roleLine, routingWords } from "../../../lib/marketplace";
15+import { loadCatalog } from "../../../lib/marketplace.server";
16+import { page } from "../../../lib/meta";
17+import { requireUser, roleIn } from "../../../lib/session.server";
18+import { useMarketplace } from "./layout";
19+
20+export function meta({ params, ...args }: Route.MetaArgs) {
21+ const template = args.loaderData?.templates?.find((t) => t.id === params.template);
22+ return page(args, { title: `${template ? template.title : "Agent"} · Marketplace · ${params.owner} · g1t` });
23+}
24+
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = requireUser(context, request);
27+ const slug = params.owner.toLowerCase();
28+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
29+ const catalog = await loadCatalog(slug, viewer);
30+ if (catalog.templates && !catalog.templates.some((t) => t.id === params.template)) throw data(null, { status: 404 });
31+ return catalog;
32+}
33+
34+export default function CatalogAgent({ loaderData, params }: Route.ComponentProps) {
35+ const { slug, owner, username, requests } = useMarketplace();
36+ const listing = agentListings(loaderData.templates ?? [], loaderData.agents, requests?.requests ?? [], username).find((l) => l.template.id === params.template);
37+ const back = (
38+ <Link to={marketplacePath(slug, "agents")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
39+ <ArrowLeft size={14} />
40+ All roles
41+ </Link>
42+ );
43+ if (!listing) {
44+ return (
45+ <div>
46+ {back}
47+ <p className="mt-6 text-sm text-muted">The agent catalog can't be shown right now: the agents service didn't answer. Reload in a minute.</p>
48+ </div>
49+ );
50+ }
51+ const { template, hired } = listing;
52+ const voice = PRESETS.find((preset) => preset.value === template.personality_preset);
53+ const others = template.name_ideas.filter((name) => name !== template.display_name);
54+ return (
55+ <div>
56+ {back}
57+ <header className="mt-5 flex flex-wrap items-center gap-4">
58+ <PixelCreature seed={template.handle} size={64} />
59+ <div className="min-w-0 grow basis-60">
60+ <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight">
61+ {template.display_name}
62+ <TierBadge tier="official" />
63+ </h2>
64+ <p className="mt-0.5 text-sm text-muted">{roleLine(template)}</p>
65+ </div>
66+ <AgentAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
67+ </header>
68+
69+ <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
70+ <div className="min-w-0 space-y-8">
71+ <section aria-labelledby="duties">
72+ <h3 id="duties" className="text-sm font-semibold">
73+ What it's responsible for
74+ </h3>
75+ <ul className="mt-3 space-y-2">
76+ {template.responsibilities.map((duty) => (
77+ <li key={duty} className="flex gap-2.5 text-sm text-fg-soft">
78+ <span className="mt-2 size-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
79+ {duty}
80+ </li>
81+ ))}
82+ </ul>
83+ </section>
84+
85+ {template.subagents.length > 0 && (
86+ <section aria-labelledby="helpers">
87+ <h3 id="helpers" className="text-sm font-semibold">
88+ Helpers it works with
89+ </h3>
90+ <p className="mt-1 text-sm text-muted">Smaller agents it starts for one piece of its work, paid from its own budget.</p>
91+ <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
92+ {template.subagents.map((helper) => (
93+ <li key={helper.name} className="px-4 py-3">
94+ <p className="font-mono text-[0.8125rem]">{helper.name}</p>
95+ <p className="mt-0.5 text-sm text-muted">{helper.description}</p>
96+ </li>
97+ ))}
98+ </ul>
99+ </section>
100+ )}
101+
102+ <section aria-labelledby="instructions">
103+ <h3 id="instructions" className="text-sm font-semibold">
104+ The instructions it starts from
105+ </h3>
106+ <p className="mt-1 text-sm text-muted">Yours to change once it is added, on its profile.</p>
107+ <pre className="mt-3 max-h-96 overflow-auto rounded-xl border border-line bg-surface p-4 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{template.instructions}</pre>
108+ </section>
109+ </div>
110+
111+ <aside className="space-y-6 text-sm">
112+ <dl className="space-y-4 rounded-xl border border-line bg-surface p-4">
113+ <div>
114+ <dt className="text-xs text-faint">Voice</dt>
115+ <dd className="mt-0.5">{voice ? voice.label : template.personality_preset}</dd>
116+ {voice && <dd className="text-xs text-muted">{voice.about}</dd>}
117+ </div>
118+ <div>
119+ <dt className="text-xs text-faint">Models</dt>
120+ <dd className="mt-0.5">{routingWords(template.routing)}</dd>
121+ </div>
122+ <div>
123+ <dt className="text-xs text-faint">Budget</dt>
124+ <dd className="mt-0.5">The workspace's default for a new agent, billed at what it costs</dd>
125+ </div>
126+ {others.length > 0 && (
127+ <div>
128+ <dt className="text-xs text-faint">Other names it suits</dt>
129+ <dd className="mt-0.5 text-muted">{others.slice(0, 5).join(", ")}</dd>
130+ </div>
131+ )}
132+ </dl>
133+ <section aria-labelledby="here" className="rounded-xl border border-line bg-surface p-4">
134+ <h3 id="here" className="text-xs text-faint">
135+ In this workspace
136+ </h3>
137+ {hired.length === 0 ? (
138+ <p className="mt-1 text-muted">Nobody has this job yet.</p>
139+ ) : (
140+ <ul className="mt-2 space-y-1.5">
141+ {hired.map((agent) => (
142+ <li key={agent.id} className="flex items-center gap-2">
143+ <PixelCreature seed={agent.handle} size={20} />
144+ <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline">
145+ {agent.display_name} <span className="text-faint">@{agent.handle}</span>
146+ </Link>
147+ </li>
148+ ))}
149+ </ul>
150+ )}
151+ </section>
152+ </aside>
153+ </div>
154+ </div>
155+ );
156+}
+60−0
1+/**
2+ * The agent catalog: every role an agent can be hired into, by
3+ * department, with who in the workspace was hired into each. Owners add
4+ * one through the new-agent form with the role chosen; anyone else asks.
5+ */
6+import type { Route } from "./+types/agents";
7+import { AgentCard } from "../../../components/marketplace";
8+import { G1tMark } from "../../../components/orchestrator";
9+import { EmptyState } from "../../../components/ui";
10+import { agentListings } from "../../../lib/marketplace";
11+import { loadCatalog } from "../../../lib/marketplace.server";
12+import { requireUser, roleIn } from "../../../lib/session.server";
13+import { useMarketplace } from "./layout";
14+
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = requireUser(context, request);
17+ const slug = params.owner.toLowerCase();
18+ if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
19+ return loadCatalog(slug, viewer);
20+}
21+
22+export default function AgentCatalog({ loaderData }: Route.ComponentProps) {
23+ const { slug, owner, username, requests } = useMarketplace();
24+ if (loaderData.templates == null) {
25+ return <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>;
26+ }
27+ const listings = agentListings(loaderData.templates, loaderData.agents, requests?.requests ?? [], username);
28+ // By department, in the order the catalog lists them.
29+ const departments = new Map<string, typeof listings>();
30+ for (const listing of listings) {
31+ const key = listing.template.department || "Other";
32+ departments.set(key, [...(departments.get(key) ?? []), listing]);
33+ }
34+ return (
35+ <div className="space-y-10">
36+ <div className="flex flex-wrap items-start gap-4 rounded-2xl border border-line bg-surface p-5">
37+ <G1tMark size={40} />
38+ <div className="min-w-0 grow basis-72">
39+ <p className="text-sm font-semibold">Start with g1t, add specialists as the work grows</p>
40+ <p className="mt-1 max-w-3xl text-sm text-muted">
41+ g1t, the orchestrator, is in every workspace and knows every agent in it. Each role below is a starting point: pick a name, a team and a budget when you add it, and change anything after. Every agent works with the access of the person asking, never more, and starts with the workspace's default budget.
42+ </p>
43+ </div>
44+ <p className="shrink-0 text-xs text-faint">
45+ {listings.length} {listings.length === 1 ? "role" : "roles"}, built by g1t
46+ </p>
47+ </div>
48+ {[...departments].map(([department, list]) => (
49+ <section key={department} aria-label={department}>
50+ <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2>
51+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
52+ {list.map((listing) => (
53+ <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
54+ ))}
55+ </div>
56+ </section>
57+ ))}
58+ </div>
59+ );
60+}
+121−0
1+/**
2+ * The Marketplace's front page: what comes with every workspace, roles
3+ * from the agent catalog, integrations (connected first) and extensions.
4+ * Every listing is real: what can be added today with its real state, or
5+ * g1t's own extensions marked Soon until their first release.
6+ */
7+import { Rocket } from "lucide-react";
8+import type { ReactNode } from "react";
9+import { Link } from "react-router";
10+
11+import { connectorsFor } from "@g1t/contracts/connectors";
12+
13+import type { Route } from "./+types/discover";
14+import { appIcon } from "../../../components/apps";
15+import { AgentCard, ExtensionCard, IntegrationCard, SectionHead, SeeAll } from "../../../components/marketplace";
16+import { G1tMark } from "../../../components/orchestrator";
17+import { EmptyState } from "../../../components/ui";
18+import { appOf } from "../../../lib/apps";
19+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
20+import { agentListings, extensionListings, integrationListings, marketplacePath } from "../../../lib/marketplace";
21+import { loadCatalog, loadConnected } from "../../../lib/marketplace.server";
22+import { requireUser, roleIn } from "../../../lib/session.server";
23+import { useMarketplace } from "./layout";
24+
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = requireUser(context, request);
27+ const slug = params.owner.toLowerCase();
28+ if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
29+ const [catalog, connected] = await Promise.all([loadCatalog(slug, viewer), loadConnected(slug, viewer)]);
30+ return { ...catalog, connected };
31+}
32+
33+/** How many of each kind of listing the front page shows before "All". */
34+const AGENTS_SHOWN = 5;
35+const INTEGRATIONS_SHOWN = 6;
36+
37+export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) {
38+ const { slug, owner, username, requests, installs } = useMarketplace();
39+ const asked = requests?.requests ?? [];
40+ const agents = agentListings(loaderData.templates ?? [], loaderData.agents, asked, username);
41+ const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug);
42+ const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username);
43+ const builtIn: { name: string; to: string; icon: ReactNode }[] = [
44+ ...(["code", "chat", "agents", "artifacts"] as const).map((key) => ({ name: appOf(key).name, to: appOf(key).path(slug), icon: appIcon(key, 15) })),
45+ { name: "Deployments", to: `/${slug}/-/projects`, icon: <Rocket size={15} /> },
46+ ];
47+ return (
48+ <div className="space-y-12">
49+ <section aria-labelledby="included" className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
50+ <div className="flex flex-wrap items-baseline justify-between gap-2">
51+ <h2 id="included" className="text-base font-semibold tracking-tight">
52+ In every workspace
53+ </h2>
54+ <span className="text-xs text-faint">Nothing to add</span>
55+ </div>
56+ <p className="mt-1 max-w-2xl text-sm text-muted">Code and Deployments are built in, because what agents make has to be reviewed, tested and shipped. So are Chat, Agents and Artifacts.</p>
57+ <ul className="mt-4 flex flex-wrap gap-2">
58+ {builtIn.map(({ name, to, icon }) => (
59+ <li key={name}>
60+ <Link to={to} className="flex h-9 items-center gap-2 rounded-full border border-line bg-bg pr-3.5 pl-2.5 text-sm transition-colors hover:border-line-strong">
61+ <span className="text-muted">{icon}</span>
62+ {name}
63+ </Link>
64+ </li>
65+ ))}
66+ </ul>
67+ </section>
68+
69+ <section aria-labelledby="agents">
70+ <SectionHead id="agents" title="Agents" aside={agents.length > 0 && <SeeAll to={marketplacePath(slug, "agents")}>All {agents.length} roles</SeeAll>}>
71+ Each role brings responsibilities, a voice and the helpers it works with. Add one and rename it, or keep to g1t, which brings in whoever fits.
72+ </SectionHead>
73+ {loaderData.templates == null ? (
74+ <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>
75+ ) : (
76+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
77+ <Link
78+ to={`/${slug}/-/agents`}
79+ className="flex flex-col rounded-xl border border-accent/30 bg-accent/[0.06] p-4 transition-colors hover:border-accent/60"
80+ >
81+ <div className="flex items-center gap-3">
82+ <G1tMark size={44} />
83+ <div className="min-w-0">
84+ <p className="text-sm font-semibold">g1t</p>
85+ <p className="text-xs text-muted">Orchestrator · in every workspace</p>
86+ </div>
87+ </div>
88+ <p className="mt-3 grow text-[0.8125rem] leading-snug text-muted">Ask g1t anything. It answers itself or hands the work to the agent that fits, and you see the hand-off.</p>
89+ <p className="mt-4 border-t border-accent/20 pt-3 text-xs text-accent">Already here</p>
90+ </Link>
91+ {agents.slice(0, AGENTS_SHOWN).map((listing) => (
92+ <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
93+ ))}
94+ </div>
95+ )}
96+ </section>
97+
98+ <section aria-labelledby="integrations">
99+ <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All {integrations.length}</SeeAll>}>
100+ Integrations connect what you already use and give agents new abilities, without adding pages.
101+ </SectionHead>
102+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
103+ {integrations.slice(0, INTEGRATIONS_SHOWN).map((listing) => (
104+ <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
105+ ))}
106+ </div>
107+ </section>
108+
109+ <section aria-labelledby="extensions">
110+ <SectionHead id="extensions" title="Extensions" aside={<SeeAll to={marketplacePath(slug, "extensions")}>All extensions</SeeAll>}>
111+ Extensions add pages, data, cards and agent roles for what your team does besides code. Each is shared from a public repository, and the install screen lists every permission and every domain its data goes to.
112+ </SectionHead>
113+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
114+ {extensions.map((listing) => (
115+ <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
116+ ))}
117+ </div>
118+ </section>
119+ </div>
120+ );
121+}
+154−0
1+/**
2+ * One extension's page: what it adds, what it may do (in words and as
3+ * scopes), where its data goes, where it runs, its version and source,
4+ * and, once installed, the switch that turns it off and the way to remove it.
5+ */
6+import { ArrowLeft, Globe, Power, ShieldCheck, Trash2 } from "lucide-react";
7+import { Link, data, useFetcher } from "react-router";
8+
9+import { describeScope, isScope } from "@g1t/contracts/scopes";
10+import { dataDisclosure, extensionById } from "@g1t/contracts/marketplace";
11+
12+import type { Route } from "./+types/extension";
13+import { ACTION, ComingBadge, ExtensionAction, ExtensionMark, TierBadge } from "../../../components/marketplace";
14+import { SubmitButton } from "../../../components/ui";
15+import { extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace";
16+import { page } from "../../../lib/meta";
17+import { useMarketplace } from "./layout";
18+
19+export function meta({ params, ...args }: Route.MetaArgs) {
20+ const extension = extensionById(params.extension);
21+ return page(args, { title: `${extension?.name ?? "Extension"} · Marketplace · ${params.owner} · g1t` });
22+}
23+
24+export function loader({ params }: Route.LoaderArgs) {
25+ if (!extensionById(params.extension)) throw data(null, { status: 404 });
26+ return null;
27+}
28+
29+export default function MarketplaceExtension({ params }: Route.ComponentProps) {
30+ const { slug, owner, username, requests, installs } = useMarketplace();
31+ const manifest = extensionById(params.extension)!;
32+ const [listing] = extensionListings([manifest], installs, requests?.requests ?? [], username);
33+ const install = listing!.install;
34+ const fetcher = useFetcher<{ error: string | null }>();
35+ const action = marketplacePath(slug, "requests");
36+ const Group = ({ title, items, empty }: { title: string; items: string[]; empty: string }) => (
37+ <div className="rounded-xl border border-line bg-surface p-4">
38+ <h4 className="text-xs text-faint">{title}</h4>
39+ {items.length === 0 ? (
40+ <p className="mt-1.5 text-sm text-muted">{empty}</p>
41+ ) : (
42+ <ul className="mt-1.5 space-y-1 text-sm">
43+ {items.map((item) => (
44+ <li key={item}>{item}</li>
45+ ))}
46+ </ul>
47+ )}
48+ </div>
49+ );
50+ return (
51+ <div>
52+ <Link to={marketplacePath(slug, "extensions")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
53+ <ArrowLeft size={14} />
54+ All extensions
55+ </Link>
56+ <header className="mt-5 flex flex-wrap items-center gap-4">
57+ <ExtensionMark manifest={manifest} size={64} />
58+ <div className="min-w-0 grow basis-60">
59+ <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight">
60+ {manifest.name}
61+ <TierBadge tier={manifest.publisher.tier} />
62+ {manifest.status !== "available" && <ComingBadge />}
63+ </h2>
64+ <p className="mt-0.5 text-sm text-muted">
65+ {manifest.publisher.name} · {manifest.category} · Free
66+ </p>
67+ </div>
68+ <ExtensionAction listing={listing!} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
69+ </header>
70+ <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{manifest.description}</p>
71+
72+ <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
73+ <section aria-labelledby="adds" className="min-w-0">
74+ <h3 id="adds" className="text-sm font-semibold">
75+ What it adds to the workspace
76+ </h3>
77+ <div className="mt-3 grid gap-3 sm:grid-cols-2">
78+ <Group title="Pages, in its sidebar" items={manifest.adds.pages} empty="None" />
79+ <Group title="Agent roles you can add" items={manifest.adds.agent_roles} empty="None. Your agents get its tools." />
80+ <Group title="Tools for agents" items={manifest.adds.tools} empty="None" />
81+ <Group title="Notifications it sends" items={manifest.adds.notifications} empty="None" />
82+ </div>
83+ </section>
84+
85+ <aside className="space-y-4 text-sm">
86+ <section aria-labelledby="may" className="rounded-xl border border-line bg-surface p-4">
87+ <h3 id="may" className="flex items-center gap-1.5 text-xs text-faint">
88+ <ShieldCheck size={13} />
89+ It will be able to
90+ </h3>
91+ <ul className="mt-2 space-y-1.5">
92+ {manifest.permissions.map((permission) => (
93+ <li key={permission}>{permission}</li>
94+ ))}
95+ </ul>
96+ <h4 className="mt-4 text-xs text-faint">With access to</h4>
97+ <ul className="mt-1.5 space-y-1 text-muted">
98+ {manifest.scopes.map((scope) => (
99+ <li key={scope}>
100+ <span className="font-mono text-xs text-fg-soft">{scope}</span>
101+ {isScope(scope) && <span className="block text-xs">{describeScope(scope)}</span>}
102+ </li>
103+ ))}
104+ </ul>
105+ </section>
106+ <section aria-labelledby="where" className="rounded-xl border border-line bg-surface p-4">
107+ <h3 id="where" className="flex items-center gap-1.5 text-xs text-faint">
108+ <Globe size={13} />
109+ Where it runs, and where data goes
110+ </h3>
111+ <p className="mt-2">{runtimeWords(manifest)}</p>
112+ <p className={manifest.domains.length > 0 ? "mt-1 text-warn" : "mt-1 text-muted"}>{dataDisclosure(manifest)}</p>
113+ </section>
114+ <dl className="space-y-3 rounded-xl border border-line bg-surface p-4">
115+ <div>
116+ <dt className="text-xs text-faint">Version</dt>
117+ <dd className="mt-0.5">{install ? `${install.version} installed` : (manifest.version ?? "Not published yet")}</dd>
118+ </div>
119+ <div>
120+ <dt className="text-xs text-faint">Source</dt>
121+ <dd className="mt-0.5">
122+ {manifest.source ? (
123+ <Link to={`/${manifest.source.repo}/tree/${manifest.source.tag}`} className="font-mono text-xs hover:underline">
124+ {manifest.source.repo}@{manifest.source.tag}
125+ </Link>
126+ ) : (
127+ <span className="text-muted">Published with its first release</span>
128+ )}
129+ </dd>
130+ </div>
131+ <div>
132+ <dt className="text-xs text-faint">Price</dt>
133+ <dd className="mt-0.5">Free. What its agents do is billed at what it costs, like any agent's work.</dd>
134+ </div>
135+ </dl>
136+ {install && owner && (
137+ <fetcher.Form method="post" action={action} className="flex flex-wrap gap-2">
138+ <input type="hidden" name="listing" value={listing!.ref} />
139+ <input type="hidden" name="enabled" value={install.enabled ? "off" : "on"} />
140+ <SubmitButton fetcher={fetcher} name="intent" value="switch" className={ACTION.quiet} pending="Saving…">
141+ <Power size={14} />
142+ {install.enabled ? "Switch off" : "Switch on"}
143+ </SubmitButton>
144+ <SubmitButton fetcher={fetcher} name="intent" value="uninstall" className={ACTION.quiet} pending="Removing…">
145+ <Trash2 size={14} />
146+ Uninstall
147+ </SubmitButton>
148+ </fetcher.Form>
149+ )}
150+ </aside>
151+ </div>
152+ </div>
153+ );
154+}
+62−0
1+/**
2+ * Extensions in the Marketplace: every listed extension, from its
3+ * manifest (@g1t/contracts marketplace.ts), published ones first, with what
4+ * the workspace installed; then how extensions are published and who
5+ * stands behind each tier.
6+ */
7+import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace";
8+
9+import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace";
10+import { TIERS, extensionListings } from "../../../lib/marketplace";
11+import { useMarketplace } from "./layout";
12+
13+export default function MarketplaceExtensions() {
14+ const { slug, owner, username, requests, installs } = useMarketplace();
15+ const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username);
16+ const published = listings.filter((l) => l.manifest.status === "available");
17+ const coming = listings.filter((l) => l.manifest.status !== "available");
18+ return (
19+ <div className="space-y-12">
20+ {published.length > 0 && (
21+ <section aria-labelledby="published">
22+ <SectionHead id="published" title="Ready to install" aside={`${published.length}`} />
23+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
24+ {published.map((listing) => (
25+ <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
26+ ))}
27+ </div>
28+ </section>
29+ )}
30+ <section aria-labelledby="soon">
31+ <SectionHead id="soon" title={published.length > 0 ? "Coming" : "Coming from g1t"} aside={`${coming.length}`}>
32+ Extensions add pages, data, cards and agent roles for what your team does besides code. These are g1t's own, listed before their first release; each can be installed once it is published.
33+ </SectionHead>
34+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
35+ {coming.map((listing) => (
36+ <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
37+ ))}
38+ </div>
39+ </section>
40+ <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
41+ <h2 id="how" className="text-base font-semibold tracking-tight">
42+ How extensions are shared
43+ </h2>
44+ <ul className="mt-3 max-w-3xl space-y-2 text-sm text-muted">
45+ <li>An extension is a public repository on g1t with a manifest. Tagging a release publishes a version.</li>
46+ <li>An install keeps the version it was installed at. Updates are offered to owners, never forced.</li>
47+ <li>Before anything is added, the install screen lists every permission, and says which domains outside g1t its data goes to.</li>
48+ <li>Its pages load in a sandboxed frame from g1t's user-content domain, and reach g1t only through what it was allowed.</li>
49+ <li>Every call it makes is in the audit log. Owners can cap what it spends, and switch it off at once.</li>
50+ </ul>
51+ <ul className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
52+ {LISTING_TIERS.map((tier) => (
53+ <li key={tier} className="rounded-xl border border-line bg-bg p-4">
54+ <TierBadge tier={tier} />
55+ <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p>
56+ </li>
57+ ))}
58+ </ul>
59+ </section>
60+ </div>
61+ );
62+}
+102−0
1+/**
2+ * Integrations in the Marketplace: every connector a workspace can connect
3+ * today, connected ones first, then the ones the catalog lists as coming.
4+ * Each is set up on its own page under Workspace → Integrations; owners
5+ * connect, anyone else asks.
6+ */
7+import { Search } from "lucide-react";
8+import { useState } from "react";
9+import { Link } from "react-router";
10+
11+import { connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors";
12+
13+import type { Route } from "./+types/integrations";
14+import { ConnectorMark } from "../../../components/connectors";
15+import { ComingBadge, IntegrationCard, SectionHead } from "../../../components/marketplace";
16+import { comingIntegrations, integrationListings, integrationMatches } from "../../../lib/marketplace";
17+import { loadConnected } from "../../../lib/marketplace.server";
18+import { requireUser, roleIn } from "../../../lib/session.server";
19+import { useMarketplace } from "./layout";
20+
21+export async function loader({ params, context, request }: Route.LoaderArgs) {
22+ const viewer = requireUser(context, request);
23+ const slug = params.owner.toLowerCase();
24+ if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
25+ return { connected: await loadConnected(slug, viewer) };
26+}
27+
28+export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) {
29+ const { slug, owner, username, requests } = useMarketplace();
30+ const [query, setQuery] = useState("");
31+ const views = connectorsFor("workspace");
32+ const all = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug);
33+ const shown = all.filter((listing) => integrationMatches(listing, query));
34+ const connected = shown.filter((listing) => listing.connected);
35+ const available = shown.filter((listing) => !listing.connected);
36+ const wanted = query.trim().toLowerCase();
37+ const personal = CONNECTORS.filter((c) => !c.scopes.includes("workspace")).map((c) => connectorView(c, "personal")).filter((view) => view != null);
38+ const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted)));
39+ return (
40+ <div className="space-y-10">
41+ <div className="flex flex-wrap items-center justify-between gap-3">
42+ <label className="flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-sm focus-within:border-line-strong">
43+ <Search size={15} className="shrink-0 text-faint" />
44+ <input
45+ value={query}
46+ onChange={(event) => setQuery(event.target.value)}
47+ placeholder="Find an integration"
48+ aria-label="Find an integration"
49+ autoComplete="off"
50+ className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
51+ />
52+ </label>
53+ <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg">
54+ Workspace integrations settings
55+ </Link>
56+ </div>
57+
58+ {connected.length > 0 && (
59+ <section aria-labelledby="connected">
60+ <SectionHead id="connected" title="Connected" aside={`${connected.length}`} />
61+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
62+ {connected.map((listing) => (
63+ <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
64+ ))}
65+ </div>
66+ </section>
67+ )}
68+
69+ <section aria-labelledby="available">
70+ <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`} />
71+ {available.length === 0 ? (
72+ <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">{wanted ? "No integration you can connect matches." : "Everything available is connected."}</p>
73+ ) : (
74+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
75+ {available.map((listing) => (
76+ <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
77+ ))}
78+ </div>
79+ )}
80+ </section>
81+
82+ {coming.length > 0 && (
83+ <section aria-labelledby="coming">
84+ <SectionHead id="coming" title="Coming" aside={<ComingBadge />}>
85+ Planned, not built. Nobody can connect these yet.
86+ </SectionHead>
87+ <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
88+ {coming.map((view) => (
89+ <li key={view.id} className="flex items-center gap-3 rounded-lg border border-dashed border-line px-3 py-2.5">
90+ <ConnectorMark view={view} size={28} />
91+ <span className="min-w-0">
92+ <span className="block truncate text-sm">{view.name}</span>
93+ <span className="block truncate text-xs text-faint">{view.description}</span>
94+ </span>
95+ </li>
96+ ))}
97+ </ul>
98+ </section>
99+ )}
100+ </div>
101+ );
102+}
+111−0
1+/**
2+ * The Marketplace: what a workspace can add, which anyone in it may
3+ * browse. Owners add agents and integrations; everyone else asks an owner,
4+ * and the agents service keeps the request (lib/marketplace.ts). It is not
5+ * in the dock: the Apps page and the launcher lead here, as does ⌘K.
6+ *
7+ * This layout holds the heading, the tabs and the viewer's requests, which
8+ * every tab reads to say what is waiting.
9+ */
10+import { Blocks, Bot, Inbox, Plug, Sparkles } from "lucide-react";
11+import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router";
12+
13+import type { ExtensionInstall, InstallRequests } from "@g1t/contracts";
14+
15+import type { Route } from "./+types/layout";
16+import { TabStrip } from "../../../components/ui/tab-strip";
17+import { marketplacePath, openRequests } from "../../../lib/marketplace";
18+import { page } from "../../../lib/meta";
19+import { workspaceAgents } from "../../../lib/services.server";
20+import { requireUser, roleIn } from "../../../lib/session.server";
21+
22+export function meta({ params, ...args }: Route.MetaArgs) {
23+ return page(args, { title: `Marketplace · ${params.owner} · g1t` });
24+}
25+
26+export type MarketplaceData = {
27+ slug: string;
28+ name: string;
29+ owner: boolean;
30+ username: string;
31+ /** The viewer's requests (an owner's: everyone's); null when the agents service did not answer. */
32+ requests: InstallRequests | null;
33+ /** The extensions installed in the workspace; empty when the agents service did not answer. */
34+ installs: ExtensionInstall[];
35+};
36+
37+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<MarketplaceData> {
38+ const viewer = requireUser(context, request);
39+ const slug = params.owner.toLowerCase();
40+ const role = roleIn(viewer, slug);
41+ if (!role) throw data(null, { status: 404 });
42+ const membership = viewer.workspaces?.find((m) => m.slug === slug);
43+ const [requests, installs] = await Promise.all([
44+ workspaceAgents
45+ .installRequests(slug, viewer)
46+ .then((result) => (result.ok ? result.value : null))
47+ .catch(() => null),
48+ workspaceAgents
49+ .extensionInstalls(slug, viewer)
50+ .then((result) => (result.ok ? result.value : []))
51+ .catch(() => []),
52+ ]);
53+ return { slug, name: membership?.name?.trim() || slug, owner: role === "owner", username: viewer.username, requests, installs };
54+}
55+
56+/** The Marketplace's own data, from any of its pages. */
57+export function useMarketplace(): MarketplaceData {
58+ return useRouteLoaderData("routes/workspace/marketplace/layout") as MarketplaceData;
59+}
60+
61+export default function MarketplaceLayout({ loaderData }: Route.ComponentProps) {
62+ const { slug, name, owner, requests } = loaderData;
63+ const waiting = requests ? openRequests(requests.requests).length : 0;
64+ const { pathname } = useLocation();
65+ const tabs = [
66+ { to: marketplacePath(slug), label: "Discover", icon: <Sparkles size={14} />, end: true },
67+ { to: marketplacePath(slug, "agents"), label: "Agents", icon: <Bot size={14} /> },
68+ { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> },
69+ { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> },
70+ { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting },
71+ ];
72+ return (
73+ <main className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10">
74+ <div className="flex flex-wrap items-end justify-between gap-4">
75+ <div>
76+ <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1>
77+ <p className="mt-1 max-w-2xl text-sm text-muted">
78+ Agents, integrations and extensions {name} can add.{" "}
79+ {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."}
80+ </p>
81+ </div>
82+ <Link to={`/${slug}/-/apps`} className="text-sm text-muted hover:text-fg">
83+ Your apps
84+ </Link>
85+ </div>
86+ <TabStrip label="Marketplace" className="mt-6 gap-1 border-b border-line">
87+ {tabs.map((tab) => (
88+ <NavLink
89+ key={tab.to}
90+ to={tab.to}
91+ end={tab.end}
92+ prefetch="intent"
93+ data-active={tab.end ? pathname === tab.to : pathname.startsWith(tab.to)}
94+ className={({ isActive }) =>
95+ `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
96+ isActive ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg"
97+ }`
98+ }
99+ >
100+ {tab.icon}
101+ {tab.label}
102+ {tab.count != null && tab.count > 0 && <span className="rounded-full bg-warn/15 px-1.5 py-px text-xs text-warn tabular-nums">{tab.count}</span>}
103+ </NavLink>
104+ ))}
105+ </TabStrip>
106+ <div className="mt-8">
107+ <Outlet />
108+ </div>
109+ </main>
110+ );
111+}
+99−0
1+/**
2+ * Install requests: for an owner, everything members asked the workspace
3+ * to add, with the way to add it, mark it added or turn it down; for
4+ * anyone else, their own and how each was answered. Every Marketplace form
5+ * posts here: asking, answering, and installing, switching off or removing
6+ * an extension.
7+ */
8+import { Link, data } from "react-router";
9+
10+import { connectorsFor } from "@g1t/contracts/connectors";
11+
12+import type { Route } from "./+types/requests";
13+import { RequestRow, SectionHead } from "../../../components/marketplace";
14+import { EmptyState } from "../../../components/ui";
15+import { addPath, marketplaceForm, marketplacePath, openRequests } from "../../../lib/marketplace";
16+import { workspaceAgents } from "../../../lib/services.server";
17+import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
18+import { useMarketplace } from "./layout";
19+
20+export async function action({ params, context, request }: Route.ActionArgs) {
21+ assertSameOrigin(request);
22+ const viewer = requireUser(context, request);
23+ const slug = params.owner.toLowerCase();
24+ if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
25+ const asked = marketplaceForm(await request.formData());
26+ if (!asked) return data({ error: "Nothing to do." }, { status: 400 });
27+ const call = (): Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }> => {
28+ switch (asked.intent) {
29+ case "request":
30+ return workspaceAgents.requestInstall(slug, viewer, asked.listing, asked.note);
31+ case "resolve":
32+ return workspaceAgents.resolveInstallRequest(slug, viewer, asked.id, asked.status);
33+ case "install":
34+ return workspaceAgents.installExtension(slug, viewer, asked.extension);
35+ case "switch":
36+ return workspaceAgents.setExtensionEnabled(slug, viewer, asked.listing, asked.enabled);
37+ case "uninstall":
38+ return workspaceAgents.uninstallExtension(slug, viewer, asked.listing);
39+ }
40+ };
41+ const result = await call().catch(() => null);
42+ if (!result) return data({ error: "The request didn't go through. Try again in a moment." }, { status: 503 });
43+ if (!result.ok) return data({ error: result.error.message }, { status: result.error.code === "conflict" ? 409 : 422 });
44+ return { error: null };
45+}
46+
47+export default function MarketplaceRequests() {
48+ const { slug, owner, requests } = useMarketplace();
49+ if (!requests) {
50+ return <EmptyState title="Requests can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>;
51+ }
52+ const views = connectorsFor("workspace");
53+ const waiting = openRequests(requests.requests);
54+ const answered = requests.requests.filter((request) => request.status !== "open");
55+ if (requests.requests.length === 0) {
56+ return owner ? (
57+ <EmptyState title="No requests">When a member asks for an agent or an integration, it shows here, and you're notified.</EmptyState>
58+ ) : (
59+ <EmptyState title="You haven't asked for anything">
60+ Find an agent or an integration and choose <b>Request</b>. The workspace's owners are notified, and you hear back when one answers.
61+ </EmptyState>
62+ );
63+ }
64+ return (
65+ <div className="space-y-10">
66+ <section aria-labelledby="waiting">
67+ <SectionHead id="waiting" title={owner ? "Waiting on an owner" : "Waiting"} aside={`${waiting.length}`}>
68+ {owner
69+ ? "Adding an agent from the catalog answers every request for its role. After connecting an integration, mark its requests added."
70+ : "Your requests the workspace's owners haven't answered yet."}
71+ </SectionHead>
72+ {waiting.length === 0 ? (
73+ <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">Nothing is waiting.</p>
74+ ) : (
75+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
76+ {waiting.map((request) => (
77+ <RequestRow key={request.id} request={request} slug={slug} owner={owner} addTo={owner ? addPath(request, slug, views) : null} />
78+ ))}
79+ </ul>
80+ )}
81+ </section>
82+ {answered.length > 0 && (
83+ <section aria-labelledby="answered">
84+ <SectionHead id="answered" title="Answered" aside="Last 30 days" />
85+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
86+ {answered.map((request) => (
87+ <RequestRow key={request.id} request={request} slug={slug} owner={owner} addTo={null} />
88+ ))}
89+ </ul>
90+ </section>
91+ )}
92+ {!owner && (
93+ <p className="text-sm text-muted">
94+ Looking for something else? <Link to={marketplacePath(slug)} className="text-accent hover:underline">Browse the Marketplace</Link>.
95+ </p>
96+ )}
97+ </div>
98+ );
99+}
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./chat-markdown": "./src/chat-markdown.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+1−0
2828 export * from "./identity";
2929 export * from "./inbox";
3030 export * from "./instances";
31+export * from "./marketplace";
3132 export * from "./ids";
3233 export * from "./integrations";
3334 export * from "./members";
+69−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { FIRST_PARTY_EXTENSIONS, type ExtensionManifest, checkManifest, dataDisclosure, extensionFrameUrl, parseListing, versionOfTag } from "./marketplace.ts";
5+import { isScope } from "./scopes.ts";
6+
7+const published: ExtensionManifest = {
8+ ...FIRST_PARTY_EXTENSIONS[0]!,
9+ id: "standup",
10+ status: "available",
11+ source: { repo: "flagon-io/standup", tag: "v1.2.0" },
12+ version: "1.2.0",
13+};
14+
15+test("g1t's own extensions are listed as coming, with manifests publishing would accept", () => {
16+ const ids = new Set<string>();
17+ for (const extension of FIRST_PARTY_EXTENSIONS) {
18+ assert.ok(!ids.has(extension.id), extension.id);
19+ ids.add(extension.id);
20+ assert.equal(extension.status, "soon", `${extension.id} isn't published yet`);
21+ assert.equal(extension.source, null);
22+ assert.equal(extension.pricing, null);
23+ assert.equal(extension.publisher.tier, "official");
24+ const checked = checkManifest(extension, isScope);
25+ assert.ok(checked.ok, `${extension.id}: ${checked.ok ? "" : checked.message}`);
26+ }
27+});
28+
29+test("publishing checks a manifest strictly", () => {
30+ assert.ok(checkManifest(published, isScope).ok);
31+ const refused = (changes: Partial<ExtensionManifest> | Record<string, unknown>, why: RegExp) => {
32+ const checked = checkManifest({ ...published, ...changes }, isScope);
33+ assert.equal(checked.ok, false, JSON.stringify(changes));
34+ if (!checked.ok) assert.match(checked.message, why);
35+ };
36+ refused({ id: "Bad Id" }, /id/);
37+ refused({ scopes: ["everything:admin"] }, /scope/);
38+ refused({ source: null }, /source/);
39+ refused({ domains: ["not a host"] }, /domains/);
40+ refused({ runtime: "connected", domains: [] }, /declares the domains/);
41+ refused({ publisher: { name: "anyone", tier: "community" } }, /Community extensions run on their publisher's servers/);
42+ refused({ pricing: { monthly: 5 } }, /free/);
43+ refused({ permissions: [] }, /permissions/);
44+ refused({ ui: { entry: "index.html" } }, /ui.entry/);
45+ // A community extension that runs on its own servers, declaring them, is fine.
46+ assert.ok(checkManifest({ ...published, runtime: "connected", publisher: { name: "anyone", tier: "community" }, domains: ["api.example.com"] }, isScope).ok);
47+});
48+
49+test("the install screen says where data goes", () => {
50+ assert.equal(dataDisclosure({ domains: [] }), "Its data stays in g1t.");
51+ assert.equal(dataDisclosure({ domains: ["api.acme.dev", "eu.acme.dev"] }), "Data leaves g1t to api.acme.dev, eu.acme.dev.");
52+});
53+
54+test("an extension's page loads from the user-content origin, by version", () => {
55+ assert.equal(extensionFrameUrl(published, "https://g1tusercontent.com/"), "https://g1tusercontent.com/x/standup/1.2.0/index.html");
56+ assert.equal(extensionFrameUrl({ ...published, ui: null }, "https://g1tusercontent.com"), null);
57+ assert.equal(extensionFrameUrl(FIRST_PARTY_EXTENSIONS[0]!, "https://g1tusercontent.com"), null, "nothing published, nothing to load");
58+});
59+
60+test("a tag publishes a version", () => {
61+ assert.equal(versionOfTag("v1.4.0"), "1.4.0");
62+ assert.equal(versionOfTag("2.0.0-beta.1"), "2.0.0-beta.1");
63+ assert.equal(versionOfTag("latest"), null);
64+});
65+
66+test("listings are referenced by kind and id", () => {
67+ assert.deepEqual(parseListing("extension:on-call"), { kind: "extension", id: "on-call" });
68+ assert.equal(parseListing("app:on-call"), null);
69+});
+324−0
1+/**
2+ * The Marketplace: what a workspace can add, the installs it has, and
3+ * members' requests to add things. Anyone in a workspace browses it; only
4+ * owners add things. A member asks instead, and the request reaches every
5+ * owner as a notification.
6+ *
7+ * Every listing has a reference, `<kind>:<id>`:
8+ *
9+ * - `agent:<template>`: an agent hired from one of the catalog's roles
10+ * (the agents service's templates); the agents service keeps the agent;
11+ * - `integration:<connector>`: a connector from the catalog
12+ * (./connectors.ts); the integrations service keeps the connection;
13+ * - `extension:<id>`: an extension, described by its manifest
14+ * (`ExtensionManifest`): who publishes it, where its source is, the
15+ * scopes it asks for, the domains its data goes to, the page it shows
16+ * inside g1t, and its pricing. An install (`ExtensionInstall`) pins a
17+ * version and can be switched off at once.
18+ *
19+ * Extensions are shared the way workflow actions are: a public
20+ * repository with a manifest, where tagging a release publishes a
21+ * version, and an install keeps the version it was installed at until an
22+ * owner takes an update. Nothing is paid yet, but listings carry a
23+ * pricing field and installs a plan, so paid listings are billing work.
24+ *
25+ * The agents service keeps installs (`extension_installs`) and requests
26+ * (`install_requests`). Wire shapes are snake_case. No imports, so services
27+ * test it under Node.
28+ */
29+
30+/** What a listing adds to a workspace. */
31+export type ListingKind = "agent" | "integration" | "extension";
32+
33+export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"];
34+
35+/** Whether a listing can be added today, or is planned. */
36+export type ListingStatus = "available" | "soon";
37+
38+/**
39+ * Where an extension's server code runs: on g1t (official, verified and
40+ * internal publishers, and community code once it has an isolated
41+ * sandbox), or on the publisher's own servers, reached over HTTPS.
42+ */
43+export type ExtensionRuntime = "hosted" | "connected";
44+
45+/** The workspace areas an extension can add to. */
46+export type ExtensionAdds = {
47+ /** Its pages, in its sidebar, in order. */
48+ pages: string[];
49+ /** Agent roles it brings, hired like any other. */
50+ agent_roles: string[];
51+ /** Tools agents can call. */
52+ tools: string[];
53+ /** Notification kinds it sends. */
54+ notifications: string[];
55+};
56+
57+/**
58+ * An extension, as its manifest describes it. A published one comes from
59+ * `.g1t/extension.json` in its source repository at a tag; first-party
60+ * listings not yet published (`status: "soon"`) have no source yet.
61+ */
62+export type ExtensionManifest = {
63+ /** Lowercase letters, digits and hyphens: `support`. */
64+ id: string;
65+ name: string;
66+ /** One line, for cards. */
67+ tagline: string;
68+ /** A paragraph, for its page. */
69+ description: string;
70+ category: string;
71+ publisher: { name: string; tier: ListingTier };
72+ status: ListingStatus;
73+ /** Its public repository on g1t and the tag a version was published from; null until it is published. */
74+ source: { repo: string; tag: string } | null;
75+ /** The version a tag published (`1.4.0`); null until it is published. */
76+ version: string | null;
77+ runtime: ExtensionRuntime;
78+ /** The g1t scopes its token asks for (./scopes.ts), shown in plain words before install. */
79+ scopes: string[];
80+ /** What it can do, in plain words, as the install screen lists it. */
81+ permissions: string[];
82+ /**
83+ * Every host outside g1t its data goes to. The install screen says
84+ * "Data leaves g1t to …" for each; calls anywhere else are refused.
85+ * Empty: its data stays in g1t.
86+ */
87+ domains: string[];
88+ /**
89+ * Its page inside g1t: a path served from the user-content domain
90+ * (g1tusercontent.com, or a self-hosted instance's own), loaded in a
91+ * sandboxed frame that reaches g1t only through the bridge. Null: no page.
92+ */
93+ ui: { entry: string } | null;
94+ adds: ExtensionAdds;
95+ /** Empty while every listing is free; a later price list goes here. */
96+ pricing: null;
97+};
98+
99+/** An extension installed in a workspace. */
100+export type ExtensionInstall = {
101+ id: string;
102+ /** `extension:<id>`. */
103+ listing: string;
104+ /** The version installed; updates are offered, never forced. */
105+ version: string;
106+ /** The plan it is on: `free` until listings have prices. */
107+ plan: string;
108+ installed_by: string;
109+ /** RFC 3339. */
110+ installed_at: string;
111+ /** Off: the kill switch. Its token stops working and its page doesn't load. */
112+ enabled: boolean;
113+ /** Who last turned it off, and when; null while it is on. */
114+ disabled_by: string | null;
115+ disabled_at: string | null;
116+ /** Its monthly spend cap, in micro-dollars; null: the workspace's limit applies. */
117+ budget_monthly_micros: number | null;
118+};
119+
120+/** The plan an install records while nothing is paid. */
121+export const FREE_PLAN = "free";
122+
123+/** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */
124+export function checkManifest(raw: unknown, knownScope: (scope: string) => boolean): { ok: true; value: ExtensionManifest } | { ok: false; message: string } {
125+ const m = raw as Partial<ExtensionManifest> | null;
126+ const bad = (message: string) => ({ ok: false as const, message });
127+ if (!m || typeof m !== "object") return bad("The manifest isn't a JSON object.");
128+ if (typeof m.id !== "string" || !/^[a-z][a-z0-9-]{1,31}$/.test(m.id)) return bad("`id` is 2 to 32 lowercase letters, digits and hyphens, starting with a letter.");
129+ for (const field of ["name", "tagline", "description", "category"] as const) {
130+ if (typeof m[field] !== "string" || !m[field]!.trim()) return bad(`\`${field}\` is required.`);
131+ }
132+ if (!m.publisher || typeof m.publisher.name !== "string" || !LISTING_TIERS.includes(m.publisher.tier as ListingTier)) return bad("`publisher` needs a name and a tier.");
133+ if (m.status !== "available" && m.status !== "soon") return bad("`status` is available or soon.");
134+ if (m.runtime !== "hosted" && m.runtime !== "connected") return bad("`runtime` is hosted or connected.");
135+ if (m.runtime === "hosted" && m.publisher.tier === "community") return bad("Community extensions run on their publisher's servers (`connected`) until hosted code has an isolated sandbox.");
136+ if (m.status === "available" && (!m.source || !m.version)) return bad("A published extension names its source repository, tag and version.");
137+ if (m.source && (typeof m.source.repo !== "string" || !/^[a-z0-9-]+\/[A-Za-z0-9._-]+$/.test(m.source.repo) || typeof m.source.tag !== "string" || !m.source.tag)) return bad("`source` is a repository (`owner/name`) and a tag.");
138+ if (!Array.isArray(m.scopes) || m.scopes.some((scope) => typeof scope !== "string" || !knownScope(scope))) return bad("Every scope must be one g1t has.");
139+ if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words.");
140+ if (!Array.isArray(m.domains) || m.domains.some((domain) => typeof domain !== "string" || !isHost(domain))) return bad("`domains` are host names, such as api.example.com.");
141+ if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on.");
142+ if (m.ui !== null && (typeof m.ui !== "object" || typeof m.ui?.entry !== "string" || !m.ui.entry.startsWith("/"))) return bad("`ui.entry` is a path, such as /index.html.");
143+ if (!m.adds || !["pages", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, agent_roles, tools and notifications.");
144+ if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null.");
145+ return { ok: true, value: m as ExtensionManifest };
146+}
147+
148+function isHost(value: string): boolean {
149+ return /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value);
150+}
151+
152+/** What the install screen says about where an extension's data goes. */
153+export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains">): string {
154+ if (manifest.domains.length === 0) return "Its data stays in g1t.";
155+ return `Data leaves g1t to ${manifest.domains.join(", ")}.`;
156+}
157+
158+/** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */
159+export function extensionFrameUrl(manifest: Pick<ExtensionManifest, "id" | "version" | "ui">, usercontentOrigin: string): string | null {
160+ if (!manifest.ui || !manifest.version) return null;
161+ return `${usercontentOrigin.replace(/\/+$/, "")}/x/${manifest.id}/${manifest.version}${manifest.ui.entry}`;
162+}
163+
164+/** A version as a tag names it: `v1.4.0` and `1.4.0` are both `1.4.0`; null when it isn't one. */
165+export function versionOfTag(tag: string): string | null {
166+ const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag.trim());
167+ return match ? match[1]! : null;
168+}
169+
170+/**
171+ * g1t's own extensions. None is published yet: each is listed so people
172+ * see what is coming, and can't be installed until its first release.
173+ */
174+export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [
175+ {
176+ id: "support",
177+ name: "Support",
178+ tagline: "Customer conversations an agent answers from your docs, escalating the rest.",
179+ description:
180+ "Conversations, escalations, macros and a knowledge base. A support agent answers what your docs cover, turns bug reports into issues in Code, and hands anything else to a person. Email to customers waits for approval.",
181+ category: "Customers",
182+ publisher: { name: "g1t", tier: "official" },
183+ status: "soon",
184+ source: null,
185+ version: null,
186+ runtime: "hosted",
187+ scopes: ["issues:write", "artifacts:read", "notifications:write"],
188+ permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"],
189+ domains: [],
190+ ui: { entry: "/index.html" },
191+ adds: { pages: ["Conversations", "Escalations", "Macros", "Knowledge"], agent_roles: ["Support Specialist"], tools: ["conversation", "macro"], notifications: ["Escalations"] },
192+ pricing: null,
193+ },
194+ {
195+ id: "recruiting",
196+ name: "Recruiting",
197+ tagline: "Openings, candidates and interview loops. Agents screen, people decide.",
198+ description: "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
199+ category: "People",
200+ publisher: { name: "g1t", tier: "official" },
201+ status: "soon",
202+ source: null,
203+ version: null,
204+ runtime: "hosted",
205+ scopes: ["artifacts:write", "notifications:write"],
206+ permissions: ["Store candidates and openings in workspace data", "Read and write interview events on connected calendars", "Email candidates from a shared address, with your approval"],
207+ domains: [],
208+ ui: { entry: "/index.html" },
209+ adds: { pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"], agent_roles: ["Recruiter"], tools: ["candidate", "opening"], notifications: ["Offers to sign"] },
210+ pricing: null,
211+ },
212+ {
213+ id: "mail",
214+ name: "Mail",
215+ tagline: "Email on your own domain, with shared inboxes agents work in.",
216+ description:
217+ "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.",
218+ category: "Communication",
219+ publisher: { name: "g1t", tier: "official" },
220+ status: "soon",
221+ source: null,
222+ version: null,
223+ runtime: "hosted",
224+ scopes: ["notifications:write", "artifacts:read"],
225+ permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
226+ domains: [],
227+ ui: { entry: "/index.html" },
228+ adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
229+ pricing: null,
230+ },
231+ {
232+ id: "on-call",
233+ name: "On-call",
234+ tagline: "Rotations, pages and who is on call now.",
235+ description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.",
236+ category: "Engineering",
237+ publisher: { name: "g1t", tier: "official" },
238+ status: "soon",
239+ source: null,
240+ version: null,
241+ runtime: "hosted",
242+ scopes: ["notifications:write", "issues:read"],
243+ permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"],
244+ domains: [],
245+ ui: { entry: "/index.html" },
246+ adds: { pages: ["Rotations", "Pages", "Incidents"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
247+ pricing: null,
248+ },
249+];
250+
251+/** A first-party extension by id. */
252+export function extensionById(id: string): ExtensionManifest | undefined {
253+ return FIRST_PARTY_EXTENSIONS.find((extension) => extension.id === id);
254+}
255+
256+/**
257+ * Who stands behind a listing: g1t itself, a reviewed publisher, anyone,
258+ * or the workspace's own people.
259+ */
260+export type ListingTier = "official" | "verified" | "community" | "internal";
261+
262+export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
263+
264+/** A listing's reference, split: `agent:engineering` is `{ kind: "agent", id: "engineering" }`. */
265+export type ListingRef = { kind: ListingKind; id: string };
266+
267+/** Where a request stands: waiting on an owner, added, or turned down. */
268+export type InstallRequestStatus = "open" | "done" | "declined";
269+
270+export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"];
271+
272+/** A member's request that the workspace add a listing. */
273+export type InstallRequest = {
274+ id: string;
275+ /** `<kind>:<id>`. */
276+ listing: string;
277+ kind: ListingKind;
278+ /** The listing's name when it was asked for, such as `Software Engineer` or `Sentry`. */
279+ name: string;
280+ /** Why they want it, in their words, or null. */
281+ note: string | null;
282+ /** Who asked, by username. */
283+ requested_by: string;
284+ /** RFC 3339. */
285+ requested_at: string;
286+ status: InstallRequestStatus;
287+ /** The owner who added it or turned it down, by username. */
288+ resolved_by: string | null;
289+ /** RFC 3339. */
290+ resolved_at: string | null;
291+};
292+
293+/**
294+ * Requests as one person sees them: an owner, every request in the
295+ * workspace (`can_resolve`); anyone else, their own. Open ones first, then
296+ * the latest; answered ones for 30 days.
297+ */
298+export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean };
299+
300+/** The longest note a request carries. */
301+export const MAX_REQUEST_NOTE = 280;
302+/** The most requests one person keeps open in a workspace. */
303+export const MAX_OPEN_REQUESTS = 20;
304+/** How long an answered request stays listed. */
305+export const ANSWERED_REQUESTS_DAYS = 30;
306+
307+/** A listing's reference: `agent:engineering`. */
308+export function listingRef(kind: ListingKind, id: string): string {
309+ return `${kind}:${id}`;
310+}
311+
312+/** A reference split into kind and id; null when it is not one. */
313+export function parseListing(value: unknown): ListingRef | null {
314+ if (typeof value !== "string") return null;
315+ const match = /^(agent|integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim());
316+ return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
317+}
318+
319+/** A note as kept: trimmed, at most `MAX_REQUEST_NOTE` characters, null when empty. */
320+export function cleanRequestNote(value: unknown): string | null {
321+ if (typeof value !== "string") return null;
322+ const note = value.replace(/\s+/g, " ").trim().slice(0, MAX_REQUEST_NOTE);
323+ return note || null;
324+}
+32−0
1414 // Model tiers (`ModelTier`, `MODEL_TIERS`) are integrations.ts's, the
1515 // same ones runs are routed between.
1616 import type { ModelTier } from "./integrations";
17+import type { ExtensionInstall, InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace";
1718
1819 /** The built-in orchestrator's handle; nobody else's agent may take it. */
1920 export const BUILTIN_AGENT_HANDLE = "g1t";
713714 cardAction(input: AgentCardAction): Promise<Result<CardActionResult>>;
714715 policy(workspace: string, viewer: User): Promise<Result<AgentPolicy>>;
715716 setPolicy(workspace: string, viewer: User, policy: Partial<AgentPolicy>): Promise<Result<AgentPolicy>>;
717+ /**
718+ * The Marketplace's install requests (./marketplace.ts): every one in the
719+ * workspace for an owner, a member's own for anyone else.
720+ */
721+ installRequests(workspace: string, viewer: User): Promise<Result<InstallRequests>>;
722+ /**
723+ * A member asks the workspace's owners to add a listing (`agent:<template>`
724+ * or `integration:<connector>`), and every owner is notified. Owners add
725+ * things themselves, so they don't ask. Asking again while a request for
726+ * the same listing is open is a conflict.
727+ */
728+ requestInstall(workspace: string, viewer: User, listing: string, note?: string | null): Promise<Result<InstallRequest>>;
729+ /** An owner marks a request added (`done`) or turns it down (`declined`); whoever asked is told. */
730+ resolveInstallRequest(workspace: string, viewer: User, id: string, status: Exclude<InstallRequestStatus, "open">): Promise<Result<InstallRequest>>;
731+ /** The extensions installed in the workspace; any member sees them. */
732+ extensionInstalls(workspace: string, viewer: User): Promise<Result<ExtensionInstall[]>>;
733+ /** Owners install a published extension at its current version; open requests for it are answered. */
734+ installExtension(workspace: string, viewer: User, extension: string): Promise<Result<ExtensionInstall>>;
735+ /** Owners switch an install on or off: off is the kill switch. */
736+ setExtensionEnabled(workspace: string, viewer: User, listing: string, enabled: boolean): Promise<Result<ExtensionInstall>>;
737+ /** Owners cap what an install spends a month; null leaves it to the workspace's limit. */
738+ setExtensionBudget(workspace: string, viewer: User, listing: string, monthlyMicros: number | null): Promise<Result<ExtensionInstall>>;
739+ uninstallExtension(workspace: string, viewer: User, listing: string): Promise<Result<null>>;
716740 };
717741
718742 async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
763787 cardAction: (input) => call("card_action", input),
764788 policy: (workspace, viewer) => call("policy", { workspace, viewer }),
765789 setPolicy: (workspace, viewer, policy) => call("set_policy", { workspace, viewer, policy }),
790+ installRequests: (workspace, viewer) => call("install_requests", { workspace, viewer }),
791+ requestInstall: (workspace, viewer, listing, note) => call("request_install", { workspace, viewer, listing, note: note ?? null }),
792+ resolveInstallRequest: (workspace, viewer, id, status) => call("resolve_install_request", { workspace, viewer, id, status }),
793+ extensionInstalls: (workspace, viewer) => call("extension_installs", { workspace, viewer }),
794+ installExtension: (workspace, viewer, extension) => call("install_extension", { workspace, viewer, extension }),
795+ setExtensionEnabled: (workspace, viewer, listing, enabled) => call("set_extension_enabled", { workspace, viewer, listing, enabled }),
796+ setExtensionBudget: (workspace, viewer, listing, monthlyMicros) => call("set_extension_budget", { workspace, viewer, listing, monthly_micros: monthlyMicros }),
797+ uninstallExtension: (workspace, viewer, listing) => call("uninstall_extension", { workspace, viewer, listing }),
766798 };
767799 }
+53−0
1+-- The Marketplace: members' install requests (src/installs.ts), where a
2+-- member asks the workspace's owners to add an agent from the catalog, an
3+-- integration or an extension; and the extensions a workspace installed
4+-- (src/extensions.ts).
5+
6+CREATE TABLE install_requests (
7+ id TEXT PRIMARY KEY,
8+ -- By id, not slug: a renamed workspace keeps its requests.
9+ workspace_id TEXT NOT NULL,
10+ -- `agent:<template>`, `integration:<connector>` or `extension:<id>` (@g1t/contracts marketplace.ts).
11+ listing TEXT NOT NULL,
12+ -- The listing's name when it was asked for, so a request still reads
13+ -- after the listing changes.
14+ name TEXT NOT NULL,
15+ note TEXT,
16+ requested_by TEXT NOT NULL,
17+ requested_by_id TEXT NOT NULL,
18+ requested_at TEXT NOT NULL,
19+ -- open, done or declined.
20+ status TEXT NOT NULL DEFAULT 'open',
21+ resolved_by TEXT,
22+ resolved_at TEXT
23+);
24+
25+-- One open request per person and listing: asking twice is one request.
26+CREATE UNIQUE INDEX install_requests_open ON install_requests (workspace_id, listing, requested_by_id) WHERE status = 'open';
27+CREATE INDEX install_requests_by_workspace ON install_requests (workspace_id, status, requested_at);
28+
29+-- Extensions installed in a workspace: which version, on which plan, who
30+-- installed it, and whether it is switched on. An uninstalled extension
31+-- keeps its row, with when and by whom.
32+CREATE TABLE extension_installs (
33+ id TEXT PRIMARY KEY,
34+ workspace_id TEXT NOT NULL,
35+ -- `extension:<id>`.
36+ listing TEXT NOT NULL,
37+ version TEXT NOT NULL,
38+ -- `free` until listings have prices.
39+ plan TEXT NOT NULL DEFAULT 'free',
40+ installed_by TEXT NOT NULL,
41+ installed_at TEXT NOT NULL,
42+ -- 1 on, 0 off: the kill switch.
43+ enabled INTEGER NOT NULL DEFAULT 1,
44+ disabled_by TEXT,
45+ disabled_at TEXT,
46+ -- A monthly cap on what it spends, in micro-dollars; null: the workspace's limit.
47+ budget_monthly_micros INTEGER,
48+ uninstalled_by TEXT,
49+ uninstalled_at TEXT
50+);
51+
52+-- One live install per workspace and extension.
53+CREATE UNIQUE INDEX extension_installs_live ON extension_installs (workspace_id, listing) WHERE uninstalled_at IS NULL;
+88−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import type { ExtensionManifest } from "@g1t/contracts";
7+import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
8+
9+import { type Catalog, extensionIdOf, install, listInstalls, setBudget, setEnabled, uninstall } from "./extensions.ts";
10+import { findListing } from "./installs.ts";
11+
12+/** D1, as far as installs use it, over node's SQLite with the service's migrations. */
13+function fakeD1(): D1Database {
14+ const db = new DatabaseSync(":memory:");
15+ const dir = new URL("../migrations/", import.meta.url);
16+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
17+ const statement = (sql: string, params: unknown[] = []) => ({
18+ bind: (...values: unknown[]) => statement(sql, values),
19+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
20+ run: async () => db.prepare(sql).run(...(params as never[])),
21+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
22+ });
23+ return { prepare: (sql: string) => statement(sql) } as unknown as D1Database;
24+}
25+
26+const standup: ExtensionManifest = { ...FIRST_PARTY_EXTENSIONS[0]!, id: "standup", name: "Standup", status: "available", source: { repo: "flagon-io/standup", tag: "v1.2.0" }, version: "1.2.0" };
27+const catalog: Catalog = (id) => (id === "standup" ? standup : FIRST_PARTY_EXTENSIONS.find((e) => e.id === id));
28+const ws = "wsp_acme";
29+const at = new Date("2026-10-10T12:00:00.000Z");
30+
31+test("a published extension installs at its version, on the free plan, switched on", async () => {
32+ const db = fakeD1();
33+ const done = await install(db, catalog, ws, "ins_1", "standup", "chase", at);
34+ assert.ok(done.ok);
35+ assert.deepEqual(
36+ { listing: done.value.listing, version: done.value.version, plan: done.value.plan, enabled: done.value.enabled, by: done.value.installed_by },
37+ { listing: "extension:standup", version: "1.2.0", plan: "free", enabled: true, by: "chase" },
38+ );
39+ const again = await install(db, catalog, ws, "ins_2", "standup", "chase", at);
40+ assert.equal(!again.ok && again.error.code, "conflict");
41+ assert.deepEqual((await listInstalls(db, ws)).map((i) => i.listing), ["extension:standup"]);
42+ assert.deepEqual(await listInstalls(db, "wsp_other"), []);
43+});
44+
45+test("nothing unpublished installs", async () => {
46+ const db = fakeD1();
47+ const support = await install(db, catalog, ws, "ins_1", "support", "chase", at);
48+ assert.equal(!support.ok && support.error.code, "invalid");
49+ assert.match(!support.ok ? support.error.message : "", /isn't published yet/);
50+ const missing = await install(db, catalog, ws, "ins_2", "nope", "chase", at);
51+ assert.equal(!missing.ok && missing.error.code, "not_found");
52+ // Nor can members ask for what can't be installed.
53+ assert.equal(findListing("extension:support"), null);
54+});
55+
56+test("the kill switch turns an install off at once, and back on", async () => {
57+ const db = fakeD1();
58+ await install(db, catalog, ws, "ins_1", "standup", "chase", at);
59+ const off = await setEnabled(db, ws, "extension:standup", false, "dee", at);
60+ assert.ok(off.ok);
61+ assert.equal(off.value.enabled, false);
62+ assert.equal(off.value.disabled_by, "dee");
63+ const on = await setEnabled(db, ws, "extension:standup", true, "chase", at);
64+ assert.ok(on.ok);
65+ assert.equal(on.value.enabled, true);
66+ assert.equal(on.value.disabled_by, null);
67+ const missing = await setEnabled(db, ws, "extension:other", false, "dee", at);
68+ assert.equal(!missing.ok && missing.error.code, "not_found");
69+});
70+
71+test("an install has a budget, and uninstalling keeps the record", async () => {
72+ const db = fakeD1();
73+ await install(db, catalog, ws, "ins_1", "standup", "chase", at);
74+ const capped = await setBudget(db, ws, "extension:standup", 25_000_000);
75+ assert.ok(capped.ok);
76+ assert.equal(capped.value.budget_monthly_micros, 25_000_000);
77+ assert.equal((await setBudget(db, ws, "extension:standup", -1)).ok, false);
78+ assert.ok((await uninstall(db, ws, "extension:standup", "chase", at)).ok);
79+ assert.deepEqual(await listInstalls(db, ws), []);
80+ assert.equal((await uninstall(db, ws, "extension:standup", "chase", at)).ok, false);
81+ // Installed again, it is a new install.
82+ assert.ok((await install(db, catalog, ws, "ins_2", "standup", "chase", at)).ok);
83+});
84+
85+test("extension references", () => {
86+ assert.equal(extensionIdOf("extension:standup"), "standup");
87+ assert.equal(extensionIdOf("integration:sentry"), null);
88+});
+110−0
1+/**
2+ * Extensions installed in a workspace (docs.g1t.sh/guides/marketplace/):
3+ * the version an install keeps, its plan, who installed it, whether it is
4+ * switched on (the kill switch), and its monthly spend cap. Kept in
5+ * `extension_installs`; one live install per workspace and extension.
6+ *
7+ * Only a published listing installs: one whose manifest is `available`,
8+ * with a source and a version. The catalog of manifests is passed in
9+ * (`Catalog`): today g1t's own (@g1t/contracts FIRST_PARTY_EXTENSIONS),
10+ * later every published listing. Who may do what, the audit log and
11+ * telling people are index.ts's; this file is the rules and the table,
12+ * tested on SQLite.
13+ */
14+import type { ExtensionInstall, ExtensionManifest, FailureCode, Result } from "@g1t/contracts";
15+import { FREE_PLAN, listingRef, parseListing } from "@g1t/contracts/marketplace";
16+
17+const ok = <T>(value: T): Result<T> => ({ ok: true, value });
18+const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
19+
20+/** Finds an extension's manifest by id. */
21+export type Catalog = (id: string) => ExtensionManifest | undefined;
22+
23+type Row = {
24+ id: string;
25+ listing: string;
26+ version: string;
27+ plan: string;
28+ installed_by: string;
29+ installed_at: string;
30+ enabled: number;
31+ disabled_by: string | null;
32+ disabled_at: string | null;
33+ budget_monthly_micros: number | null;
34+};
35+
36+const COLUMNS = "id, listing, version, plan, installed_by, installed_at, enabled, disabled_by, disabled_at, budget_monthly_micros";
37+
38+function toInstall(row: Row): ExtensionInstall {
39+ return { ...row, enabled: row.enabled === 1 };
40+}
41+
42+/** The workspace's live installs, oldest first. */
43+export async function listInstalls(db: D1Database, workspaceId: string): Promise<ExtensionInstall[]> {
44+ const { results } = await db.prepare(`SELECT ${COLUMNS} FROM extension_installs WHERE workspace_id = ? AND uninstalled_at IS NULL ORDER BY installed_at`).bind(workspaceId).all<Row>();
45+ return results.map(toInstall);
46+}
47+
48+/** Installs a published extension at its current version, on the free plan. A conflict when it is installed already. */
49+export async function install(db: D1Database, catalog: Catalog, workspaceId: string, id: string, extensionId: string, by: string, now = new Date()): Promise<Result<ExtensionInstall>> {
50+ const manifest = catalog(extensionId);
51+ if (!manifest) return fail("not_found", "There is no such extension.");
52+ if (manifest.status !== "available" || !manifest.version || !manifest.source) return fail("invalid", `${manifest.name} isn't published yet, so it can't be installed.`);
53+ const row: Row = {
54+ id,
55+ listing: listingRef("extension", manifest.id),
56+ version: manifest.version,
57+ plan: FREE_PLAN,
58+ installed_by: by,
59+ installed_at: now.toISOString(),
60+ enabled: 1,
61+ disabled_by: null,
62+ disabled_at: null,
63+ budget_monthly_micros: null,
64+ };
65+ const inserted = await db
66+ .prepare(
67+ `INSERT INTO extension_installs (id, workspace_id, listing, version, plan, installed_by, installed_at, enabled)
68+ VALUES (?, ?, ?, ?, ?, ?, ?, 1) ON CONFLICT DO NOTHING RETURNING id`,
69+ )
70+ .bind(row.id, workspaceId, row.listing, row.version, row.plan, row.installed_by, row.installed_at)
71+ .first<{ id: string }>();
72+ return inserted ? ok(toInstall(row)) : fail("conflict", `${manifest.name} is installed already.`);
73+}
74+
75+/** Switches an install on or off. Off is the kill switch: it takes effect at once. */
76+export async function setEnabled(db: D1Database, workspaceId: string, listing: string, enabled: boolean, by: string, now = new Date()): Promise<Result<ExtensionInstall>> {
77+ const row = await db
78+ .prepare(
79+ `UPDATE extension_installs SET enabled = ?, disabled_by = ?, disabled_at = ?
80+ WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING ${COLUMNS}`,
81+ )
82+ .bind(enabled ? 1 : 0, enabled ? null : by, enabled ? null : now.toISOString(), workspaceId, listing)
83+ .first<Row>();
84+ return row ? ok(toInstall(row)) : fail("not_found", "That extension isn't installed.");
85+}
86+
87+/** Sets an install's monthly spend cap; null leaves it to the workspace's limit. */
88+export async function setBudget(db: D1Database, workspaceId: string, listing: string, micros: number | null): Promise<Result<ExtensionInstall>> {
89+ if (micros !== null && (!Number.isSafeInteger(micros) || micros < 0)) return fail("invalid", "A budget is a whole, non-negative number of micro-dollars.");
90+ const row = await db
91+ .prepare(`UPDATE extension_installs SET budget_monthly_micros = ? WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING ${COLUMNS}`)
92+ .bind(micros, workspaceId, listing)
93+ .first<Row>();
94+ return row ? ok(toInstall(row)) : fail("not_found", "That extension isn't installed.");
95+}
96+
97+/** Removes an install, keeping its row with who removed it and when. */
98+export async function uninstall(db: D1Database, workspaceId: string, listing: string, by: string, now = new Date()): Promise<Result<null>> {
99+ const row = await db
100+ .prepare("UPDATE extension_installs SET uninstalled_by = ?, uninstalled_at = ?, enabled = 0 WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING id")
101+ .bind(by, now.toISOString(), workspaceId, listing)
102+ .first<{ id: string }>();
103+ return row ? ok(null) : fail("not_found", "That extension isn't installed.");
104+}
105+
106+/** The extension id in an `extension:<id>` reference, or null. */
107+export function extensionIdOf(listing: unknown): string | null {
108+ const parsed = parseListing(listing);
109+ return parsed?.kind === "extension" ? parsed.id : null;
110+}
+193−3
1414 type AgentDelivery,
1515 type CardActionResult,
1616 type G1tEvent,
17+ type ExtensionInstall,
18+ type InstallRequest,
19+ type InstallRequests,
1720 type NewWorkspaceAgent,
1821 type Result,
1922 type ServiceBinding,
2326 askerAccess,
2427 awaitsConfirmation,
2528 chatClient,
29+ cleanRequestNote,
30+ extensionById,
2631 fail,
2732 identityClient,
2833 newId,
34+ notifyClient,
2935 ok,
3036 openD1,
3137 } from "@g1t/contracts";
4551 import { type SessionEnv, sweep } from "./sessions.ts";
4652 import * as views from "./views.ts";
4753 import { monthKey } from "./budget.ts";
54+import * as extensions from "./extensions.ts";
55+import { type Answered, type Person, answerLine, findListing, listRequests, listingPath, openRequest, requestsPath, resolveListing, resolveRequest } from "./installs.ts";
4856
4957 export { Desk } from "./desk.ts";
5058
213221 }
214222 this.audit(a.viewer!, a.workspace, "create_agent", definition.handle, `Created @${definition.handle} (version 1)`);
215223 this.defer(this.hello(a.workspace, managed.value, id, a.viewer!));
224+ // Hiring from a catalog role answers every request for that role.
225+ if (definition.template) this.defer(this.answerListing(a.workspace, managed.value, `agent:${definition.template}`, a.viewer!));
216226 const row = await this.row(managed.value, definition.handle);
217227 return ok(toAgent(row!, new Date()));
218228 }
219229
230+ // ── The Marketplace's install requests (./installs.ts) ─────────────────
231+
232+ /** Requests as the viewer sees them: every one for an owner, their own for anyone else. */
233+ async installRequests(a: { workspace: string; viewer: User | null }): Promise<Result<InstallRequests>> {
234+ if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
235+ const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
236+ if (!seen.ok) return seen;
237+ const owner = canManage(a.viewer, a.workspace);
238+ const requests = await listRequests(this.db, seen.value, a.viewer!, owner);
239+ return ok({ requests, can_resolve: owner });
240+ }
241+
242+ /** A member asks the owners to add something; each owner is notified. */
243+ async requestInstall(a: { workspace: string; viewer: User | null; listing: unknown; note?: unknown }): Promise<Result<InstallRequest>> {
244+ if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
245+ const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
246+ if (!seen.ok) return seen;
247+ const viewer = a.viewer!;
248+ if ((viewer.kind ?? "user") !== "user") return fail("forbidden", "Only people ask the workspace's owners to add things.");
249+ if (canManage(viewer, a.workspace)) return fail("invalid", "You're an owner of this workspace: add it yourself.");
250+ const listing = findListing(a.listing);
251+ if (!listing) return fail("not_found", "That isn't something a workspace can add yet.");
252+ const opened = await openRequest(this.db, seen.value, newId("ins"), listing, viewer, cleanRequestNote(a.note));
253+ if (!opened.ok) return opened;
254+ const slug = a.workspace.toLowerCase();
255+ this.audit(viewer, slug, "request_install", listing.ref, `Asked the owners to add ${listing.name}`, "marketplace");
256+ this.defer(this.tellOwners(slug, viewer, opened.value));
257+ return opened;
258+ }
259+
260+ /** An owner adds or turns down a request; whoever asked is told. */
261+ async resolveInstallRequest(a: { workspace: string; viewer: User | null; id: unknown; status: unknown }): Promise<Result<InstallRequest>> {
262+ const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
263+ if (!managed.ok) {
264+ return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners answer requests.") : managed;
265+ }
266+ if (a.status !== "done" && a.status !== "declined") return fail("invalid", "Answer a request with done or declined.");
267+ const answered = await resolveRequest(this.db, managed.value, String(a.id ?? ""), a.status, a.viewer!);
268+ if (!answered.ok) return answered;
269+ const slug = a.workspace.toLowerCase();
270+ const { request } = answered.value;
271+ this.audit(a.viewer!, slug, "answer_install_request", request.listing, `${request.status === "done" ? "Added" : "Turned down"} ${request.name} for @${request.requested_by}`, "marketplace");
272+ this.defer(this.tellRequester(slug, a.viewer!, answered.value));
273+ return ok(request);
274+ }
275+
276+ // ── Extensions installed in the workspace (./extensions.ts) ──────────────
277+
278+ /** The workspace's installs; any member sees them. */
279+ async extensionInstalls(a: { workspace: string; viewer: User | null }): Promise<Result<ExtensionInstall[]>> {
280+ if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
281+ const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
282+ if (!seen.ok) return seen;
283+ return ok(await extensions.listInstalls(this.db, seen.value));
284+ }
285+
286+ /** Owners install a published extension; whoever asked for it is told. */
287+ async installExtension(a: { workspace: string; viewer: User | null; extension: unknown }): Promise<Result<ExtensionInstall>> {
288+ const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
289+ if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners install extensions.") : managed;
290+ const installed = await extensions.install(this.db, extensionById, managed.value, newId("ins"), String(a.extension ?? ""), a.viewer!.username);
291+ if (!installed.ok) return installed;
292+ const slug = a.workspace.toLowerCase();
293+ this.audit(a.viewer!, slug, "install_extension", installed.value.listing, `Installed ${installed.value.listing} ${installed.value.version}`, "marketplace");
294+ this.defer(this.answerListing(slug, managed.value, installed.value.listing, a.viewer!));
295+ return installed;
296+ }
297+
298+ /** Owners switch an install on or off. */
299+ async setExtensionEnabled(a: { workspace: string; viewer: User | null; listing: unknown; enabled: unknown }): Promise<Result<ExtensionInstall>> {
300+ const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
301+ if (!managed.ok) return managed;
302+ const listing = String(a.listing ?? "");
303+ if (!extensions.extensionIdOf(listing)) return fail("invalid", "Name an extension as extension:<id>.");
304+ const changed = await extensions.setEnabled(this.db, managed.value, listing, a.enabled === true, a.viewer!.username);
305+ if (changed.ok) this.audit(a.viewer!, a.workspace, a.enabled === true ? "enable_extension" : "disable_extension", listing, `${a.enabled === true ? "Switched on" : "Switched off"} ${listing}`, "marketplace");
306+ return changed;
307+ }
308+
309+ /** Owners cap what an install spends a month. */
310+ async setExtensionBudget(a: { workspace: string; viewer: User | null; listing: unknown; monthly_micros: unknown }): Promise<Result<ExtensionInstall>> {
311+ const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
312+ if (!managed.ok) return managed;
313+ const listing = String(a.listing ?? "");
314+ const micros = a.monthly_micros == null ? null : Number(a.monthly_micros);
315+ const changed = await extensions.setBudget(this.db, managed.value, listing, micros);
316+ if (changed.ok) this.audit(a.viewer!, a.workspace, "set_extension_budget", listing, `Set ${listing}'s monthly budget`, "marketplace");
317+ return changed;
318+ }
319+
320+ /** Owners remove an install. */
321+ async uninstallExtension(a: { workspace: string; viewer: User | null; listing: unknown }): Promise<Result<null>> {
322+ const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
323+ if (!managed.ok) return managed;
324+ const listing = String(a.listing ?? "");
325+ const removed = await extensions.uninstall(this.db, managed.value, listing, a.viewer!.username);
326+ if (removed.ok) this.audit(a.viewer!, a.workspace, "uninstall_extension", listing, `Uninstalled ${listing}`, "marketplace");
327+ return removed;
328+ }
329+
330+ /** Marks every open request for `listing` added, and tells each person who asked. */
331+ private async answerListing(slug: string, workspaceId: string, listing: string, by: User): Promise<void> {
332+ try {
333+ const answered = await resolveListing(this.db, workspaceId, listing, by as Person);
334+ await Promise.all(answered.map((one) => this.tellRequester(slug.toLowerCase(), by, one)));
335+ } catch (error) {
336+ console.error("agents: requests for a listing were not answered", listing, String(error));
337+ }
338+ }
339+
340+ /** Every owner hears of a new request (at most 20 of them). */
341+ private async tellOwners(slug: string, asker: User, request: InstallRequest): Promise<void> {
342+ if (!this.env.NOTIFY) return;
343+ try {
344+ const members = await identityClient(this.env.IDENTITY).listMembers(slug, asker);
345+ if (!members.ok) throw new Error(members.error.message);
346+ const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
347+ const notify = notifyClient(this.env.NOTIFY);
348+ await Promise.all(
349+ owners.map((owner) =>
350+ notify
351+ .notify(
352+ { username: owner.username },
353+ {
354+ id: `install-request:${request.id}:${owner.username}`,
355+ kind: "approval",
356+ workspace: slug,
357+ title: `@${asker.username} asks you to add ${request.name}`,
358+ body: request.note ?? (request.kind === "agent" ? "An agent from the catalog. Add it, or turn the request down." : request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."),
359+ href: requestsPath(slug),
360+ actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null },
361+ created_at: new Date().toISOString(),
362+ },
363+ )
364+ .catch(() => undefined),
365+ ),
366+ );
367+ } catch (error) {
368+ console.error("agents: owners were not told of an install request", request.id, String(error));
369+ }
370+ }
371+
372+ /** The person who asked hears their request was answered. */
373+ private async tellRequester(slug: string, by: User, answered: Answered): Promise<void> {
374+ if (!this.env.NOTIFY) return;
375+ const { request } = answered;
376+ const line = answerLine(request, `@${by.username}`);
377+ await notifyClient(this.env.NOTIFY)
378+ .notify(
379+ { user_id: answered.requested_by_id },
380+ {
381+ id: `install-answer:${request.id}`,
382+ kind: "inbox",
383+ workspace: slug,
384+ title: line.title,
385+ body: line.body,
386+ href: request.status === "done" ? listingPath(slug, request.listing) : requestsPath(slug),
387+ actor: { kind: "user", id: by.id, name: by.username, avatar: by.avatar ?? null, avatar_seed: null },
388+ created_at: new Date().toISOString(),
389+ },
390+ )
391+ .catch((error: unknown) => console.error("agents: a requester was not told", request.id, String(error)));
392+ }
393+
220394 /**
221395 * A new agent's first words: the DM with the person who made it is
222396 * opened, and the agent says hello there in its own voice, as a reply
354528 * The events service's audit contract speaks camelCase (Rust's
355529 * `NewAuditEntry`).
356530 */
357− private audit(actor: User, workspace: string, action: string, handle: string, message: string): void {
531+ private audit(actor: User, workspace: string, action: string, handle: string, message: string, area: "agents" | "marketplace" = "agents"): void {
358532 const kind = actor.kind === "workspace" ? "workspace" : actor.kind === "agent" ? "agent" : actor.kind === "system" ? "system" : "person";
359533 const entry = {
360534 actorKind: kind,
371545 repo: null,
372546 number: null,
373547 gitRef: null,
374− path: `agents/${handle}`,
548+ path: `${area}/${handle}`,
375549 outcome: "allowed",
376− rule: "owner",
550+ rule: area === "marketplace" && action === "request_install" ? "member" : "owner",
377551 result: "ok",
378552 message,
379553 requestId: `req_${crypto.randomUUID()}`,
455629 return Response.json(await service.cardAction(args));
456630 case "policy":
457631 return Response.json(await service.view(args, (ctx) => views.policy(ctx)));
632+ case "install_requests":
633+ return Response.json(await service.installRequests(args));
634+ case "request_install":
635+ return Response.json(await service.requestInstall(args));
636+ case "resolve_install_request":
637+ return Response.json(await service.resolveInstallRequest(args));
638+ case "extension_installs":
639+ return Response.json(await service.extensionInstalls(args));
640+ case "install_extension":
641+ return Response.json(await service.installExtension(args));
642+ case "set_extension_enabled":
643+ return Response.json(await service.setExtensionEnabled(args));
644+ case "set_extension_budget":
645+ return Response.json(await service.setExtensionBudget(args));
646+ case "uninstall_extension":
647+ return Response.json(await service.uninstallExtension(args));
458648 case "set_policy":
459649 return Response.json(await service.view(args, (ctx) => views.setPolicy(ctx, args.policy)));
460650 default:
+126−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import { MAX_OPEN_REQUESTS, cleanRequestNote, parseListing } from "@g1t/contracts/marketplace";
7+
8+import { answerLine, findListing, listRequests, listingPath, openRequest, resolveListing, resolveRequest } from "./installs.ts";
9+
10+/** D1, as far as install requests use it, over node's SQLite with the service's migrations. */
11+function fakeD1(): D1Database {
12+ const db = new DatabaseSync(":memory:");
13+ const dir = new URL("../migrations/", import.meta.url);
14+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
15+ const statement = (sql: string, params: unknown[] = []) => ({
16+ bind: (...values: unknown[]) => statement(sql, values),
17+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
18+ run: async () => db.prepare(sql).run(...(params as never[])),
19+ all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
20+ });
21+ return { prepare: (sql: string) => statement(sql) } as unknown as D1Database;
22+}
23+
24+let ids = 0;
25+const nextId = () => `ins_${++ids}`;
26+const ws = "wsp_acme";
27+const ana = { id: "usr_ana", username: "ana" };
28+const bo = { id: "usr_bo", username: "bo" };
29+const owner = { id: "usr_own", username: "chase" };
30+const at = new Date("2026-10-10T12:00:00.000Z");
31+const later = (days: number) => new Date(at.getTime() + days * 86_400_000);
32+
33+test("only what a workspace can add today is a listing", () => {
34+ assert.deepEqual(findListing("agent:engineering"), { ref: "agent:engineering", kind: "agent", id: "engineering", name: "Software Engineer" });
35+ assert.equal(findListing("integration:sentry")?.name, "Sentry");
36+ assert.equal(findListing("agent:astronaut"), null, "no such role");
37+ assert.equal(findListing("integration:slack"), null, "Slack is planned, not available");
38+ assert.equal(findListing("integration:mcp"), null, "MCP clients are connected by each person, not a workspace");
39+ assert.equal(findListing("extension:support"), null, "no extensions are listed yet");
40+ assert.equal(findListing(42), null);
41+});
42+
43+test("references parse strictly, and notes are tidied", () => {
44+ assert.deepEqual(parseListing(" integration:alerts-webhook "), { kind: "integration", id: "alerts-webhook" });
45+ assert.equal(parseListing("agent:"), null);
46+ assert.equal(parseListing("agent:Engineering"), null);
47+ assert.equal(parseListing("agent:a b"), null);
48+ assert.equal(cleanRequestNote(" for on-call "), "for on-call");
49+ assert.equal(cleanRequestNote(" "), null);
50+ assert.equal(cleanRequestNote("x".repeat(400))?.length, 280);
51+ assert.equal(cleanRequestNote(null), null);
52+});
53+
54+test("a member's request is kept, and asking twice is one request", async () => {
55+ const db = fakeD1();
56+ const listing = findListing("agent:qa")!;
57+ const first = await openRequest(db, ws, nextId(), listing, ana, "Flaky checks", at);
58+ assert.ok(first.ok);
59+ assert.equal(first.value.status, "open");
60+ assert.equal(first.value.name, "QA Engineer");
61+ assert.equal(first.value.note, "Flaky checks");
62+ const again = await openRequest(db, ws, nextId(), listing, ana, null, later(0.1));
63+ assert.equal(again.ok, false);
64+ assert.equal(!again.ok && again.error.code, "conflict");
65+ // Someone else asking for the same thing is their own request.
66+ assert.ok((await openRequest(db, ws, nextId(), listing, bo, null, at)).ok);
67+});
68+
69+test("an owner sees every request; a member sees their own", async () => {
70+ const db = fakeD1();
71+ await openRequest(db, ws, nextId(), findListing("agent:qa")!, ana, null, at);
72+ await openRequest(db, ws, nextId(), findListing("integration:linear")!, bo, null, later(0.01));
73+ await openRequest(db, "wsp_other", nextId(), findListing("agent:docs")!, ana, null, at);
74+ assert.deepEqual((await listRequests(db, ws, owner, true, at)).map((r) => r.listing), ["integration:linear", "agent:qa"]);
75+ assert.deepEqual((await listRequests(db, ws, ana, false, at)).map((r) => r.listing), ["agent:qa"]);
76+});
77+
78+test("answering a request: once, by status, and answered ones age out", async () => {
79+ const db = fakeD1();
80+ const opened = await openRequest(db, ws, nextId(), findListing("integration:sentry")!, ana, null, at);
81+ assert.ok(opened.ok);
82+ const answered = await resolveRequest(db, ws, opened.value.id, "declined", owner, later(1));
83+ assert.ok(answered.ok);
84+ assert.equal(answered.value.request.status, "declined");
85+ assert.equal(answered.value.request.resolved_by, "chase");
86+ assert.equal(answered.value.requested_by_id, "usr_ana");
87+ const twice = await resolveRequest(db, ws, opened.value.id, "done", owner, later(1));
88+ assert.equal(!twice.ok && twice.error.code, "conflict");
89+ const elsewhere = await resolveRequest(db, "wsp_other", opened.value.id, "done", owner, later(1));
90+ assert.equal(!elsewhere.ok && elsewhere.error.code, "not_found");
91+ // Turned down, the person may ask again.
92+ assert.ok((await openRequest(db, ws, nextId(), findListing("integration:sentry")!, ana, null, later(2))).ok);
93+ // Answered requests stay listed for 30 days.
94+ assert.equal((await listRequests(db, ws, owner, true, later(20))).length, 2);
95+ assert.equal((await listRequests(db, ws, owner, true, later(40))).length, 1, "only the open one");
96+});
97+
98+test("adding a listing answers every open request for it", async () => {
99+ const db = fakeD1();
100+ await openRequest(db, ws, nextId(), findListing("agent:support")!, ana, null, at);
101+ await openRequest(db, ws, nextId(), findListing("agent:support")!, bo, null, at);
102+ await openRequest(db, ws, nextId(), findListing("agent:sales")!, bo, null, at);
103+ const answered = await resolveListing(db, ws, "agent:support", owner, later(1));
104+ assert.deepEqual(answered.map((a) => a.requested_by_id).sort(), ["usr_ana", "usr_bo"]);
105+ assert.ok(answered.every((a) => a.request.status === "done"));
106+ const open = (await listRequests(db, ws, owner, true, later(1))).filter((r) => r.status === "open");
107+ assert.deepEqual(open.map((r) => r.listing), ["agent:sales"]);
108+});
109+
110+test("a person keeps at most so many requests open", async () => {
111+ const db = fakeD1();
112+ // The catalog has fewer listings than the limit, so the same few are asked, answered and asked again.
113+ const listings = ["agent:engineering", "agent:qa", "agent:operations", "agent:docs", "agent:product", "agent:support", "agent:sales", "integration:sentry", "integration:datadog", "integration:linear", "integration:jira", "integration:github", "integration:webhooks", "integration:alerts-webhook", "integration:anthropic", "integration:openai", "integration:gemini", "integration:xai", "integration:mistral", "integration:deepseek"];
114+ assert.equal(listings.length, MAX_OPEN_REQUESTS);
115+ for (const ref of listings) assert.ok((await openRequest(db, ws, nextId(), findListing(ref)!, ana, null, at)).ok, ref);
116+ const over = await openRequest(db, ws, nextId(), findListing("integration:groq")!, ana, null, at);
117+ assert.equal(!over.ok && over.error.code, "limit");
118+});
119+
120+test("what the asker is told, and where it leads", () => {
121+ const base = { id: "ins_1", listing: "agent:qa", kind: "agent" as const, name: "QA Engineer", note: null, requested_by: "ana", requested_at: at.toISOString(), resolved_by: "chase", resolved_at: at.toISOString() };
122+ assert.equal(answerLine({ ...base, status: "done" }, "@chase").title, "QA Engineer was added to the workspace");
123+ assert.match(answerLine({ ...base, status: "declined" }, "@chase").title, /@chase turned down your request for QA Engineer/);
124+ assert.equal(listingPath("acme", "agent:qa"), "/acme/-/marketplace/agents/qa");
125+ assert.equal(listingPath("acme", "integration:sentry"), "/acme/-/marketplace/integrations");
126+});
+199−0
1+/**
2+ * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a
3+ * member asks the workspace's owners to add an agent from the catalog or
4+ * an integration, and an owner adds it or turns it down. Kept in
5+ * `install_requests`, one open request per person and listing.
6+ *
7+ * Only what can be added today can be asked for: a role from the agent
8+ * catalog (./templates.ts), or a connector the catalog marks available for
9+ * a workspace (@g1t/contracts/connectors). Who is told, and how, is
10+ * index.ts's; this file is the rules and the table, tested on SQLite.
11+ */
12+import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts";
13+import { CONNECTORS } from "@g1t/contracts/connectors";
14+import { ANSWERED_REQUESTS_DAYS, MAX_OPEN_REQUESTS, extensionById, listingRef, parseListing } from "@g1t/contracts/marketplace";
15+
16+// Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests.
17+const ok = <T>(value: T): Result<T> => ({ ok: true, value });
18+const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
19+
20+import { TEMPLATES } from "./templates.ts";
21+
22+/** Something the workspace can add, as a request names it. */
23+export type Listing = { ref: string; kind: ListingKind; id: string; name: string };
24+
25+/** The listing `value` names, when it is one that can be added today; null otherwise. */
26+export function findListing(value: unknown): Listing | null {
27+ const parsed = parseListing(value);
28+ if (!parsed) return null;
29+ if (parsed.kind === "agent") {
30+ const template = TEMPLATES.find((t) => t.id === parsed.id);
31+ return template ? { ref: listingRef("agent", template.id), kind: "agent", id: template.id, name: template.title } : null;
32+ }
33+ if (parsed.kind === "extension") {
34+ const extension = extensionById(parsed.id);
35+ return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null;
36+ }
37+ const connector = CONNECTORS.find((c) => c.id === parsed.id);
38+ if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null;
39+ return { ref: listingRef("integration", connector.id), kind: "integration", id: connector.id, name: connector.name };
40+}
41+
42+type Row = {
43+ id: string;
44+ listing: string;
45+ name: string;
46+ note: string | null;
47+ requested_by: string;
48+ requested_by_id: string;
49+ requested_at: string;
50+ status: string;
51+ resolved_by: string | null;
52+ resolved_at: string | null;
53+};
54+
55+const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at";
56+
57+export function toRequest(row: Row): InstallRequest {
58+ return {
59+ id: row.id,
60+ listing: row.listing,
61+ kind: parseListing(row.listing)?.kind ?? "agent",
62+ name: row.name,
63+ note: row.note,
64+ requested_by: row.requested_by,
65+ requested_at: row.requested_at,
66+ status: (["open", "done", "declined"].includes(row.status) ? row.status : "open") as InstallRequestStatus,
67+ resolved_by: row.resolved_by,
68+ resolved_at: row.resolved_at,
69+ };
70+}
71+
72+/** Who is asking or answering. */
73+export type Person = { id: string; username: string };
74+
75+/**
76+ * Requests as `viewer` sees them: every one in the workspace for an owner,
77+ * their own for anyone else. Open ones first, then the latest; answered
78+ * ones for `ANSWERED_REQUESTS_DAYS`. At most 200.
79+ */
80+export async function listRequests(db: D1Database, workspaceId: string, viewer: Person, owner: boolean, now = new Date()): Promise<InstallRequest[]> {
81+ const since = new Date(now.getTime() - ANSWERED_REQUESTS_DAYS * 86_400_000).toISOString();
82+ const mine = owner ? "" : " AND requested_by_id = ?3";
83+ const statement = db.prepare(
84+ `SELECT ${COLUMNS} FROM install_requests
85+ WHERE workspace_id = ?1 AND (status = 'open' OR resolved_at >= ?2)${mine}
86+ ORDER BY status = 'open' DESC, requested_at DESC LIMIT 200`,
87+ );
88+ const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id);
89+ const { results } = await bound.all<Row>();
90+ return results.map(toRequest);
91+}
92+
93+/**
94+ * Opens a request for `listing`, as `id`. A conflict when the person has
95+ * one open for it already; a limit when they have `MAX_OPEN_REQUESTS` open.
96+ */
97+export async function openRequest(
98+ db: D1Database,
99+ workspaceId: string,
100+ id: string,
101+ listing: Listing,
102+ by: Person,
103+ note: string | null,
104+ now = new Date(),
105+): Promise<Result<InstallRequest>> {
106+ const open = await db
107+ .prepare("SELECT COUNT(*) AS n FROM install_requests WHERE workspace_id = ? AND requested_by_id = ? AND status = 'open'")
108+ .bind(workspaceId, by.id)
109+ .first<{ n: number }>();
110+ if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS) {
111+ return fail("limit", `You have ${MAX_OPEN_REQUESTS} requests waiting on an owner. Ask again once some are answered.`);
112+ }
113+ const row: Row = {
114+ id,
115+ listing: listing.ref,
116+ name: listing.name,
117+ note,
118+ requested_by: by.username,
119+ requested_by_id: by.id,
120+ requested_at: now.toISOString(),
121+ status: "open",
122+ resolved_by: null,
123+ resolved_at: null,
124+ };
125+ // The unique index on open requests turns a second press into nothing.
126+ const inserted = await db
127+ .prepare(
128+ `INSERT INTO install_requests (id, workspace_id, listing, name, note, requested_by, requested_by_id, requested_at, status)
129+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') ON CONFLICT DO NOTHING RETURNING id`,
130+ )
131+ .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at)
132+ .first<{ id: string }>();
133+ if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`);
134+ return ok(toRequest(row));
135+}
136+
137+/** A request answered, with who asked, for telling them. */
138+export type Answered = { request: InstallRequest; requested_by_id: string };
139+
140+/** An owner adds (`done`) or turns down (`declined`) an open request. */
141+export async function resolveRequest(
142+ db: D1Database,
143+ workspaceId: string,
144+ id: string,
145+ status: Exclude<InstallRequestStatus, "open">,
146+ by: Person,
147+ now = new Date(),
148+): Promise<Result<Answered>> {
149+ if (status !== "done" && status !== "declined") return fail("invalid", "Answer a request with done or declined.");
150+ const row = await db
151+ .prepare(
152+ `UPDATE install_requests SET status = ?, resolved_by = ?, resolved_at = ?
153+ WHERE id = ? AND workspace_id = ? AND status = 'open' RETURNING ${COLUMNS}`,
154+ )
155+ .bind(status, by.username, now.toISOString(), id, workspaceId)
156+ .first<Row>();
157+ if (row) return ok({ request: toRequest(row), requested_by_id: row.requested_by_id });
158+ const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>();
159+ return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request.");
160+}
161+
162+/**
163+ * Every open request for `listing`, marked added: an owner added it, so
164+ * whoever asked for it has what they asked for.
165+ */
166+export async function resolveListing(db: D1Database, workspaceId: string, listing: string, by: Person, now = new Date()): Promise<Answered[]> {
167+ const { results } = await db
168+ .prepare(
169+ `UPDATE install_requests SET status = 'done', resolved_by = ?, resolved_at = ?
170+ WHERE workspace_id = ? AND listing = ? AND status = 'open' RETURNING ${COLUMNS}`,
171+ )
172+ .bind(by.username, now.toISOString(), workspaceId, listing)
173+ .all<Row>();
174+ return results.map((row) => ({ request: toRequest(row), requested_by_id: row.requested_by_id }));
175+}
176+
177+/** Where a request is looked at in the workspace `slug`. */
178+export function requestsPath(slug: string): string {
179+ return `/${slug}/-/marketplace/requests`;
180+}
181+
182+/** Where the thing a request asked for is, once added. */
183+export function listingPath(slug: string, listing: string): string {
184+ const parsed = parseListing(listing);
185+ if (parsed?.kind === "agent") return `/${slug}/-/marketplace/agents/${parsed.id}`;
186+ if (parsed?.kind === "extension") return `/${slug}/-/marketplace/extensions/${parsed.id}`;
187+ return `/${slug}/-/marketplace/integrations`;
188+}
189+
190+/** What the person who asked is told when their request is answered. */
191+export function answerLine(request: InstallRequest, by: string): { title: string; body: string } {
192+ if (request.status === "declined") {
193+ return { title: `${by} turned down your request for ${request.name}`, body: "Ask them in chat if you want to know why." };
194+ }
195+ return {
196+ title: `${request.name} was added to the workspace`,
197+ body: request.kind === "agent" ? `${by} added it from the agent catalog.` : request.kind === "extension" ? `${by} installed it.` : `${by} connected it.`,
198+ };
199+}