moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,249 @@
# PR4 Recon · Slice 1 — Current Marketplace Front-End
> Read-only recon for PR4 (warm-Hive). Maps the **current** Marketplace FE against the
> screen-09 contract (Variation A browse+ask, Variation B inline-in-chat, shared install
> "sync" store). Cites real code; flags the delta; names exact integration points.
> Branch: `feature/warm-hive-pr4`. No source was modified.
---
## 1. What exists today
The Marketplace surface today is the **UX-Refactor Phase-4B "consolidated Extend
surface"** — a faceted *package/extension browser*, NOT the agent-searchable shelf the
design contract describes. There are two real surfaces:
- **A grid browser** — `MarketplaceApp.tsx` (route `/marketplace``MarketplaceRoute.tsx``SurfaceBoundary`).
- **An inline-in-chat install card** — `CapabilityRequestCard.tsx`, parsed out of agent
text by `capability-request-parser.ts` (`segmentText`) inside `TextBlock`.
These two surfaces are **independent** — they do **not** share an install store, do not
share micro-state vocabulary, and do not cross-reflect (installing in one does not update
the other). That is the single biggest structural gap vs the screen-09 "sync" mandate.
### 1a. The grid browser — `MarketplaceApp.tsx`
Layout (top to bottom), all in one file, 418 LOC:
- **Header** (`apps/web/src/components/os/apps/MarketplaceApp.tsx:273-331`): a `Store` icon
(honey via `var(--honey-500)`), title "Marketplace", and a right-aligned
`"{visible.length} extensions"` count (`:277`). This count is a *render-time array
length*, NOT a per-workspace "N in this workspace" chip bar.
- **Two tabs** — `Browse | Audit` (`:283-297`), `role="tablist"`. Audit renders
`InstallAuditPanel` (the C18 shared install-audit feed). Browse is the grid.
- **B7 facet rail** (`:302-317`): pills for `['all', ...EXTENSION_TYPES]` =
**All · Skills · Agents · Connectors · MCPs · Models · Templates** (7 facets, labels in
`FACET_LABELS` `:37-45`). `EXTENSION_TYPES` is canonical in
`packages/shared/src/types.ts:375-378` (`skill·agent·connector·mcp·model·template`).
- **A keyword search input** (`:319-328`) — a plain `<Input>` with a `Search` icon,
placeholder "Search skills, agents, connectors, MCPs, models, templates…". Debounced
300ms (`:202-209`); marketplace facets re-query the server, others client-filter.
- **Body** (`:334-385`): federated-provenance note (`:339-343`), loading / error+Retry /
empty states (honest, not fake-empty), then a vertical list of `<ExtensionCard>`.
Per-card UI — `ExtensionCard.tsx`:
- A generic `Package` icon (NOT a per-kind badge), name, an Installed/Available
`StatusBadge`, optional scan-status badge.
- A row of small text chips: **`ext.type`** (this is the only "kind badge" today — a plain
text pill `:44`), optional category, optional trust, and a muted `ext.source` label.
- A single right-side action that is **lifecycle/kind-aware but NOT type-aware**:
- installable + not installed → **"Install"** (Download icon) — same label for every type.
- installable + installed (kind `package`) → **"Remove"** (Trash icon, destructive).
- federated (connector/agent/mcp-catalog/model/template) → **"Open in <app>"** deep-link
(`ExternalLink`), e.g. "Connector Hub", "Agent Center", "MCP Hub" (`:72-79`).
- There is **no install count** rendered, no progress→done micro-states beyond a single
spinner on the Install button (`installing``<Loader2>` `:68`).
### 1b. Data flow — federate-at-read (A5)
The pure merge/normalize layer is `apps/web/src/lib/extension-catalog.ts` (no adapter
import — unit-testable). `loadFacet` (`MarketplaceApp.tsx:116-188`) fans out to per-domain
adapter calls based on the active facet, `Promise.allSettled`-merges, then
`sortExtensions`. The mapping (from the file header `extension-catalog.ts:7-14`):
| facet | adapter call | normalizer | install path | installable |
|-----------|------------------------------------------------|-----------------------|---------------------|-------------|
| skill | `getMarketplace({type:'skill'})` + `getMarketplacePacks()` | `fromMarketplacePackage` / `fromSkillPack` | real (POST /api/marketplace/install) | pkg ✓ / pack ✗ |
| mcp | `getMcps()` + `getMarketplace({type:'mcp'})` | `fromMcpCatalogRow` / `fromMarketplacePackage` | catalog → MCP Hub; registry-pkg → real | catalog ✗ / pkg ✓ |
| agent | `getPersonas()` | `fromPersona` | local (Agent Center) | ✗ |
| connector | `getConnectors()` | `fromConnector` | local (Connector Hub)| ✗ |
| model | `getModels()` | `fromModel` | local (Settings) | ✗ |
| template | `getWorkspaceTemplates()` | `fromTemplate` | local (Home) | ✗ |
Install actions (`MarketplaceApp.tsx`):
- `handleInstall` (`:224-251`) — only `kind==='package'` installs here; goes through the
shared `ApprovalModal` first (`buildInstallRequest` `:76-92`, scan-derived risk via
`installRiskFor`/`classifyInstallRisk`). 403 `TIER_INSUFFICIENT` → dispatches
`waggle:tier-insufficient` (UpgradeModal); 403 SecurityGate block → destructive toast.
On success it **optimistically flips local state** (`:231`) — this is the *only* "sync",
and it is purely local to this component's `extensions` array.
- `handleUninstall` (`:253-262`) — confirmed via a second `ApprovalModal`
(`buildRemoveRequest` `:65-74`).
- `handleOpenIn` (`:264-266`) — dispatches `waggle:open-app` for federated deep-links.
Adapter methods consumed (all in `apps/web/src/lib/adapter.ts`):
`getMarketplace` (`:2082-2092`, `/api/marketplace?type=…`), `getMarketplacePacks`
(`:1309-1313`), `installMarketplacePackage` (`:1338-1343`, `fetchRaw` so status survives),
`uninstallMarketplacePackage` (`:1345-1350`), `searchMarketplace` (`:1334-1336`, used by
the inline card), `getMcps` (`:2004-2011`), `getConnectors` (`:1958-1964`),
`connectConnector` (`:1971-1979`, `/api/connectors/:id/connect`), `installMcp`
(`:2017-2029`, `/api/mcps/install`), `getPersonas`, `getModels`, `getWorkspaceTemplates`,
`getExtendAudit` (`:2095-2103`, C18 audit feed).
### 1c. The inline-in-chat card — `CapabilityRequestCard.tsx`
Parsed from agent text by `capability-request-parser.ts:segmentText` — two patterns: a
structured HTML marker `<!--waggle:capability_request {...}-->` (Pattern A) and a legacy
`` `install_capability` with name "X" and source "Y" `` phrasing (Pattern B). Card shows
name, a source pill, optional `reason` ("why"), and Install/Dismiss. Phases:
`pending → installing → installed | declined | failed` (`:17`). For marketplace kind it
**resolves name → packageId via `searchMarketplace`** then `installMarketplacePackage`;
for skill kind it calls `adapter.installPack`. 403 → `waggle:tier-insufficient`.
This card is the seed of screen-09 Variation B but it is **skill/marketplace-only** (no
connector or MCP kind), has **no vault-aware approval** ("token goes to your vault"), no
"connected follow-up", and shares **no state** with the grid.
### 1d. Test coverage — `phase4b-marketplace-extend.test.tsx`
11 tests, all green, pinning the *current* Phase-4B contract: federate-at-read of all six
domains, honest federated notes, browse-only packs (no pack install testid), registry-MCP
packages install through the real route, ApprovalModal-gated install with scan-derived
risk, 403-tier-vs-403-securitygate split, all-backends-down error+Retry, Remove confirm,
failed-uninstall does-not-flip-installed, and the Audit tab type filter. **Any PR4 rework
of MarketplaceApp must update / supersede these** — they assert the current testids
(`extension-install-pkg:7`, `extension-facets`, `federated-note`) and the
`{ type, limit:30 }` call shape.
---
## 2. Gap vs screen-09 Variation A (the delta)
| screen-09 element | current state | delta |
|---|---|---|
| **Centered agent-search bar** ("Describe what you want to do…" → "Ask the agent") | plain keyword `<Input>` that filters/queries the registry; no agent round-trip | MISSING — no NL→agent-suggestion call; search is literal substring/registry-`?query=`. |
| **Example chips** | none | MISSING. |
| **Agent-suggestion box** (recommends a connector + skill + tool, each with a "why" + install button) | none in the grid (only the unrelated inline `CapabilityRequestCard` carries a `reason`) | MISSING — no suggestion surface, no "why" reason rendered in the grid, no per-suggestion install. |
| **"N in this workspace" count bar (chips)** | a single render-time `"{visible.length} extensions"` text label `:277` | PARTIAL/WRONG semantics — it counts *visible filtered rows*, not *installed-in-this-workspace* capabilities, and is not a chip bar. |
| **Category filter All/Skills/Connectors/MCP** | 7-facet rail All/Skills/Agents/Connectors/MCPs/Models/Templates | SUPERSET — current has the 3 design facets *plus* Agents/Models/Templates. Design shows 4 (All/Skills/Connectors/MCP). PR4 must decide: collapse to the design's 3-shelf framing or keep the 6-domain superset. |
| **Card grid** (kind badge + name + desc + **install count** + Add button) | vertical *list* of cards; "kind badge" is a plain text `ext.type` pill; **no install count**; single "Install"/"Remove"/"Open in" action | PARTIAL — needs a real grid, a styled kind badge, an install-count field, and **type-aware** primary actions. |
| **Type-aware one-click flows** (skill Add→Adding…→Added; connector Connect→Signing in…→Connected ~1.1s token→vault; MCP Enable→Enabling…→Enabled) | one generic "Install" label + single spinner; connectors/MCPs are *federated deep-links* (Open-in), not in-place actions | MISSING — no per-type verb, no progress→done micro-states, no in-place connect/enable; connector/MCP installs currently *leave* the Marketplace. |
| **Toast + count-bar update per action** | install toasts fire; count bar does not track installed-count | PARTIAL — toasts ✓, count-bar-update ✗. |
| **Shared "sync" store** (one store powers grid + agent-pick + inline card; install anywhere reflects everywhere) | grid keeps a local `extensions` array; inline card keeps its own `phase`; no shared store, no cross-reflect | MISSING — the headline architectural gap. |
### Variation B (inline) delta
- `CapabilityRequestCard` exists but is **skill/marketplace-only** — no connector / MCP
kinds. Needs the same type-aware verbs as the grid (Connect/Enable).
- **No vault-aware approval** copy ("token goes to your vault"). The warm
`InlineApprovalCard` (`components/os/warm/InlineApprovalCard.tsx`) is the obvious
adoption target — it already renders `--honey-wash` + risk vocabulary + Approve/Not-now,
and connectors carry credentials via `connectConnector(id, {token,…})`.
- **No "connected follow-up"** state distinct from generic "Installed".
### Data availability for "install count"
The marketplace DB schema **does** persist a `downloads` (and `stars`) column
(`packages/server/src/local/routes/marketplace.ts:860`, `upsertPackage`), so an install
count is *backed by data*. But it is **not surfaced**: the FE `MarketplacePackageRow`
(`extension-catalog.ts:54-65`) omits `downloads`, `getMarketplace` returns
`packages: unknown[]`, and `Extension` has no `installs` field. PR4 would thread
`downloads` → `Extension.installCount` → `ExtensionCard`.
---
## 3. Warm-Hive tokens / components to adopt
MarketplaceApp/ExtensionCard are still on the **legacy semantic-token** vocabulary
(`bg-secondary/20`, `text-muted-foreground`, `border-border/30`, `var(--honey-500)`,
`bg-primary`). The warm-Hive PR1+ vocabulary they should migrate to:
- **Tokens** (defined `apps/web/src/waggle-theme.css`, used across `warm/*`):
`--honey`, `--honey-wash`, `--honey-line`, `--shadow-honey`, `--surface`, `--line`,
`--line-strong`, `--text`, `--text-2`, `--text-dim`, `--attention`.
- **`AskBar`** (`components/os/warm/AskBar.tsx`) — the warm full-width ask pill (honey "+",
⌘K hint, honey send). This is the direct fit for the screen-09 "centered agent-search
bar" (swap placeholder to "Describe what you want to do…", wire submit → agent
suggestion instead of free chat).
- **`InlineApprovalCard`** (`components/os/warm/InlineApprovalCard.tsx`) — the vault-aware
honey approval card for Variation B's connector token→vault approval.
- **`SectionLabel`**, **`IconTile`/`HexCheckTile`**, **`StatusBadge`**
(`components/ui/status-badge.tsx`, already used) for kind badges / count chips.
- **`StreakChip`/`RunChip`** patterns (`components/os/warm/`) as the visual idiom for the
"N in this workspace" chip bar.
- All warm atoms are barrel-exported from `apps/web/src/components/os/warm/index.ts`.
---
## 4. Exact integration points a PR4 build would touch
- **Grid host**: `apps/web/src/components/os/apps/MarketplaceApp.tsx` — `MarketplaceApp`
component; `loadFacet` (fan-out/merge), `handleInstall`/`handleUninstall`/`handleOpenIn`,
`buildInstallRequest`/`buildRemoveRequest`/`installRiskFor`.
- **Card**: `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` — `ExtensionCard`
(add kind badge styling, install count, type-aware action verbs + micro-states).
- **View-model + normalizers**: `apps/web/src/lib/extension-catalog.ts` — `Extension`
interface (add `installCount`, an `installState` micro-phase, make connector/mcp
`installable`), `fromConnector`/`fromMcpCatalogRow`/`fromMarketplacePackage`,
`filterExtensions`, `sortExtensions`.
- **Inline card**: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx`
+ `capability-request-parser.ts` (`segmentText`, `CapabilityRequest`) — extend `kind`
to `'connector' | 'mcp'`, wire `connectConnector`/`installMcp`, vault-aware approval.
- **Shared store (NEW)**: nothing exists today. A PR4 "sync" store would be a new module
(e.g. a context/zustand/event-bus in `apps/web/src/lib/` or `providers/`) that both
`MarketplaceApp` and `CapabilityRequestCard` subscribe to; cross-reflect can also reuse
the existing `window.dispatchEvent('waggle:*')` event idiom already used for
`waggle:open-app` and `waggle:tier-insufficient`.
- **Adapter (existing, reuse)**: `apps/web/src/lib/adapter.ts` — `getMarketplace`,
`installMarketplacePackage`, `uninstallMarketplacePackage`, `searchMarketplace`,
`getConnectors`, `connectConnector`, `getMcps`, `installMcp`, `getExtendAudit`.
- **Types**: `packages/shared/src/types.ts` — `EXTENSION_TYPES` / `ExtensionType`
(`:375-378`) is the canonical facet list; decide whether the design's 4-facet shelf
collapses or wraps this 6-domain tuple.
- **Server (read-only context)**: `packages/server/src/local/routes/marketplace.ts`
(`/api/marketplace`, `/install`, `/uninstall`, `downloads` column),
`packages/server/src/local/routes/extend.ts` (C18 audit).
- **Route wrapper**: `apps/web/src/routes/MarketplaceRoute.tsx` (unchanged unless the shell
framing changes).
- **Tests to supersede**: `apps/web/src/test/phase4b-marketplace-extend.test.tsx`.
---
## 5. Risks
- **Test churn**: 11 existing tests pin current testids and the `{type,limit:30}` call
shape; a Variation-A rework rewrites most of them.
- **Facet framing decision**: design says 3 shelves (Skills/Connectors/MCP); code has 6
domains incl. Agents/Models/Templates with honest federated notes. Collapsing loses the
honesty work; keeping diverges from the design's "one simple shelf".
- **In-place connect/enable vs deep-link**: design wants connectors/MCPs installed *in the
Marketplace* (Connect→Connected, Enable→Enabled). Today they are federated deep-links to
Connector Hub / MCP Hub which own the real OAuth/security-scan/approval flows
(`connectConnector`, `installMcp` w/ SecurityGate). Doing it in-place must NOT bypass
those flows — risk of duplicating or weakening security gating.
- **"Sync" consistency**: a shared store must reconcile optimistic flips with server truth
(install can 403/securitygate-block after the optimistic flip) across both surfaces.
- **Install count truthfulness**: `downloads` is local-single-user and often 0; rendering
it as a social "install count" may be misleading (mind-isolation / honest-stats ethos
the repo enforces elsewhere).
---
## 6. Open questions for the lead
1. **Facets**: collapse to the design's All/Skills/Connectors/MCP (3 shelves), or keep the
6-domain superset (Agents/Models/Templates) with federated notes?
2. **In-place vs deep-link** for connector/MCP: do Connect/Enable happen *inside*
Marketplace (new in-place flow that must reuse the Hub security/OAuth paths), or stay
deep-links? The design clearly wants in-place.
3. **Shared "sync" store** mechanism: new context/store module vs reuse the existing
`window` CustomEvent bus? What's the source of truth (server re-read vs optimistic +
reconcile)?
4. **"N in this workspace"**: counts *installed* capabilities (which backend gives the
per-workspace installed set?) — is `getExtendAudit` / a capabilities-status read the
source, or a new `/api/marketplace?installed=true&workspace=…`?
5. **Install count**: surface `downloads` honestly, or omit it given local-single-user
data is ~0 and the repo's honest-stats ethos?
6. **Agent-suggestion box**: which backend produces the connector+skill+tool
recommendation with a "why"? Is there an agent/LLM route to call, or is this a curated
heuristic over the registry?
7. **Inline Variation B**: extend `CapabilityRequestCard` in place, or replace it with a
shared picker component used by both grid and chat?

View File

@@ -0,0 +1,183 @@
# PR4 Recon — Slice 2: Marketplace Backend + Routes
**Design contract:** SCREENS.md §09 (Marketplace — skills + connectors + MCP as one shelf, agent-searchable).
**PR4 scope (BUILD-PLAN.md §6, line 142):** "Marketplace + **shared install store ('sync')** (grid + agent-pick + inline-in-chat)" → `MarketplaceApp`, **new install store**, screen 09.
**Branch:** `feature/warm-hive-pr4`. **Mode:** READ ONLY (no source touched).
This doc maps the *existing* backend territory a PR4 "shared install store (sync)" would build ON. It does not propose the plan.
---
## 0. TL;DR for the synthesizer
There is **no single install store today**. The §09 contract assumes *one* store powering grid + agent-pick + inline-card, with type-aware micro-states (skill Add→Adding→Added, connector Connect→Signing in→Connected token→vault, MCP Enable→Enabling→Enabled) and a workspace-scoped "N in this workspace" count bar. The actual backend splits install state across **three independent persistence layers**, none of which agree on a shared shape, and **none of which are workspace-scoped**:
| Kind | "Is it installed?" source of truth | Install verb / route | Workspace-scoped? |
|---|---|---|---|
| **skill** / **plugin** / **mcp** (marketplace pkg) | `installations` table in `marketplace.db` (`status='installed'`), keyed by **numeric `package_id`** | `POST /api/marketplace/install` | **No** — global |
| **mcp** (runtime) | `<dataDir>/.mcp.json` ⋈ live `McpRuntime` server states, keyed by **string server name** | `POST /api/mcps/install` (delegates to marketplace) | **Partial** — a server carries one optional `workspaceId` tag (C19), but install presence is global |
| **connector** | **Vault** credential under `connector:{id}` (presence = "connected"), keyed by **string connector id** | `POST /api/connectors/:id/connect` (token→vault) | **No** — vault is single-user/global |
The install-audit trail (`install_audit` table) **is the only thing that already unifies all three** as a write-side feed — but it is an append-only *log*, not a queryable "installed set," and it has **no `workspace_id` column** (schema.ts:135-152). So the count-bar's "N in this workspace" claim has **no backing data today**; it would either need a new scoping column or be redefined as a global count.
The current FE (`MarketplaceApp.tsx`) already does federate-at-read across 6 domains but holds install state only in **local React `useState`** — installing patches `extensions[]` in place; there is no cross-view store, so "installing in any view reflects in all" (the §09 CRITICAL line) is **not satisfied** today.
---
## 1. Marketplace DB schema (`packages/marketplace/src/db.ts` + types.ts)
`MarketplaceDB` wraps a `better-sqlite3` handle at `~/.waggle/marketplace.db` (overridable; the server opens `<dataDir>/marketplace.db`, seeded by copy from `packages/marketplace/marketplace.db` — server/local/index.ts:457-506). WAL mode, FK on.
### Tables (inferred from queries — there is **no `CREATE TABLE` DDL in the TS source**; the schema ships pre-built inside the committed `marketplace.db` seed file)
- **`packages`** — the catalog. Columns referenced in code (db.ts, types.ts `MarketplacePackage`): `id` (PK), `source_id` (FK→sources), `name`, `display_name`, `description`, `author`, `package_type` (`skill|plugin|mcp_server|template|pack`), **`waggle_install_type`** (`skill|plugin|mcp` — the dispatch discriminant), `waggle_install_path`, `version`, `license`, `repository_url`, `homepage_url`, `downloads`, `stars`, `rating`, `rating_count`, `category`, `subcategory`, `install_manifest` (JSON), `platforms` (JSON), `min_waggle_version`, `dependencies` (JSON), `packs` (JSON), `created_at`, `updated_at`. **Plus security columns** added by the installer's `recordScanResult()` (types.ts:81-90 `PackageSecurityColumns`): `security_status`, `security_score`, `last_scanned_at`, `content_hash`, `scan_engines`, `scan_findings`, `scan_blocked`.
- **`packages_fts`** — FTS5 virtual table joined on `p.id = fts.rowid` for full-text search (db.ts:103). `rank` column used for relevance ordering.
- **`sources`** — catalog provenance. Columns (db.ts:271-283, types.ts `MarketplaceSource`): `id`, `name`, `display_name`, `url`, `source_type`, `platform`, `total_packages`, `install_method`, `api_endpoint`, `description`, `last_synced_at`, **`is_custom`** (auto-migrated, db.ts:57-67), **`sync_state`** (auto-migrated JSON for resumable sync, db.ts:69-78).
- **`packs`** — capability bundles. Columns (types.ts `MarketplacePack`): `id`, `slug`, `display_name`, `description`, `target_roles`, `icon`, `priority` (`core|recommended|optional`), `connectors_needed` (JSON), `created_at`.
- **`pack_packages`** — pack↔package join with `is_core` flag (db.ts:191-194).
- **`installations`** — **THE install-state source of truth for marketplace packages** (db.ts:350-404). Columns (types.ts `Installation`): `id`, `package_id` (FK), `installed_version`, `installed_at`, `install_path`, `status` (`installed|updating|failed|uninstalled`), `config` (JSON — secret VALUES redacted to `[redacted]`, keys kept; installer.ts:164-173). **No `workspace_id`.**
- **`scan_history`** — append-only security scan log (installer.ts:644-659): `package_id`, `scanned_at`, `overall_severity`, `security_score`, `content_hash`, `engines_used`, `findings`, `blocked`, `scan_duration_ms`, `triggered_by`.
### Install-state API on `MarketplaceDB` (the methods a sync store would call)
- `recordInstallation(packageId, version, installPath, config)` → inserts `installations` row, `status='installed'` (db.ts:350).
- `isInstalled(packageId): boolean``SELECT 1 FROM installations WHERE package_id=? AND status='installed'` (db.ts:390). **This is the per-package installed check.**
- `markUninstalled(packageId)` → flips status to `'uninstalled'` (db.ts:400).
- `listInstallations(): InstalledPackageRow[]` → joins `installations``packages` for `status='installed'`, ordered by `installed_at DESC` (db.ts:376).
- `getInstalledCount(): number`**global** count of `status='installed'` rows (db.ts:500). This is the only existing "count bar" primitive — **not workspace-aware**.
- `getPackage(id)` / `getPackageByName(name)` — by numeric id / string name.
- `search(SearchOptions): SearchResult` — FTS5 + faceted filters; returns `{ packages, total, facets:{types,categories,sources}, installedCount }` (db.ts:86-165). `installedCount` here is the global `getInstalledCount()`.
---
## 2. List/search/install/uninstall APIs (`packages/server/src/local/routes/marketplace.ts`)
Registered as `marketplaceRoutes(fastify)` (server/local/index.ts:95). Every route gates on `fastify.marketplace` (the decorated `MarketplaceDB|null`, index.ts:506); `requireDb()` returns **503** if absent.
| Method · Route | Purpose | Tier gate | Notes |
|---|---|---|---|
| `GET /api/marketplace/search` | FTS5 + facet search of the catalog | none | Annotates each pkg with `installed: db.isInstalled(pkg.id)`, `scanStatus`, `scanScore`; appends `categories: PACKAGE_CATEGORIES` (marketplace.ts:57-118). **This is the grid's read.** |
| `GET /api/marketplace` (bare alias) | S21 alias → injects into `/search`; accepts 6-domain `type` facet; returns honest empty `{federated:true}` for non-marketplace domains | none | `extend.ts:49-85`. **This is what the current FE `adapter.getMarketplace()` calls.** |
| `GET /api/marketplace/packs` · `/packs/:slug` | List packs / pack detail | none | marketplace.ts:123-146 |
| `GET /api/marketplace/enterprise-packs` | KVARK-gated packs | `requireTier('ENTERPRISE')` | marketplace.ts:152 |
| **`POST /api/marketplace/install`** | **Install a package by numeric `packageId`** | `requireTier('PRO')` | marketplace.ts:181-432. SecurityGate pre-scan (heuristics-only) → severity gating (CRITICAL=403 always; HIGH=403 unless `force`; MEDIUM/LOW proceed) → `MarketplaceInstaller.install()` → audit rows. Returns `{...InstallResult, security:{...}}`, **200** on success / **422** on fail / **403** on block. |
| `POST /api/marketplace/uninstall` | Uninstall by `packageId` | none | marketplace.ts:437-470. For MCP packages ALSO tears down the live runtime + `<dataDir>/.mcp.json` (else it resurrects at boot). |
| `GET /api/marketplace/installed` | `listInstallations()` | none | marketplace.ts:475 |
| `POST /api/marketplace/security-check` | Scan-only by id | none | marketplace.ts:486 |
| `GET/POST/DELETE /api/marketplace/sources` | Source CRUD + sync-on-add | none | marketplace.ts:525-641 |
| `GET /api/marketplace/categories` | `PACKAGE_CATEGORIES` taxonomy (21 cats) | none | marketplace.ts:646 — feeds the category filter |
| `POST /api/marketplace/sync` | Manual sync from sources | none | marketplace.ts:655 |
| `GET /api/marketplace/security-status` | Cisco scanner availability + aggregate scan counts | none | marketplace.ts:712 |
| `POST /api/marketplace/publish` | Publish a local skill into the catalog | `requireTier('PRO')` | marketplace.ts:764 |
**Tier note for the §09 "one-click install":** `POST /api/marketplace/install` is **PRO-gated**. The skill "Add (instant)" micro-state in the contract collides with PRO-gating unless the install path for FREE-tier skills differs. The MCP path (`/api/mcps/install`) is also PRO. Connector connect (`/api/connectors/:id/connect`) is **ungated**.
---
## 3. Installer flow per type (`packages/marketplace/src/installer.ts`)
`MarketplaceInstaller.install(request: InstallRequest)` (installer.ts:81) is the **single dispatch entrypoint** for skill/plugin/mcp. Flow:
1. `db.getPackage(packageId)` → 404-shaped result if missing.
2. Idempotency: if `!force && db.isInstalled(pkg.id)` → returns success "already installed" (installer.ts:96).
3. **Security gate** (`SecurityGate.scan`) on resolved content → `recordScanResult()` → if `blocked && !forceInsecure` returns blocked result (installer.ts:116-130).
4. **Dispatch on `pkg.waggle_install_type`** (installer.ts:137):
- **`skill`** → `installSkill()` (installer.ts:309): writes `~/.waggle/skills/{name}.md` from `manifest.skill_content` / `skill_url` / repo `SKILL.md` / generated stub; then `PUT /api/skills/{name}` notify. **Instant** (matches §09 "Add→Added instant").
- **`plugin`** → `installPlugin()` (installer.ts:367): mkdir `~/.waggle/plugins/{name}/`, git-clone or `npm install`, write `plugin.json`, install bundled skills, update `registry.json`, run post-install hooks, `POST /api/plugins/install` notify. Slow / multi-step.
- **`mcp`** → `installMcp()` (installer.ts:489): optional `npm install -g`, apply settings to env, write the server entry into `<dataDir>/.mcp.json` (`mcpConfigPath()` = `WAGGLE_DATA_DIR/.mcp.json`, installer.ts:47). **Does NOT start the runtime** — that is the `/api/mcps/install` route's job (mcps.ts:277-294). (§09 "Enable→Enabling→Enabled" = install + runtime start.)
5. On success: `db.recordInstallation()` (with redacted setting keys) and attaches `scanResult`.
`uninstall(packageId)` (installer.ts:256) mirror-dispatches: `uninstallSkill` (rm file + DELETE notify), `uninstallPlugin` (rmdir + registry + DELETE), `uninstallMcp` (remove from `.mcp.json`), then `db.markUninstalled()`.
**Notify pattern:** the installer best-effort POSTs to `API_BASE` (`WAGGLE_API_URL` || `http://localhost:3000`, installer.ts:52) — fire-and-forget, swallows failure (installer.ts:764). Note the default port 3000 vs the running dev sidecar; relevant if a sync store relied on the notify hook firing.
---
## 4. The three install-state stores in detail (the "sync" problem)
The §09 CRITICAL line — *"one store powers the grid, the agent picks, AND the inline card … installing in any view reflects in all"* — has no backend equivalent. The three stores:
### 4a. Marketplace packages (skill/plugin/mcp) — `installations` table
- Truth: `MarketplaceDB.isInstalled(packageId: number)`. Keyed by **numeric package id**.
- Read surfaces: `/api/marketplace/search` (annotates `installed`), `/api/marketplace/installed`.
### 4b. MCP runtime — `<dataDir>/.mcp.json` ⋈ `McpRuntime` (`routes/mcps.ts`)
- Truth: `GET /api/mcps` (mcps.ts:143) = `MCP_CATALOG` (from `@waggle/shared`) ⋈ persisted `.mcp.json` entries ⋈ live `runtime.getServerStates()`. Keyed by **string server name**. `installed = name ∈ (persisted runtime)`.
- Statuses: `installed | running | error | stopped` (mcps.ts:67 `toInstanceStatus`).
- `POST /api/mcps/install` (mcps.ts:189) **delegates to `/api/marketplace/install`** via `fastify.inject`, then registers + `start()`s the runtime server (8s budget). So an MCP "install" touches BOTH 4a and 4b. **This is the closest thing to a working cross-store sync** — but it is MCP-specific and one-directional.
- **Workspace tag:** `PATCH /api/mcps/:id/permissions` sets a single `workspaceId` (C19 — "single-workspace scoping v1", mcps.ts:535). This is the ONLY place a "workspace" appears in install state, and it is a *scope filter on tool exposure*, not an install-presence scoping.
### 4c. Connectors — Vault (`routes/connectors.ts`)
- Truth: presence of a vault credential under `connector:{id}` (`fastify.vault.getConnectorCredential(id)`). "Connected" = credential exists & not expired.
- Read: `GET /api/connectors``connectorRegistry.getDefinitions()` (a STATIC catalog of all connectors) + `GET /api/connectors/:id/health` for live `connected|disconnected|expired|error` status.
- Install verb: `POST /api/connectors/:id/connect` — stores token→vault (matches §09 "token → vault"), re-inits the connector, writes audit. **Ungated.** Disconnect/revoke purge vault + OAuth tokens.
- **Connectors are NOT in `marketplace.db` at all.** They never appear in `installations`/`isInstalled`. The §09 grid showing connectors alongside skills/MCP must federate from `/api/connectors`.
### 4d. The unifying write-side feed — `install_audit` (the one cross-type thing that exists)
- `fastify.auditStore` (`@waggle/core` `install-audit.ts`) writes to table `install_audit` (schema.ts:135-152). All three stores already write here on install/uninstall/connect/revoke (marketplace.ts, mcps.ts `recordMcpAudit`, connectors.ts `recordConnectorAudit`).
- **Read:** `GET /api/extend/audit` (extend.ts:88) — ONE shared feed across `native|skill|plugin|mcp|connector|marketplace` with `?type=` / `?capability=` filters. The `AuditStore` API: `getRecent(limit)`, `getRecentByType(type, limit)`, `getByCapability(name)`.
- **CRITICAL GAP for the count bar:** `install_audit` has **NO `workspace_id` column** (schema.ts:135-152 — confirmed; `ai_interactions`/`execution_traces` DO have it at schema.ts:174/219, install_audit does not). It is an append-only event log, not a "current installed set." A "N in this workspace" count therefore has **zero backing data** today.
---
## 5. The install-audit trail (what's already auditable)
`RecordAuditInput` fields (used by all three route files): `capabilityName`, `capabilityType` (`native|skill|plugin|mcp|connector|marketplace`), `source`, `riskLevel` (`low|medium|high|critical`), `trustSource` (`builtin|starter_pack|local_user|third_party_verified|third_party_unverified|unknown|security-gate`), `approvalClass` (`standard|elevated|critical|blocked`), `action` (`proposed|approved|installed|rejected|failed|blocked|uninstalled`), `initiator` (`agent|user|system`), `detail`. CHECK constraints in schema.ts:143-150 enforce these — **adding a value (e.g. a `synced` action or a `workspace_id`) is a migration + CHECK change**, and the file warns these drifted once and crashed `acquire_capability` (schema.ts:139-142). Treat the audit vocabulary as a contract.
The marketplace install route writes audit rows for CRITICAL-block / HIGH-block / HIGH-force-override / MEDIUM / LOW / blocked-by-installer / forceInsecure-override (marketplace.ts:222-415) — a thorough trust trail, all global.
---
## 6. Exact integration points a PR4 "sync store" would hook into
**Reads (catalog + installed-state):**
- `GET /api/marketplace/search` (grid; annotates `installed` per pkg) — or the bare `GET /api/marketplace?type=` alias the FE already uses.
- `GET /api/marketplace/installed``MarketplaceDB.listInstallations()`.
- `GET /api/mcps` (catalog ⋈ persisted ⋈ runtime; `installed` + `status`).
- `GET /api/connectors` + `GET /api/connectors/:id/health` (connector connected-state).
- `GET /api/marketplace/categories` (the All/Skills/Connectors/MCP filter taxonomy).
- `GET /api/extend/audit` (the existing cross-type unified feed — the natural read for a "what's installed across everything" view, modulo it being a log).
**Install verbs (the three one-click flows):**
- skill / plugin → `POST /api/marketplace/install` `{ packageId }` (PRO). FE adapter: `adapter.installMarketplacePackage(packageId)``/api/marketplace/install` (adapter.ts:1339).
- mcp → `POST /api/mcps/install` `{ mcpId, settings, force }` (PRO) — delegates to marketplace install + runtime start; uninstall via `POST /api/mcps/:id/revoke`.
- connector → `POST /api/connectors/:id/connect` `{ token|apiKey }` (token→vault); disconnect `POST /api/connectors/:id/disconnect`, strong revoke `POST /api/connectors/:id/revoke`.
**Installer functions (package layer, if the store goes below the routes):**
- `MarketplaceInstaller.install(InstallRequest)` / `.uninstall(packageId)` / `.installPack(slug)` (installer.ts:81/256/210).
- `MarketplaceDB.isInstalled(id)` / `recordInstallation(...)` / `markUninstalled(id)` / `getInstalledCount()` / `listInstallations()` (db.ts).
**Server decoration:** `fastify.marketplace: MarketplaceDB | null` (index.ts:506); `fastify.connectorRegistry`, `fastify.vault`, `fastify.auditStore`, `fastify.agentState.mcpRuntime` are the sibling singletons a unified store would coordinate.
**Current FE consumer to refactor:** `apps/web/src/components/os/apps/MarketplaceApp.tsx` (417 LOC) — already federates 6 domains at read (skill via `getMarketplace`, packs, mcp via `getMcps`+`getMarketplace`, persona, connector via `getConnectors`, model, template) but holds install state in **local `useState extensions[]`**; install just patches the row (`installed: true`) — **no shared store, no cross-view propagation.** View-model federation already lives in `apps/web/src/lib/extension-catalog.ts`.
---
## 7. Key deltas vs the §09 contract (territory, not plan)
1. **No single install store.** Three stores (marketplace `installations` / `.mcp.json`+runtime / vault), three key types (numeric id / string name / string id), three install verbs, three tiers (PRO / PRO / ungated). The §09 "one store … reflects in all" is the central build.
2. **No workspace scoping of install state.** `installations` and `install_audit` have no `workspace_id`; vault is global; only MCP carries a single optional `workspaceId` *tool-scope* tag. The "N in this workspace" count bar has no data source — needs either a new scoping dimension or redefinition to a global count.
3. **No agent-suggestion endpoint.** The §09 "agent-suggestion box (connector + skill + tool, each with a why)" has no backing route. FTS search (`/api/marketplace/search`) + the agent's `acquire_capability` path (the verbose-`need` FTS handling in db.ts:91-99 / `toFtsMatchQuery`) is the nearest substrate, but a "recommend one of each kind with a reason" composite does not exist.
4. **No inline-in-chat picker route.** Variation B (mid-conversation connector offer with vault-aware approval) reuses the same install verbs but has no dedicated surfacing/approval endpoint; the connector connect flow + the existing approval/confirmation machinery (`routes/approval.ts`, agent `confirmation.ts`) are the substrate.
5. **Connectors absent from `marketplace.db`.** Any unified grid must federate connectors from `/api/connectors`, not from the marketplace catalog — they share no row shape with `MarketplacePackage`.
6. **Audit feed is a log, not a set.** `GET /api/extend/audit` unifies *events* across types but cannot answer "what is currently installed in workspace W" without scanning + reducing; the count bar wants a live set.
---
## 8. Risks / sharp edges for a builder
- **Schema DDL is invisible.** The `packages`/`sources`/`packs`/`installations`/`scan_history` tables exist only inside the committed `marketplace.db` seed binary — there is no `CREATE TABLE` in TS. Adding a `workspace_id` to `installations` means a runtime `ALTER TABLE` migration in `MarketplaceDB.migrateSchema()` (the established pattern, db.ts:57-78), not an edit to a schema file.
- **`install_audit` CHECK constraints are a hard contract.** Adding a `synced` action or any new vocabulary needs a coordinated `runMigrations()` + CHECK rewrite; schema.ts explicitly records a prior drift that crashed `acquire_capability`.
- **PRO-gating vs "instant Add".** Skill install is PRO-gated server-side; the §09 instant micro-state must reconcile with `requireTier('PRO')` (or rely on a different non-gated skill path).
- **MCP install is the only working cross-store coupling** (`/api/mcps/install` writes both `installations` and `.mcp.json`+runtime, and revoke keeps marketplace `installed` honest, mcps.ts:509-519). A unified store should mirror this bidirectional bookkeeping for the other types, or it will drift (the FE will show "installed" after a revoke, etc.).
- **OSS-sync constraint (§7.5):** `install_audit` DDL inside `mind/schema.ts` is OSS-excluded (Waggle governance). A `workspace_id` addition there has nowhere to land on the public mirror — fine for the monorepo, but flag it as a curated-strip item.
- **Three-store consistency under failure.** The installer's `notify` hook is fire-and-forget to `localhost:3000` (not the running sidecar port); a sync store cannot rely on the notify callback to invalidate caches.
---
## 9. Open questions for the founder/lead
1. **Workspace scoping:** Is "N in this workspace" a real per-workspace install set (requires a new `workspace_id` on installs + per-workspace activation model), or a relabeled global count for v1? This is the single biggest decision — it determines whether the sync store needs a new data dimension across all three backends.
2. **Skill install tier:** Should the §09 instant skill "Add" stay PRO-gated, or is there a FREE skill-install lane? (CLAUDE.md moat: skills/connectors are the upgrade trigger — so PRO-gating may be intentional and the "Add" CTA should show the upgrade nudge instead of installing.)
3. **Agent-suggestion box:** Build a new composite recommend endpoint (one connector + one skill + one tool + "why"), or compose it client-side from three `search` calls + an LLM rationale? No backing route exists either way.
4. **Sync store location:** A FE-only store (React context / Zustand over the existing routes) vs a new server-side unified `/api/install-state` read that reduces all three stores into one shape. The §09 "one store reflects in all" is achievable purely client-side IF every view subscribes to it; a server read is only needed for the count bar's correctness across reloads.
5. **Inline-in-chat (Variation B):** Reuse `/api/connectors/:id/connect` + existing approval machinery, or a dedicated in-chat install/approval contract? Decide before building the chat surface.

View File

@@ -0,0 +1,370 @@
# PR4 Recon · Slice 3 — Shared Install State ("sync")
**Branch:** `feature/warm-hive-pr4` · **Screen:** 09 Marketplace · **Read-only recon.**
**Design contract:** `docs/design_handoff_waggle_app/SCREENS.md` §09 (lines 189-207) +
the reference implementation in `docs/design_handoff_waggle_app/design-files/screens/marketplace.html`
(lines 203-310 — the canonical `installed`/`installing` Set + `renderAll()` "sync" model).
> This is THE critical design element of PR4: "one store powers the grid, the agent picks,
> AND the inline card. Installing in any view reflects in all of them."
---
## 0. TL;DR — the delta in one paragraph
There is **no shared install state today.** Three install backends exist and are battle-tested,
but each lives in its own server module with its **own persistence mechanism and its own
notion of "installed"**: skills = markdown files on disk reloaded into `agentState.skills`;
connectors = vault credentials; MCP = `.mcp.json` + a live runtime. The FE has **no unified
store** — `MarketplaceApp` (the existing Phase-4B Extend surface) federates the six facets
**at read** into a local `useState<Extension[]>` and **discards that view on unmount**. Installing
in the grid does NOT reflect in chat's `CapabilityRequestCard` (which keeps its own local
`phase` state) and vice-versa. There is **no count bar**, **no type-aware Add/Connect/Enable
verbs**, and **no progress→done micro-states** on the cards. The single cross-type fact that
IS already shared is the **install-audit trail** (`server.auditStore`, the `install_audit`
table) — every install path writes to it, but it is an append-only event log, not a queryable
"installed set." PR4 must introduce a real FE store + a thin server aggregate so the three
backends present as ONE reactive installed set.
---
## 1. Where install state lives TODAY — per type
### 1a. Skills — filesystem-backed
**Server:** `packages/server/src/local/routes/skills.ts`
- **Source of truth:** `.md` files in `<dataDir>/skills/` (default `~/.waggle/skills/`).
"Installed" = file exists on disk; "active" = also loaded into `server.agentState.skills`.
- **Install:** copies starter/pack `.md` into `skillsDir`, then reloads
`server.agentState.skills.length = 0; push(...loadSkills(waggleHome))` (skills.ts:204-205,
333-334). Authored skills go through `writeSkill(...)` (the P5/D4 shared write-service that
redacts + stamps provenance + audits) at skills.ts:439, 500, 545.
- **Routes:**
- `POST /api/skills/starter-pack/:id` (skills.ts:168) — single starter skill, instant
- `POST /api/skills/capability-packs/:id` (skills.ts:271) — install a whole pack
- `POST /api/skills/:id/install` — alias in `skills-aliases.ts` (the one the FE adapter
`installSkill(id, source, packageId?)` actually calls — adapter.ts:1240-1256)
- `DELETE /api/skills/:name` (skills.ts:563) — uninstall (audits `'uninstalled'`)
- `GET /api/skills` (skills.ts:345) — list installed (with provenance/initiator)
- `GET /api/skills/capability-packs/catalog` (skills.ts:238) — packs with per-skill
`state: 'active'|'installed'|'available'` and a `packState`/`installedCount`/`totalCount`
- **Audit:** `server.auditStore.record({ capabilityType: 'skill', action: 'installed'|'uninstalled', ... })`
- **Micro-state in design:** `skill``Add → Adding… → Added` (instant; ~480ms in the mock).
### 1b. Connectors — vault-credential-backed
**Server:** `packages/server/src/local/routes/connectors.ts`
- **Source of truth:** the **Vault** (`packages/core/src/vault.ts`). "Installed/connected" =
a credential exists under `connector:{id}` (`fastify.vault.setConnectorCredential`,
connectors.ts:122). Status is derived **live** by `connectorRegistry.healthCheck(id)` /
credential presence — there is no stored "installed" boolean.
- **Connect (the design's "token → vault"):** `POST /api/connectors/:id/connect`
(connectors.ts:96) stores the credential in the vault, re-inits the connector, and audits
`action:'installed', trustSource:'local_user'`. OAuth tokens are also written by
`oauth.ts` under `${provider}_oauth_token` (provider-keyed, NOT connector-keyed — see the
`OAUTH_PROVIDER_FOR_CONNECTOR` Google-family map at connectors.ts:16-23).
- **FE connect flow (the real vault path):** `ConnectorsApp.tsx`
`adapter.addVaultSecret({ key: 'connector:{id}', value: token, type:'bearer' })` THEN
`adapter.connectConnector(id)` (ConnectorsApp.tsx:154-155).
- **Routes:** `GET /api/connectors` (list+status, connectors.ts:38), `/connect`, `/disconnect`,
`/sync` (C16 health re-probe + `lastSyncAt` stamp), `/revoke` (C17 strong purge incl. OAuth).
- **Adapter:** `getConnectors()` (adapter.ts:1958), `connectConnector(id, creds?)` (1971),
`disconnectConnector` (1981), `syncConnector` (1986), `revokeConnector` (1994).
- **Micro-state in design:** `connector``Connect → Signing in… → Connected` (~1.1s, the
"token goes to your vault" approval). **This is the only flow with an approval gate** in
Variation B (the vault-aware "Add & connect" approval, marketplace.html:179-183).
### 1c. MCP — `.mcp.json` + live runtime
**Server:** `packages/server/src/local/routes/mcps.ts`
- **Source of truth:** persisted `<dataDir>/.mcp.json` (`mcp-config.ts`) **⋈** the live
`McpRuntime` server states (`fastify.agentState.mcpRuntime`). "Installed" = present in
`.mcp.json` OR registered in the runtime (mcps.ts:147). The C4 boot loader re-registers
persisted entries so installs survive restarts.
- **Install:** `POST /api/mcps/install` (mcps.ts:189, **PRO-gated** via `requireTier('PRO')`)
**delegates to the marketplace installer** (`fastify.inject('/api/marketplace/install')`) so
SecurityGate + scan + install_audit ride along, then `saveMcpServerEntry` + `runtime.addServer`
+ `instance.start()`. Custom servers: `POST /api/mcps` (mcps.ts:331, also PRO-gated).
- **Enable/disable:** `POST /api/mcps/:id/start` (mcps.ts:471), `/stop` (487), `/revoke` (496,
removes from runtime + config + marks the marketplace package uninstalled).
- **Routes/list:** `GET /api/mcps` (mcps.ts:143) returns `{ mcps, total, installed }` where
each row carries `installed`, `status`, `scope`, `state`, `tools`.
- **Adapter:** `getMcps()` (adapter.ts:2004), `installMcp(mcpId, opts)` (2017),
`addCustomMcp` (2031), `testMcp` (2045).
- **Micro-state in design:** `mcp``Enable → Enabling… → Enabled` (~720ms in the mock).
### 1d. Marketplace registry (the spine the others lean on)
**Server:** `packages/server/src/local/routes/marketplace.ts` (uses `@waggle/marketplace`
`MarketplaceDB`/`MarketplaceInstaller`/`SecurityGate`). The `packages` table is SQLite; a
package's domain is `waggle_install_type` (`'skill'|'mcp'|'plugin'`), NOT a `type` column.
- `GET /api/marketplace/search` (`installed: db.isInstalled(pkg.id)`, marketplace.ts:107)
- `POST /api/marketplace/install` (marketplace.ts:181, PRO-gated; SecurityGate + audit)
- `POST /api/marketplace/uninstall` (marketplace.ts:437; MCP cleanup ride-along)
- `GET /api/marketplace/installed` (marketplace.ts:475 → `db.listInstallations()`)
- `GET /api/marketplace/packs` (raw `MarketplacePack` rows — **no install route exists**, so
packs are browse-only today; see `fromSkillPack` `installable:false`)
- **Adapter:** `getMarketplace({query,type,limit})` (adapter.ts:2082), `searchMarketplace`
(1334), `installMarketplacePackage(packageId)` (1338, returns raw Response),
`uninstallMarketplacePackage` (1345), `getMarketplacePacks` (1309).
---
## 2. The ONLY thing shared today: the install-audit trail
`server.auditStore` (the `install_audit` table) is the single cross-type surface every install
path already writes to:
- skills: skills.ts:212, 315 + the `writeSkill`/`deleteSkill` service
- connectors: connectors.ts `recordConnectorAudit` (29) on connect/sync/revoke
- mcp: mcps.ts `recordMcpAudit` (93) on install/custom-add/revoke
- marketplace: writes its own rows on non-clean scans
It is exposed as `GET /api/audit/installs` (skills.ts:718) and a shared Extend feed
`GET /api/extend/audit?type=…` (rendered by `extend/InstallAuditPanel.tsx`, used as the
Marketplace "Audit" tab). **But it is an append-only EVENT log, not a queryable installed-set**
— you cannot ask it "what is installed right now" without replaying install/uninstall pairs.
`install_audit` is also **OSS-EXCLUDED** (CLAUDE.md §7.5) — fine for a Waggle-only count, but it
must NOT become the substrate's source of truth.
> **No skill_share/diffusion wiring lives in the server `local/` layer.** `lifecycle.ts` has
> none; the `onSkillDistillationFire → skill_share` callback is the agent layer (CLAUDE.md §10
> Phase 3). `waggle-dance-bridge.ts` only *categorizes* an incoming `skill_share` subtype into
> the `'handoff'` UI bucket (waggle-dance-bridge.ts:53). Diffusion is **out of scope for the
> install-sync store** — it is a separate signal stream (relevant to screen 12, not 09).
---
## 3. The FE today — `MarketplaceApp` federates at READ, holds no shared state
`apps/web/src/components/os/apps/MarketplaceApp.tsx` (routed at `/marketplace` via
`MarketplaceRoute.tsx`; mounted bare in Desktop) is the existing Phase-4B six-facet Extend
surface. How it works today:
- `loadFacet(f, q)` (MarketplaceApp.tsx:116) fires **N parallel adapter calls** (one per facet)
and merges results into a **local** `useState<Extension[]>` (line 105) via the pure
normalizers in `lib/extension-catalog.ts`.
- Install dispatch (`handleInstall`, MarketplaceApp.tsx:224) **only works for
`kind:'package'`** (marketplace registry rows). Connectors, MCP catalog rows, agents,
models, templates are `kind:'federated'``installable:false` → render an **"Open in
<app>"** deep-link, NOT an install button (`ExtensionCard.tsx:72`). Skill *packs* are
`installable:false` too (no install-pack route).
- On a successful install it mutates ONLY its own local array
(`setExtensions(prev => prev.map(...installed:true))`, line 231). **Nothing else on the
screen or app knows.** Unmount = state gone, re-fetch on next mount.
### Card today vs. design
`ExtensionCard.tsx` shows a generic **`Install` / `Remove`** button + a static
`Installed`/`Available` `StatusBadge`. It has **none** of:
- type-aware verbs (`Add`/`Connect`/`Enable`) — design `VERB` map, marketplace.html:216
- a per-item `installing` micro-state (`Adding…`/`Signing in…`/`Enabling…`)
- a `+`/`✓` affordance keyed off a shared installed Set
### Variation B today — `CapabilityRequestCard` (a partial, divergent precursor)
`apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` already surfaces an
**inline install affordance in chat**, parsed out of agent text by
`capability-request-parser.ts` (a `<!--waggle:capability_request {...}-->` marker or a legacy
`install_capability with name "X"` phrasing). BUT:
- It handles **only `skill` (starter-pack) and `marketplace`** kinds — **no connector vault
approval, no MCP enable** (the design's headline Variation-B example is a *Salesforce
connector* with the "token → vault" approval, which this card cannot render).
- It owns its **own local `phase` state** (`'pending'|'installing'|'installed'|...`, line 17/32)
— completely disconnected from the grid. Installing here never updates the grid's count bar
or card; installing in the grid never flips this card to "installed."
- It is **not vault-aware** and has no "token goes to your vault" approve-row.
So: two install UIs, two private states, three backends, zero shared store, no count bar.
---
## 4. What "one store + cross-view reactivity" requires
### 4a. The design's reference model (what we must reproduce, for real)
`marketplace.html` is the spec in code: a single module-level `installed: Set` + `installing:
Set`, a `btnHTML(item)` that reads those Sets for `[idle, in-progress, done]` per `item.k`
(`VERB` map), ONE delegated click handler (`document.addEventListener('click', … install(id))`),
and a `renderAll()` that re-paints **grid + picks + count-bar + inline-chat card** off the same
two Sets. `install(id)` flips `installing→installed` with a type-keyed delay, fires a toast, and
re-renders everything. The inline-chat `renderInline()` (line 299) reads the SAME `installed`
Set — that is the entire "sync" mechanism.
### 4b. FE store shape (the new artifact PR4 introduces)
A React context/store — call it `InstallStore` (the BUILD-PLAN's "new install store") — holding:
- `installed: Map<extKey, InstalledRecord>` and `installing: Set<extKey>` where `extKey` is the
existing namespaced id (`extension-catalog`'s `connector:slack`, `mcp:fs`, `pkg:42`,
`skill:teardown`) so it federates across types without collision.
- `install(ext)`**type-aware dispatcher** routing to the right adapter call:
- `skill``adapter.installSkill(id, source, packageId?)` / `installPack` (instant)
- `connector` → vault approval → `addVaultSecret` + `connectConnector(id)` (~1.1s, gated)
- `mcp``adapter.installMcp(mcpId)` (PRO-gated; SecurityGate)
- `package` (registry) → `adapter.installMarketplacePackage(packageId)`
optimistically add to `installing`, on success move to `installed` + toast, on failure roll back.
- A subscription so the grid, the agent-pick box, the count bar, AND the chat
`CapabilityRequestCard` all read the same Sets and re-render on change (the cross-view
reactivity = React context consumers, replacing today's per-component `useState`).
- Pattern to match: this codebase already uses small custom contexts (`ServiceProvider`,
`ShellContext`, `ThemeProvider` in `apps/web/src/providers/`) and per-domain hooks
(`useChat`, `useMemory`, `useWorkspaces` in `apps/web/src/hooks/`). The install store should
be a sibling provider + a `useInstallStore()` hook — **no Redux/Zustand precedent in this repo.**
### 4c. Server aggregate (the gap)
The store needs an **initial installed-set hydrate** and a **count**. Today that means fanning
out the same N calls `MarketplaceApp.loadFacet` already does (`/api/skills`,
`/api/connectors`, `/api/mcps`, `/api/marketplace/installed`) and merging. Options for the
"N in this workspace" bar:
- **Cheap path (no new route):** derive the count/chips on the FE from the existing per-type
list endpoints the store already calls (each returns an `installed` flag/`status`). This is
the surgical, ship-now option.
- **Aggregate route (nicer):** a new `GET /api/extend/installed` (or `/api/marketplace/sync`)
returning `{ items:[{key,type,name,installed}], count }` by merging the three backends server-
side. The Marketplace already owns an "Extend" namespace (`extend.ts`, the audit feed); this
would slot beside it. Mind the §7.5 mind-isolation rule: keep it workspace-scoped, no cross-
mind reads.
### 4d. The three micro-state flows differ structurally — the store must encode that
| type | adapter call | gate / approval | latency profile | persistence |
|---|---|---|---|---|
| skill | `installSkill`/`installPack` | none (bundled) | instant (~480ms) | FS file + `agentState` reload |
| connector | `addVaultSecret`+`connectConnector` | **vault approval** ("token → vault"); may need OAuth | ~1.1s (sign-in) | vault credential |
| mcp | `installMcp` | **PRO tier + SecurityGate** scan | ~720ms (spawn/enable) | `.mcp.json` + runtime |
| package (registry skill/mcp) | `installMarketplacePackage` | **PRO tier + SecurityGate** | varies | marketplace.db install row |
The store's `install()` cannot be one uniform call — it is a switch on `ext.type`/`ext.kind`,
each branch with its own optimistic/confirm/rollback semantics. Connector is the only branch
that surfaces an interstitial approval before the optimistic flip.
---
## 5. Concrete deltas vs. the screen-09 contract
1. **No shared install store at all** — grid, agent-picks, and inline-chat each hold private
state (or none). The store + cross-view reactivity is net-new (the BUILD-PLAN's "new install
store").
2. **No count bar** — the design's "N in this workspace" chip bar (`ibCount`/`ibChips`,
marketplace.html:151-155) has no FE or server surface today.
3. **Cards lack type-aware verbs + micro-states**`ExtensionCard` shows generic
`Install`/`Remove`; design wants `Add/Connect/Enable` × `idle/in-progress/done`.
4. **Connectors & MCP are not installable from the grid today** — they render as federated
"Open in <app>" deep-links (`installable:false`). The design wants a one-click
Connect/Enable IN the grid that hits the real vault/runtime paths.
5. **Variation B is partial**`CapabilityRequestCard` exists but handles only skill+marketplace,
owns disconnected local state, and has **no vault-aware connector approval** (the literal
headline example of Variation B).
6. **No centered agent-search "Ask the agent" + suggestion box** — MarketplaceApp has a plain
filter search, not the "Describe what you want to do…" agent-pick surface with
connector+skill+tool recommendations and "why" reasons. (This is the Variation-A search half;
Slice-3's job is the *install state* under it, but the picks must read the same store.)
7. **`installable:false` on skill packs** — no install-pack route server-side; either the store
skips packs or PR4 adds the route.
---
## 6. Integration points a PR4 build would touch
**FE (new + edit):**
- NEW: `apps/web/src/providers/InstallProvider.tsx` (or `hooks/useInstallStore.ts`) — the shared
Set-based store + type-aware `install()` dispatcher + count selector. Mount alongside
`ServiceProvider`/`ShellContext`.
- EDIT: `apps/web/src/components/os/apps/MarketplaceApp.tsx` — read installed/installing from the
store instead of local `useState`; add the count bar + agent-search/picks; route card installs
through the store.
- EDIT: `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` — type-aware verbs +
`idle/in-progress/done` micro-states off the store.
- EDIT: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` +
`capability-request-parser.ts` — extend the `CapabilityRequest.kind` union to
`'connector'|'mcp'` (with the vault approval row for connector), read/write the SHARED store.
- REUSE: `apps/web/src/lib/extension-catalog.ts` (`Extension`, namespaced ids, `fromConnector`/
`fromMcpCatalogRow`/`fromMarketplacePackage`/`fromSkillPack`) — the existing federation
normalizers are the store's value type; today they hard-code `installable:false` for
connectors/MCP — those flip true once the store can install them.
**FE adapter (already present — wire, don't recreate):** `adapter.installSkill` /
`installPack` / `connectConnector` / `addVaultSecret` / `installMcp` /
`installMarketplacePackage`; list/hydrate via `getSkills`/`getConnectors`/`getMcps`/
`getMarketplace`/`getMarketplaceInstalled` equivalents.
**Server (mostly reuse; one optional new route):**
- REUSE: `POST /api/skills/:id/install` (skills-aliases.ts), `POST /api/connectors/:id/connect`
(connectors.ts:96), `POST /api/mcps/install` (mcps.ts:189), `POST /api/marketplace/install`
(marketplace.ts:181) — the install verbs already exist and audit.
- REUSE for hydrate: `GET /api/skills`, `GET /api/connectors`, `GET /api/mcps`,
`GET /api/marketplace/installed`.
- OPTIONAL NEW: `GET /api/extend/installed` (aggregate installed-set + count) beside
`extend.ts` — only if the FE doesn't derive the count from the per-type lists. Keep
workspace-scoped (CLAUDE.md §7.5 mind-isolation).
- DO NOT TOUCH: the install-audit substrate as a source of truth (`install_audit` is OSS-excluded
+ append-only).
**Shared types:** `packages/shared/src/types.ts` `EXTENSION_TYPES`/`ExtensionType` (line 375) is
the facet vocabulary the store keys on — no change needed unless a `package` vs domain
discriminator is wanted.
---
## 7. Risks
- **Three backends, three "installed" definitions** — FS file vs vault credential vs
`.mcp.json`⋈runtime. A naive single boolean store will drift from reality (e.g. an MCP that
failed to `start()` is persisted-but-not-running). The store's record must carry enough state
(`status`/`state`) to stay honest, mirroring `getMcps`'s `status` field.
- **Tier + SecurityGate gating is real** — MCP and marketplace installs are PRO-gated and can be
scan-blocked (403/422 with `{requiresApproval, blocked, severity}`). The store's optimistic
flip must roll back on these and route to the UpgradeModal/ApprovalModal, exactly as
`MarketplaceApp.handleInstall` (line 238) and `MCPHubApp` already do. Don't let the count bar
show an item that the gate rejected.
- **Connector OAuth complexity** — the "Connect → Signing in…" flow may be a real OAuth redirect
(oauth.ts) for some connectors, not just a token paste. The ~1.1s mock latency hides a
potentially multi-step, navigation-away flow; the store needs a pending/await state that
survives that.
- **Reflecting external installs** — installs done in the dedicated apps (`ConnectorsApp`,
`MCPHubApp`, skills center) must also update the shared store, or the count bar lies. Either
those apps adopt the store too, or the store re-hydrates on focus/navigation.
- **Audit-vocabulary mismatch** — `install_audit`'s action enum has no `'enabled'`/`'connected'`/
`'synced'` verbs (connectors map sync→`'approved'`, connectors.ts:225). If the store ever reads
the audit feed for state, it inherits this lossiness. Read from the per-type list endpoints
instead.
- **`apps/web` is the only typechecked surface** (CLAUDE.md §2) — a new provider with subtle
types is fine, but any server aggregate route runs under `tsx` transpile-only and won't be
typechecked by `npm run build`; run `tsc -p packages/server` explicitly.
---
## 8. Open questions for the founder/lead
1. **Aggregate route vs FE-derived count** — ship the count bar by deriving from the existing
per-type list endpoints (zero new server surface, fastest), or add a real
`GET /api/extend/installed`? (Recommend FE-derive for PR4; promote to a route only if reused.)
2. **Do the dedicated apps adopt the store too?** The "installing in ANY view reflects in ALL"
contract technically includes `ConnectorsApp`/`MCPHubApp`/skills center, not just the three
Marketplace surfaces. In-scope for PR4, or PR4 covers grid+picks+inline only and the store
re-hydrates on nav?
3. **Connector install in-grid = real OAuth?** Some connectors are token-paste (vault), some are
OAuth redirect. Does the grid's one-click "Connect" do the full sign-in inline, or open the
Connector Hub for OAuth ones while doing token-paste inline? (Affects the micro-state UX.)
4. **Skill packs installable?** They are browse-only today (no install-pack route). Add a real
`POST /api/marketplace/install-pack` for PR4, or keep packs browse-only and have the store
skip them?
5. **Agent-pick search backing** — is the "Ask the agent" suggestion box a real agent call
(`/api/command` / the agent loop) returning connector+skill+tool picks, or a heuristic FE
matcher like the mock's `recs` map? (Slice 3 only owns that the picks read the shared store;
the recommendation engine itself may be another slice.)
6. **PRO-gating in the count** — should PRO-gated items (MCP, marketplace) appear installable to
FREE users with an upsell on click, or render gated up front? (`MarketplaceApp` currently
lets the click 403 → UpgradeModal.)
---
## 9. Key files (quick index)
| path | role |
|---|---|
| `docs/design_handoff_waggle_app/SCREENS.md` §09 (189-207) | the screen-09 contract |
| `docs/design_handoff_waggle_app/design-files/screens/marketplace.html` (203-310) | canonical "sync" reference impl (installed/installing Sets + renderAll) |
| `apps/web/src/components/os/apps/MarketplaceApp.tsx` | existing Extend grid — federates at read, local state only |
| `apps/web/src/lib/extension-catalog.ts` | pure facet normalizers + namespaced ids = the store's value type |
| `apps/web/src/components/os/apps/extend/ExtensionCard.tsx` | grid card — generic Install/Remove, needs type-aware verbs |
| `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` | Variation-B precursor — skill+marketplace only, private state, no vault approval |
| `apps/web/src/components/os/apps/chat-blocks/capability-request-parser.ts` | inline-card parser (marker + legacy phrasing) |
| `apps/web/src/lib/adapter.ts` (1240/1338/1958/2004 …) | all install/list verbs already exist |
| `packages/server/src/local/routes/skills.ts` | skills install (FS files + agentState reload + audit) |
| `packages/server/src/local/routes/connectors.ts` | connectors install (vault credentials + audit) |
| `packages/server/src/local/routes/mcps.ts` | MCP install (.mcp.json + runtime, PRO+SecurityGate) |
| `packages/server/src/local/routes/marketplace.ts` | registry install/uninstall/installed + SecurityGate |
| `packages/server/src/local/routes/extend.ts` + `extend/InstallAuditPanel.tsx` | shared install-audit feed (the ONLY cross-type surface today) |
| `packages/server/src/local/waggle-dance-bridge.ts` (53) | only categorizes incoming skill_share → not install-sync |
| `packages/shared/src/types.ts` (375) | `EXTENSION_TYPES` facet vocabulary |
| `apps/web/src/providers/{ServiceProvider,ShellContext,ThemeProvider}.tsx` | the context pattern the new InstallProvider should follow |

View File

@@ -0,0 +1,348 @@
# PR4 Recon — Slice 4: Inline-in-Chat Install (Marketplace Variation B)
> **Scope:** Screen 09 Marketplace, **Variation B** — Waggle offers a *missing connector*
> mid-conversation with a vault-aware approval ("token goes to your vault"), then shows the
> connected follow-up. Map the chat step/block rendering, the approval/confirmation infra,
> and the vault-write path; identify the exact hook points a PR4 build would touch.
>
> **READ-ONLY recon.** No source modified. Citations are `file:line` against
> `feature/warm-hive-pr4` (working tree at recon time).
---
## 1. The design contract (what Variation B must do)
From `docs/design_handoff_waggle_app/SCREENS.md:199-207`:
- **Variation B (Inline in chat):** "the same picker surfaced mid-conversation — Waggle
offers a missing Salesforce connector with a vault-aware approval ('token goes to your
vault'), then shows the connected follow-up."
- **CRITICAL — shared install state ("sync"):** "one store powers the grid, the agent picks,
AND the inline card." Type-aware one-click flows with progress→done micro-states:
- **skill** Add→Adding…→Added (instant)
- **connector** Connect→Signing in…→Connected (~1.1s, **token→vault**)
- **MCP** Enable→Enabling…→Enabled
- Each fires a toast + updates the count bar. **Installing in any view reflects in all.**
PR4 BUILD-PLAN row (`docs/redesign-warm-hive/BUILD-PLAN.md:142`):
`PR4 | Marketplace + shared install store ("sync") (grid + agent-pick + inline-in-chat) | MarketplaceApp, new install store | 09`
So Variation B is **one of three consumers** of a single shared install store. This slice maps
the *chat-side* surface (how the agent raises the offer mid-turn, how the FE renders the inline
card, and how approval → connect → vault wires). The grid + store itself is the broader PR4 build.
---
## 2. End-to-end map of the chat turn (what exists today)
### 2.1 SSE event protocol (server → FE)
`POST /api/chat` (`packages/server/src/local/routes/chat.ts:351`) is the SSE endpoint. It hijacks
the reply (`chat.ts:462`) and writes events with `sendEvent(event, data)` (`chat.ts:475-477`):
```
event: <name>\ndata: <JSON>\n\n
```
Event names emitted today (the wire vocabulary the FE switches on):
`step`, `tool` / `tool_result` (auto_recall only), `token`, `gepa_choices`, `model_switch`,
`approval_required`, `done`, `error`. (Note: the agent loop itself emits `tool_start`/`tool_end`
for real tool calls — see the FE switch in §2.3 — via the agent-loop callbacks, not direct
`sendEvent` in chat.ts.)
The **approval handshake** is the load-bearing primitive for Variation B. It lives in a
per-request `pre:tool` hook registered at `chat.ts:909-1045`:
1. A tool reaches the gate; `needsConfirmationWithAutonomy(toolName, args, autonomyLevel)`
decides if it gates (`chat.ts:916`).
2. The hook enriches the request with **risk metadata**`install_capability` gets a
content-based `assessTrust()` (`chat.ts:960-982`), every other gated tool gets
`classifyGatedToolRisk()` (`chat.ts:989-999`). `description` comes from
`describeToolUse(toolName, input)` (`chat-helpers.ts:106`).
3. It emits `sendEvent('approval_required', { requestId, toolName, input, sourceWorkspaceId,
...trustMeta })` (`chat.ts:1005-1009`).
4. It **blocks** on `new Promise<boolean>` registered in `server.agentState.pendingApprovals`
keyed by `requestId` (`chat.ts:1020-1036`), with a 5-min auto-deny timeout.
5. The FE posts the decision to `POST /api/approval/:requestId`
(`packages/server/src/local/routes/approval.ts:10`), which calls `pending.resolve(approved)`
(`approval.ts:31`) → the hook returns `{ cancel: true }` on deny (`chat.ts:1041`) or lets the
tool run on approve.
This approval handshake is **exactly the mechanism Variation B needs** — a server-side pause
mid-turn that surfaces an FE card and resumes on the user's click.
### 2.2 Block model (the FE message content type)
`apps/web/src/lib/types.ts:484-532` — `ContentBlock` is a discriminated union of:
`TextContentBlock` | `StepContentBlock` | `ToolUseContentBlock` | `ModelSwitchContentBlock` |
`ErrorContentBlock`. There is **no approval/install block type** — approvals are kept *out* of
the block stream (see §2.4) and rendered as a separate singleton.
`StepContentBlock` (`types.ts:497-508`) already carries an **optional `provenance: { sources }`**
field (PR3.5) — the precedent for type-specific metadata riding on a step block.
### 2.3 SSE → block reduction (`useChat`)
`apps/web/src/hooks/useChat.ts:125-273` consumes the SSE stream and reduces events into
`blocks[]` on the last assistant message. The relevant cases:
- `step` → pushes a `StepContentBlock` (`useChat.ts:152-175`), copying `data.provenance.sources`.
- `tool_start` / `tool_end` → push/patch a `ToolUseContentBlock` (`useChat.ts:177-222`).
- `approval_request` / `approval_required` → `setPendingApproval(data as ApprovalRequest)` and
**`return msgs`** — i.e. deliberately does NOT add a block (`useChat.ts:258-263`).
- `approveAction(requestId, approved, { always })` → `adapter.respondApproval(...)` then
`setPendingApproval(null)` (`useChat.ts:317-333`).
So today there is **one** pending approval at a time, held in component state, not in the block
stream. The hook returns `{ messages, isLoading, sendMessage, clearHistory, pendingApproval,
approveAction }` (`useChat.ts:335`).
### 2.4 Block rendering
`apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx:53-104` walks `blocks[]`:
- consecutive `step` blocks are grouped into one collapsible **`ActivityStream`** card
(`BlockRenderer.tsx:30-51`) — the "magic" surface.
- `tool_use` blocks route to **`ArtifactBlock`** (completed file-writes — `ArtifactBlock.tsx:21-28`
is the routing predicate `isArtifactBlock`) or the generic `ToolUseBlock`.
- `text` / `model_switch` / `error` each have a renderer.
`ArtifactBlock.tsx` is the **closest existing precedent** for what an inline install card should be:
a tool-result that, when completed, renders as a rich actionable card (icon + name + button) instead
of a debug row. An inline-install card is the same pattern for a *connector/skill/MCP* tool result.
### 2.5 Approval rendering (the inline card that exists)
`ChatApp` renders the singleton approval **inside the message list** at the bottom:
`{pendingApproval && <ApprovalGate request={pendingApproval} onRespond={onApprove} />}`
(`ChatApp.tsx:1213-1215`).
**`ApprovalGate`** (`ChatApp.tsx:225-311`) is already an in-thread, warm-token card:
`--honey-wash` fill + attention border + `AlertTriangle` + a `RiskBadge` + `description` + a
mono `toolName inputSummary` line + `trustSource` + Approve / Always-allow / Not-now buttons +
a "Show details" JSON toggle. It is **generic** (driven by `ApprovalRequest`), not connector-aware.
There is also a **reusable warm primitive** `InlineApprovalCard`
(`apps/web/src/components/os/warm/InlineApprovalCard.tsx`, exported from `warm/index.ts:19`) — a
cleaner card that takes an `ApprovalRequest`, `title`, `onApprove`/`onDecline`/`onAlwaysAllow`,
and an `approveLabel`. Its **default title is literally "Approve before I leave your machine"** —
already in the vault-aware register Variation B wants. NOTE: `ApprovalGate` (the one actually
wired in ChatApp) and `InlineApprovalCard` are **two parallel implementations of the same idea**;
both share the risk vocabulary via `lib/risk-display.ts`. A build should pick one.
`ApprovalRequest` shape (`apps/web/src/lib/types.ts:410-430`): `{ requestId, toolName, description,
input, rawJson?, sourceWorkspaceId?, riskLevel?, approvalClass?, trustSource?, assessmentMode?,
explanation?, permissions? }`. All of these are already on the wire from `chat.ts:1005-1009`.
---
## 3. The install / connect / vault paths (what an "approve" must trigger)
There are **three different install backends today**, none of which is currently reachable as a
single agent-loop "offer a missing connector" tool:
### 3.1 Skill install — agent-loop reachable (the only one that is)
`packages/agent/src/skill-tools.ts:480-668` — the `install_capability` tool. It copies a curated
starter-skill `.md` into the skills dir (`skill-tools.ts:631-633`), runs a `SecurityGate` scan,
records install-audit rows, hot-reloads via `onSkillsChanged()`, and returns the skill content.
**No vault.** This is the "skill: Add→Adding…→Added (instant)" lane. It already flows through the
chat approval gate (it is in `ALWAYS_CONFIRM`, `confirmation.ts:19`) and gets the **richest** trust
metadata on the approval event (`chat.ts:960-982`).
### 3.2 Connector connect — HTTP route, NOT agent-loop reachable
`POST /api/connectors/:id/connect` (`packages/server/src/local/routes/connectors.ts:96-156`):
- validates the connector exists in `connectorRegistry`,
- requires `token` or `apiKey` in the body,
- **writes the token to vault**: `fastify.vault.setConnectorCredential(id, { type, value,
refreshToken, expiresAt, scopes })` (`connectors.ts:122-128`) — **this is the "token→vault"
step the design names**,
- re-initializes the connector (`connector.connect(fastify.vault)`, `connectors.ts:138`),
- records an install-audit row (`connectors.ts:145-153`).
FE adapter: `adapter.connectConnector(id, credentials)` → POSTs that route
(`apps/web/src/lib/adapter.ts:1971-1979`).
**There is no agent tool that calls this.** The agent can only *discover* connectors via
`find_connector` / `list_connector_categories` (`packages/agent/src/connector-search.ts:161-265`),
which return catalog JSON (name, installCmd, url) as text — the agent literally cannot connect one.
### 3.3 Connector OAuth — separate browser-redirect flow
`packages/server/src/local/routes/oauth.ts` — `GET /api/oauth/:provider/authorize` →
provider page → `GET /api/oauth/:provider/callback` stores tokens in vault under
`${provider}_oauth_token` (keyed by **provider**, not connector id — see
`connectors.ts:16-23` `OAUTH_PROVIDER_FOR_CONNECTOR`). Only 5 providers configured
(github/slack/google/notion/jira, `oauth.ts:26-62`) and each needs app `client_id`/`client_secret`
pre-seeded in vault. This is the heavyweight path; the design's "token goes to your vault" implies
the **lightweight token-paste** path of §3.2, not OAuth.
### 3.4 Connector tools become live on the NEXT turn
Important for "the connected follow-up": connector action tools are **dynamic**.
`connectorRegistry.generateTools()` generates `connector_<id>_<action>` tools **only for connected
connectors** (`packages/server/src/local/index.ts:1005-1006` comment + `1034`). The agent loop
rebuilds `effectiveTools` per request (`chat.ts:1048-1073` → `buildToolsForWorkspace`). So once a
connector is connected mid-conversation, its tools appear on the **next** user turn (or next loop
iteration if connect happens inside the same turn before tool-pool rebuild — but the rebuild is
per-`/api/chat` call, so realistically next turn). `describeToolUse` already formats
`connector_<id>_<action>` as "<action> via <id>" (`chat-helpers.ts:192-196`).
### 3.5 MCP enable — yet another backend
MCP servers install through the MCP Hub (`MCPHubApp.tsx`, security scan + scope + approval). Not
chat-loop reachable today. Out of the *critical* path for Variation B's "Salesforce connector"
example, but the shared store must cover the "MCP Enable→Enabling…→Enabled" lane.
---
## 4. The DELTA — what's missing vs the Screen-09 contract
| # | Contract requirement | Current state | Gap |
|---|---|---|---|
| D1 | Agent can **offer a missing connector mid-conversation** | Agent can only `find_connector` (returns catalog text); no tool connects one | **No `connect_capability`/`offer_connector` tool** that raises an approval whose approve-side writes a token to vault. Needs a new agent tool OR a server-side "offer" step. |
| D2 | Inline card is **vault-aware** ("token goes to your vault") | `ApprovalGate`/`InlineApprovalCard` are generic; show `toolName input` + risk | No connector-typed variant: no token-input field, no "encrypted in your local vault" copy, no Connect→Signing in…→Connected micro-states. |
| D3 | **Token→vault** on approve (~1.1s) | `POST /api/connectors/:id/connect` writes vault, but is reached only from MarketplaceApp/ConnectorsApp FE | The chat approve path resolves a `boolean` promise (`approval.ts:31`); it has **no channel to carry a token** nor to invoke `connectConnector`. The approval contract is boolean-only. |
| D4 | **Connected follow-up** shown in thread | Connector tools regenerate per request; agent can use them next turn | No explicit "connected" confirmation block; the follow-up is implicit. Needs a success block/toast + (optionally) auto-continue of the original ask. |
| D5 | **Shared install store ("sync")** — one store, all 3 views reflect | `MarketplaceApp` uses **local `useState`** (`installing`/`extensions`, `MarketplaceApp.tsx:108`, `:231`); `ConnectorsApp`/`MCPHubApp` each own their own state | **No shared store.** Installing in chat would not reflect in the grid or count bar. This is the central PR4 artifact ("new install store"). |
| D6 | Type-aware **micro-states** + toast + count-bar update | Skill install returns text; connector connect returns `{connected:true}`; toasts exist per-app (`useToast`) | No unified progress→done state machine keyed by kind; no count bar; no cross-view toast. |
| D7 | Approval can carry **kind** (skill/connector/MCP) | `approval_required` carries `toolName` + trust meta, but kind is inferred from toolName | A connector offer needs an explicit `kind: 'connector'` + connector `id`/`name`/`why` so the card renders type-aware. |
---
## 5. Exact integration points a PR4 build would touch
### Server (agent loop + routes)
- **New agent tool** (e.g. `offer_connector` / `connect_capability`) in a new file under
`packages/agent/src/` (sibling to `connector-search.ts`), registered into `baseTools`
(`packages/server/src/local/index.ts:759`). It should be **gated** (add to `ALWAYS_CONFIRM`,
`packages/agent/src/confirmation.ts:16-26`, or rely on `connector_` prefix patterns) so it
hits the chat approval hook.
- **`chat.ts:909-1045` pre:tool hook** — extend the trust-metadata branch (currently special-cases
`install_capability` at `chat.ts:960`) to emit a **connector-typed** `approval_required` payload
(`kind: 'connector'`, connector `id`/`name`, "why", and a flag that a token field is needed).
- **`describeToolUse`** (`chat-helpers.ts:106-201`) — add a case for the new tool so the card's
description line is specific ("Connect Salesforce — token stored in your local vault").
- **Approval contract widening** — `POST /api/approval/:requestId` (`approval.ts:10`) +
`pendingApprovals` resolve currently carry only `boolean`. To pass a pasted **token** from the
inline card to the connect step, EITHER:
(a) the card calls `adapter.connectConnector(id, { token })` directly (writing vault via
`connectors.ts:96`) and *then* approves the tool with a boolean (token never transits the
approval channel — cleanest, reuses existing vault route), OR
(b) widen the approval body to carry the token and have the new tool's `execute` call
`setConnectorCredential`. Option (a) is the lower-risk path and keeps the vault write on the
audited `/connect` route.
- **Vault write** stays `fastify.vault.setConnectorCredential(id, …)` (`connectors.ts:122`) — do
not build a parallel secret store (CLAUDE.md §7.1).
### FE (chat + store)
- **`useChat.ts`** — the `approval_required` case (`useChat.ts:258-263`) sets a singleton
`pendingApproval`. For Variation B the connector offer can stay on this channel (it IS an
approval), but the payload must carry `kind`/connector fields so the renderer can branch.
Alternatively introduce an **install/offer block type** in `ContentBlock` (`types.ts:484`) +
`BlockRenderer` (`BlockRenderer.tsx`) so the offer lives *in* the thread like `ArtifactBlock`,
surviving history reload — recommended for the "connected follow-up" persistence.
- **New `ConnectorOfferCard`** (or extend `InlineApprovalCard`, `warm/InlineApprovalCard.tsx`) —
type-aware card with a token field, "encrypted in your local vault" copy, and
Connect→Signing in…→Connected micro-states. Render it from `ChatApp` where `ApprovalGate` renders
today (`ChatApp.tsx:1213-1215`), branching on `pendingApproval.kind`.
- **Shared install store** (the PR4 centerpiece, `BUILD-PLAN.md:142` "new install store") — a
React context/zustand store keyed by capability id with `{ kind, state: idle|installing|done }`,
consumed by `MarketplaceApp` (replacing its local `useState` at `MarketplaceApp.tsx:108/231`),
`ConnectorsApp`, `MCPHubApp`, AND the chat offer card. The card's approve handler calls
`adapter.connectConnector` and updates the store → grid + count bar reflect instantly.
- **Adapter** — reuse `adapter.connectConnector(id, { token })` (`adapter.ts:1971`),
`adapter.respondApproval(requestId, true)` (`adapter.ts:1757`), `adapter.installMarketplacePackage`
(skills/packages), and the connector list `adapter.getConnectors()` (for the count bar).
### Recommended seam (lowest-risk wiring)
1. New gated agent tool `offer_connector(id, why)` → emits connector-typed `approval_required`
(no token in the tool args; the token is collected by the FE card).
2. FE renders `ConnectorOfferCard`; on Connect it (a) `adapter.connectConnector(id, { token })`
→ vault write on the audited route, (b) updates the shared install store (grid/count-bar sync),
(c) `adapter.respondApproval(requestId, true)` to release the agent.
3. The tool's `execute` returns "Connected — Salesforce tools now available"; the agent uses
`connector_<id>_<action>` tools on the **next** turn (already live after `generateTools()`).
4. A success block/toast renders the "connected follow-up".
---
## 6. Risks / sharp edges
- **Boolean-only approval channel.** The existing approval handshake resolves a `boolean`
(`approval.ts:31`). Threading a secret token through it would put a credential on the approval
wire — prefer the FE-calls-`/connect`-directly seam (Option 5a) so the token stays on the
dedicated vault route.
- **Two parallel inline-approval components** (`ApprovalGate` in `ChatApp.tsx:225` vs warm
`InlineApprovalCard`). Only `ApprovalGate` is wired. Building a third card risks a 3-way drift;
consolidate onto the warm primitive.
- **Approval is a singleton, out-of-band of blocks** (`useChat.ts:262` returns without pushing a
block). It does **not** survive history reload and there is only one at a time. If the offer must
persist in the transcript / show a permanent "connected" follow-up, it needs to become a real
`ContentBlock` (new type), which touches the block union, `useChat`, `BlockRenderer`, and history
serialization.
- **Connector tools are next-turn, not same-turn.** The "connected follow-up that actually uses the
connector" won't have the `connector_<id>_*` tool in-loop on the same turn the connect happened
(tool pool is built once per `/api/chat`, `chat.ts:1048`). A same-turn auto-continue would need an
explicit re-dispatch.
- **No shared store today** means a chat-side install silently diverges from the grid/count bar —
the single most load-bearing PR4 requirement ("installing in any view reflects in all"). The store
must land before any of the three surfaces is "done".
- **MCP + skill lanes differ from connector.** Skill install is agent-reachable + vault-free;
connector connect is vault-bound + HTTP-only; MCP is Hub-only. A unified "type-aware one-click"
card must dispatch to three different backends behind one store interface.
- **OAuth vs token-paste.** Some connectors (the Google family, jira, slack, github) are OAuth, not
token-paste (`oauth.ts:26-62`). The vault-aware token field only fits `bearer`/`apiKey` connectors;
OAuth connectors need the redirect flow, which cannot complete inside an inline card without a
popup/redirect. The card must branch on `connector.authType`.
---
## 7. Open questions for the founder/lead
1. **Approval channel for tokens** — keep the approval handshake boolean and have the FE call
`/connect` directly (audited vault route), or widen the approval body to carry the token?
(Recommend the former.)
2. **Offer as block vs singleton** — should the inline connector offer (and its "connected"
follow-up) be a persistent `ContentBlock` in the transcript, or stay the ephemeral singleton
approval? (Persistence implies a new block type + history serialization.)
3. **One inline card or two** — consolidate `ApprovalGate` and `InlineApprovalCard` into the
warm primitive before adding a connector variant?
4. **OAuth connectors** — for OAuth-only connectors (Google/Slack/etc.), does Variation B fall
back to "open the Connector Hub" or attempt an in-chat popup redirect? Token-paste only covers
`bearer`/`apiKey` connectors.
5. **Same-turn vs next-turn follow-up** — is "shows the connected follow-up" satisfied by a
success toast + the tool being available next turn, or must the agent auto-continue and use the
connector in the same turn (requires re-dispatch)?
6. **Shared store shape** — new dedicated store, or extend an existing provider? It must be the
single source the grid count bar, the agent-pick suggestion box, and this card all read/write.
7. **What raises the offer** — a new gated agent tool the model calls when it hits a capability
gap, or a server-side heuristic (e.g. tool-not-found → CapabilityRouter, `chat.ts:1080`) that
injects the offer? The `CapabilityRouter` already exists for tool-not-found handling and could
be the trigger.
---
## 8. Key files (quick index)
| Path | Role |
|---|---|
| `docs/design_handoff_waggle_app/SCREENS.md:189-207` | Screen-09 contract (Variation A/B + sync) |
| `packages/server/src/local/routes/chat.ts:909-1045` | pre:tool approval hook — emits `approval_required`, blocks on `pendingApprovals` |
| `packages/server/src/local/routes/chat.ts:475-477,1005-1009` | `sendEvent` + the approval payload shape |
| `packages/server/src/local/routes/approval.ts:10-35` | `POST /api/approval/:requestId` → resolves the boolean promise |
| `packages/server/src/local/routes/connectors.ts:96-156` | `POST /connect` — **token→vault** (`setConnectorCredential`) |
| `packages/server/src/local/routes/oauth.ts` | OAuth redirect flow (heavyweight, 5 providers) |
| `packages/agent/src/connector-search.ts:161-265` | `find_connector` / `list_connector_categories` (discovery only — no connect) |
| `packages/agent/src/skill-tools.ts:480-668` | `install_capability` — the only agent-loop install (skills, vault-free) |
| `packages/agent/src/confirmation.ts:16-26` | `ALWAYS_CONFIRM` gate set + `needsConfirmation` |
| `packages/server/src/local/index.ts:759,1005-1034` | tool-pool assembly; connector tools generated for **connected** connectors only |
| `packages/server/src/local/routes/chat-helpers.ts:106-201` | `describeToolUse` (approval description line) |
| `apps/web/src/hooks/useChat.ts:258-333` | FE: `approval_required` → singleton `pendingApproval`; `approveAction` |
| `apps/web/src/components/os/apps/ChatApp.tsx:225-311,1213-1215` | `ApprovalGate` inline card + its render slot |
| `apps/web/src/components/os/warm/InlineApprovalCard.tsx` | reusable warm inline-approval primitive ("Approve before I leave your machine") |
| `apps/web/src/components/os/apps/chat-blocks/ArtifactBlock.tsx` | precedent: tool-result → rich actionable in-thread card |
| `apps/web/src/components/os/apps/chat-blocks/BlockRenderer.tsx` | block dispatch (where a new offer block would route) |
| `apps/web/src/lib/types.ts:410-430,484-532` | `ApprovalRequest` + `ContentBlock` union |
| `apps/web/src/lib/adapter.ts:1757,1971` | `respondApproval`, `connectConnector` |
| `apps/web/src/components/os/apps/MarketplaceApp.tsx:108,221-231` | grid install — **local `useState`** (no shared store) |

View File

@@ -0,0 +1,234 @@
# PR4 Recon — Slice 5: Agent-Search / Agent-Pick
**Screen 09 Marketplace · Variation A "agent-suggestion box"**
"Describe what you want to do…" → agent recommends a **connector + skill + tool**, each with a **"why"** reason and an **install** button.
Branch: `feature/warm-hive-pr4` · READ-ONLY recon · cited file:line as of 2026-06-16.
---
## TL;DR
The agent-pick engine **already exists and is good**: `searchCapabilities()` in
`packages/agent/src/capability-acquisition.ts:179` takes a natural-language `need` and returns a
ranked, deduped `AcquisitionProposal` of native tools / installed skills / starter-pack skills /
marketplace packages — **each candidate already carries a `matchReason` ("why") and an
`installAction`** plus a trust assessment. It fuses **keyword scoring** (no embeddings) across local
sources with **pre-fetched marketplace FTS5 candidates**.
**The gap is entirely the surface, not the brain.** This engine is reachable **only as an agent tool**
(`acquire_capability`, `packages/agent/src/skill-tools.ts:406`) invoked inside the chat loop — there is
**no HTTP route** that the Screen-09 agent-search bar could `POST` a need to and render the
three-up suggestion box. PR4 must add a thin REST endpoint over the existing `searchCapabilities()`
(plus its deps assembly that today lives inline in `local/index.ts:620`) and a new
`MarketplaceApp` agent-search UI. The matching scoring is single-recommendation today
(`proposal.recommendation` is ONE candidate); the design wants **one-of-each-kind** (connector+skill+tool),
which is a selection/grouping change on top of the existing ranked `candidates[]`, not new matching.
---
## What exists today (verified)
### 1. The matching brain — `searchCapabilities()` (the agent-pick core)
`packages/agent/src/capability-acquisition.ts`
- **Input** (`SearchCapabilitiesInput`, :170): `{ need, installedSkills[], starterSkillsDir,
nativeToolNames[], marketplaceCandidates[] }`. Marketplace candidates are **pre-fetched and passed
in** — `searchCapabilities` itself does no IO except reading starter-skill `.md` files from disk
(`loadStarterSkillsMeta`, :142).
- **Algorithm** (no embeddings — pure keyword): `extractKeywords()` (:64, stop-word filtered) →
`scoreMatch()` (:74, name-hit ×2 / content-hit ×1, normalized 01) across four source lanes:
1. native tools (scored vs `NATIVE_TOOL_HINTS` map, :113);
2. installed/active skills (:218);
3. starter-pack skills not yet installed (:241);
4. marketplace candidates, merging the FTS score when present via `Math.max(keywordScore, mkt.score)` (:270).
- **Output** (`AcquisitionProposal`, :39): `{ need, gapDetected, summary, candidates[]≤8,
recommendation, alreadyHandled }`. Each `CapabilityCandidate` (:27) already has the exact fields
Screen-09 needs: `name`, `type` ('native'|'skill'|'plugin'|'mcp'|'connector'|'marketplace'),
`availability`, `description`, `matchReason` (**the "why"**, built by `buildMatchReason` :99),
`installAction` (string|null), and `trust`.
- **Recommendation is SINGLE** (:307314): picks the best installable (or best active if
already-handled). It does **not** group into connector+skill+tool. The full ranked `candidates[]`
is there to do that, but the grouping logic does not exist yet.
- **`summary`** is a markdown string built for the **chat** surface (`buildProposalSummary`, :330): it
even **emits the inline-install marker** (`<!--waggle:capability_request {...}-->`, :385) verbatim for
the chat card. This is debug/chat-grade prose — a UI agent-suggestion box would consume the
**structured `candidates`/`recommendation`, not `summary`**.
### 2. How it's invoked today — the agent tool `acquire_capability`
`packages/agent/src/skill-tools.ts:404478`
- Tool `acquire_capability` (param: `need`) gathers deps: `getInstalledSkills()`, `starterSkillsDir`,
`nativeToolNames`, and calls `deps.searchMarketplace(need)` (graceful try/catch) to pre-fetch
marketplace candidates, then calls `searchCapabilities(...)` and **returns `proposal.summary`** (the
markdown string) to the model. Audit event recorded on gap (:461).
- Companion tool `install_capability` (:482) installs **starter-pack skills only** (validated by
`validateInstallCandidate`, capability-acquisition.ts:424 — rejects any source ≠ `starter-pack`).
- The deps are wired in `packages/server/src/local/index.ts:620` (`createSkillTools({...})`):
- `nativeToolNames` = union of mind/system/plan/git/document tool names (:624);
- `getInstalledSkills` = live `server.agentState.skills` (:631, hot-reloadable);
- `searchMarketplace` = `marketplaceDb.search({ query, limit: 10 })` mapped to `MarketplaceCandidate[]`
(:641656) — note `score` is hardcoded `undefined` (FTS rank not surfaced through the API).
### 3. The marketplace search it sits on
`packages/server/src/local/routes/marketplace.ts:57` — `GET /api/marketplace/search`
→ `MarketplaceDB.search()` (`packages/marketplace/src/db.ts:86`).
- FTS5 over the `packages` table. **Critically, `db.search` already tolerates a verbose NL `need`**:
`toFtsMatchQuery()` (db.ts:9199) relaxes the raw string into an OR-of-prefixes and falls back to an
unfiltered listing rather than throwing — so the agent's natural-language need works as-is.
- `SearchResult` (`types.ts:233`): `{ packages[], total, facets{types,categories,sources},
installedCount }`. Each `MarketplacePackage` carries `waggle_install_type` ('skill'|'connector'|'mcp')
→ **the kind badge**, `package_type`, `description`, `downloads` (install count), and (route-annotated
at marketplace.ts:105) `installed`, `scanStatus`. **`installedCount`** is the natural source for the
"N in this workspace" count bar (currently catalog-wide, not workspace-scoped — see Gaps).
### 4. The parallel, narrower "recommend" path (skills-only) — DO NOT confuse with agent-pick
`packages/agent/src/skill-recommender.ts` (`SkillRecommender.recommend(context, topN)`, class at :118).
- Multi-signal keyword + bigram + synonym-cluster matcher over **installed skills only** (no
marketplace, no connectors, no MCP). Returns `SkillRecommendation[] = {skillName, reason,
relevanceScore}` — also a "why" (`reason`), but skills-only.
- Exposed over HTTP at `GET /api/skills/suggestions?context=&topN=`
(`packages/server/src/local/routes/skills.ts:393406`). This is the **only** existing HTTP surface
that returns "what should I use" with a reason — but it's the wrong shelf (skills only, already
installed) for Screen-09's connector+skill+tool suggestion box. Useful as a *prior-art pattern* for
shaping the new route.
### 5. The router (a third matcher) — for completeness
`packages/agent/src/capability-router.ts` (`CapabilityRouter.resolve(query)`, :58). Maps a query to
ranked routes across native/connector/skill/plugin/mcp/subagent with confidences. **Resolution, not
recommendation** — returns "where could this be handled" not "install this". Connector lane (:85)
knows `connected` status and emits a suggestion when not connected. Not currently HTTP-exposed; a
secondary input if PR4 wants live-connector awareness in the suggestion box.
### 6. The inline-card render path (the "sync" downstream, shared with the grid)
- Parser: `apps/web/src/components/os/apps/chat-blocks/capability-request-parser.ts` — `segmentText()`
splits agent text on the `<!--waggle:capability_request {name,source,reason}-->` marker (and a legacy
phrasing) into install-card segments, deduped by `source::name`.
- Card: `apps/web/src/components/os/apps/chat-blocks/CapabilityRequestCard.tsx` — renders the
**pending→installing→installed/failed** micro-states (:17,:107149) with a "why" line from
`request.reason` (:103). Install routing already branches by source: marketplace →
`adapter.searchMarketplace` then `adapter.installMarketplacePackage(pkg.id)` (:4854, tier-gated, 403
→ UpgradeModal); starter-pack → `adapter.installPack` (:69). **This is exactly the type-aware
one-click flow §09 asks for, already built for the inline-chat variation (Variation B).**
### 7. Adapter methods already present (the install actions)
`apps/web/src/lib/adapter.ts`: `searchMarketplace(query,limit)` (:1334), `installMarketplacePackage(id)`
(:1338), `installPack(skillId)` (:1292), `connectConnector(id,creds)` (:1971), `installMcp(mcpId)`
(:2017). All three install kinds in the §09 sync spec (skill Add / connector Connect / MCP Enable)
have adapter coverage.
---
## Gaps vs the Screen-09 contract
1. **No HTTP route for agent-pick.** `searchCapabilities()` is reachable ONLY inside the chat agent
loop via the `acquire_capability` tool. The Screen-09 centered agent-search bar ("Ask the agent")
needs a `POST /api/marketplace/agent-search` (or similar) that runs `searchCapabilities` and returns
**structured candidates** (not the chat `summary` markdown). Must be built on top of the existing
engine.
2. **Returns chat-grade `summary`, not a structured suggestion box.** The tool returns
`proposal.summary` (markdown for the model). The UI needs the raw `candidates[]`/`recommendation`
JSON. The data is computed (proposal object) but **discarded** at skill-tools.ts:476 — a route would
return the object directly.
3. **Single recommendation, not one-of-each-kind.** `proposal.recommendation` is ONE candidate
(capability-acquisition.ts:307). §09 wants **connector + skill + tool** (three, one per kind) each
with its own why + install. Needs a small grouping pass over the ranked `candidates[]` (top per
`type`/`availability`) — new logic, but trivial given candidates already carry `type`.
4. **Marketplace deps assembly lives inline in `local/index.ts`, not reusable.** The
`getInstalledSkills` / `nativeToolNames` / `searchMarketplace` closure that feeds the tool is
constructed once at server boot (index.ts:620). A new route needs the same deps — either lift this
into a shared helper or have the route reconstruct it. (Native tool names, in particular, are only
assembled in that closure.)
5. **"N in this workspace" count bar is catalog-wide, not workspace-scoped.** `installedCount`
(db.search → types.ts:242) counts all installed packages globally; the marketplace DB is not
workspace-partitioned. §09's per-workspace chip count has **no backing field** today.
6. **No "example chips" / suggested-need seeding.** Pure UI; no backend. Can be static or derived
from persona `suggestedCommands` — out of agent-pick scope.
7. **No shared install-state store on the frontend.** §09's CRITICAL "sync" (one store powers grid +
agent-pick + inline card, installing in any view reflects in all) — the current `MarketplaceApp.tsx`
(no agent-search at all, install state local to each card) and `CapabilityRequestCard` (local
`useState` phase) have **independent** state. This is the headline PR4 frontend build; agent-pick is
one of the three consumers of that store. (Owned by the "shared install store" slice — flagged here
as the integration boundary.)
8. **`MarketplaceApp.tsx` has no agent-search UI.** Verified: the 417-line component
(`apps/web/src/components/os/apps/MarketplaceApp.tsx`) contains no "Ask the agent" / suggestion /
`acquire`-style references — only grid install/uninstall via `installMarketplacePackage` (:228).
Variation A's centered bar + suggestion box must be built net-new.
9. **Marketplace FTS rank not surfaced.** `searchMarketplace` hardcodes `score: undefined`
(index.ts:651), so marketplace candidates rank purely by keyword re-scoring inside
`searchCapabilities`. Acceptable, but means FTS relevance is currently dropped on the floor for the
agent-pick path.
---
## Exact integration points a PR4 build would touch
- **Reuse (engine):** `searchCapabilities(input): AcquisitionProposal`
— `packages/agent/src/capability-acquisition.ts:179`. Exported from `@waggle/agent`
(`packages/agent/src/index.ts:326`). Types `CapabilityCandidate` / `AcquisitionProposal` /
`MarketplaceCandidate` / `SearchCapabilitiesInput` are all exported.
- **Build (route):** new `POST /api/marketplace/agent-search` (body `{ need }`) in the marketplace
route file `packages/server/src/local/routes/marketplace.ts` — runs `searchCapabilities` with deps
assembled like `local/index.ts:620657`, returns structured `{ candidates, recommendation,
groupedByKind }`. Pattern to mirror for shape/contract: `GET /api/skills/suggestions`
(`packages/server/src/local/routes/skills.ts:393`).
- **Reuse (deps):** marketplace search `MarketplaceDB.search({query,limit})`
(`packages/marketplace/src/db.ts:86`); installed-skills source `server.agentState.skills`; native
tool-name union (currently only assembled at `local/index.ts:624` — lift if reused).
- **Add (grouping):** a `pickOnePerKind(candidates)` helper (new) to satisfy connector+skill+tool —
trivial reduce over `candidate.type`.
- **Install actions (already present, reuse):** `adapter.installMarketplacePackage` /
`adapter.installPack` / `adapter.connectConnector` / `adapter.installMcp`
(`apps/web/src/lib/adapter.ts:1338/1292/1971/2017`). Marker/card render reuse:
`segmentText` + `CapabilityRequestCard` (chat-blocks/).
- **Count bar:** `SearchResult.installedCount` (`packages/marketplace/src/types.ts:242`) via
`/api/marketplace/search`; needs workspace-scoping if §09's per-workspace count is taken literally.
- **Audit (existing, ride along):** `fastify.auditStore?.record(...)` already called on gap in
skill-tools.ts:461 — a route should record proposals the same way.
---
## Risks / watch-outs
- **Two parallel matchers + a router** (`searchCapabilities` vs `SkillRecommender` vs
`CapabilityRouter`) — building a new route on the wrong one (e.g. `SkillRecommender`, which is
skills-only and HTTP-exposed already) would silently drop connectors/MCP. **Use
`searchCapabilities`.**
- **`summary` vs structured-candidates confusion** — the tool's return value is markdown; do not parse
it for the UI. Return the proposal object from the new route.
- **Keyword-only matching** — no embeddings; verbose/synonym-heavy needs may under-match. Acceptable
for v1 but the suggestion box may look thin on phrasing mismatch. (`SkillRecommender` has synonym
expansion; `searchCapabilities` does not.)
- **Tier gating asymmetry** — marketplace/MCP install is PRO-gated (marketplace.ts:181 `requireTier`),
starter-pack/skill is free. The suggestion box must reflect this (the inline card already 403→Upgrade,
CapabilityRequestCard.tsx:55).
- **Workspace scoping of installs** — marketplace DB is global; the "N in this workspace" framing may
over-promise isolation that the substrate doesn't provide.
- **Native tool-name list is closure-local** (index.ts:624) — a route reconstructing deps must not
drift from the real registered tool set, or agent-pick "already have a tool" answers go stale.
---
## Open questions for the founder/lead
1. **Route shape:** dedicated `POST /api/marketplace/agent-search`, or extend
`GET /api/marketplace/search` with an `agentPick=true` mode? (The former is cleaner given the verbose
NL body + structured proposal response.)
2. **One-of-each-kind vs top-N:** §09 shows exactly connector+skill+tool (3). When a kind has no match
(e.g. no relevant connector), show 2? Show an empty-kind hint? Define the grouping contract.
3. **Count bar semantics:** is "N in this workspace" literally per-workspace (needs new
workspace-scoped install tracking) or is catalog-wide `installedCount` acceptable for v1?
4. **Suggestion-box "why":** use the engine's `matchReason` (keyword-hit-grade, e.g. "name matches:
risk") as-is, or have the route pass candidates to the LLM for a one-line natural "why"? The former
is free + deterministic; the latter is prettier but adds a model call.
5. **Shared install store ownership:** confirm the frontend "sync" store is a separate PR4 slice that
agent-pick plugs into (this recon treats it as the integration boundary, not part of Slice 5).

View File

@@ -0,0 +1,40 @@
# PR4 Grounding — recon verified against live code (2026-06-16)
5-reader workflow `wf_19393d8b-c2d` (~669k tokens). Verdicts: Slice1 minor-drift · Slice2 confirmed · Slice3 confirmed · Slice4 **MAJOR-drift** · Slice5 minor-drift. **Net: the build plan is sound; the recon DOCS had 2 material errors that change Phases C/D execution (not the architecture).**
## Material drifts (these change HOW a phase is built)
### D-A (Slice 4, MAJOR) — Phase D binds to `CapabilityRequestCard`, NOT the approval gate
Recon doc `04-inline-in-chat.md` pointed Phase D at `ApprovalGate`/`InlineApprovalCard` (the SSE `approval_required` singleton). **Wrong.** The live inline-install card is **`chat-blocks/CapabilityRequestCard.tsx`**, parsed from agent TEXT by `capability-request-parser.segmentText` (marker `<!--waggle:capability_request {json}-->` or legacy phrasing) and rendered by `TextBlock.tsx:25-27`. The build plan §2/§5 already names CapabilityRequestCard correctly — so the PLAN is right, only recon-04's framing was misleading. **Phase D = (1) widen `CapabilityRequest.kind` `'skill'|'marketplace'` → `+'connector'|'mcp'` (parser already passes `kind` through verbatim — parser.ts:22 — no parser change to ACCEPT, only to RENDER/DISPATCH); (2) add connector/mcp dispatch branches in `CapabilityRequestCard.handleInstall` routing through the shared store; (3) connector approve is FE-direct `adapter.connectConnector(id,{token})` — token NEVER transits the boolean approval wire (`PendingApproval.resolve(approved:boolean)` — index.ts:168). Do NOT build a 3rd ConnectorOfferCard.** Marker regex `(\{[^}]+\})` forbids nested `}` — fine, a connector offer is flat `{name,source,kind,reason}`.
### D-B (Slice 5, minor but load-bearing) — `searchCapabilities` has NO connector/mcp lane
`searchCapabilities()` only ever EMITS `type ∈ {'native','skill','marketplace'}` — never `'connector'|'mcp'|'plugin'` (truth table below). ALL marketplace results collapse to `type:'marketplace'` because the `searchMarketplace` closure (index.ts:646) drops `waggle_install_type`. Connectors aren't even an input (they live in `connectorRegistry`, not marketplace.db). **So §09's literal "connector + skill + tool" three-up box CANNOT be produced by `pickOnePerKind(candidate.type)`.****PHASE-C DECISION (founder-gate when I reach C):** either (a) **honest scope** — group on what the engine really emits: *native-tool + skill + marketplace-pkg*, each with real `matchReason` "why" + `installAction` (delivers a true three-up box, no connector lane); or (b) **add a connector lane** — keyword-match `connectorRegistry.getDefinitions()` in the route and inject connector candidates + carry `waggle_install_type` through `MarketplaceCandidate` so mcp/skill packages bucket correctly (scope+). Recommend (a) for v1; flag (b) as the "literal §09" upgrade.
## Verified contracts (verbatim — bind to these)
### Phase A — shared install store (UNAFFECTED by drifts; fully grounded, build now)
- **No FE store exists** (grep InstallProvider/useInstallStore/InstallStore → none). Providers pattern = React context (`ServiceProvider`/`ShellContext`/`ThemeProvider`), no Redux/Zustand. `useService()` exposes `{connecting}`; gate authed hydrate on connect-settled (MarketplaceApp.tsx:101).
- **Value type + normalizers REUSE** `apps/web/src/lib/extension-catalog.ts`: `Extension{id,name,description,type,source,installed,lifecycle:'available'|'installed',installable,kind:'package'|'pack'|'federated',packageId?,scanStatus?,trust?,category?,openIn?}`. ID scheme keys the store: `pkg:<n>|pack:<slug|id|name>|connector:<id>|agent:<id>|model:<id>|template:<id>|mcp:<id>`. `installable` is hard-coded FALSE today for connector/mcp/pack → Phase B flips connector/mcp to true once the store can install them.
- **Install verbs (adapter, REUSE):** skill `installSkill(id, source:'starter'|'pack'|'marketplace', packageId?)` | starter `installPack(skillId):void` | marketplace pkg `installMarketplacePackage(packageId:number):Promise<Response>` (RAW — inspect .status/.json; 403 TIER fires `waggle:tier-insufficient`, 403 `{blocked}`, 422 fail) | connector `connectConnector(id,{token?|apiKey?...}):void` (UNGATED) | mcp `installMcp(mcpId,opts?):{installed,server?,status?,requiresApproval?}` (PRO).
- **Hydrate reads:** `GET /api/skills` {skills:[{name,...}],count} · `GET /api/connectors` {connectors:ConnectorDefinition[]} (status derived live: connected/disconnected/expired/error) · `GET /api/mcps` {mcps:McpListItem[],total,installed} (per-row installed/status/scope) · `GET /api/marketplace/installed` {installations,total} + `/search` per-row `installed` flag (the ONLY queryable installed-set; skills/connectors/mcp derive at read).
- **Reconcile hazard:** install can 403(TIER)/403(SecurityGate)/422 AFTER an optimistic flip → store rolls back + routes to upgrade(`waggle:tier-insufficient`)/approval; count bar must never show a gate-rejected item.
- **Count bar (D1 ratified):** FE-derived GLOBAL count (install_audit has NO workspace_id, is append-only + OSS-excluded; `getInstalledCount()` is global). Label honestly "installed", not "in this workspace".
### Phase B — Variation A grid
- `ExtensionCard` props `{ext, installing?, onInstall?(ext), onUninstall?(ext), onOpenIn?(appId)}`; testids root `extension-card`, install `extension-install-${ext.id}`. Today: generic Install/Remove/Open-in, no type-aware verbs, no micro-states. Phase B adds Add/Connect/Enable + idle→in-progress→done from the store.
- `MarketplaceApp` exports (named) `installRiskFor`/`buildRemoveRequest`/`buildInstallRequest` + default. Facets today = `['all',...EXTENSION_TYPES]` = 7. D2 collapse → 4 (All/Skills/Connectors/MCP). Count today = `visible.length` → replace with store count bar.
- **Tests to rewrite = 10 `it()` blocks** (recon said 11 — DRIFT) in `apps/web/src/test/phase4b-marketplace-extend.test.tsx`; adapter mocked via `vi.hoisted`. Pinned testids: extension-facets, federated-note, extension-card, extension-install-pkg:7/:9, approval-modal(-approve); call shapes `getMarketplace({type:'skill',limit:30})`/`({type:'mcp',limit:30})`.
- **Path correction:** ExtensionCard lives at `components/os/apps/extend/ExtensionCard.tsx` (nested under apps/), NOT `os/extend/`.
### Phase C — POST /api/marketplace/agent-search (route under `packages/server/src/local/routes/`, NOT `src/routes/`)
- Engine `searchCapabilities(input:SearchCapabilitiesInput):AcquisitionProposal` exported from `@waggle/agent` (index.ts:326). Input `{need, installedSkills:[{name,content}], starterSkillsDir, nativeToolNames?, marketplaceCandidates?}` — engine does NO marketplace IO (candidates passed in).
- `CapabilityCandidate{name,type,availability,description,source,matchScore,matchReason,installAction,trust?}`. **Emitted-type truth table:** native→`type:'native',availability:'active',installAction:null`; active skill→`'skill','active',null`; starter→`'skill','installable','install_capability'`; marketplace→`'marketplace','installable','install_capability'`. `recommendation` = SINGLE candidate; `candidates` capped at 8; `summary` is markdown for the model — **return STRUCTURED candidates/recommendation, NOT summary** (skill-tools.ts:476 discards it).
- **Deps to lift (index.ts:620-657):** `nativeToolNames` = union of mind/system/plan/git/document tool names (closure-local; OMITS search/browser/cli/cron/connector — so web_search can't score even today); `getInstalledSkills` = `server.agentState.skills ?? loadSkills`; `searchMarketplace` = `marketplaceDb.search({query,limit:10}).packages → MarketplaceCandidate` (drops waggle_install_type + score). Ride-along audit `fastify.auditStore?.record({...action:'proposed',initiator:'agent'})`.
### Phase D — inline (see D-A above for the corrected target)
- Boolean approval channel CONFIRMED: `PendingApproval.resolve:(approved:boolean)` (index.ts:168); `POST /api/approval/:requestId` body `{approved,always?,reason?,sourceWorkspaceId?}` — NO token. `adapter.respondApproval(requestId,approved,opts)`.
- Connect route `POST /api/connectors/:id/connect` body `{token?|apiKey?,refreshToken?,expiresAt?,scopes?,email?}` → vault `setConnectorCredential`; 404/400/503; authType = `registry.get(id)?.authType ?? 'bearer'`. **Card must branch on authType** — token-paste fits bearer/api_key only; OAuth (oauth2) needs redirect → Hub fallback (D3). Google family connector-id ≠ provider key (`OAUTH_PROVIDER_FOR_CONNECTOR`).
- Gate a new agent offer/connect tool: add exact name to `ALWAYS_CONFIRM` (confirmation.ts:16) OR name it `connector_<id>_<writeverb>_*` (CONNECTOR_WRITE_PATTERNS).
## install_audit (do not touch as state)
Append-only INSERT-only, NO `workspace_id`, action vocab = `proposed|approved|installed|rejected|failed|blocked|uninstalled` (NO 'synced'/'connected' — adding one is a CHECK migration). OSS-EXCLUDED (interleaved in hive-mind-core schema.ts/db.ts; curated out by hand) → install_audit-only changes have nowhere to land on the mirror. NEVER the source of installed state.