# Pluggable Tool-Adapter Registry — Design **Date:** 2026-06-30 · **Author:** Claude Opus 4.8 (1M) · **Owner:** Marko (founder) **Arc:** AI-OS external-agent recon · STEAL NOW item **#5** from `docs/analysis/external-agent-launching-and-memory-comparison-2026-06-29.md` **Effort:** M · **Branch:** `feat/tool-adapter-registry` · **v1 scope (founder-approved):** full registry + JSON loader. --- ## 1. Goal & Non-Goals **Goal.** Turn the 7 hardcoded external tools — currently the same IDs duplicated across **8 structures** in `shared` + `agent` — into one derived **adapter registry**, and let a self-hosted / KVARK operator add a **PATH-based CLI runtime by dropping a JSON file** (`~/.waggle/adapters/*.json`), with **no core edit, no recompile, and no executable code load** (honors CLAUDE.md §7: no eval / no dynamic require). **Non-goals (YAGNI):** - No third-party **GUI-desktop** adapters (candidate-path detection stays built-in code — see §3 escape hatch). CLI agents are the launch target. - No `require()`/dynamic code load — third-party adapters are **declarative data only**. - No change to the launch/observe behavior shipped in #1–#4. No UI redesign (the dock derives its cohort lists from the same source). - No signed-plugin trust store (that was the rejected "M-plus" option). ## 2. The 8 hardcoded structures collapsing into 1 source of truth | Today (authored separately) | After | |---|---| | `shared`: `SUPPORTED_TOOLS`, `LAUNCH_COHORT`, `TOOL_DISPLAY_NAMES` | derive from `BUILTIN_TOOL_MANIFESTS` | | `agent`: `HOOK_POINTER_BY_TOOL`, `detectorsById` + per-tool `detect*` fns | one manifest-driven detect loop | | `agent` `tool-launcher`: `HOOKS_COHORT`, `hookPackageFor` | derive from the registry (`hookCapable`) | | `agent` `launcher-prompt-args`: `promptArgsForTool` | built-ins unchanged; third-party via declarative `promptArgTemplate` | ## 3. Architecture — data (shared) vs behavior (agent) ### Unit A — `ToolManifest` + built-in manifests (`packages/shared/src/tool-detection.ts`) ```ts export type ToolDetectSpec = | { kind: 'path'; binaryName: string } // PATH lookup (CLI tools; the only third-party-allowed kind) | { kind: 'candidates' }; // GUI/desktop — paths resolved by an agent-side resolver (built-in only) export interface ToolManifest { id: string; displayName: string; launchable: boolean; hookCapable: boolean; // ⟺ ships a real @waggle/hive-mind-hooks- bin hookPointer: string; // relative pointer path for hook-status probe detect: ToolDetectSpec; /** Declarative inline-prompt arg template for THIRD-PARTY path adapters, e.g. * ['--print', '{prompt}']. Built-ins keep their logic in launcher-prompt-args.ts. */ promptArgTemplate?: string[]; /** true = first-party (the 7); false/absent = loaded third-party. */ builtin?: boolean; } ``` `SUPPORTED_TOOLS` stays the `as const` literal **type anchor** (preserves the `ToolId` union → zero blast radius on the ~7 places typed `Record`). `BUILTIN_TOOL_MANIFESTS: ToolManifest[]` holds the per-tool **data** (the 7). The other consts **derive** from it: `TOOL_DISPLAY_NAMES`, `LAUNCH_COHORT`, `HOOK_POINTER_BY_TOOL` (and a new `HOOKS_COHORT` = manifests where `hookCapable`). Pure data → consumed by both the web bundle and the sidecar. ### Unit B — registry + detection loop (`packages/agent`) - **`tool-registry.ts` (new).** `getToolRegistry(deps?)` returns the merged adapter list: the 7 built-in manifests (each `candidates` manifest paired with its existing agent-side resolver `(deps) => string[]`, keyed by id — the **escape hatch**) **plus** validated third-party manifests from the loader (Unit C). Built-in ids win on collision. - **`tool-detection.ts`.** `detectAll` iterates `getToolRegistry()` instead of the `detectorsById` map: `detect.kind:'path'` → `detectByPath(id, binaryName)`; `detect.kind:'candidates'` → the adapter's resolver → `detectByCandidates`. `probeHooks` reads `manifest.hookPointer`. The bespoke `detectClaudeCode`/`detectCursor`/… wrappers and `detectorsById` are deleted; `detectByPath` / `detectByCandidates` / the 3 candidate-path helpers are **kept** (they're the reusable engine). - **`tool-launcher.ts`.** `HOOKS_COHORT` and the launch-cohort guard derive from the registry (`hookCapable` / `launchable`); `hookPackageFor(id)` = `manifest.hookPackage ?? @waggle/hive-mind-hooks-`. ### Unit C — declarative loader (`packages/agent/src/tool-manifest-loader.ts`, new) Reads `~/.waggle/adapters/*.json`, **zod-validates** each, and returns `ToolManifest[]`: - Allowed `detect.kind` for third-party: **`'path'` only** (candidates would require code). - **Safe-string refinement** on `id` / `binaryName` / `hookPointer` / template entries: no shell metacharacters (`; | & $ \` ( )`), no path traversal (`..`), no absolute path separators in `binaryName`. Reject (skip + log) any manifest that fails — never throw into detection. - Injected `readDir` / `readFile` deps so the loader is hermetic in tests; missing dir → `[]`. - `builtin: false` stamped on every loaded manifest. ## 4. Data Flow ``` BUILTIN_TOOL_MANIFESTS (shared, data) ──┐ ├─▶ getToolRegistry() ──▶ detectAll loops adapters ~/.waggle/adapters/*.json ─ loader ─────┘ (built-in resolvers for `candidates`; (zod + safe-string, kind:'path' only) detectByPath for `path`) SUPPORTED_TOOLS/ToolId (anchor, unchanged) LAUNCH_COHORT / HOOKS_COHORT / TOOL_DISPLAY_NAMES / HOOK_POINTER_BY_TOOL ── derive from manifests ``` ## 5. Error Handling / Security - Loader never throws into detection: a malformed/unsafe manifest is skipped + logged; detection proceeds with built-ins. - `detect.kind:'candidates'` from a third-party manifest is rejected (code-only strategy). - Safe-string refinement blocks shell-metachar / traversal injection in adapter fields (the boundary defense for external descriptors). - Built-in ids always win over a third-party manifest claiming the same id (no built-in hijack). - No `require()`, no eval, no dynamic import of adapter code — data only. ## 6. Testing (TDD) - **shared:** `BUILTIN_TOOL_MANIFESTS` has 7 entries; `LAUNCH_COHORT`/`HOOKS_COHORT`/`TOOL_DISPLAY_NAMES`/`HOOK_POINTER_BY_TOOL` derive correctly and match today's values (regression-lock the current 7-tool reality). - **loader:** valid path-manifest → parsed + `builtin:false`; `kind:'candidates'` rejected; shell-metachar / `..` rejected; missing dir → `[]`; injected fake fs. - **registry:** built-ins present; a loaded third-party `path` adapter appears and is detectable; built-in id wins a collision. - **detection:** `detectAll` over the registry yields the same results as today for the 7 (injected spawns — existing `tool-detection.test.ts` stays green); a third-party path adapter detects via `detectByPath`. - **launcher:** `HOOKS_COHORT` derivation matches today's 6; `hookPackageFor` honors a manifest override. **Gates:** `tsc` 0 (shared/agent/server/web); existing `tool-detection`/`tool-launcher`/`tools-routes*` suites stay green; new units RED→GREEN. ## 7. File Change List | File | Change | |---|---| | `packages/shared/src/tool-detection.ts` | `ToolManifest`/`ToolDetectSpec` types, `BUILTIN_TOOL_MANIFESTS`, derive the 5 consts | | `packages/agent/src/tool-registry.ts` | **new** — `getToolRegistry()` (built-ins + loaded), candidate-resolver map | | `packages/agent/src/tool-manifest-loader.ts` | **new** — zod + safe-string loader for `~/.waggle/adapters/*.json` | | `packages/agent/src/tool-detection.ts` | drive `detectAll` from the registry; delete per-tool wrappers + `detectorsById` | | `packages/agent/src/tool-launcher.ts` | derive `HOOKS_COHORT` / cohort guard / `hookPackageFor` from the registry | | `packages/agent/src/index.ts` | export registry + loader + manifest types | | `apps/web/src/components/os/apps/LauncherApp.tsx` | derive its local `LAUNCH_COHORT`/`HOOKS_COHORT` from shared manifests (kill the local copies) | | tests (5–6) | per §6 | ## 8. Open Questions - **None blocking.** Scope (full registry + loader), the candidate-path escape hatch, and the data-only/no-`require()` security model are founder-approved. Third-party GUI-desktop adapters + a signed-plugin trust store are explicit future items, not gaps.