Explorar el Código

feat: add offline agent marketplace foundation

Alvin Unreal hace 5 días
padre
commit
db5de11a90
Se han modificado 89 ficheros con 12508 adiciones y 1624 borrados
  1. 63 13
      README.md
  2. 32 1
      bun.lock
  3. 84 0
      docs/adr/002-v3-agent-resolution-foundation.md
  4. 21 0
      docs/configuration.md
  5. 79 0
      docs/marketplace.md
  6. 1 0
      docs/quick-reference.md
  7. 16 0
      docs/tools.md
  8. 447 388
      oh-my-opencode-slim.schema.json
  9. 3 0
      package.json
  10. 6 8
      src/agents/codemap.md
  11. 8 9
      src/agents/council.ts
  12. 52 20
      src/agents/custom.test.ts
  13. 7 82
      src/agents/designer.ts
  14. 7 50
      src/agents/explorer.ts
  15. 7 57
      src/agents/fixer.ts
  16. 181 27
      src/agents/index.test.ts
  17. 716 75
      src/agents/index.ts
  18. 7 41
      src/agents/librarian.ts
  19. 7 39
      src/agents/observer.ts
  20. 7 44
      src/agents/oracle.ts
  21. 24 0
      src/agents/orchestrator.test.ts
  22. 104 83
      src/agents/orchestrator.ts
  23. 355 0
      src/agents/registry.test.ts
  24. 10 0
      src/agents/resolve-prompt-warn.test.ts
  25. 424 0
      src/agents/role-definitions.ts
  26. 40 13
      src/cli/config-io.test.ts
  27. 129 60
      src/cli/config-io.ts
  28. 11 9
      src/cli/doctor.test.ts
  29. 16 0
      src/cli/index.ts
  30. 63 0
      src/cli/marketplace.test.ts
  31. 247 0
      src/cli/marketplace.ts
  32. 7 7
      src/cli/providers.test.ts
  33. 8 6
      src/cli/providers.ts
  34. 1 1
      src/codemap.md
  35. 9 1
      src/config/agent-mcps.ts
  36. 11 0
      src/config/agent-roles.ts
  37. 4 0
      src/config/index.ts
  38. 46 0
      src/config/jsonc.ts
  39. 131 54
      src/config/loader.test.ts
  40. 132 8
      src/config/loader.ts
  41. 32 20
      src/config/project-local-customization.test.ts
  42. 74 21
      src/config/runtime.test.ts
  43. 27 63
      src/config/runtime.ts
  44. 51 0
      src/config/schema.test.ts
  45. 84 2
      src/config/schema.ts
  46. 11 5
      src/config/strip-orchestrator-model.test.ts
  47. 2 1
      src/config/strip-orchestrator-model.ts
  48. 1 1
      src/config/utils.ts
  49. 11 11
      src/health-check.test.ts
  50. 3 2
      src/health-check.ts
  51. 38 38
      src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap
  52. 22 0
      src/hooks/filter-available-skills/index.test.ts
  53. 22 24
      src/hooks/filter-available-skills/index.ts
  54. 5 2
      src/index.test.ts
  55. 149 238
      src/index.ts
  56. 233 0
      src/marketplace/activation-config.ts
  57. 1669 0
      src/marketplace/activation.test.ts
  58. 474 0
      src/marketplace/activation.ts
  59. 54 0
      src/marketplace/compatibility.test.ts
  60. 45 0
      src/marketplace/compatibility.ts
  61. 131 0
      src/marketplace/config-references.test.ts
  62. 71 0
      src/marketplace/config-references.ts
  63. 72 0
      src/marketplace/errors.ts
  64. 10 0
      src/marketplace/ids.ts
  65. 11 0
      src/marketplace/index.ts
  66. 548 0
      src/marketplace/lease.test.ts
  67. 678 0
      src/marketplace/lease.ts
  68. 31 0
      src/marketplace/paths.ts
  69. 256 0
      src/marketplace/preflight.test.ts
  70. 400 0
      src/marketplace/preflight.ts
  71. 158 0
      src/marketplace/schemas.test.ts
  72. 283 0
      src/marketplace/schemas.ts
  73. 160 0
      src/marketplace/service.ts
  74. 81 0
      src/marketplace/skill-frontmatter.ts
  75. 381 0
      src/marketplace/status.test.ts
  76. 367 0
      src/marketplace/status.ts
  77. 608 0
      src/marketplace/store.test.ts
  78. 674 0
      src/marketplace/store.ts
  79. 3 0
      src/tools/codemap.md
  80. 1 0
      src/tools/index.ts
  81. 485 0
      src/tools/marketplace.test.ts
  82. 326 0
      src/tools/marketplace.ts
  83. 102 50
      src/tools/preset-switch.test.ts
  84. 10 7
      src/tools/preset-switch.ts
  85. 13 13
      src/tui-preset.ts
  86. 20 0
      src/utils/agent-variant.test.ts
  87. 53 20
      src/utils/agent-variant.ts
  88. 23 0
      src/utils/package-metadata.ts
  89. 22 10
      src/v2/tui.test.ts

+ 63 - 13
README.md

@@ -48,6 +48,9 @@ The main idea is simple: instead of forcing one model to do everything, the plug
   models at runtime with `/preset`.
 - **[Code intelligence tools](docs/tools.md)** - LSP tools, AST-aware search
   across 25 languages, and built-in MCPs for docs and GitHub code
+- **[Local marketplace](docs/marketplace.md)** - install and activate local
+  offline agent/profile packages from the CLI or the in-session
+  `marketplace` tool; changes apply after reload
   search.
 - **[Fully customizable](docs/configuration.md)** - custom agents, prompt
   overrides, per-agent skill/MCP permissions, and
@@ -108,6 +111,44 @@ have Bun installed:
 npx oh-my-opencode-slim@latest install
 ```
 
+### Local Marketplace Packages
+
+The marketplace lifecycle is local and offline. Package manifests are
+data-only, exact-version locked, and stored under the XDG data directory.
+Installation and preset activation are separate: install a package, then enable
+it in the active preset. Activated agents and profiles apply after the next
+OpenCode session/reload; the live registry is never hot-swapped.
+Manifests include bounded author, tag, license, plugin/role-contract,
+routing/delegation, role-target, capability, and override metadata. `instructions`
+is the single package instruction surface; profile packages declare explicit
+append/replace composition semantics. Executable fields,
+package-to-package dependencies, and arbitrary file maps are rejected.
+
+```bash
+bunx oh-my-opencode-slim marketplace install ./package.json
+bunx oh-my-opencode-slim marketplace list
+bunx oh-my-opencode-slim marketplace verify [author/name]
+bunx oh-my-opencode-slim marketplace update ./package-v2.json
+bunx oh-my-opencode-slim marketplace enable author/name
+bunx oh-my-opencode-slim marketplace profile librarian author/profile
+bunx oh-my-opencode-slim marketplace disable author/name
+bunx oh-my-opencode-slim marketplace remove author/name
+bunx oh-my-opencode-slim marketplace status
+```
+
+Use `update` explicitly to select a different exact version. `enable` activates
+an installed `agent` package in the active preset as a separately named
+role-derived agent. `profile` selects at most one installed `profile` package
+per supported specialist role; `--clear` writes a tombstone. Required skills
+and MCPs are preflighted against built-in capabilities and on-disk host
+configuration; missing required dependencies disable that package for the
+session. Optional requirements stay unavailable and are never auto-installed.
+`import` is an alias for `install` and records the canonical absolute local
+source path in the lockfile. Startup reads only the local store and never
+contacts a registry. The orchestrator can perform the same local lifecycle
+with the in-session `marketplace` tool; see
+[Local Marketplace](docs/marketplace.md).
+
 ### Run from Master
 
 Use this if you want the latest code, easier bug fixes, or a local setup for
@@ -186,6 +227,10 @@ Then:
 > Because background agents are now the default workflow, it is **highly recommended** to enable and configure **[Multiplexer Integration](docs/multiplexer-integration.md)**. It automatically opens each agent in a dedicated Tmux, Zellij, Herdr, cmux, or kitty pane, so you can watch specialists work live while the Orchestrator continues coordinating the session.
 
 The default generated configuration includes both `openai` and `opencode-go` presets.
+Preset agent overrides are stored under each preset's explicit `agents` field;
+`marketplace` activation selects installed packages for that preset. Agent, model,
+skill, MCP, permission, and routing decisions are resolved once at startup and
+apply after the next reload rather than hot-swapping a live session.
 
 ```jsonc
 {
@@ -193,21 +238,25 @@ The default generated configuration includes both `openai` and `opencode-go` pre
   "preset": "openai",
   "presets": {
     "openai": {
-      "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "high", "skills": ["*"], "mcps": ["*", "!context7"] },
-      "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["context7", "gh_grep"] },
-      "explorer": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": [] },
-      "designer": { "model": "openai/gpt-5.6-luna", "variant": "medium", "skills": [], "mcps": [] },
-      "fixer": { "model": "openai/gpt-5.6-luna", "variant": "high", "skills": [], "mcps": [] }
+      "agents": {
+        "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "high", "skills": ["*"], "mcps": ["*", "!context7"] },
+        "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
+        "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["context7", "gh_grep"] },
+        "explorer": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": [] },
+        "designer": { "model": "openai/gpt-5.6-luna", "variant": "medium", "skills": [], "mcps": [] },
+        "fixer": { "model": "openai/gpt-5.6-luna", "variant": "high", "skills": [], "mcps": [] }
+      }
     },
     "opencode-go": {
-      "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "thinking" },
-      "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max" },
-      "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
-      "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
-      "designer": { "model": "opencode-go/kimi-k2.7-code" },
-      "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
-      "observer": { "model": "opencode-go/mimo-v2.5" }
+      "agents": {
+        "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "thinking" },
+        "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max" },
+        "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
+        "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
+        "designer": { "model": "opencode-go/kimi-k2.7-code" },
+        "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high" },
+        "observer": { "model": "opencode-go/mimo-v2.5" }
+      }
     }
   }
 }
@@ -690,6 +739,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Skills](docs/skills.md)** | Bundled skills such as `simplify`, `codemap`, `clonedeps`, `deepwork`, `verification-planning`, `reflect`, `worktrees`, and `oh-my-opencode-slim` |
 | **[MCPs](docs/mcps.md)** | `context7`, `gh_grep`, and how MCP permissions work per agent |
 | **[Tools](docs/tools.md)** | Built-in tool capabilities like `webfetch`, LSP tools, code search, and formatters |
+| **[Marketplace](docs/marketplace.md)** | Local offline package install, activation, status, and the in-session `marketplace` tool |
 
 ---
 

+ 32 - 1
bun.lock

@@ -9,14 +9,17 @@
         "@mozilla/readability": "^0.6.0",
         "@opencode-ai/plugin": "1.18.23",
         "@opencode-ai/sdk": "1.18.23",
+        "gray-matter": "4.0.3",
         "jsdom": "^29.0.0",
         "lru-cache": "^11.5.2",
+        "semver": "^7.7.4",
         "turndown": "^7.2.4",
       },
       "devDependencies": {
         "@biomejs/biome": "2.5.4",
         "@types/jsdom": "28",
         "@types/node": "^26.1.1",
+        "@types/semver": "^7.8.0",
         "@types/turndown": "^5.0.6",
         "all-contributors-cli": "^6.26.1",
         "bun-types": "^1.3.14",
@@ -211,6 +214,8 @@
 
     "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
 
+    "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="],
+
     "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
 
     "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
@@ -263,6 +268,8 @@
 
     "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
 
+    "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+
     "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
 
     "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="],
@@ -339,6 +346,10 @@
 
     "escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
 
+    "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+
+    "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
+
     "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
 
     "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
@@ -363,6 +374,8 @@
 
     "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
 
+    "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
+
     "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
 
     "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -379,6 +392,8 @@
 
     "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
 
+    "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
+
     "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
 
     "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@@ -387,6 +402,8 @@
 
     "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
 
+    "js-yaml": ["js-yaml@3.15.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w=="],
+
     "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
 
     "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -397,6 +414,8 @@
 
     "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
 
+    "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
+
     "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
 
     "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -487,7 +506,9 @@
 
     "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
 
-    "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+    "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
+
+    "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
 
     "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
 
@@ -505,10 +526,14 @@
 
     "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
 
+    "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+
     "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
 
     "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
 
+    "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
+
     "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
 
     "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -577,8 +602,14 @@
 
     "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
 
+    "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
     "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
 
+    "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+    "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
     "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
 
     "@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],

+ 84 - 0
docs/adr/002-v3-agent-resolution-foundation.md

@@ -0,0 +1,84 @@
+# ADR 002: V3 agent resolution foundation
+
+## Status
+
+Accepted and implemented for Deepwork Phase 1.
+
+## Decision
+
+The plugin builds one runtime-only `ResolvedAgentRegistry`. Agent SDK
+registration, model fallback chains, routing guidance, skill visibility, MCP
+selection, TUI model state, and diagnostics all consume that snapshot. The
+registry is rebuilt on plugin startup/reload; it is never hot-swapped during a
+conversation.
+
+The supported specialist role catalog is explicit: `explorer`, `librarian`,
+`oracle`, `designer`, `fixer`, and `observer`. Their reusable
+`RoleDefinition`s are the only role baselines available for future derived
+agents and profiles. `orchestrator`, `council`, and `councillor` remain special
+agents and are not profile targets.
+
+Presets are structured objects:
+
+```json
+{
+  "agents": {
+    "explorer": { "model": "provider/model" }
+  },
+  "marketplace": {
+    "agents": ["community/example"],
+    "profiles": { "oracle": "community/oracle-profile" }
+  }
+}
+```
+
+Activation arrays are optional so omitted and explicit empty values remain
+distinguishable during typed merges. Package IDs are trimmed and unique; profile
+targets are limited to supported specialist roles and `null` is a removal
+tombstone. Phase 3 resolves installed packages from the local store for the
+effective active preset, preflights required skills/MCPs, and applies derived
+agents and specialist profiles into the one `ResolvedAgentRegistry`. Package
+state remains outside `PluginConfigSchema`.
+
+Prompt resolution is a pure ordered pipeline: built-in baseline, package
+append/replace (reserved for later phases), preset override, root override,
+host-supported fields, then user append files. Mandatory plugin suffixes are
+added last, so replacements cannot remove workflow or role-policy gates.
+Inline replacement wins over a replacement file, and append files remain
+supported. Inputs are construction-time deterministic and do not contain
+volatile request data.
+
+Typed alias-aware merges are used for agent records and structured presets.
+Scalars and arrays replace; provider option objects merge; model inheritance is
+an explicit directive that clears a lower model, while an explicit model clears
+lower inheritance. The loader preserves structural presets and the resolved
+registry combines the active preset with root/host layers exactly once. User
+configuration remains editable and higher precedence than package content.
+Task controls are editable defaults; only documented plugin gates are applied
+after host projection.
+
+Required dependencies for future packages will be preflighted from built-in
+capabilities and on-disk host configuration. Missing required dependencies
+disable a package for that session; optional dependencies remain unavailable.
+Those package behaviors belong to later phases, not this ADR's implementation.
+
+## Resolution truth table
+
+| Surface | Lower layer | Higher layer | Merge rule | Phase 1 result |
+| --- | --- | --- | --- | --- |
+| Role baseline | `RoleDefinition` | user agent override | typed field merge | Implemented |
+| Prompt | baseline | replacement file/inline prompt | inline > file > baseline | Implemented |
+| Prompt append | selected prompt | append file | append after selected prompt | Implemented |
+| Prompt gates | all editable prompt layers | plugin suffix | append last, immutable | Implemented |
+| Model | baseline/active preset | root/host override | explicit model wins; inheritance is directive | Implemented |
+| Model array | lower chain | higher chain | replace array; first entry is startup model | Implemented |
+| Options | lower object | higher object | shallow typed object merge | Implemented |
+| Skills | role defaults | explicit agent skills | explicit list replaces defaults; disabled skills deny | Implemented |
+| MCPs | role defaults | explicit agent MCPs | explicit list replaces defaults; disabled MCPs are unavailable | Implemented |
+| Permissions | defaults | user rules | editable rules first, immutable gates after host projection | Implemented |
+| Routing | resolved enabled agents | none | canonical deterministic name ordering | Implemented |
+| Preset | user preset | project preset | structural merge by name; active agent layers merge once in registry | Implemented |
+| Marketplace agent | role baseline | package config | append-only role derivation | Implemented |
+| Marketplace profile | role baseline | one selected profile | one profile per explicit supported role; null is tombstone | Implemented |
+| Dependency preflight | package requirements | capabilities | required missing dependency disables package | Implemented |
+| Package state | plugin config | lock/store | separate parsed state, exact versions, offline startup | Implemented |

+ 21 - 0
docs/configuration.md

@@ -482,6 +482,27 @@ Theme colors adapt to the active OpenCode theme. Dynamic councillors inherit
 the configured `council` color unless `agents.councillor.color` overrides it.
 `color` works in top-level `agents` overrides and inside `presets`.
 
+### Local marketplace packages
+
+Preset `marketplace` activation selects installed local packages for that
+preset. Installation and activation are separate; changes apply after the
+next OpenCode session or reload. See **[Local Marketplace](marketplace.md)**
+for the CLI, in-session `marketplace` tool, and status diagnostics.
+
+```jsonc
+{
+  "preset": "work",
+  "presets": {
+    "work": {
+      "marketplace": {
+        "agents": ["community/example"],
+        "profiles": { "oracle": "community/oracle-profile" }
+      }
+    }
+  }
+}
+```
+
 ### Per-preset agent configuration
 
 To get per-preset behavior for any agent, built-in (`council`, `oracle`,

+ 79 - 0
docs/marketplace.md

@@ -0,0 +1,79 @@
+# Local Marketplace
+
+Install, inspect, and activate local offline marketplace packages. There is
+no website, network registry, or remote package resolution in this release.
+
+Package manifests are data-only, exact-version locked, and stored under the
+XDG data directory. Installation and preset activation are separate: install
+a package, then enable it in the active preset. Activated agents and
+profiles apply after the next OpenCode session or reload. The live agent
+registry is never hot-swapped.
+
+## CLI
+
+```bash
+bunx oh-my-opencode-slim marketplace install ./package.json
+bunx oh-my-opencode-slim marketplace import ./package.json
+bunx oh-my-opencode-slim marketplace list
+bunx oh-my-opencode-slim marketplace show author/name
+bunx oh-my-opencode-slim marketplace verify [author/name]
+bunx oh-my-opencode-slim marketplace update ./package-v2.json
+bunx oh-my-opencode-slim marketplace enable author/name
+bunx oh-my-opencode-slim marketplace profile librarian author/profile
+bunx oh-my-opencode-slim marketplace profile oracle --clear
+bunx oh-my-opencode-slim marketplace disable author/name
+bunx oh-my-opencode-slim marketplace remove author/name
+bunx oh-my-opencode-slim marketplace status [--json]
+```
+
+`import` is an alias for `install` and records the canonical absolute local
+source path in the lockfile. Use `update` to select a different exact
+version. `enable` activates an installed `agent` package in the active
+preset as a separately named role-derived agent. `profile` selects at most
+one installed `profile` package per supported specialist role; `--clear`
+writes a tombstone.
+
+`status` reports installed packages, configured activation, live-session
+agents when used from the in-session tool, diagnostics, and whether a
+reload is required. Diagnostics include store problems (missing, corrupt,
+or operational read failures) and activation rejections (collision,
+missing required dependency, invalid alias). Operational failures leave
+in-session `reload_required` as `unknown` because disk identity cannot be
+compared.
+
+## In-session tool
+
+The orchestrator can use the `marketplace` tool for the same local
+lifecycle: install, import, list, show, verify, update, enable, disable,
+profile, remove, and status. Do not shell out to the CLI for these actions
+when the tool is available.
+
+Disable it with `disabled_tools: ["marketplace"]`. Specialists cannot
+invoke it.
+
+Read-only actions (list, show, verify, status) do not change activation.
+Mutating actions write the local store and plugin config only and report
+`reload_required` only when disk activation differs from this session.
+Inactive or idempotent mutations do not include a reload note. The CLI
+has no live registry, so its reload status is `unknown`. In-session
+status is also `unknown` when the store or desired activation cannot be
+read (for example EACCES).
+
+## Status fields
+
+| Field | Meaning |
+|-------|---------|
+| installed | Exact locked versions in the local store |
+| configured_agents / configured_profiles | Active-preset activation on disk |
+| live_packages | Packages already in this session's registry, with version, digest, and runtime name |
+| diagnostics | Store and activation issues (missing, corrupt, operational, collision, missing required dependency, invalid alias), labeled `disk` or `live` |
+| reload_required | `true`/`false` when live and desired identities can be compared; `unknown` for CLI and when store/desired resolution failed operationally |
+
+## Limits
+
+- Local `package.json` files only. No remote URLs or registry IDs.
+- Required skills and MCPs are preflighted against built-in capabilities
+  and on-disk host configuration. Missing required dependencies disable
+  that package for the session. Optional requirements stay unavailable
+  and are never auto-installed.
+- Startup reads only the local store and never contacts a registry.

+ 1 - 0
docs/quick-reference.md

@@ -26,6 +26,7 @@
 | [Skills](skills.md) | `simplify`, `codemap`, `clonedeps` - skills assignment syntax |
 | [MCPs](mcps.md) | `context7`, `gh_grep` - permissions per agent, global disable |
 | [Tools](tools.md) | Background tasks, LSP, code search (`ast_grep`), formatters |
+| [Marketplace](marketplace.md) | Local offline package install, activation, status, and the in-session tool |
 | [Configuration](configuration.md) | Config files, prompt overriding, JSONC, full option reference table |
 
 ## 💡 Author's Setup

+ 16 - 0
docs/tools.md

@@ -86,6 +86,22 @@ lifecycle, cancellation, and explicit-wait edge cases behind these tools.
 
 ---
 
+## Local Marketplace
+
+| Tool | Description |
+|------|-------------|
+| `marketplace` | Install, inspect, and activate local offline marketplace packages |
+
+`marketplace` is orchestrator-only. list/show/verify/status are read-only.
+install/import/update/enable/disable/profile/remove write store/config only
+and report reload only when disk activation differs from the live session.
+The CLI reports reload status as unknown. In-session status is also
+unknown when the store or desired activation cannot be read. There is no
+network registry.
+See [Local Marketplace](marketplace.md).
+
+---
+
 ## Repeated Tool-Call Loop Guard
 
 A safety net for model-side infinite loops where a sub-agent (e.g. a model

+ 447 - 388
oh-my-opencode-slim.schema.json

@@ -27,235 +27,404 @@
       },
       "additionalProperties": {
         "type": "object",
-        "propertyNames": {
-          "type": "string"
-        },
-        "additionalProperties": {
-          "type": "object",
-          "properties": {
-            "model": {
-              "anyOf": [
-                {
-                  "type": "string"
-                },
-                {
-                  "minItems": 1,
-                  "type": "array",
-                  "items": {
-                    "anyOf": [
-                      {
-                        "type": "string"
-                      },
-                      {
-                        "type": "object",
-                        "properties": {
-                          "id": {
+        "properties": {
+          "agents": {
+            "default": {},
+            "type": "object",
+            "propertyNames": {
+              "type": "string"
+            },
+            "additionalProperties": {
+              "type": "object",
+              "properties": {
+                "model": {
+                  "anyOf": [
+                    {
+                      "type": "string"
+                    },
+                    {
+                      "minItems": 1,
+                      "type": "array",
+                      "items": {
+                        "anyOf": [
+                          {
                             "type": "string"
                           },
-                          "variant": {
-                            "type": "string"
+                          {
+                            "type": "object",
+                            "properties": {
+                              "id": {
+                                "type": "string"
+                              },
+                              "variant": {
+                                "type": "string"
+                              }
+                            },
+                            "required": [
+                              "id"
+                            ]
                           }
-                        },
-                        "required": [
-                          "id"
                         ]
                       }
-                    ]
-                  }
-                }
-              ]
-            },
-            "inheritModelFrom": {
-              "type": "string",
-              "enum": [
-                "session",
-                "orchestrator"
-              ]
-            },
-            "temperature": {
-              "type": "number",
-              "minimum": 0,
-              "maximum": 2
-            },
-            "variant": {
-              "type": "string"
-            },
-            "skills": {
-              "type": "array",
-              "items": {
-                "type": "string"
-              }
-            },
-            "mcps": {
-              "type": "array",
-              "items": {
-                "type": "string"
-              }
-            },
-            "prompt": {
-              "type": "string",
-              "minLength": 1
-            },
-            "orchestratorPrompt": {
-              "type": "string",
-              "minLength": 1
-            },
-            "options": {
-              "type": "object",
-              "propertyNames": {
-                "type": "string"
-              },
-              "additionalProperties": {}
-            },
-            "displayName": {
-              "type": "string",
-              "minLength": 1
-            },
-            "color": {
-              "description": "Agent display color as #RRGGBB or an OpenCode theme color",
-              "anyOf": [
-                {
-                  "type": "string",
-                  "pattern": "^#[0-9a-fA-F]{6}$"
+                    }
+                  ]
                 },
-                {
+                "inheritModelFrom": {
                   "type": "string",
                   "enum": [
-                    "primary",
-                    "secondary",
-                    "accent",
-                    "success",
-                    "warning",
-                    "error",
-                    "info"
+                    "session",
+                    "orchestrator"
                   ]
-                }
-              ]
-            },
-            "description": {
-              "type": "string",
-              "minLength": 1
-            },
-            "permission": {
-              "anyOf": [
-                {
+                },
+                "baseRole": {
                   "type": "string",
                   "enum": [
-                    "ask",
-                    "allow",
-                    "deny"
+                    "explorer",
+                    "librarian",
+                    "oracle",
+                    "designer",
+                    "fixer",
+                    "observer"
                   ]
                 },
-                {
+                "temperature": {
+                  "type": "number",
+                  "minimum": 0,
+                  "maximum": 2
+                },
+                "variant": {
+                  "type": "string"
+                },
+                "skills": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                "mcps": {
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                "prompt": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "orchestratorPrompt": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "options": {
                   "type": "object",
-                  "properties": {
-                    "read": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
+                  "propertyNames": {
+                    "type": "string"
+                  },
+                  "additionalProperties": {}
+                },
+                "displayName": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "color": {
+                  "description": "Agent display color as #RRGGBB or an OpenCode theme color",
+                  "anyOf": [
+                    {
+                      "type": "string",
+                      "pattern": "^#[0-9a-fA-F]{6}$"
+                    },
+                    {
+                      "type": "string",
+                      "enum": [
+                        "primary",
+                        "secondary",
+                        "accent",
+                        "success",
+                        "warning",
+                        "error",
+                        "info"
+                      ]
+                    }
+                  ]
+                },
+                "description": {
+                  "type": "string",
+                  "minLength": 1
+                },
+                "permission": {
+                  "anyOf": [
+                    {
+                      "type": "string",
+                      "enum": [
+                        "ask",
+                        "allow",
+                        "deny"
                       ]
                     },
-                    "edit": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
+                    {
+                      "type": "object",
+                      "properties": {
+                        "read": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "glob": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
+                        "edit": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "grep": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
+                        "glob": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "list": {
-                      "anyOf": [
-                        {
+                        "grep": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "list": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "bash": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "task": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "external_directory": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "lsp": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "skill": {
+                          "anyOf": [
+                            {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            },
+                            {
+                              "type": "object",
+                              "propertyNames": {
+                                "type": "string"
+                              },
+                              "additionalProperties": {
+                                "type": "string",
+                                "enum": [
+                                  "ask",
+                                  "allow",
+                                  "deny"
+                                ]
+                              }
+                            }
+                          ]
+                        },
+                        "todowrite": {
                           "type": "string",
                           "enum": [
                             "ask",
@@ -263,25 +432,7 @@
                             "deny"
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "bash": {
-                      "anyOf": [
-                        {
+                        "question": {
                           "type": "string",
                           "enum": [
                             "ask",
@@ -289,25 +440,7 @@
                             "deny"
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "task": {
-                      "anyOf": [
-                        {
+                        "webfetch": {
                           "type": "string",
                           "enum": [
                             "ask",
@@ -315,25 +448,7 @@
                             "deny"
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "external_directory": {
-                      "anyOf": [
-                        {
+                        "websearch": {
                           "type": "string",
                           "enum": [
                             "ask",
@@ -341,25 +456,7 @@
                             "deny"
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "lsp": {
-                      "anyOf": [
-                        {
+                        "codesearch": {
                           "type": "string",
                           "enum": [
                             "ask",
@@ -367,129 +464,80 @@
                             "deny"
                           ]
                         },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "skill": {
-                      "anyOf": [
-                        {
+                        "doom_loop": {
                           "type": "string",
                           "enum": [
                             "ask",
                             "allow",
                             "deny"
                           ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
+                        }
+                      },
+                      "additionalProperties": {
+                        "anyOf": [
+                          {
                             "type": "string",
                             "enum": [
                               "ask",
                               "allow",
                               "deny"
                             ]
+                          },
+                          {
+                            "type": "object",
+                            "propertyNames": {
+                              "type": "string"
+                            },
+                            "additionalProperties": {
+                              "type": "string",
+                              "enum": [
+                                "ask",
+                                "allow",
+                                "deny"
+                              ]
+                            }
                           }
-                        }
-                      ]
-                    },
-                    "todowrite": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "question": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "webfetch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "websearch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "codesearch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "doom_loop": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    }
-                  },
-                  "additionalProperties": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
                         ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
                       }
-                    ]
-                  }
+                    }
+                  ]
                 }
-              ]
+              },
+              "additionalProperties": false
             }
           },
-          "additionalProperties": false
-        }
+          "marketplace": {
+            "type": "object",
+            "properties": {
+              "agents": {
+                "type": "array",
+                "items": {
+                  "type": "string",
+                  "minLength": 1
+                }
+              },
+              "profiles": {
+                "type": "object",
+                "propertyNames": {
+                  "type": "string"
+                },
+                "additionalProperties": {
+                  "anyOf": [
+                    {
+                      "type": "string",
+                      "minLength": 1
+                    },
+                    {
+                      "type": "null"
+                    }
+                  ]
+                }
+              }
+            },
+            "additionalProperties": false
+          }
+        },
+        "additionalProperties": false
       }
     },
     "agents": {
@@ -539,6 +587,17 @@
               "orchestrator"
             ]
           },
+          "baseRole": {
+            "type": "string",
+            "enum": [
+              "explorer",
+              "librarian",
+              "oracle",
+              "designer",
+              "fixer",
+              "observer"
+            ]
+          },
           "temperature": {
             "type": "number",
             "minimum": 0,

+ 3 - 0
package.json

@@ -89,8 +89,10 @@
     "@mozilla/readability": "^0.6.0",
     "@opencode-ai/plugin": "1.18.23",
     "@opencode-ai/sdk": "1.18.23",
+    "gray-matter": "4.0.3",
     "jsdom": "^29.0.0",
     "lru-cache": "^11.5.2",
+    "semver": "^7.7.4",
     "turndown": "^7.2.4"
   },
   "optionalDependencies": {
@@ -102,6 +104,7 @@
     "@biomejs/biome": "2.5.4",
     "@types/jsdom": "28",
     "@types/node": "^26.1.1",
+    "@types/semver": "^7.8.0",
     "@types/turndown": "^5.0.6",
     "all-contributors-cli": "^6.26.1",
     "bun-types": "^1.3.14",

+ 6 - 8
src/agents/codemap.md

@@ -37,7 +37,7 @@ Each agent is a **prompt-driven specialist** with a factory function that create
 2. **Dynamic councillors**: `buildCouncillorAgents()` (`council-agents.ts`) creates one `councillor-<name>` subagent per council preset seat, attaching `_modelArray` fallback chains for multi-model councillors
 3. **Permission application**: `applyDefaultPermissions()` sets read/write permissions based on agent type
 4. **Task-rejection instruction**: `appendTaskRejectionInstruction()` appends the "outside your role" instruction to specialist prompts (`task-rejection.ts`)
-5. **Display name injection**: Orchestrator prompt rewrites `@agent` mentions to user-configured display names
+5. **Routing finalization**: Routing entries are built from the actual resolved agent definitions, with display names and custom/ACP guidance applied before sorting and orchestrator construction
 6. **Configuration export**: `getAgentConfigs()` converts `AgentDefinition` to OpenCode SDK format with classification metadata
 
 ## Flow
@@ -70,13 +70,11 @@ const orchestrator = createOrchestratorAgent(
 );
 applyDefaultPermissions(orchestrator, orchestratorOverride?.skills, config?.disabled_skills);
 
-// 4. Collect display names and inject into orchestrator prompt
-const displayNameMap = new Map<string, string>();
-// ... populate from orchestrator and all subagents ...
-injectDisplayNames(orchestrator, displayNameMap);
-
-// 5. Inject council-dispatch instructions when dynamic councillors exist
-// 6. Return agents array [orchestrator, ...allSubAgents]
+// 4. Build deterministic routes from the actual resolved subagents
+const routing = buildRoutingEntriesForResolvedAgents(runtime, allSubAgents);
+// 5. Construct the orchestrator from those finalized routes
+// 6. Inject council-dispatch instructions when dynamic councillors exist
+// 7. Return agents array [orchestrator, ...allSubAgents]
 return [orchestrator, ...allSubAgents];
 ```
 

+ 8 - 9
src/agents/council.ts

@@ -4,7 +4,7 @@ import { createSynthesisOnlyPermission } from './permissions';
 // NOTE: Councillor system prompts live in the councillor agent factory.
 // The council agent synthesizes councillor responses passed by the orchestrator.
 
-const COUNCIL_SYNTHESIS_REINFORCEMENT = `\n\n---\n\nYou MUST follow the Synthesis Process steps before producing output: review each councillor response individually by name, then produce the required output with a synthesized Council Response, a Per-Councillor Details section using each councillor's exact seat name (e.g. "alpha", not the model label), and a Council Summary with Consensus Level (unanimous|majority|split), Agreed Points, Disagreements + resolution, Remaining Uncertainty, and Recommended Action.`;
+export const COUNCIL_SYNTHESIS_REINFORCEMENT = `\n\n---\n\nYou MUST follow the Synthesis Process steps before producing output: review each councillor response individually by name, then produce the required output with a synthesized Council Response, a Per-Councillor Details section using each councillor's exact seat name (e.g. "alpha", not the model label), and a Council Summary with Consensus Level (unanimous|majority|split), Agreed Points, Disagreements + resolution, Remaining Uncertainty, and Recommended Action.`;
 
 const COUNCIL_AGENT_PROMPT = `You are the Council agent - a \
 synthesizer for multi-model consensus.
@@ -62,14 +62,13 @@ export function createCouncilAgent(
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  const prompt =
-    resolvePrompt(
-      'council',
-      customPrompt,
-      undefined,
-      COUNCIL_AGENT_PROMPT,
-      customAppendPrompt,
-    ) + COUNCIL_SYNTHESIS_REINFORCEMENT;
+  const prompt = resolvePrompt(
+    'council',
+    customPrompt,
+    undefined,
+    COUNCIL_AGENT_PROMPT,
+    customAppendPrompt,
+  );
 
   return {
     name: 'council',

+ 52 - 20
src/agents/custom.test.ts

@@ -1,7 +1,11 @@
 import { describe, expect, spyOn, test } from 'bun:test';
 import { DEFAULT_MODELS, type PluginConfig } from '../config';
 import { RuntimeConfig } from '../config/runtime';
-import { createAgents, getAgentConfigs } from './index';
+import {
+  buildResolvedAgentRegistry,
+  createAgents,
+  getAgentConfigs,
+} from './index';
 
 const TEST_DIRECTORY = 'runtime-test-agents-custom';
 function runtimeFor(config: PluginConfig | undefined = {}) {
@@ -31,7 +35,7 @@ describe('custom-agent creation', () => {
     expect(customAgent).toBeDefined();
     expect(customAgent?.config.model).toBe('openai/gpt-5.6');
     expect(customAgent?.config.prompt).toBe(
-      'You are the custom reviewer agent.',
+      'You are the custom reviewer agent.\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
     );
   });
 
@@ -52,7 +56,7 @@ describe('custom-agent creation', () => {
 
     expect(customAgent).toBeDefined();
     expect(customAgent?.config.prompt).toBe(
-      'You are a custom subagent for auditing.',
+      'You are a custom subagent for auditing.\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
     );
 
     const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
@@ -134,6 +138,34 @@ describe('custom-agent creation', () => {
     );
   });
 
+  test('role-derived custom agents inherit the canonical role policy', () => {
+    const config: PluginConfig = {
+      agents: {
+        'oracle-derived': {
+          baseRole: 'oracle',
+          model: 'provider/oracle-derived',
+        },
+        'librarian-derived': {
+          baseRole: 'librarian',
+          model: 'provider/librarian-derived',
+        },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const derived = agents.find((agent) => agent.name === 'oracle-derived');
+
+    expect(derived?.baseRole).toBe('oracle');
+    expect(derived?.config.prompt).toContain('You are Oracle');
+    expect(derived?.description).toContain('Strategic technical advisor');
+    const registry = buildResolvedAgentRegistry(runtimeFor(config));
+    expect(registry.mcpLists['oracle-derived']).toEqual([]);
+    expect(registry.skillPermissions['oracle-derived']?.simplify).toBe('allow');
+    expect(registry.mcpLists['librarian-derived']).toEqual([
+      'context7',
+      'gh_grep',
+    ]);
+  });
+
   test('creates wrapper agents from acpAgents config', () => {
     const config: PluginConfig = {
       acpAgents: {
@@ -165,7 +197,9 @@ describe('custom-agent creation', () => {
       preset: 'opencode-go',
       presets: {
         'opencode-go': {
-          orchestrator: { model: 'opencode-go/glm-5.2' },
+          agents: {
+            orchestrator: { model: 'opencode-go/glm-5.2' },
+          },
         },
       },
       agents: {
@@ -261,7 +295,7 @@ describe('custom-agent creation', () => {
     );
   });
 
-  test('appends ACP routing prompts separately without heading, and preserves rewriting', () => {
+  test('folds custom and ACP routing prompts into their route entries', () => {
     const config: PluginConfig = {
       agents: {
         explorer: {
@@ -291,19 +325,16 @@ describe('custom-agent creation', () => {
     const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
     const prompt = orchestrator?.config.prompt ?? '';
 
-    // Verify Project-specific routing guidance exists and has the custom agent override prompt rewritten
-    expect(prompt).toContain('# Project-specific routing guidance');
+    // Both guidance blocks are part of their agent's single route entry.
+    expect(prompt).not.toContain('# Project-specific routing guidance');
     expect(prompt).toContain(
       'Please use @janitor to clean up after @fancy-explorer has completed.',
     );
 
-    // Verify ACP routing prompt is appended but NOT under the Project-specific routing guidance section
+    // Verify ACP routing guidance is embedded in its route entry.
     // (i.e. it comes after or is separate, let's verify exact substring sequence or that the ACP test isn't inside the heading section)
-    const pieces = prompt.split('# Project-specific routing guidance');
-    expect(pieces.length).toBe(2);
-
-    const headingContent = pieces[1];
-    // The headingContent should contain the custom prompt but not contain the ACP prompt if ACP prompt is appended after/before.
+    expect(prompt.match(/Please use @janitor/g)?.length).toBe(1);
+    // Both guidance snippets should be present exactly once in the prompt.
     // Wait, in our implementation, order of appending is:
     // 1) overridden/custom prompts under # Project-specific routing guidance.
     // 2) ACP routing prompts (without the header).
@@ -311,12 +342,14 @@ describe('custom-agent creation', () => {
     // But the ACP prompt is not under that header's specific block if it's appended separately.
     // Wait, is there a way to verify they are separated? Yes, we can verify that the custom prompt block and ACP prompt block are two separate parts,
     // and that ACP prompt is appended at the very end of the string, outside of the heading's contiguous text block or that if only ACP is present, the heading doesn't show up.
-    expect(headingContent).toContain(
-      'Please use @janitor to clean up after @fancy-explorer has completed.',
-    );
-    expect(headingContent).toContain(
+    expect(prompt).toContain(
       'Please delegate research tasks to @claude-research or @fancy-explorer.',
     );
+    expect(
+      prompt.match(
+        /Please delegate research tasks to @claude-research or @fancy-explorer\./g,
+      )?.length,
+    ).toBe(1);
 
     // Let's also check a scenario where only ACP routing prompt is present. There should be NO heading at all!
     const configOnlyAcp: PluginConfig = {
@@ -476,9 +509,8 @@ describe('permission edge cases', () => {
     const planner = agents.find((a) => a.name === 'planner');
 
     expect(planner).toBeDefined();
-    // The shorthand string should be preserved as-is, not spread into
-    // character keys like { "0": "a", "1": "s", "2": "k" }
-    expect(planner?.config.permission).toBe('ask');
+    // Shorthand permissions are normalized before defaults and gates apply.
+    expect(planner?.config.permission).toMatchObject({ '*': 'ask' });
   });
 
   test('orchestrator permission override does not replace plugin gates', () => {

+ 7 - 82
src/agents/designer.ts

@@ -1,90 +1,15 @@
-import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who creates and reviews intentional, polished experiences.
-
-**Role**: Craft and review cohesive UI/UX that balances visual impact with usability.
-
-## Design Principles
-
-**Typography**
-- Choose distinctive, characterful fonts that elevate aesthetics
-- Avoid generic defaults (Arial, Inter)-opt for unexpected, beautiful choices
-- Pair display fonts with refined body fonts for hierarchy
-
-**Color & Theme**
-- Commit to a cohesive aesthetic with clear color variables
-- Dominant colors with sharp accents > timid, evenly-distributed palettes
-- Create atmosphere through intentional color relationships
-
-**Motion & Interaction**
-- Leverage framework animation utilities when available (Tailwind's transition/animation classes)
-- Focus on high-impact moments: orchestrated page loads with staggered reveals
-- Use scroll-triggers and hover states that surprise and delight
-- One well-timed animation > scattered micro-interactions
-- Drop to custom CSS/JS only when utilities can't achieve the vision
-
-**Spatial Composition**
-- Break conventions: asymmetry, overlap, diagonal flow, grid-breaking
-- Generous negative space OR controlled density-commit to the choice
-- Unexpected layouts that guide the eye
-
-**Visual Depth**
-- Create atmosphere beyond solid colors: gradient meshes, noise textures, geometric patterns
-- Layer transparencies, dramatic shadows, decorative borders
-- Contextual effects that match the aesthetic (grain overlays, custom cursors)
-
-**Styling Approach**
-- Default to Tailwind CSS utility classes when available-fast, maintainable, consistent
-- Use custom CSS when the vision requires it: complex animations, unique effects, advanced compositions
-- Balance utility-first speed with creative freedom where it matters
-
-**Match Vision to Execution**
-- Maximalist designs → elaborate implementation, extensive animations, rich effects
-- Minimalist designs → restraint, precision, careful spacing and typography
-- Elegance comes from executing the chosen vision fully, not halfway
-
-## Constraints
-- Respect existing design systems when present
-- Leverage component libraries where available
-- Prioritize visual excellence-code perfection comes second
-- Use grounded, normal, regular english - don't use jargon or overly technical language
-
-${WRITABLE_FILE_OPERATIONS_RULES}
-
-## Review Responsibilities
-- Review existing UI for usability, responsiveness, visual consistency, and polish when asked
-- Call out concrete UX issues and improvements, not just abstract design advice
-
-## Verification
-- Run only validation assigned by the Orchestrator; do not broaden it
-  automatically.
-- Report validation results and skips accurately.
-- Assigned validation should be user-visible.
-
-## Output Quality
-You're capable of extraordinary creative work. Commit fully to distinctive visions and show what's possible when breaking conventions thoughtfully.`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createDesignerAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = DESIGNER_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${DESIGNER_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'designer',
-    description:
-      'UI/UX design, review, and implementation. Use for styling, responsive design, component architecture and visual polish.',
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.designer,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 7 - 50
src/agents/explorer.ts

@@ -1,58 +1,15 @@
-import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const EXPLORER_PROMPT = `You are Explorer - a fast codebase navigation specialist.
-
-**Role**: Quick contextual grep for codebases. Answer "Where is X?", "Find Y", "Which file has Z".
-
-**When to use which tools**:
-- **Text/regex patterns** (strings, comments, variable names): grep
-- **Structural patterns** (function shapes, class structures): ast_grep_search
-- **File discovery** (find by name/extension): glob
-
-${READONLY_FILE_OPERATIONS_RULES}
-
-**Behavior**:
-- Be fast and thorough
-- Fire multiple searches in parallel if needed
-- Return file paths with relevant snippets
-
-**Output Format**:
-<results>
-<files>
-- /path/to/file.ts:42 - Brief description of what's there
-</files>
-<answer>
-Concise answer to the question
-</answer>
-</results>
-
-**Constraints**:
-- READ-ONLY: Search and report, don't modify
-- Be exhaustive but concise
-- Include line numbers when relevant
-`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createExplorerAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = EXPLORER_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${EXPLORER_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'explorer',
-    description:
-      "Fast codebase search and pattern matching. Use for finding files, locating code patterns, and answering 'where is X?' questions.",
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.explorer,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 7 - 57
src/agents/fixer.ts

@@ -1,65 +1,15 @@
-import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
-
-**Role**: Execute code changes efficiently. You receive complete context from research agents and clear task specifications from the Orchestrator. Your job is to implement, not plan or research.
-
-**Behavior**:
-- Execute the task specification provided by the Orchestrator
-- Report completion with summary of changes
-
-${WRITABLE_FILE_OPERATIONS_RULES}
-
-**Constraints**:
-- NO external research (no context7, gh_grep)
-- NO spawning subagents; telling the caller which specialist to use is fine
-- No multi-step research/planning; minimal execution sequence ok
-- If context is insufficient: use grep/glob/read directly - do not delegate
-- Only ask for missing inputs you truly cannot retrieve yourself
-- Do not act as the primary reviewer; implement requested changes and surface obvious issues briefly
-- No design work — layout, styling, visual hierarchy, responsive behavior, animation, component feel. Refuse and tell the caller to use @designer.
-
-**Verification**:
-- Run only validation assigned by the Orchestrator; do not broaden it
-  automatically.
-- Report validation results and skips accurately.
-
-**Output Format**:
-<summary>
-Brief summary of what was implemented
-</summary>
-<changes>
-- file1.ts: Changed X to Y
-- file2.ts: Added Z function
-</changes>
-<verification>
-- Performed: [command/check, or skipped with reason]
-- Result: [passed/failed/unknown]
-</verification>
-
-`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createFixerAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = FIXER_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${FIXER_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'fixer',
-    description:
-      'Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.',
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.fixer,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 181 - 27
src/agents/index.test.ts

@@ -10,11 +10,13 @@ import {
   SUBAGENT_NAMES,
 } from '../config';
 import { RuntimeConfig } from '../config/runtime';
+import { COUNCIL_SYNTHESIS_REINFORCEMENT } from './council';
 import {
   applyModelInheritanceToConfig,
   createAgents,
   getAgentConfigs,
   getDisabledAgents,
+  isMarketplacePermissionDenied,
   isSubagent,
   resolveAgentConfigModel,
 } from './index';
@@ -116,7 +118,9 @@ describe('built-in subagent preset fallback', () => {
       preset: 'opencode-go',
       presets: {
         'opencode-go': {
-          orchestrator: { model: 'opencode-go/glm-5.2' },
+          agents: {
+            orchestrator: { model: 'opencode-go/glm-5.2' },
+          },
         },
       },
       council: councilConfig(),
@@ -137,7 +141,9 @@ describe('built-in subagent preset fallback', () => {
       preset: 'minimal',
       presets: {
         minimal: {
-          oracle: { model: 'anthropic/claude-sonnet-4-6' },
+          agents: {
+            oracle: { model: 'anthropic/claude-sonnet-4-6' },
+          },
         },
       },
       agents: {
@@ -170,7 +176,9 @@ describe('fixer agent fallback', () => {
       preset: 'balanced',
       presets: {
         balanced: {
-          orchestrator: { model: 'orchestrator-model' },
+          agents: {
+            orchestrator: { model: 'orchestrator-model' },
+          },
         },
       },
       agents: {
@@ -191,7 +199,9 @@ describe('fixer agent fallback', () => {
       preset: 'balanced',
       presets: {
         balanced: {
-          orchestrator: { model: 'orchestrator-model' },
+          agents: {
+            orchestrator: { model: 'orchestrator-model' },
+          },
         },
       },
       agents: {
@@ -212,9 +222,11 @@ describe('fixer agent fallback', () => {
       preset: 'split',
       presets: {
         split: {
-          orchestrator: { model: 'orchestrator-model' },
-          librarian: { model: 'librarian-local-model' },
-          fixer: { inheritModelFrom: 'session' },
+          agents: {
+            orchestrator: { model: 'orchestrator-model' },
+            librarian: { model: 'librarian-local-model' },
+            fixer: { inheritModelFrom: 'session' },
+          },
         },
       },
     };
@@ -231,8 +243,10 @@ describe('fixer agent fallback', () => {
       preset: 'split',
       presets: {
         split: {
-          orchestrator: { model: 'orchestrator-model' },
-          fixer: { model: 'preset-fixer-model' },
+          agents: {
+            orchestrator: { model: 'orchestrator-model' },
+            fixer: { model: 'preset-fixer-model' },
+          },
         },
       },
       agents: {
@@ -250,7 +264,9 @@ describe('fixer agent fallback', () => {
       preset: 'split',
       presets: {
         split: {
-          explorer: { model: 'preset/explorer' },
+          agents: {
+            explorer: { model: 'preset/explorer' },
+          },
         },
       },
       agents: {
@@ -398,6 +414,15 @@ describe('orchestrator agent', () => {
     ).toBe('allow');
   });
 
+  test('orchestrator is allowed to invoke marketplace', () => {
+    const agents = createAgents(runtimeFor());
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(
+      (orchestrator as { config: { permission: Record<string, unknown> } })
+        .config.permission.marketplace,
+    ).toBe('allow');
+  });
+
   test('orchestrator accepts overrides', () => {
     const config: PluginConfig = {
       agents: {
@@ -544,18 +569,20 @@ describe('spaced model ID registrations', () => {
       preset: 'spaced',
       presets: {
         spaced: {
-          explorer: {
-            model: [
-              { id: primary, variant: 'fast' },
-              { id: secondary, variant: 'balanced' },
-            ],
-          },
-          librarian: { model: primary, variant: 'direct' },
-          reviewer: {
-            model: [
-              { id: secondary, variant: 'precise' },
-              { id: fallback, variant: 'economy' },
-            ],
+          agents: {
+            explorer: {
+              model: [
+                { id: primary, variant: 'fast' },
+                { id: secondary, variant: 'balanced' },
+              ],
+            },
+            librarian: { model: primary, variant: 'direct' },
+            reviewer: {
+              model: [
+                { id: secondary, variant: 'precise' },
+                { id: fallback, variant: 'economy' },
+              ],
+            },
           },
         },
       },
@@ -752,6 +779,122 @@ describe('tool permissions', () => {
     }
   });
 
+  test('subagents are denied access to marketplace', () => {
+    const agents = createAgents(runtimeFor());
+
+    for (const name of ['oracle', 'explorer', 'fixer']) {
+      const agent = agents.find((candidate) => candidate.name === name);
+      expect(
+        (agent as { config: { permission: Record<string, unknown> } }).config
+          .permission.marketplace,
+      ).toBe('deny');
+    }
+  });
+
+  test('hostile marketplace allow rules cannot open the tool on non-orchestrators', () => {
+    const agents = createAgents(
+      runtimeFor({
+        disabled_agents: [],
+        agents: {
+          explorer: {
+            displayName: 'Scout',
+            permission: { marketplace: 'allow' },
+          },
+          oracle: { permission: { marketplace: 'allow' } },
+          researcher: {
+            baseRole: 'librarian',
+            model: 'test/researcher',
+            permission: { marketplace: 'allow' },
+          },
+        },
+        council: councilConfig(),
+      }),
+    );
+    for (const name of [
+      'explorer',
+      'oracle',
+      'researcher',
+      'council',
+      'councillor-alpha',
+    ]) {
+      const agent = agents.find((candidate) => candidate.name === name);
+      expect(
+        (agent as { config: { permission: Record<string, unknown> } }).config
+          .permission.marketplace,
+      ).toBe('deny');
+    }
+    expect(
+      (
+        agents.find((agent) => agent.name === 'explorer') as {
+          config: { permission: Record<string, unknown> };
+        }
+      ).config.permission.marketplace,
+    ).toBe('deny');
+  });
+
+  test('omits marketplace prompt guidance when orchestrator permission denies it', () => {
+    const agents = createAgents(
+      runtimeFor({
+        agents: {
+          orchestrator: { permission: { marketplace: 'deny' } },
+        },
+      }),
+    );
+    const prompt = agents.find((agent) => agent.name === 'orchestrator')?.config
+      .prompt;
+    expect(prompt).not.toContain('Use the `marketplace` tool');
+  });
+
+  test('omits marketplace prompt guidance when host permission denies it', () => {
+    const runtime = runtimeFor({ disabled_agents: [] });
+    runtime.captureHostConfig({
+      agent: { orchestrator: { permission: { marketplace: 'deny' } } },
+    });
+    const agents = createAgents(runtime);
+    const prompt = agents.find((agent) => agent.name === 'orchestrator')?.config
+      .prompt;
+    expect(prompt).not.toContain('Use the `marketplace` tool');
+  });
+
+  test('omits marketplace prompt guidance for nested pattern-map deny', () => {
+    const agents = createAgents(
+      runtimeFor({
+        agents: {
+          orchestrator: { permission: { marketplace: { '*': 'deny' } } },
+        },
+      }),
+    );
+    const prompt = agents.find((agent) => agent.name === 'orchestrator')?.config
+      .prompt;
+    expect(prompt).not.toContain('Use the `marketplace` tool');
+    expect(
+      isMarketplacePermissionDenied({ marketplace: { '*': 'deny' } }),
+    ).toBe(true);
+    expect(isMarketplacePermissionDenied({ marketplace: 'allow' })).toBe(false);
+  });
+
+  test('ACP wrappers are denied marketplace', () => {
+    const configs = getAgentConfigs(
+      runtimeFor({
+        acpAgents: {
+          bridge: { command: 'bridge-acp' },
+        },
+      }),
+    );
+    expect(
+      (configs.bridge.permission as Record<string, unknown>).marketplace,
+    ).toBe('deny');
+  });
+
+  test('omits marketplace prompt guidance when the tool is disabled', () => {
+    const agents = createAgents(
+      runtimeFor({ disabled_tools: ['marketplace'] }),
+    );
+    const prompt = agents.find((agent) => agent.name === 'orchestrator')?.config
+      .prompt;
+    expect(prompt).not.toContain('Use the `marketplace` tool');
+  });
+
   test('council agent has synthesis-only (deny-all) permissions', () => {
     const agents = createAgents(
       runtimeFor({
@@ -814,6 +957,13 @@ test('orchestrator prompt excludes Council Mode when no councillors', () => {
   expect(prompt).not.toContain('## Council Mode');
 });
 
+test('council synthesis reinforcement is appended exactly once', () => {
+  const agents = createAgents(runtimeFor({ council: councilConfig() }));
+  const council = agents.find((agent) => agent.name === 'council');
+  const prompt = council?.config.prompt as string;
+  expect(prompt.split(COUNCIL_SYNTHESIS_REINFORCEMENT).length - 1).toBe(1);
+});
+
 describe('isSubagent type guard', () => {
   test('returns true for valid subagent names', () => {
     expect(isSubagent('explorer')).toBe(true);
@@ -891,7 +1041,9 @@ describe('createAgents', () => {
     const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
     const explorer = agents.find((agent) => agent.name === 'explorer');
 
-    expect(explorer?.config.prompt).toBe('Replacement explorer prompt.');
+    expect(explorer?.config.prompt).toBe(
+      `Replacement explorer prompt.\n\n${TASK_REJECTION_INSTRUCTION}`,
+    );
     expect(orchestrator?.config.prompt).not.toContain(
       TASK_REJECTION_INSTRUCTION,
     );
@@ -1479,9 +1631,11 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
     const result = PluginConfigSchema.safeParse({
       presets: {
         openai: {
-          oracle: {
-            model: 'openai/gpt-5.6',
-            prompt: 'ignored preset built-in prompt override',
+          agents: {
+            oracle: {
+              model: 'openai/gpt-5.6',
+              prompt: 'ignored preset built-in prompt override',
+            },
           },
         },
       },
@@ -1901,7 +2055,7 @@ describe('resolveAgentConfigModel', () => {
     const config: PluginConfig = {
       preset: 'default',
       presets: {
-        default: { oracle: { model: 'test/primary' } },
+        default: { agents: { oracle: { model: 'test/primary' } } },
       },
     };
     expect(resolveAgentConfigModel(runtimeFor(config), 'fixer')).toBe(

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 716 - 75
src/agents/index.ts


+ 7 - 41
src/agents/librarian.ts

@@ -1,49 +1,15 @@
-import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebases and documentation.
-
-**Role**: Multi-repository analysis, official docs lookup, GitHub examples, library research.
-
-**Capabilities**:
-- Search and analyze external repositories
-- Find official documentation for libraries
-- Locate implementation examples in open source
-- Understand library internals and best practices
-
-**Tools to Use**:
-- context7: Official documentation lookup
-- gh_grep: Search GitHub repositories
-
-${READONLY_FILE_OPERATIONS_RULES}
-
-**Behavior**:
-- Provide evidence-based answers with sources
-- Quote relevant code snippets
-- Link to official docs when available
-- Distinguish between official and community patterns
-`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createLibrarianAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = LIBRARIAN_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${LIBRARIAN_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'librarian',
-    description:
-      'External documentation and library research. Use for official docs lookup, GitHub examples, and understanding library internals.',
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.librarian,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 7 - 39
src/agents/observer.ts

@@ -1,47 +1,15 @@
-import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const OBSERVER_PROMPT = `You are Observer - a visual analysis specialist.
-
-**Role**: Interpret images, screenshots, PDFs, and diagrams. Extract structured observations for the Orchestrator to act on.
-
-**Behavior**:
-- Read the file(s) specified in the prompt
-- Analyze visual content - layouts, UI elements, text, relationships, flows
-- For screenshots with text/code/errors: extract the **exact text** via OCR - never paraphrase error messages or code
-- For multiple files: analyze each, then compare or relate as requested
-- Return ONLY the extracted information relevant to the goal
-- If the image is unclear, blurry, or partially visible: state what you CAN see and explicitly note what is uncertain - never guess or fabricate details
-
-**Constraints**:
-- READ-ONLY: Analyze and report, don't modify files
-- Save context tokens - the Orchestrator never processes the raw file
-- Match the language of the request
-- If info not found, state clearly what's missing
-
-${READONLY_FILE_OPERATIONS_RULES}
-`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createObserverAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = OBSERVER_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${OBSERVER_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'observer',
-    description:
-      'Visual analysis. Use for interpreting images, screenshots, PDFs, and diagrams - extracts structured observations without loading raw files into main context. Requires a vision-capable model.',
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.observer,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 7 - 44
src/agents/oracle.ts

@@ -1,52 +1,15 @@
-import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
-
-const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor and code reviewer.
-
-**Role**: High-IQ debugging, architecture decisions, code review, simplification, and engineering guidance.
-
-**Capabilities**:
-- Analyze complex codebases and identify root causes
-- Propose architectural solutions with tradeoffs
-- Review code for correctness, performance, maintainability, and unnecessary complexity
-- Enforce YAGNI and suggest simpler designs when abstractions are not pulling their weight
-- Guide debugging when standard approaches fail
-
-**Behavior**:
-- Be direct and concise
-- Provide actionable recommendations
-- Explain reasoning briefly
-- Acknowledge uncertainty when present
-- Prefer simpler designs unless complexity clearly earns its keep
-
-**Constraints**:
-- READ-ONLY: You advise, you don't implement
-- Focus on strategy, not execution
-- Point to specific files/lines when relevant
-
-${READONLY_FILE_OPERATIONS_RULES}
-`;
+import { createRoleAgent, ROLE_DEFINITIONS } from './role-definitions';
 
 export function createOracleAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  let prompt = ORACLE_PROMPT;
-
-  if (customPrompt) {
-    prompt = customPrompt;
-  } else if (customAppendPrompt) {
-    prompt = `${ORACLE_PROMPT}\n\n${customAppendPrompt}`;
-  }
-
-  return {
-    name: 'oracle',
-    description:
-      'Strategic technical advisor. Use for architecture decisions, complex debugging, code review, simplification, and engineering guidance.',
-    config: {
-      model,
-      prompt,
-    },
-  };
+  return createRoleAgent(
+    ROLE_DEFINITIONS.oracle,
+    model,
+    customPrompt,
+    customAppendPrompt,
+  );
 }

+ 24 - 0
src/agents/orchestrator.test.ts

@@ -41,4 +41,28 @@ describe('orchestrator prompt', () => {
     expect(prompt).not.toContain('End Turn After Background Tasks');
     expect(prompt).toContain('Do not immediately wait after spawning');
   });
+
+  test('instructs the orchestrator to use the marketplace tool', () => {
+    const prompt = buildOrchestratorPrompt();
+
+    expect(prompt).toContain('Use the `marketplace` tool');
+    expect(prompt).toContain('list, show, verify, and status are read-only');
+    expect(prompt).toContain(
+      'report reload_required only when disk activation differs',
+    );
+  });
+
+  test('omits marketplace instructions when the tool is disabled', () => {
+    const prompt = buildOrchestratorPrompt(
+      undefined,
+      undefined,
+      true,
+      true,
+      undefined,
+      undefined,
+      false,
+    );
+
+    expect(prompt).not.toContain('Use the `marketplace` tool');
+  });
 });

+ 104 - 83
src/agents/orchestrator.ts

@@ -1,8 +1,14 @@
 import type { AgentConfig } from '@opencode-ai/sdk/v2';
 import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
+import {
+  ROLE_DEFINITIONS,
+  renderRoleRoutingBlock,
+  type SpecialistRole,
+} from './role-definitions';
 
 export interface AgentDefinition {
   name: string;
+  baseRole?: SpecialistRole;
   displayName?: string;
   description?: string;
   config: AgentConfig;
@@ -18,77 +24,63 @@ export interface AgentDefinition {
  * shared default. `customAppendPrompt` always appends after whichever base
  * won. Deterministic per session (construction-time only) — cache-safe.
  */
+export interface PromptComposition {
+  agentName: string;
+  replacement?: string;
+  filePrompt?: string;
+  fallback: string;
+  appendPrompt?: string;
+  requiredSuffixes?: readonly string[];
+}
+
+/** Pure prompt composition. Replacement and append layers are intentionally
+ * explicit so every caller has the same precedence and suffix ordering. */
+export function composePrompt(input: PromptComposition): string {
+  const effectiveBase = input.replacement ?? input.filePrompt ?? input.fallback;
+  const parts = [effectiveBase, input.appendPrompt].filter(
+    (part): part is string => part !== undefined && part.length > 0,
+  );
+  for (const suffix of input.requiredSuffixes ?? []) {
+    const normalized = suffix.trim();
+    if (normalized && !parts[parts.length - 1].endsWith(normalized)) {
+      parts.push(normalized);
+    }
+  }
+  return parts.join('\n\n');
+}
+
 export function resolvePrompt(
   agentName: string,
   inlinePrompt: string | undefined,
   filePrompt: string | undefined,
   fallback: string,
   customAppendPrompt?: string,
+  requiredSuffixes?: readonly string[],
 ): string {
   if (inlinePrompt !== undefined && filePrompt !== undefined) {
     console.warn(
       `[oh-my-opencode] Agent '${agentName}': inline prompt overrides prompt file (${agentName}.md). Remove the inline prompt to use the file.`,
     );
   }
-  const effectiveBase = inlinePrompt ?? filePrompt ?? fallback;
-  return customAppendPrompt !== undefined
-    ? `${effectiveBase}\n\n${customAppendPrompt}`
-    : effectiveBase;
+  return composePrompt({
+    agentName,
+    replacement: inlinePrompt,
+    filePrompt,
+    fallback,
+    appendPrompt: customAppendPrompt,
+    requiredSuffixes,
+  });
 }
 
-// Agent descriptions for the orchestrator prompt
-const AGENT_DESCRIPTIONS: Record<string, string> = {
-  explorer: `@explorer
-- Lane: Fast codebase recon that returns compressed context
-- Permissions: read_files
-- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
-- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
-- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
-- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
-
-  librarian: `@librarian
-- Lane: External knowledge and library research, fast web research
-- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
-- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
-- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
-- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
-- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. "How do others solve or workaround this tricky issue?" → @librarian.`,
-
-  oracle: `@oracle
-- Lane: Architecture, risk, debugging strategy, and review
-- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
-- Permissions: read_files
-- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
-- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
-- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
-- **Review use:** @oracle is an escalation, not a default verification step. Request independent @oracle review only when its analysis is expected to materially reduce risk or uncertainty.
-- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
-- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.`,
-
-  designer: `@designer
-- Lane: UI/UX design, related edits, design polish and review
-- Permissions: read_files, write_files
-- Stats: 10x better UI/UX than orchestrator
-- Capabilities: Good design taste, visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
-- Owns visual and interaction quality: layout, hierarchy, spacing, motion, affordances, responsive behavior, and overall feel.
-- Weakness: copywriting. Ask @designer to use grounded, normal wording, then have orchestrator review/fix copy after design work without changing visual or interaction intent.
-- Avoid: "Let me ask @designer how it should look and implement yourself" → instead: "Let me ask @designer to design and implement the UI/UX changes for me"
-- **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
-- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.
-- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.`,
-
-  fixer: `@fixer
-- Lane: Bounded implementation and executioner
-- Role: Fast execution specialist for well-defined tasks
-- Permissions: read_files, write_files
-- Stats: 2x faster code edits, 1/2 cost of orchestrator
-- Weakness: design, taste
-- Tools/Constraints: Execution-focused-no research, no architectural decisions
-- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixer instances for each folder.
-- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to @fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
-- **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.`,
-
-  council: `@council
+// Parallel delegation examples
+const PARALLEL_DELEGATION_EXAMPLES = [
+  '- Multiple @explorer searches across different domains?',
+  '- @explorer + @librarian research in parallel?',
+  '- Multiple @fixer instances for faster, scoped implementation?',
+  '- @observer + @explorer in parallel (visual analysis + code search)?',
+];
+
+export const COUNCIL_ROUTING_BLOCK = `@council
 - Lane: High-stakes multi-model decision support
 - Role: Multi-LLM consensus engine that receives raw councillor responses and synthesizes them into a structured council report.
 - Permissions: Read files
@@ -98,33 +90,23 @@ const AGENT_DESCRIPTIONS: Record<string, string> = {
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
 - **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
 - **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Per-Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
-- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.`,
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.`;
 
-  observer: `@observer
-- Lane: Visual/media analysis isolated from orchestrator context
-- Role: Visual analysis specialist for images, PDFs, and diagrams
-- Permissions: Read files
-- Stats: Saves main context tokens - @observer processes raw files, returns structured observations
-- Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
-- **Delegate when:** Need to analyze a multimedia file• Extract information
-- **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
-- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
-- **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."`,
-};
+export function renderCouncilRoutingBlock(runtimeName: string): string {
+  return COUNCIL_ROUTING_BLOCK.replaceAll('@council', `@${runtimeName}`);
+}
 
-// Parallel delegation examples
-const PARALLEL_DELEGATION_EXAMPLES = [
-  '- Multiple @explorer searches across different domains?',
-  '- @explorer + @librarian research in parallel?',
-  '- Multiple @fixer instances for faster, scoped implementation?',
-  '- @observer + @explorer in parallel (visual analysis + code search)?',
-];
+export interface RoutingEntry {
+  readonly agentName: string;
+  readonly routingBlock: string;
+}
 
 /**
  * Build the orchestrator prompt with dynamic agent filtering.
  * @param disabledAgents - Set of disabled agent names to exclude from the prompt
  * @param waitForUserEnabled - Whether explicit text-only HITL waiting is available
  * @param wakeSchedulerEnabled - Whether the orchestrator wake scheduler can resume the session after idle
+ * @param marketplaceEnabled - Whether the local marketplace tool is available
  * @returns The complete orchestrator prompt string
  */
 export function buildOrchestratorPrompt(
@@ -132,12 +114,28 @@ export function buildOrchestratorPrompt(
   excludeDescriptions?: string[],
   waitForUserEnabled = true,
   wakeSchedulerEnabled = true,
+  routingEntries?: readonly RoutingEntry[],
+  runtimeNameByCanonicalId?: Readonly<Record<string, string>>,
+  marketplaceEnabled = true,
 ): string {
-  // Filter agent descriptions
-  const enabledAgents = Object.entries(AGENT_DESCRIPTIONS)
-    .filter(([name]) => !disabledAgents?.has(name))
-    .filter(([name]) => !excludeDescriptions?.includes(name))
-    .map(([, desc]) => desc)
+  const entries = routingEntries ?? [
+    ...Object.entries(ROLE_DEFINITIONS).map(([name, role]) => ({
+      agentName: name,
+      routingBlock: renderRoleRoutingBlock(role, name),
+    })),
+    { agentName: 'council', routingBlock: COUNCIL_ROUTING_BLOCK },
+  ];
+  const enabledAgents = entries
+    .filter(({ agentName }) => !disabledAgents?.has(agentName))
+    .filter(({ agentName }) => !excludeDescriptions?.includes(agentName))
+    .sort((left, right) =>
+      left.agentName < right.agentName
+        ? -1
+        : left.agentName > right.agentName
+          ? 1
+          : 0,
+    )
+    .map(({ routingBlock }) => routingBlock)
     .join('\n\n');
 
   // Filter parallel delegation examples - remove lines mentioning any disabled agent
@@ -147,12 +145,29 @@ export function buildOrchestratorPrompt(
       if (mentions.length === 0) return true;
       return mentions.every((name) => !disabledAgents?.has(name));
     },
-  ).join('\n');
+  )
+    .map((line) => {
+      let rendered = line;
+      for (const [canonicalName, runtimeName] of Object.entries(
+        runtimeNameByCanonicalId ?? {},
+      )) {
+        rendered = rendered.replace(
+          new RegExp(`@${canonicalName}\\b`, 'g'),
+          `@${runtimeName}`,
+        );
+      }
+      return rendered;
+    })
+    .join('\n');
 
   const externalManualWaitInstruction = waitForUserEnabled
     ? '- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then call `wait_for_user` as your final tool action and end the turn. Do not rely on ordinary text alone to mark this waiting state, and do not call more tools after `wait_for_user`. Background tasks are not external manual work — never use `wait_for_user` to await them; the system resumes automatically via the Background Job Board and orchestrator wake scheduler.'
     : '- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then use the `question` tool as the blocking boundary and ask them to respond when finished. `wait_for_user` is disabled, so do not reference or call it.';
 
+  const marketplaceInstruction = marketplaceEnabled
+    ? '- Use the `marketplace` tool for local offline package lifecycle and status. list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write store/config only and never hot-swap the live registry; they report reload_required only when disk activation differs from this session. Do not shell out to the CLI for these actions.'
+    : '';
+
   return `<Role>
 You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
 
@@ -277,7 +292,7 @@ After spawning all independent background tasks and any remaining non-overlappin
 - Don't guess at critical details (file paths, API choices, architectural decisions)
 - Do make reasonable assumptions for minor details and state them briefly
 - When user input is required before work can continue and the user can answer immediately—including clarification, permission, a choice, or pasted command output—use the \`question\` tool. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
-${externalManualWaitInstruction}
+${[externalManualWaitInstruction, marketplaceInstruction].filter(Boolean).join('\n')}
 - For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution
@@ -316,12 +331,18 @@ export function createOrchestratorAgent(
   excludeDescriptions?: string[],
   waitForUserEnabled = true,
   wakeSchedulerEnabled = true,
+  routingEntries?: readonly RoutingEntry[],
+  runtimeNameByCanonicalId?: Readonly<Record<string, string>>,
+  marketplaceEnabled = true,
 ): AgentDefinition {
   const basePrompt = buildOrchestratorPrompt(
     disabledAgents,
     excludeDescriptions,
     waitForUserEnabled,
     wakeSchedulerEnabled,
+    routingEntries,
+    runtimeNameByCanonicalId,
+    marketplaceEnabled,
   );
   const prompt = resolvePrompt(
     'orchestrator',

+ 355 - 0
src/agents/registry.test.ts

@@ -0,0 +1,355 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { CouncilConfigSchema } from '../config/council-schema';
+import { RuntimeConfig } from '../config/runtime';
+import { COUNCIL_SYNTHESIS_REINFORCEMENT } from './council';
+import {
+  buildResolvedAgentRegistry,
+  projectAgentPermission,
+  type ResolvedAgentRegistry,
+} from './index';
+import {
+  ROLE_DEFINITIONS,
+  SUPPORTED_SPECIALIST_ROLES,
+} from './role-definitions';
+import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
+
+const DIRECTORY = 'resolved-agent-registry-test';
+
+function registryFor(config: PluginConfig = {}): ResolvedAgentRegistry {
+  RuntimeConfig.reset(DIRECTORY);
+  const runtime = RuntimeConfig.init(DIRECTORY, config);
+  return buildResolvedAgentRegistry(runtime);
+}
+
+describe('ResolvedAgentRegistry', () => {
+  test('keeps SDK, model, skill, MCP, and routing surfaces consistent', () => {
+    const registry = registryFor({
+      agents: {
+        explorer: {
+          model: ['provider/primary', 'provider/fallback'],
+          skills: ['simplify'],
+          mcps: ['context7'],
+        },
+      },
+    });
+
+    expect(registry.agents.map((agent) => agent.name)).toContain('explorer');
+    expect(registry.sdkConfigs.explorer.model).toBe('provider/primary');
+    expect(registry.modelArrays.explorer).toEqual([
+      { id: 'provider/primary' },
+      { id: 'provider/fallback' },
+    ]);
+    expect(registry.modelChains.explorer).toEqual([
+      'provider/primary',
+      'provider/fallback',
+    ]);
+    expect(registry.mcpLists.explorer).toEqual(['context7']);
+    expect(registry.skillPermissions.explorer.simplify).toBe('allow');
+    expect(registry.runtimeNameByCanonicalId.explorer).toBe('explorer');
+    expect(registry.canonicalIdByRuntimeName.explorer).toBe('explorer');
+    expect(registry.routing.map((entry) => entry.agentName)).toEqual(
+      [...registry.routing].map((entry) => entry.agentName).sort(),
+    );
+    expect(Object.isFrozen(registry)).toBe(true);
+    expect(Object.isFrozen(registry.modelArrays.explorer)).toBe(true);
+    expect(Object.isFrozen(registry.sdkConfigs.explorer)).toBe(true);
+    expect(Object.isFrozen(registry.sdkConfigs.explorer.permission)).toBe(true);
+  });
+
+  test('does not expose special agents as supported role definitions', async () => {
+    const { SUPPORTED_SPECIALIST_ROLES } = await import('./role-definitions');
+    expect(SUPPORTED_SPECIALIST_ROLES).not.toContain('orchestrator');
+    expect(SUPPORTED_SPECIALIST_ROLES).not.toContain('councillor');
+  });
+
+  test('preserves complete built-in role parity', () => {
+    const registry = registryFor({ disabled_agents: [] });
+    for (const roleName of SUPPORTED_SPECIALIST_ROLES) {
+      const role = ROLE_DEFINITIONS[roleName];
+      const agent = registry.agents.find((entry) => entry.name === roleName);
+      expect(agent?.baseRole).toBe(roleName);
+      expect(agent?.config.prompt).toContain(role.basePrompt);
+      expect(agent?.description).toBe(role.description);
+      expect(registry.mcpLists[roleName]).toEqual([...role.defaultMcps]);
+      expect(registry.routing).toContainEqual(
+        expect.objectContaining({ agentName: roleName }),
+      );
+      const permission = registry.sdkConfigs[roleName]?.permission as Record<
+        string,
+        unknown
+      >;
+      expect(permission.edit).toBe(
+        role.permissionPolicy === 'read-write' ? 'allow' : 'deny',
+      );
+      for (const skill of role.defaultSkills) {
+        expect(
+          (registry.skillPermissions[roleName] as Record<string, unknown>)[
+            skill
+          ],
+        ).toBe('allow');
+      }
+    }
+  });
+
+  test('discovers preset-only custom agents and preserves their model chains', () => {
+    const registry = registryFor({
+      preset: 'custom',
+      presets: {
+        custom: {
+          agents: {
+            auditOracle: {
+              baseRole: 'oracle',
+              model: ['provider/one', 'provider/two'],
+            },
+          },
+        },
+      },
+    });
+
+    expect(registry.agents.map((agent) => agent.name)).toContain('auditOracle');
+    expect(registry.modelArrays.auditOracle).toEqual([
+      { id: 'provider/one' },
+      { id: 'provider/two' },
+    ]);
+    expect(registry.modelChains.auditOracle).toEqual([
+      'provider/one',
+      'provider/two',
+    ]);
+  });
+
+  test('renders derived role routing against the runtime agent name', () => {
+    const registry = registryFor({
+      agents: {
+        auditOracle: { baseRole: 'oracle', model: 'provider/oracle' },
+      },
+    });
+    const route = registry.routing.find(
+      (entry) => entry.agentName === 'auditOracle',
+    );
+
+    expect(route?.routingBlock).toContain('@auditOracle');
+    expect(route?.routingBlock).not.toContain('@oracle');
+    expect(
+      registry.agents.find((agent) => agent.name === 'orchestrator')?.config
+        .prompt,
+    ).toContain('@auditOracle');
+  });
+
+  test('does not route an unconfigured or disabled council', () => {
+    expect(registryFor().routing.map((entry) => entry.agentName)).not.toContain(
+      'council',
+    );
+    expect(
+      registryFor({ disabled_agents: ['council'] }).routing.map(
+        (entry) => entry.agentName,
+      ),
+    ).not.toContain('council');
+  });
+
+  test('routes an enabled council exactly when it is registered', () => {
+    const registry = registryFor({
+      disabled_agents: [],
+      council: CouncilConfigSchema.parse({
+        presets: { default: { alpha: { model: 'provider/councillor' } } },
+      }),
+    });
+
+    expect(registry.sdkConfigs.council).toBeDefined();
+    expect(
+      registry.routing.filter((entry) => entry.agentName === 'council'),
+    ).toHaveLength(1);
+  });
+
+  test('renders council routing with its resolved display name', () => {
+    const registry = registryFor({
+      agents: { council: { displayName: 'consensus' } },
+      council: CouncilConfigSchema.parse({
+        presets: { default: { alpha: { model: 'provider/councillor' } } },
+      }),
+    });
+    const route = registry.routing.find(
+      (entry) => entry.agentName === 'consensus',
+    );
+
+    expect(route?.routingBlock).toContain('@consensus');
+    expect(route?.routingBlock).not.toContain('@council');
+  });
+
+  test('folds custom and ACP routing guidance into one deterministic registry', () => {
+    const rawCustomGuidance =
+      'Use @janitor after @explorer completes the initial scan.';
+    const customGuidance =
+      'Use @janitor after @scout completes the initial scan.';
+    const acpGuidance = 'Delegate provider research to @claude-research.';
+    const registry = registryFor({
+      agents: {
+        explorer: { model: 'provider/explorer', displayName: 'scout' },
+        janitor: {
+          model: 'provider/janitor',
+          orchestratorPrompt: rawCustomGuidance,
+        },
+      },
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+          orchestratorPrompt: acpGuidance,
+        },
+      },
+    });
+    const orchestrator = registry.agents.find(
+      (agent) => agent.name === 'orchestrator',
+    );
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(
+      registry.routing.filter((entry) =>
+        entry.routingBlock.includes(customGuidance),
+      ),
+    ).toHaveLength(1);
+    expect(
+      registry.routing.filter((entry) =>
+        entry.routingBlock.includes(acpGuidance),
+      ),
+    ).toHaveLength(1);
+    expect(prompt.match(new RegExp(customGuidance, 'g'))).toHaveLength(1);
+    expect(prompt.match(new RegExp(acpGuidance, 'g'))).toHaveLength(1);
+    expect(registry.routing.map((entry) => entry.agentName)).toEqual(
+      [...registry.routing].map((entry) => entry.agentName).sort(),
+    );
+    expect(registry.routing.map((entry) => entry.agentName)).toContain('scout');
+    expect(registry.routing.map((entry) => entry.agentName)).not.toContain(
+      'explorer',
+    );
+  });
+
+  test('finalizes host fields and MCP permissions in owned registry projections', () => {
+    RuntimeConfig.reset(DIRECTORY);
+    const runtime = RuntimeConfig.init(DIRECTORY, {
+      agents: { explorer: { mcps: ['context7'] } },
+    });
+    runtime.captureHostConfig({
+      agent: {
+        explorer: {
+          model: 'host/explorer',
+          options: { nested: { value: 1 } },
+          permission: {
+            bash: { 'git status': 'allow' },
+          },
+        },
+      },
+      mcp: { context7: { type: 'remote' } },
+    });
+    const registry = buildResolvedAgentRegistry(runtime, {
+      availableMcpNames: ['context7'],
+    });
+    const explorer = registry.sdkConfigs.explorer as Record<string, unknown>;
+    const permission = explorer.permission as Record<string, unknown>;
+
+    expect(explorer.model).toBe('host/explorer');
+    expect(explorer.options).toEqual({ nested: { value: 1 } });
+    expect(permission['context7_*']).toBe('allow');
+    expect((permission.bash as Record<string, unknown>)['git status']).toBe(
+      'allow',
+    );
+    expect(Object.isFrozen(explorer.options)).toBe(true);
+  });
+
+  test('uses RoleDefinition.defaultModel for every built-in specialist', () => {
+    const registry = registryFor({ disabled_agents: [] });
+    for (const roleName of SUPPORTED_SPECIALIST_ROLES) {
+      expect(registry.sdkConfigs[roleName]?.model).toBe(
+        ROLE_DEFINITIONS[roleName].defaultModel,
+      );
+    }
+  });
+
+  test('normalizes host shorthand and reapplies only the immutable gate', () => {
+    const registry = registryFor({ disabled_agents: [] });
+    const hostEntry: Record<string, unknown> = { permission: 'ask' };
+
+    projectAgentPermission('explorer', hostEntry, registry);
+
+    expect(hostEntry.permission).toMatchObject({
+      '*': 'ask',
+      task_cancel: 'deny',
+      wait_for_user: 'deny',
+      marketplace: 'deny',
+    });
+    expect((hostEntry.permission as Record<string, unknown>).edit).toBe('deny');
+  });
+
+  test('mirrors display aliases across model, skill, and MCP consumers', () => {
+    const registry = registryFor({
+      disabled_agents: [],
+      agents: {
+        explorer: {
+          displayName: 'Scout',
+          model: ['provider/one', 'provider/two'],
+          skills: ['simplify'],
+          mcps: ['context7'],
+        },
+      },
+    });
+
+    expect(registry.canonicalIdByRuntimeName.Scout).toBe('explorer');
+    expect(registry.modelArrays.Scout).toEqual(registry.modelArrays.explorer);
+    expect(registry.mcpLists.Scout).toEqual(['context7']);
+    expect(registry.skillPermissions.Scout).toEqual(
+      registry.skillPermissions.explorer,
+    );
+    expect(registry.routing).toContainEqual(
+      expect.objectContaining({ agentName: 'Scout' }),
+    );
+    expect(registry.sdkConfigs.Scout).toMatchObject({
+      displayName: 'Scout',
+      model: 'provider/one',
+      mcps: ['context7'],
+    });
+    expect(registry.sdkConfigs.Scout.hidden).toBeUndefined();
+    expect(registry.sdkConfigs.explorer.hidden).toBe(true);
+    expect(
+      (registry.sdkConfigs.Scout.permission as Record<string, unknown>)
+        .marketplace,
+    ).toBe('deny');
+    expect(
+      (registry.sdkConfigs.explorer.permission as Record<string, unknown>)
+        .marketplace,
+    ).toBe('deny');
+  });
+
+  test('does not let host prompts replace mandatory agent suffixes', () => {
+    RuntimeConfig.reset(DIRECTORY);
+    const runtime = RuntimeConfig.init(DIRECTORY, {
+      disabled_agents: [],
+      council: CouncilConfigSchema.parse({
+        default_preset: 'default',
+        presets: {
+          default: {
+            alpha: { model: 'provider/councillor' },
+          },
+        },
+      }),
+    });
+    runtime.captureHostConfig({
+      agent: {
+        explorer: { prompt: 'host explorer prompt' },
+        council: { prompt: 'host council prompt' },
+      },
+    });
+
+    const registry = buildResolvedAgentRegistry(runtime);
+    const explorerPrompt = String(registry.sdkConfigs.explorer.prompt);
+    const councilPrompt = String(registry.sdkConfigs.council.prompt);
+
+    expect(explorerPrompt).not.toContain('host explorer prompt');
+    expect(explorerPrompt).toContain(TASK_REJECTION_INSTRUCTION);
+    expect(councilPrompt).not.toContain('host council prompt');
+    expect(councilPrompt).toContain(TASK_REJECTION_INSTRUCTION);
+    expect(councilPrompt).toContain(COUNCIL_SYNTHESIS_REINFORCEMENT);
+  });
+});

+ 10 - 0
src/agents/resolve-prompt-warn.test.ts

@@ -34,6 +34,16 @@ describe('resolvePrompt precedence', () => {
       `${FALLBACK}\n\n${APPEND}`,
     );
   });
+
+  test('required suffixes are always last and deterministic', () => {
+    const result = resolvePrompt('a', INLINE, FILE, FALLBACK, APPEND, [
+      'mandatory policy',
+      'final gate',
+    ]);
+    expect(result).toBe(
+      `${INLINE}\n\n${APPEND}\n\nmandatory policy\n\nfinal gate`,
+    );
+  });
 });
 
 describe('resolvePrompt conflict warning', () => {

+ 424 - 0
src/agents/role-definitions.ts

@@ -0,0 +1,424 @@
+import {
+  READONLY_FILE_OPERATIONS_RULES,
+  WRITABLE_FILE_OPERATIONS_RULES,
+} from '../config';
+import { DEFAULT_AGENT_MCPS } from '../config/agent-mcps';
+import type { SpecialistRole } from '../config/agent-roles';
+import { DEFAULT_MODELS } from '../config/constants';
+import type { AgentDefinition } from './orchestrator';
+
+export {
+  type SpecialistRole,
+  SUPPORTED_SPECIALIST_ROLES,
+} from '../config/agent-roles';
+
+export interface RoleDefinition {
+  readonly id: SpecialistRole;
+  readonly basePrompt: string;
+  readonly description: string;
+  readonly derivable: true;
+  readonly profileTargetable: true;
+  readonly defaultModel: string | undefined;
+  readonly defaultSkills: readonly string[];
+  readonly defaultMcps: readonly string[];
+  readonly permissionPolicy: 'read-only' | 'read-write';
+  readonly routingBlock: string;
+  readonly createBaseline: (model: string) => AgentDefinition;
+}
+
+function createRoleBaseline(
+  role: RoleDefinition,
+  model: string,
+): AgentDefinition {
+  return {
+    name: role.id,
+    baseRole: role.id,
+    description: role.description,
+    config: {
+      model,
+      prompt: role.basePrompt,
+    },
+  };
+}
+
+function createBaselineFactory(role: RoleDefinition) {
+  return (model: string): AgentDefinition => createRoleBaseline(role, model);
+}
+
+export function createRoleAgent(
+  role: RoleDefinition,
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  const baseline = role.createBaseline(model);
+  if (customPrompt) {
+    baseline.config.prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    baseline.config.prompt = `${baseline.config.prompt}\n\n${customAppendPrompt}`;
+  }
+  return baseline;
+}
+
+export function renderRoleRoutingBlock(
+  role: RoleDefinition,
+  runtimeName: string,
+): string {
+  return role.routingBlock.replace(
+    new RegExp(`@${role.id}\\b`, 'g'),
+    `@${runtimeName}`,
+  );
+}
+
+const EXPLORER_PROMPT = `You are Explorer - a fast codebase navigation specialist.
+
+**Role**: Quick contextual grep for codebases. Answer "Where is X?", "Find Y", "Which file has Z".
+
+**When to use which tools**:
+- **Text/regex patterns** (strings, comments, variable names): grep
+- **Structural patterns** (function shapes, class structures): ast_grep_search
+- **File discovery** (find by name/extension): glob
+
+${READONLY_FILE_OPERATIONS_RULES}
+
+**Behavior**:
+- Be fast and thorough
+- Fire multiple searches in parallel if needed
+- Return file paths with relevant snippets
+
+**Output Format**:
+<results>
+<files>
+- /path/to/file.ts:42 - Brief description of what's there
+</files>
+<answer>
+Concise answer to the question
+</answer>
+</results>
+
+**Constraints**:
+- READ-ONLY: Search and report, don't modify
+- Be exhaustive but concise
+- Include line numbers when relevant
+`;
+
+const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebases and documentation.
+
+**Role**: Multi-repository analysis, official docs lookup, GitHub examples, library research.
+
+**Capabilities**:
+- Search and analyze external repositories
+- Find official documentation for libraries
+- Locate implementation examples in open source
+- Understand library internals and best practices
+
+**Tools to Use**:
+- context7: Official documentation lookup
+- gh_grep: Search GitHub repositories
+
+${READONLY_FILE_OPERATIONS_RULES}
+
+**Behavior**:
+- Provide evidence-based answers with sources
+- Quote relevant code snippets
+- Link to official docs when available
+- Distinguish between official and community patterns
+`;
+
+const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor and code reviewer.
+
+**Role**: High-IQ debugging, architecture decisions, code review, simplification, and engineering guidance.
+
+**Capabilities**:
+- Analyze complex codebases and identify root causes
+- Propose architectural solutions with tradeoffs
+- Review code for correctness, performance, maintainability, and unnecessary complexity
+- Enforce YAGNI and suggest simpler designs when abstractions are not pulling their weight
+- Guide debugging when standard approaches fail
+
+**Behavior**:
+- Be direct and concise
+- Provide actionable recommendations
+- Explain reasoning briefly
+- Acknowledge uncertainty when present
+- Prefer simpler designs unless complexity clearly earns its keep
+
+**Constraints**:
+- READ-ONLY: You advise, you don't implement
+- Focus on strategy, not execution
+- Point to specific files/lines when relevant
+
+${READONLY_FILE_OPERATIONS_RULES}
+`;
+
+const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who creates and reviews intentional, polished experiences.
+
+**Role**: Craft and review cohesive UI/UX that balances visual impact with usability.
+
+## Design Principles
+
+**Typography**
+- Choose distinctive, characterful fonts that elevate aesthetics
+- Avoid generic defaults (Arial, Inter)-opt for unexpected, beautiful choices
+- Pair display fonts with refined body fonts for hierarchy
+
+**Color & Theme**
+- Commit to a cohesive aesthetic with clear color variables
+- Dominant colors with sharp accents > timid, evenly-distributed palettes
+- Create atmosphere through intentional color relationships
+
+**Motion & Interaction**
+- Leverage framework animation utilities when available (Tailwind's transition/animation classes)
+- Focus on high-impact moments: orchestrated page loads with staggered reveals
+- Use scroll-triggers and hover states that surprise and delight
+- One well-timed animation > scattered micro-interactions
+- Drop to custom CSS/JS only when utilities can't achieve the vision
+
+**Spatial Composition**
+- Break conventions: asymmetry, overlap, diagonal flow, grid-breaking
+- Generous negative space OR controlled density-commit to the choice
+- Unexpected layouts that guide the eye
+
+**Visual Depth**
+- Create atmosphere beyond solid colors: gradient meshes, noise textures, geometric patterns
+- Layer transparencies, dramatic shadows, decorative borders
+- Contextual effects that match the aesthetic (grain overlays, custom cursors)
+
+**Styling Approach**
+- Default to Tailwind CSS utility classes when available-fast, maintainable, consistent
+- Use custom CSS when the vision requires it: complex animations, unique effects, advanced compositions
+- Balance utility-first speed with creative freedom where it matters
+
+**Match Vision to Execution**
+- Maximalist designs → elaborate implementation, extensive animations, rich effects
+- Minimalist designs → restraint, precision, careful spacing and typography
+- Elegance comes from executing the chosen vision fully, not halfway
+
+## Constraints
+- Respect existing design systems when present
+- Leverage component libraries where available
+- Prioritize visual excellence-code perfection comes second
+- Use grounded, normal, regular english - don't use jargon or overly technical language
+
+${WRITABLE_FILE_OPERATIONS_RULES}
+
+## Review Responsibilities
+- Review existing UI for usability, responsiveness, visual consistency, and polish when asked
+- Call out concrete UX issues and improvements, not just abstract design advice
+
+## Verification
+- Run only validation assigned by the Orchestrator; do not broaden it
+  automatically.
+- Report validation results and skips accurately.
+- Assigned validation should be user-visible.
+
+## Output Quality
+You're capable of extraordinary creative work. Commit fully to distinctive visions and show what's possible when breaking conventions thoughtfully.`;
+
+const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
+
+**Role**: Execute code changes efficiently. You receive complete context from research agents and clear task specifications from the Orchestrator. Your job is to implement, not plan or research.
+
+**Behavior**:
+- Execute the task specification provided by the Orchestrator
+- Report completion with summary of changes
+
+${WRITABLE_FILE_OPERATIONS_RULES}
+
+**Constraints**:
+- NO external research (no context7, gh_grep)
+- NO spawning subagents; telling the caller which specialist to use is fine
+- No multi-step research/planning; minimal execution sequence ok
+- If context is insufficient: use grep/glob/read directly - do not delegate
+- Only ask for missing inputs you truly cannot retrieve yourself
+- Do not act as the primary reviewer; implement requested changes and surface obvious issues briefly
+- No design work — layout, styling, visual hierarchy, responsive behavior, animation, component feel. Refuse and tell the caller to use @designer.
+
+**Verification**:
+- Run only validation assigned by the Orchestrator; do not broaden it
+  automatically.
+- Report validation results and skips accurately.
+
+**Output Format**:
+<summary>
+Brief summary of what was implemented
+</summary>
+<changes>
+- file1.ts: Changed X to Y
+- file2.ts: Added Z function
+</changes>
+<verification>
+- Performed: [command/check, or skipped with reason]
+- Result: [passed/failed/unknown]
+</verification>
+
+`;
+
+const OBSERVER_PROMPT = `You are Observer - a visual analysis specialist.
+
+**Role**: Interpret images, screenshots, PDFs, and diagrams. Extract structured observations for the Orchestrator to act on.
+
+**Behavior**:
+- Read the file(s) specified in the prompt
+- Analyze visual content - layouts, UI elements, text, relationships, flows
+- For screenshots with text/code/errors: extract the **exact text** via OCR - never paraphrase error messages or code
+- For multiple files: analyze each, then compare or relate as requested
+- Return ONLY the extracted information relevant to the goal
+- If the image is unclear, blurry, or partially visible: state what you CAN see and explicitly note what is uncertain - never guess or fabricate details
+
+**Constraints**:
+- READ-ONLY: Analyze and report, don't modify files
+- Save context tokens - the Orchestrator never processes the raw file
+- Match the language of the request
+- If info not found, state clearly what's missing
+
+${READONLY_FILE_OPERATIONS_RULES}
+`;
+
+const ROLE_ROUTING_BLOCKS: Record<SpecialistRole, string> = {
+  explorer: `@explorer
+- Lane: Fast codebase recon that returns compressed context
+- Permissions: read_files
+- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
+- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
+- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
+- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
+  librarian: `@librarian
+- Lane: External knowledge and library research, fast web research
+- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
+- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
+- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
+- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. "How do others solve or workaround this tricky issue?" → @librarian.`,
+  oracle: `@oracle
+- Lane: Architecture, risk, debugging strategy, and review
+- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
+- Permissions: read_files
+- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
+- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
+- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
+- **Review use:** @oracle is an escalation, not a default verification step. Request independent @oracle review only when its analysis is expected to materially reduce risk or uncertainty.
+- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.`,
+  designer: `@designer
+- Lane: UI/UX design, related edits, design polish and review
+- Permissions: read_files, write_files
+- Stats: 10x better UI/UX than orchestrator
+- Capabilities: Good design taste, visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
+- Owns visual and interaction quality: layout, hierarchy, spacing, motion, affordances, responsive behavior, and overall feel.
+- Weakness: copywriting. Ask @designer to use grounded, normal wording, then have orchestrator review/fix copy after design work without changing visual or interaction intent.
+- Avoid: "Let me ask @designer how it should look and implement yourself" → instead: "Let me ask @designer to design and implement the UI/UX changes for me"
+- **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
+- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.`,
+  fixer: `@fixer
+- Lane: Bounded implementation and executioner
+- Role: Fast execution specialist for well-defined tasks
+- Permissions: read_files, write_files
+- Stats: 2x faster code edits, 1/2 cost of orchestrator
+- Weakness: design, taste
+- Tools/Constraints: Execution-focused-no research, no architectural decisions
+- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixer instances for each folder.
+- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to @fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
+- **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.`,
+  observer: `@observer
+- Lane: Visual/media analysis isolated from orchestrator context
+- Role: Visual analysis specialist for images, PDFs, and diagrams
+- Permissions: Read files
+- Stats: Saves main context tokens - @observer processes raw files, returns structured observations
+- Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
+- **Delegate when:** Need to analyze a multimedia file• Extract information
+- **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
+- **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."`,
+};
+
+function defineRole(
+  id: SpecialistRole,
+  basePrompt: string,
+  description: string,
+  defaultModel: string | undefined,
+  defaultSkills: readonly string[],
+  defaultMcps: readonly string[],
+  permissionPolicy: RoleDefinition['permissionPolicy'],
+): RoleDefinition {
+  const role = {
+    id,
+    basePrompt,
+    description,
+    derivable: true,
+    profileTargetable: true,
+    defaultModel,
+    defaultSkills: Object.freeze([...defaultSkills]),
+    defaultMcps: Object.freeze([...defaultMcps]),
+    permissionPolicy,
+    routingBlock: ROLE_ROUTING_BLOCKS[id],
+    createBaseline: undefined as unknown as RoleDefinition['createBaseline'],
+  } satisfies Omit<RoleDefinition, 'createBaseline'> & {
+    createBaseline: RoleDefinition['createBaseline'];
+  };
+  role.createBaseline = createBaselineFactory(role);
+  return Object.freeze(role);
+}
+
+export const ROLE_DEFINITIONS: Readonly<
+  Record<SpecialistRole, RoleDefinition>
+> = Object.freeze({
+  explorer: defineRole(
+    'explorer',
+    EXPLORER_PROMPT,
+    "Fast codebase search and pattern matching. Use for finding files, locating code patterns, and answering 'where is X?' questions.",
+    DEFAULT_MODELS.explorer,
+    [],
+    DEFAULT_AGENT_MCPS.explorer,
+    'read-only',
+  ),
+  librarian: defineRole(
+    'librarian',
+    LIBRARIAN_PROMPT,
+    'External documentation and library research. Use for official docs lookup, GitHub examples, and understanding library internals.',
+    DEFAULT_MODELS.librarian,
+    [],
+    DEFAULT_AGENT_MCPS.librarian,
+    'read-only',
+  ),
+  oracle: defineRole(
+    'oracle',
+    ORACLE_PROMPT,
+    'Strategic technical advisor. Use for architecture decisions, complex debugging, code review, simplification, and engineering guidance.',
+    DEFAULT_MODELS.oracle,
+    ['simplify', 'requesting-code-review'],
+    DEFAULT_AGENT_MCPS.oracle,
+    'read-only',
+  ),
+  designer: defineRole(
+    'designer',
+    DESIGNER_PROMPT,
+    'UI/UX design, review, and implementation. Use for styling, responsive design, component architecture and visual polish.',
+    DEFAULT_MODELS.designer,
+    [],
+    DEFAULT_AGENT_MCPS.designer,
+    'read-write',
+  ),
+  fixer: defineRole(
+    'fixer',
+    FIXER_PROMPT,
+    'Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.',
+    DEFAULT_MODELS.fixer,
+    [],
+    DEFAULT_AGENT_MCPS.fixer,
+    'read-write',
+  ),
+  observer: defineRole(
+    'observer',
+    OBSERVER_PROMPT,
+    'Visual analysis. Use for interpreting images, screenshots, PDFs, and diagrams - extracts structured observations without loading raw files into main context. Requires a vision-capable model.',
+    DEFAULT_MODELS.observer,
+    [],
+    DEFAULT_AGENT_MCPS.observer,
+    'read-only',
+  ),
+});
+
+export function getRoleDefinition(role: string): RoleDefinition | undefined {
+  return ROLE_DEFINITIONS[role as SpecialistRole];
+}

+ 40 - 13
src/cli/config-io.test.ts

@@ -17,6 +17,7 @@ import {
   detectCurrentConfig,
   disableDefaultAgents,
   enableLspByDefault,
+  mutateJsonFile,
   parseConfig,
   parseConfigFile,
   stripJsonComments,
@@ -116,6 +117,24 @@ describe('config-io', () => {
     expect(result.config).toEqual({ a: 1 } as any);
   });
 
+  test('mutateJsonFile read-modify-writes under a unique temp', () => {
+    const path = join(tmpDir, 'mutate.json');
+    writeFileSync(path, JSON.stringify({ keep: true, count: 1 }));
+    mutateJsonFile(path, (current) => {
+      const value = current as { keep: boolean; count: number };
+      return { ...value, count: value.count + 1 };
+    });
+    expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual({
+      keep: true,
+      count: 2,
+    });
+    expect(JSON.parse(readFileSync(`${path}.bak`, 'utf-8'))).toEqual({
+      keep: true,
+      count: 1,
+    });
+    expect(existsSync(`${path}.lock`)).toBe(false);
+  });
+
   test('writeConfig writes JSON and creates backup', () => {
     const path = join(tmpDir, 'test.json');
     writeFileSync(path, '{"old": true}');
@@ -509,14 +528,18 @@ describe('config-io', () => {
     expect(saved.preset).toBe('opencode-go');
     expect(saved.disabled_agents).toEqual([]);
     expect(saved.presets.openai).toBeDefined();
-    expect(saved.presets['opencode-go'].orchestrator.model).toBe(
+    expect(saved.presets['opencode-go'].agents.orchestrator.model).toBe(
       'opencode-go/minimax-m3',
     );
-    expect(saved.presets['opencode-go'].orchestrator.variant).toBe('thinking');
-    expect(saved.presets['opencode-go'].observer.model).toBe(
+    expect(saved.presets['opencode-go'].agents.orchestrator.variant).toBe(
+      'thinking',
+    );
+    expect(saved.presets['opencode-go'].agents.observer.model).toBe(
       'opencode-go/mimo-v2.5',
     );
-    expect(saved.presets['opencode-go'].observer.variant).toBeUndefined();
+    expect(
+      saved.presets['opencode-go'].agents.observer.variant,
+    ).toBeUndefined();
   });
 
   test('disableDefaultAgents disables conflicting OpenCode built-in agents', () => {
@@ -615,10 +638,12 @@ describe('config-io', () => {
         preset: 'openai',
         presets: {
           openai: {
-            orchestrator: { model: 'openai/gpt-4' },
-            oracle: { model: 'anthropic/claude-opus-4-6' },
-            explorer: { model: 'github-copilot/grok-code-fast-1' },
-            librarian: { model: 'zai-coding-plan/glm-4.7' },
+            agents: {
+              orchestrator: { model: 'openai/gpt-4' },
+              oracle: { model: 'anthropic/claude-opus-4-6' },
+              explorer: { model: 'github-copilot/grok-code-fast-1' },
+              librarian: { model: 'zai-coding-plan/glm-4.7' },
+            },
           },
         },
       }),
@@ -668,11 +693,13 @@ describe('config-io', () => {
         preset: 'dev',
         presets: {
           dev: {
-            orchestrator: {
-              model: [
-                'openai/gpt-5.6-luna',
-                { id: 'anthropic/claude-opus-4-6' },
-              ],
+            agents: {
+              orchestrator: {
+                model: [
+                  'openai/gpt-5.6-luna',
+                  { id: 'anthropic/claude-opus-4-6' },
+                ],
+              },
             },
           },
         },

+ 129 - 60
src/cli/config-io.ts

@@ -3,13 +3,19 @@ import {
   existsSync,
   mkdirSync,
   readFileSync,
-  renameSync,
   rmSync,
   statSync,
   writeFileSync,
 } from 'node:fs';
 import { homedir } from 'node:os';
-import { dirname, join } from 'node:path';
+import { basename, dirname, join } from 'node:path';
+import {
+  parseJsonConfig,
+  parseJsonConfigFile,
+  stripJsonComments,
+} from '../config/jsonc';
+import { withMarketplaceLease, writeAtomic } from '../marketplace/lease';
+import type { MarketplacePaths } from '../marketplace/paths';
 import {
   INSTALLER_MANAGED_PLUGIN_OPTION,
   type PluginEntry,
@@ -31,6 +37,8 @@ import type {
   OpenCodeConfig,
 } from './types';
 
+export { stripJsonComments } from '../config/jsonc';
+
 const PACKAGE_NAME = 'oh-my-opencode-slim';
 const DEFAULT_OPENCODE_AGENTS_TO_DISABLE = ['explore', 'general'] as const;
 
@@ -380,53 +388,137 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
   }
 }
 
-/**
- * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
- */
-export function stripJsonComments(json: string): string {
-  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g;
-  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g;
-
-  return json
-    .replace(commentPattern, (match, commentGroup) =>
-      commentGroup ? '' : match,
-    )
-    .replace(trailingCommaPattern, (match, comma, closing) =>
-      comma ? closing : match,
-    );
-}
-
 export function parseConfigFile(path: string): {
   config: OpenCodeConfig | null;
   error?: string;
 } {
-  try {
-    if (!existsSync(path)) return { config: null };
-    const stat = statSync(path);
-    if (stat.size === 0) return { config: null };
-    // Strip a UTF-8 BOM (RFC 8259 permits one) so JSON.parse does not choke.
-    const content = readFileSync(path, 'utf-8').replace(/^\uFEFF/, '');
-    if (content.trim().length === 0) return { config: null };
-    return { config: JSON.parse(stripJsonComments(content)) as OpenCodeConfig };
-  } catch (err) {
-    return { config: null, error: String(err) };
-  }
+  const result = parseJsonConfigFile(path);
+  return {
+    config: result.config as OpenCodeConfig | null,
+    error: result.error,
+  };
 }
 
 export function parseConfig(path: string): {
   config: OpenCodeConfig | null;
   error?: string;
 } {
-  const result = parseConfigFile(path);
-  if (result.config || result.error) return result;
+  const result = parseJsonConfig(path);
+  if (result.config || result.error) {
+    return {
+      config: result.config as OpenCodeConfig | null,
+      error: result.error,
+    };
+  }
 
   if (path.endsWith('.json')) {
     const jsoncPath = path.replace(/\.json$/, '.jsonc');
-    return parseConfigFile(jsoncPath);
+    return parseJsonConfigFile(jsoncPath) as {
+      config: OpenCodeConfig | null;
+      error?: string;
+    };
   }
   return { config: null };
 }
 
+function errnoCode(error: unknown): string | undefined {
+  if (!error || typeof error !== 'object') return undefined;
+  const code = (error as { code?: unknown }).code;
+  return typeof code === 'string' ? code : undefined;
+}
+
+function sleepSync(milliseconds: number): void {
+  const sleeper = new Int32Array(new SharedArrayBuffer(4));
+  Atomics.wait(sleeper, 0, 0, milliseconds);
+}
+
+function readFileBytes(filePath: string): string | null {
+  try {
+    return readFileSync(filePath, 'utf8');
+  } catch (error) {
+    if (errnoCode(error) === 'ENOENT') return null;
+    throw error;
+  }
+}
+
+function configMutationPaths(filePath: string): MarketplacePaths {
+  const root = join(dirname(filePath), `.${basename(filePath)}.write-lock`);
+  return {
+    rootDir: root,
+    packagesDir: join(root, 'packages'),
+    lockfilePath: join(root, 'lock.json'),
+    lockDir: join(root, 'lock'),
+    breakerDir: join(root, 'lock.breaker'),
+    stagingDir: join(root, '.staging'),
+  };
+}
+
+function testMutationBarrier(point: string): void {
+  const dir = process.env.CONFIG_MUTATION_BARRIER_DIR;
+  if (!dir) return;
+  const waitPath = join(dir, `${point}.wait`);
+  try {
+    statSync(waitPath);
+  } catch {
+    return;
+  }
+  writeAtomic(join(dir, `${point}.reached`), `${process.pid}\n`);
+  const goPath = join(dir, `${point}.go`);
+  while (true) {
+    try {
+      statSync(goPath);
+      return;
+    } catch {
+      sleepSync(10);
+    }
+  }
+}
+
+function publishJsonFile(filePath: string, content: string): void {
+  mkdirSync(dirname(filePath), { recursive: true });
+  if (existsSync(filePath)) {
+    copyFileSync(filePath, `${filePath}.bak`);
+  }
+  writeAtomic(filePath, content);
+}
+
+function withSerializedConfigWrite<T>(filePath: string, operation: () => T): T {
+  return withMarketplaceLease(
+    configMutationPaths(filePath),
+    (lease) => lease.commit(operation),
+    {},
+  );
+}
+
+/**
+ * Whole-file read-modify-write serialized by the generation-safe lease.
+ */
+export function mutateJsonFile(
+  filePath: string,
+  mutate: (current: unknown) => unknown,
+): void {
+  withSerializedConfigWrite(filePath, () => {
+    const original = readFileBytes(filePath);
+    const parsed =
+      original === null
+        ? null
+        : (JSON.parse(
+            stripJsonComments(original.replace(/^\uFEFF/, '')),
+          ) as unknown);
+    testMutationBarrier('config-rmw-read');
+    publishJsonFile(filePath, `${JSON.stringify(mutate(parsed), null, 2)}\n`);
+  });
+}
+
+/**
+ * Write JSON to disk atomically under the generation-safe lease.
+ */
+export function writeJsonAtomic(filePath: string, value: unknown): void {
+  withSerializedConfigWrite(filePath, () => {
+    publishJsonFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
+  });
+}
+
 /**
  * Write config to file atomically.
  */
@@ -436,19 +528,7 @@ export function writeConfig(configPath: string, config: OpenCodeConfig): void {
       '[config-manager] Writing to .jsonc file - comments will not be preserved',
     );
   }
-
-  const tmpPath = `${configPath}.tmp`;
-  const bakPath = `${configPath}.bak`;
-  const content = `${JSON.stringify(config, null, 2)}\n`;
-
-  // Backup existing config if it exists
-  if (existsSync(configPath)) {
-    copyFileSync(configPath, bakPath);
-  }
-
-  // Atomic write pattern: write to tmp, then rename
-  writeFileSync(tmpPath, content);
-  renameSync(tmpPath, configPath);
+  writeJsonAtomic(configPath, config);
 }
 
 export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
@@ -553,19 +633,7 @@ export function writeLiteConfig(
   try {
     ensureConfigDir();
     const config = generateLiteConfig(installConfig);
-
-    // Atomic write for lite config too
-    const tmpPath = `${configPath}.tmp`;
-    const bakPath = `${configPath}.bak`;
-    const content = `${JSON.stringify(config, null, 2)}\n`;
-
-    // Backup existing config if it exists
-    if (existsSync(configPath)) {
-      copyFileSync(configPath, bakPath);
-    }
-
-    writeFileSync(tmpPath, content);
-    renameSync(tmpPath, configPath);
+    writeJsonAtomic(configPath, config);
 
     return { success: true, configPath };
   } catch (err) {
@@ -685,9 +753,10 @@ export function detectCurrentConfig(): DetectedConfig {
     const configObj = liteConfig as Record<string, unknown>;
     const presetName = configObj.preset as string;
     const presets = configObj.presets as Record<string, unknown>;
-    const agents = presets?.[presetName] as
-      | Record<string, { model?: unknown }>
+    const preset = presets?.[presetName] as
+      | { agents?: Record<string, { model?: unknown }> }
       | undefined;
+    const agents = preset?.agents;
 
     if (agents && typeof agents === 'object') {
       const models = Object.values(agents)

+ 11 - 9
src/cli/doctor.test.ts

@@ -297,7 +297,7 @@ describe('runDoctorCheck', () => {
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'mypreset',
-        presets: { mypreset: { oracle: { model: 'test/model' } } },
+        presets: { mypreset: { agents: { oracle: { model: 'test/model' } } } },
       }),
     );
 
@@ -316,7 +316,7 @@ describe('runDoctorCheck', () => {
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'mypreset',
-        presets: { mypreset: { oracle: { model: 'test/model' } } },
+        presets: { mypreset: { agents: { oracle: { model: 'test/model' } } } },
       }),
     );
 
@@ -334,7 +334,7 @@ describe('runDoctorCheck', () => {
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'nonexistent',
-        presets: { other: {} },
+        presets: { other: { agents: {} } },
       }),
     );
 
@@ -354,8 +354,8 @@ describe('runDoctorCheck', () => {
       JSON.stringify({
         preset: 'config-preset',
         presets: {
-          'config-preset': { oracle: { model: 'config/model' } },
-          'env-preset': { oracle: { model: 'env/model' } },
+          'config-preset': { agents: { oracle: { model: 'config/model' } } },
+          'env-preset': { agents: { oracle: { model: 'env/model' } } },
         },
       }),
     );
@@ -378,8 +378,10 @@ describe('runDoctorCheck', () => {
         agents: { oracle: { temperature: 0.5 } },
         presets: {
           'test-preset': {
-            oracle: { model: 'user/model' },
-            explorer: { model: 'user/explorer' },
+            agents: {
+              oracle: { model: 'user/model' },
+              explorer: { model: 'user/explorer' },
+            },
           },
         },
       }),
@@ -433,8 +435,8 @@ describe('runDoctorCheck', () => {
       JSON.stringify({
         preset: 'user-preset',
         presets: {
-          'user-preset': { oracle: { model: 'user/model' } },
-          'project-preset': { oracle: { model: 'project/model' } },
+          'user-preset': { agents: { oracle: { model: 'user/model' } } },
+          'project-preset': { agents: { oracle: { model: 'project/model' } } },
         },
       }),
     );

+ 16 - 0
src/cli/index.ts

@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 import { doctor, parseDoctorArgs } from './doctor';
 import { install } from './install';
+import { marketplaceCommand } from './marketplace';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import type {
   BackgroundSubagentsArg,
@@ -75,6 +76,18 @@ oh-my-opencode-slim installer
 Usage:
   bunx oh-my-opencode-slim install [OPTIONS]
   bunx oh-my-opencode-slim doctor [OPTIONS]
+  bunx oh-my-opencode-slim marketplace install <package.json>
+  bunx oh-my-opencode-slim marketplace import <package.json> (alias for install)
+  bunx oh-my-opencode-slim marketplace update <package.json>
+  bunx oh-my-opencode-slim marketplace list
+  bunx oh-my-opencode-slim marketplace show <package-id> [--json]
+  bunx oh-my-opencode-slim marketplace verify [package-id] [--json]
+  bunx oh-my-opencode-slim marketplace remove <package-id> [--force]
+  bunx oh-my-opencode-slim marketplace enable <package-id>
+  bunx oh-my-opencode-slim marketplace disable <package-id>
+  bunx oh-my-opencode-slim marketplace profile <role> <package-id>
+  bunx oh-my-opencode-slim marketplace profile <role> --clear
+  bunx oh-my-opencode-slim marketplace status [--json]
 
 Options:
   --skills=yes|no|force  Install bundled skills; force replaces existing skill
@@ -123,6 +136,9 @@ async function main(): Promise<void> {
     const doctorArgs = parseDoctorArgs(args.slice(1));
     const exitCode = await doctor(doctorArgs);
     process.exit(exitCode);
+  } else if (args[0] === 'marketplace') {
+    const exitCode = await marketplaceCommand(args.slice(1));
+    process.exit(exitCode);
   } else if (args[0] === '-h' || args[0] === '--help') {
     printHelp();
     process.exit(0);

+ 63 - 0
src/cli/marketplace.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from 'bun:test';
+import { parseMarketplaceArgs } from './marketplace';
+
+describe('marketplace CLI parsing', () => {
+  test('parses lifecycle commands and the import alias', () => {
+    expect(parseMarketplaceArgs(['import', './package.json'])).toEqual({
+      command: 'install',
+      value: './package.json',
+      force: false,
+      json: false,
+      clear: false,
+    });
+  });
+
+  test('requires package input for install and update', () => {
+    expect(() => parseMarketplaceArgs(['install'])).toThrow();
+    expect(() => parseMarketplaceArgs(['update'])).toThrow();
+    expect(parseMarketplaceArgs(['list'])).toEqual({
+      command: 'list',
+      value: undefined,
+      force: false,
+      json: false,
+      clear: false,
+    });
+    expect(() =>
+      parseMarketplaceArgs(['remove', 'community/example', '--json']),
+    ).toThrow();
+  });
+
+  test('parses enable, disable, and profile commands', () => {
+    expect(parseMarketplaceArgs(['enable', 'community/example'])).toEqual({
+      command: 'enable',
+      value: 'community/example',
+      force: false,
+      json: false,
+      clear: false,
+    });
+    expect(
+      parseMarketplaceArgs(['profile', 'librarian', 'community/deep']),
+    ).toEqual({
+      command: 'profile',
+      role: 'librarian',
+      value: 'community/deep',
+      force: false,
+      json: false,
+      clear: false,
+    });
+    expect(parseMarketplaceArgs(['profile', 'oracle', '--clear'])).toEqual({
+      command: 'profile',
+      role: 'oracle',
+      force: false,
+      json: false,
+      clear: true,
+    });
+    expect(parseMarketplaceArgs(['status', '--json'])).toEqual({
+      command: 'status',
+      value: undefined,
+      force: false,
+      json: true,
+      clear: false,
+    });
+  });
+});

+ 247 - 0
src/cli/marketplace.ts

@@ -0,0 +1,247 @@
+import {
+  MarketplaceService,
+  type MarketplaceServiceOptions,
+} from '../marketplace';
+import {
+  disableMarketplacePackage,
+  enableMarketplaceAgent,
+  setMarketplaceProfile,
+} from '../marketplace/activation-config';
+import {
+  collectMarketplaceStatus,
+  formatMarketplaceStatus,
+  mutationReloadNotice,
+} from '../marketplace/status';
+
+export type MarketplaceCommandName =
+  | 'install'
+  | 'list'
+  | 'show'
+  | 'verify'
+  | 'update'
+  | 'remove'
+  | 'enable'
+  | 'disable'
+  | 'profile'
+  | 'status';
+
+export interface MarketplaceArgs {
+  command: MarketplaceCommandName;
+  value?: string;
+  role?: string;
+  force: boolean;
+  json: boolean;
+  clear: boolean;
+}
+
+function commandSet(): readonly string[] {
+  return [
+    'install',
+    'import',
+    'list',
+    'show',
+    'verify',
+    'update',
+    'remove',
+    'enable',
+    'disable',
+    'profile',
+    'status',
+  ];
+}
+
+export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
+  const rawCommand = args[0];
+  if (!rawCommand || !commandSet().includes(rawCommand)) {
+    throw new Error(
+      'Usage: marketplace install|import|list|show|verify|update|remove|enable|disable|profile|status [value] [options]',
+    );
+  }
+  const command: MarketplaceCommandName =
+    rawCommand === 'import'
+      ? 'install'
+      : (rawCommand as MarketplaceCommandName);
+  const force = args.includes('--force');
+  const json = args.includes('--json');
+  const clear = args.includes('--clear');
+  const options = args.filter((arg) => arg.startsWith('--'));
+  const allowedOptions = new Set(
+    command === 'remove'
+      ? ['--force']
+      : command === 'verify' || command === 'show' || command === 'status'
+        ? ['--json']
+        : command === 'profile'
+          ? ['--clear']
+          : [],
+  );
+  for (const option of options) {
+    if (!allowedOptions.has(option)) {
+      throw new Error(
+        `Option ${option} is not valid for marketplace ${rawCommand}`,
+      );
+    }
+  }
+
+  const positional = args.slice(1).filter((arg) => !arg.startsWith('--'));
+  if (command === 'profile') {
+    if (!positional[0]) {
+      throw new Error('marketplace profile requires a specialist role');
+    }
+    if (clear) {
+      if (positional.length > 1) {
+        throw new Error('marketplace profile --clear accepts only a role');
+      }
+      return { command, role: positional[0], force, json, clear };
+    }
+    if (!positional[1] || positional.length > 2) {
+      throw new Error('marketplace profile requires a role and package ID');
+    }
+    return {
+      command,
+      role: positional[0],
+      value: positional[1],
+      force,
+      json,
+      clear,
+    };
+  }
+  const needsValue = [
+    'install',
+    'show',
+    'update',
+    'remove',
+    'enable',
+    'disable',
+  ].includes(command);
+  const allowsValue = needsValue || command === 'verify';
+  if (positional.length > (allowsValue ? 1 : 0)) {
+    throw new Error(
+      `marketplace ${rawCommand} accepts ${needsValue ? 'one' : 'no more than one'} value`,
+    );
+  }
+  if (needsValue && !positional[0]) {
+    throw new Error(`marketplace ${rawCommand} requires a value`);
+  }
+  if ((command === 'install' || command === 'update') && force) {
+    throw new Error(
+      `Option --force is not valid for marketplace ${rawCommand}`,
+    );
+  }
+  return { command, value: positional[0], force, json, clear };
+}
+
+export async function marketplaceCommand(
+  args: string[],
+  options: MarketplaceServiceOptions = {},
+): Promise<number> {
+  try {
+    const parsed = parseMarketplaceArgs(args);
+    const service = new MarketplaceService(options);
+    const projectDir = options.projectDir ?? process.cwd();
+    switch (parsed.command) {
+      case 'install': {
+        const pkg = service.installFile(parsed.value as string);
+        console.log(
+          mutationReloadNotice(
+            `Installed ${pkg.manifest.id}@${pkg.manifest.version}`,
+            'unknown',
+          ),
+        );
+        return 0;
+      }
+      case 'update': {
+        const pkg = service.updateFile(parsed.value as string);
+        console.log(
+          mutationReloadNotice(
+            `Updated ${pkg.manifest.id}@${pkg.manifest.version}`,
+            'unknown',
+          ),
+        );
+        return 0;
+      }
+      case 'list':
+        for (const pkg of service.list()) {
+          console.log(
+            `${pkg.manifest.id}@${pkg.manifest.version}\t${pkg.manifest.kind}\t${pkg.manifest.displayName}`,
+          );
+        }
+        return 0;
+      case 'show': {
+        const pkg = service.show(parsed.value as string);
+        console.log(
+          parsed.json
+            ? JSON.stringify(pkg, null, 2)
+            : `${pkg.manifest.id}@${pkg.manifest.version}\n${pkg.manifest.description}\nsource: ${JSON.stringify(pkg.source)}`,
+        );
+        return 0;
+      }
+      case 'verify': {
+        const results = service.verify(parsed.value);
+        if (parsed.json) console.log(JSON.stringify(results, null, 2));
+        else {
+          for (const result of results) {
+            console.log(`${result.valid ? 'OK' : 'FAIL'} ${result.message}`);
+          }
+        }
+        return results.every((result) => result.valid) ? 0 : 1;
+      }
+      case 'remove':
+        service.remove(parsed.value as string, { force: parsed.force });
+        console.log(mutationReloadNotice(`Removed ${parsed.value}`, 'unknown'));
+        return 0;
+      case 'enable':
+        enableMarketplaceAgent(
+          projectDir,
+          parsed.value as string,
+          service.store,
+        );
+        console.log(
+          mutationReloadNotice(
+            `Enabled ${parsed.value} in the active preset`,
+            'unknown',
+          ),
+        );
+        return 0;
+      case 'disable':
+        disableMarketplacePackage(projectDir, parsed.value as string);
+        console.log(
+          mutationReloadNotice(
+            `Disabled ${parsed.value} in the active preset`,
+            'unknown',
+          ),
+        );
+        return 0;
+      case 'profile':
+        setMarketplaceProfile(
+          projectDir,
+          parsed.role as string,
+          parsed.clear ? null : (parsed.value as string),
+          service.store,
+        );
+        console.log(
+          mutationReloadNotice(
+            parsed.clear
+              ? `Cleared the ${parsed.role} profile`
+              : `Selected ${parsed.value} for ${parsed.role}`,
+            'unknown',
+          ),
+        );
+        return 0;
+      case 'status': {
+        const report = collectMarketplaceStatus({
+          service,
+          projectDir,
+        });
+        console.log(
+          parsed.json
+            ? JSON.stringify(report, null, 2)
+            : formatMarketplaceStatus(report),
+        );
+        return 0;
+      }
+    }
+  } catch (error) {
+    console.error(error instanceof Error ? error.message : String(error));
+    return 1;
+  }
+}

+ 7 - 7
src/cli/providers.test.ts

@@ -28,10 +28,10 @@ describe('providers', () => {
     expect(config.preset).toBe('openai');
     expect(config.disabled_agents).toBeUndefined();
     expect((config.presets as any)['opencode-go']).toBeDefined();
-    expect((config.presets as any)['opencode-go'].observer.model).toBe(
+    expect((config.presets as any)['opencode-go'].agents.observer.model).toBe(
       'opencode-go/mimo-v2.5',
     );
-    const agents = (config.presets as any).openai;
+    const agents = (config.presets as any).openai.agents;
     expect(agents).toBeDefined();
     expect(agents.orchestrator.model).toBe('openai/gpt-5.6-terra');
     expect(agents.orchestrator.variant).toBe('high');
@@ -46,7 +46,7 @@ describe('providers', () => {
       reset: false,
     });
 
-    const agents = (config.presets as any).openai;
+    const agents = (config.presets as any).openai.agents;
     const expected = {
       orchestrator: { model: 'openai/gpt-5.6-terra', variant: 'high' },
       oracle: { model: 'openai/gpt-5.6-sol', variant: 'high' },
@@ -71,7 +71,7 @@ describe('providers', () => {
     expect(config.preset).toBe('opencode-go');
     expect(config.disabled_agents).toEqual([]);
     expect((config.presets as any).openai).toBeDefined();
-    const agents = (config.presets as any)['opencode-go'];
+    const agents = (config.presets as any)['opencode-go'].agents;
     expect(agents).toBeDefined();
     expect(agents.orchestrator.model).toBe('opencode-go/minimax-m3');
     expect(agents.orchestrator.variant).toBe('thinking');
@@ -162,7 +162,7 @@ describe('providers', () => {
       reset: false,
     });
 
-    const agents = (config.presets as any).openai;
+    const agents = (config.presets as any).openai.agents;
     // Orchestrator should always have '*'
     expect(agents.orchestrator.skills).toEqual(['*']);
 
@@ -189,7 +189,7 @@ describe('providers', () => {
       reset: false,
     });
 
-    const agents = (config.presets as any).openai;
+    const agents = (config.presets as any).openai.agents;
     expect(agents.orchestrator.mcps).toBeDefined();
     expect(Array.isArray(agents.orchestrator.mcps)).toBe(true);
     expect(agents.librarian.mcps).toBeDefined();
@@ -203,7 +203,7 @@ describe('providers', () => {
       reset: false,
     });
 
-    const agents = (config.presets as any).openai;
+    const agents = (config.presets as any).openai.agents;
     expect(agents.orchestrator.mcps).toEqual(['*', '!context7']);
     expect(agents.librarian.mcps).toContain('context7');
     expect(agents.librarian.mcps).toContain('gh_grep');

+ 8 - 6
src/cli/providers.ts

@@ -115,12 +115,14 @@ export function generateLiteConfig(
 
   const buildPreset = (mappingName: PresetName) => {
     const mapping = MODEL_MAPPINGS[mappingName];
-    return Object.fromEntries(
-      Object.entries(mapping).map(([agentName, modelInfo]) => [
-        agentName,
-        createAgentConfig(agentName, modelInfo),
-      ]),
-    );
+    return {
+      agents: Object.fromEntries(
+        Object.entries(mapping).map(([agentName, modelInfo]) => [
+          agentName,
+          createAgentConfig(agentName, modelInfo),
+        ]),
+      ),
+    };
   };
 
   const presets = config.presets as Record<string, unknown>;

+ 1 - 1
src/codemap.md

@@ -60,7 +60,7 @@ OpenCode Core → Plugin Initialization (index.ts)
 1. **Config Loading**: `loadPluginConfig()` reads and validates plugin configuration; `RuntimeConfig` singleton seeded and host config captured
 2. **Agent Creation**: `createAgents()` instantiates agent definitions (incl. dynamic councillors) with prompts and permissions
 3. **Agent Configuration**: `getAgentConfigs()` merges defaults with user overrides and runtime presets
-4. **Tool Registration**: Tools are created conditionally based on config (task_cancel, task_message, task_revive, task_status, task_result, wait_for_user, webfetch, AST-grep, acp_run)
+4. **Tool Registration**: Tools are created conditionally based on config (task_cancel, task_message, task_revive, task_status, task_result, wait_for_user, marketplace, webfetch, AST-grep, acp_run)
 5. **MCP Registration**: Built-in MCPs are created (context7, gh_grep)
 6. **Multiplexer Setup**: Multiplexer session manager initialized for task tool sessions
 7. **Hook Initialization**: Auto-update checker, phase reminders, skill filters, task-session manager, cache monitor, orchestrator-wake scheduler, etc.

+ 9 - 1
src/config/agent-mcps.ts

@@ -1,6 +1,10 @@
 import { AGENT_ALIASES, type AgentName } from './constants';
 import type { RuntimeConfig } from './runtime';
 
+export interface ResolvedMcpSource {
+  readonly mcpLists: Readonly<Record<string, readonly string[]>>;
+}
+
 /** Default MCPs per agent - "*" means all MCPs, "!item" excludes specific MCPs */
 
 export const DEFAULT_AGENT_MCPS: Record<AgentName, string[]> = {
@@ -45,8 +49,12 @@ export function parseList(items: string[], allAvailable: string[]): string[] {
  */
 export function getAgentMcpList(
   agentName: string,
-  runtime: RuntimeConfig,
+  source: RuntimeConfig | ResolvedMcpSource,
 ): string[] {
+  if ('mcpLists' in source) {
+    return [...(source.mcpLists[agentName] ?? [])];
+  }
+  const runtime = source;
   const agents = runtime.agents();
   const agentConfig =
     agents[agentName] ??

+ 11 - 0
src/config/agent-roles.ts

@@ -0,0 +1,11 @@
+/** Specialist roles that can be used as built-in or derived agent bases. */
+export const SUPPORTED_SPECIALIST_ROLES = [
+  'explorer',
+  'librarian',
+  'oracle',
+  'designer',
+  'fixer',
+  'observer',
+] as const;
+
+export type SpecialistRole = (typeof SUPPORTED_SPECIALIST_ROLES)[number];

+ 4 - 0
src/config/index.ts

@@ -1,9 +1,13 @@
+export * from './agent-roles';
 export * from './constants';
 export * from './council-schema';
 export {
   deepMerge,
   loadAgentPrompt,
   loadPluginConfig,
+  mergeAgentOverrides,
+  mergePreset,
+  mergePresets,
 } from './loader';
 export * from './schema';
 export {

+ 46 - 0
src/config/jsonc.ts

@@ -0,0 +1,46 @@
+import { existsSync, readFileSync, statSync } from 'node:fs';
+
+export interface ParsedJsonConfig {
+  config: unknown | null;
+  error?: string;
+}
+
+/** Strip comments and trailing commas from the JSONC used by OpenCode. */
+export function stripJsonComments(json: string): string {
+  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g;
+  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g;
+
+  return json
+    .replace(commentPattern, (match, commentGroup) =>
+      commentGroup ? '' : match,
+    )
+    .replace(trailingCommaPattern, (match, comma, closing) =>
+      comma ? closing : match,
+    );
+}
+
+/** Parse JSONC text the same way OpenCode config files are loaded. */
+export function parseJsoncText(text: string): unknown {
+  return JSON.parse(stripJsonComments(text.replace(/^\uFEFF/, ''))) as unknown;
+}
+
+export function parseJsonConfigFile(filePath: string): ParsedJsonConfig {
+  try {
+    if (!existsSync(filePath)) return { config: null };
+    if (statSync(filePath).size === 0) return { config: null };
+    const content = readFileSync(filePath, 'utf8');
+    if (content.trim().length === 0) return { config: null };
+    return { config: parseJsoncText(content) };
+  } catch (error) {
+    return { config: null, error: String(error) };
+  }
+}
+
+export function parseJsonConfig(filePath: string): ParsedJsonConfig {
+  const result = parseJsonConfigFile(filePath);
+  if (result.config || result.error) return result;
+  if (filePath.endsWith('.json')) {
+    return parseJsonConfigFile(filePath.replace(/\.json$/, '.jsonc'));
+  }
+  return { config: null };
+}

+ 131 - 54
src/config/loader.test.ts

@@ -3,10 +3,68 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import type { ConfigLoadWarning } from './loader';
-import { loadAgentPrompt, loadPluginConfig } from './loader';
-
-// Test deepMerge indirectly through loadPluginConfig behavior
-// since deepMerge is not exported
+import {
+  loadAgentPrompt,
+  loadPluginConfig,
+  mergeAgentOverrides,
+  mergePreset,
+} from './loader';
+import type { Preset } from './schema';
+
+function selectedPresetAgent(config: ReturnType<typeof loadPluginConfig>) {
+  return config.preset
+    ? config.presets?.[config.preset]?.agents?.oracle
+    : undefined;
+}
+
+describe('typed agent and preset merge precedence', () => {
+  test('canonicalizes aliases and clears lower inheritance in both directions', () => {
+    expect(
+      mergeAgentOverrides(
+        { explorer: { model: 'lower/model' } },
+        { explore: { inheritModelFrom: 'session' } },
+      ),
+    ).toEqual({ explorer: { inheritModelFrom: 'session' } });
+    expect(
+      mergeAgentOverrides(
+        { explore: { inheritModelFrom: 'session' } },
+        { explorer: { model: 'higher/model' } },
+      ),
+    ).toEqual({ explorer: { model: 'higher/model' } });
+  });
+
+  test('preserves omitted activation and applies explicit empty tombstones', () => {
+    const base = {
+      agents: {},
+      marketplace: {
+        agents: ['community/one'],
+        profiles: { oracle: 'community/oracle' },
+      },
+    } satisfies Preset;
+    expect(mergePreset(base, { agents: {} })).toEqual(base);
+    expect(
+      mergePreset(base, { agents: {}, marketplace: { agents: [] } }),
+    ).toEqual({
+      agents: {},
+      marketplace: {
+        agents: [],
+        profiles: { oracle: 'community/oracle' },
+      },
+    });
+    expect(
+      mergePreset(base, {
+        agents: {},
+        marketplace: { profiles: { oracle: null } },
+      }),
+    ).toEqual({
+      agents: {},
+      marketplace: {
+        agents: ['community/one'],
+        profiles: { oracle: null },
+      },
+    });
+  });
+});
 
 describe('loadPluginConfig', () => {
   let tempDir: string;
@@ -74,7 +132,7 @@ describe('loadPluginConfig', () => {
       path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       `\uFEFF${JSON.stringify({
         preset: 'fast',
-        presets: { fast: { oracle: { model: 'fast-model' } } },
+        presets: { fast: { agents: { oracle: { model: 'fast-model' } } } },
         agents: { oracle: { temperature: 0.9 } },
         autoUpdate: false,
       })}`,
@@ -85,7 +143,7 @@ describe('loadPluginConfig', () => {
     // The BOM is stripped silently (RFC 8259 permits one); every setting
     // survives, including preset resolution.
     expect(config.autoUpdate).toBe(false);
-    expect(config.agents?.oracle?.model).toBe('fast-model');
+    expect(selectedPresetAgent(config)?.model).toBe('fast-model');
     expect(config.agents?.oracle?.temperature).toBe(0.9);
   });
 
@@ -485,7 +543,7 @@ describe('onWarning callback', () => {
       path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'nonexistent',
-        presets: { other: { oracle: { model: 'other' } } },
+        presets: { other: { agents: { oracle: { model: 'other' } } } },
         agents: { oracle: { model: 'root' } },
       }),
     );
@@ -509,7 +567,7 @@ describe('onWarning callback', () => {
       path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'nonexistent',
-        presets: { other: { oracle: { model: 'other' } } },
+        presets: { other: { agents: { oracle: { model: 'other' } } } },
         agents: { oracle: { model: 'root' } },
       }),
     );
@@ -956,7 +1014,7 @@ describe('disabled_* key normalization', () => {
       JSON.stringify({
         disabled_tools: 'webfetch',
         preset: 'fast',
-        presets: { fast: { oracle: { model: 'fast-model' } } },
+        presets: { fast: { agents: { oracle: { model: 'fast-model' } } } },
         agents: { oracle: { temperature: 0.9 } },
       }),
     );
@@ -967,8 +1025,8 @@ describe('disabled_* key normalization', () => {
     });
 
     expect(config.disabled_tools).toEqual(['webfetch']);
-    // Preset resolution still runs and merges with root agents
-    expect(config.agents?.oracle?.model).toBe('fast-model');
+    // Preset resolution remains structural; registry construction merges it.
+    expect(selectedPresetAgent(config)?.model).toBe('fast-model');
     expect(config.agents?.oracle?.temperature).toBe(0.9);
     expect(warnings).toHaveLength(1);
     expect(warnings[0]?.kind).toBe('normalized');
@@ -1259,13 +1317,13 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'fast',
         presets: {
-          fast: { oracle: { model: 'fast-model' } },
+          fast: { agents: { oracle: { model: 'fast-model' } } },
         },
       }),
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('fast-model');
+    expect(selectedPresetAgent(config)?.model).toBe('fast-model');
   });
 
   test('root agents override preset agents', () => {
@@ -1278,8 +1336,10 @@ describe('preset resolution', () => {
         preset: 'fast',
         presets: {
           fast: {
-            oracle: { model: 'fast-model', temperature: 0.1 },
-            explorer: { model: 'explorer-model' },
+            agents: {
+              oracle: { model: 'fast-model', temperature: 0.1 },
+              explorer: { model: 'explorer-model' },
+            },
           },
         },
         agents: {
@@ -1289,9 +1349,10 @@ describe('preset resolution', () => {
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('fast-model');
+    expect(selectedPresetAgent(config)?.model).toBe('fast-model');
+    expect(selectedPresetAgent(config)?.temperature).toBe(0.1);
     expect(config.agents?.oracle?.temperature).toBe(0.9);
-    expect(config.agents?.explorer?.model).toBe('explorer-model');
+    expect(selectedPresetAgent(config)?.options).toBeUndefined();
   });
 
   test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
@@ -1303,7 +1364,7 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: 'other' } },
+          other: { agents: { oracle: { model: 'other' } } },
         },
         agents: { oracle: { model: 'root' } },
       }),
@@ -1322,13 +1383,13 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'dev',
         presets: {
-          dev: { oracle: { model: 'dev-model' } },
+          dev: { agents: { oracle: { model: 'dev-model' } } },
         },
       }),
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('dev-model');
+    expect(selectedPresetAgent(config)?.model).toBe('dev-model');
   });
 
   test('invalid preset shape: bad agent config in preset fails schema validation', () => {
@@ -1342,7 +1403,7 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'invalid',
         presets: {
-          invalid: { oracle: { temperature: 5 } },
+          invalid: { agents: { oracle: { temperature: 5 } } },
         },
       }),
     );
@@ -1360,7 +1421,7 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: 'other' } },
+          other: { agents: { oracle: { model: 'other' } } },
         },
         agents: { oracle: { model: 'root' } },
       }),
@@ -1384,7 +1445,7 @@ describe('preset resolution', () => {
       JSON.stringify({
         preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: 'other' } },
+          other: { agents: { oracle: { model: 'other' } } },
         },
       }),
     );
@@ -1407,9 +1468,11 @@ describe('preset resolution', () => {
         preset: 'openai',
         presets: {
           openai: {
-            oracle: {
-              model: 'openai/gpt-5.6',
-              options: { textVerbosity: 'low' },
+            agents: {
+              oracle: {
+                model: 'openai/gpt-5.6',
+                options: { textVerbosity: 'low' },
+              },
             },
           },
         },
@@ -1422,10 +1485,13 @@ describe('preset resolution', () => {
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('openai/gpt-5.6');
-    // deepMerge should combine both option keys
-    expect(config.agents?.oracle?.options).toEqual({
+    expect(selectedPresetAgent(config)?.model).toBe('openai/gpt-5.6');
+    // Loader preserves the two structural layers; registry construction
+    // performs their typed merge exactly once.
+    expect(selectedPresetAgent(config)?.options).toEqual({
       textVerbosity: 'low',
+    });
+    expect(config.agents?.oracle?.options).toEqual({
       reasoningEffort: 'medium',
     });
   });
@@ -1440,10 +1506,12 @@ describe('preset resolution', () => {
         preset: 'anthropic-thinking',
         presets: {
           'anthropic-thinking': {
-            oracle: {
-              model: 'anthropic/claude-sonnet-4-6',
-              options: {
-                thinking: { type: 'enabled', budgetTokens: 16000 },
+            agents: {
+              oracle: {
+                model: 'anthropic/claude-sonnet-4-6',
+                options: {
+                  thinking: { type: 'enabled', budgetTokens: 16000 },
+                },
               },
             },
           },
@@ -1452,8 +1520,10 @@ describe('preset resolution', () => {
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
-    expect(config.agents?.oracle?.options).toEqual({
+    expect(selectedPresetAgent(config)?.model).toBe(
+      'anthropic/claude-sonnet-4-6',
+    );
+    expect(selectedPresetAgent(config)?.options).toEqual({
       thinking: { type: 'enabled', budgetTokens: 16000 },
     });
   });
@@ -1468,9 +1538,11 @@ describe('preset resolution', () => {
         preset: 'concise',
         presets: {
           concise: {
-            oracle: {
-              model: 'openai/gpt-5.6',
-              options: { textVerbosity: 'low' },
+            agents: {
+              oracle: {
+                model: 'openai/gpt-5.6',
+                options: { textVerbosity: 'low' },
+              },
             },
           },
         },
@@ -1483,8 +1555,11 @@ describe('preset resolution', () => {
     );
 
     const config = loadPluginConfig(projectDir);
-    expect(config.agents?.oracle?.model).toBe('openai/gpt-5.6');
-    // root wins over preset for same key
+    expect(selectedPresetAgent(config)?.model).toBe('openai/gpt-5.6');
+    // Root remains separate until registry construction.
+    expect(selectedPresetAgent(config)?.options).toEqual({
+      textVerbosity: 'low',
+    });
     expect(config.agents?.oracle?.options).toEqual({
       textVerbosity: 'high',
     });
@@ -1516,8 +1591,8 @@ describe('environment variable preset override', () => {
       JSON.stringify({
         preset: 'config-preset',
         presets: {
-          'config-preset': { oracle: { model: 'config-model' } },
-          'env-preset': { oracle: { model: 'env-model' } },
+          'config-preset': { agents: { oracle: { model: 'config-model' } } },
+          'env-preset': { agents: { oracle: { model: 'env-model' } } },
         },
       }),
     );
@@ -1525,7 +1600,7 @@ describe('environment variable preset override', () => {
     process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
     const config = loadPluginConfig(projectDir);
     expect(config.preset).toBe('env-preset');
-    expect(config.agents?.oracle?.model).toBe('env-model');
+    expect(selectedPresetAgent(config)?.model).toBe('env-model');
   });
 
   test('Env var works when config has no preset', () => {
@@ -1536,7 +1611,7 @@ describe('environment variable preset override', () => {
       path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         presets: {
-          'env-preset': { oracle: { model: 'env-model' } },
+          'env-preset': { agents: { oracle: { model: 'env-model' } } },
         },
       }),
     );
@@ -1544,7 +1619,7 @@ describe('environment variable preset override', () => {
     process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
     const config = loadPluginConfig(projectDir);
     expect(config.preset).toBe('env-preset');
-    expect(config.agents?.oracle?.model).toBe('env-model');
+    expect(selectedPresetAgent(config)?.model).toBe('env-model');
   });
 
   test('Env var is ignored if empty string', () => {
@@ -1556,7 +1631,7 @@ describe('environment variable preset override', () => {
       JSON.stringify({
         preset: 'config-preset',
         presets: {
-          'config-preset': { oracle: { model: 'config-model' } },
+          'config-preset': { agents: { oracle: { model: 'config-model' } } },
         },
       }),
     );
@@ -1564,7 +1639,7 @@ describe('environment variable preset override', () => {
     process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
     const config = loadPluginConfig(projectDir);
     expect(config.preset).toBe('config-preset');
-    expect(config.agents?.oracle?.model).toBe('config-model');
+    expect(selectedPresetAgent(config)?.model).toBe('config-model');
   });
 
   test('Env var is ignored if undefined', () => {
@@ -1576,7 +1651,7 @@ describe('environment variable preset override', () => {
       JSON.stringify({
         preset: 'config-preset',
         presets: {
-          'config-preset': { oracle: { model: 'config-model' } },
+          'config-preset': { agents: { oracle: { model: 'config-model' } } },
         },
       }),
     );
@@ -1584,7 +1659,7 @@ describe('environment variable preset override', () => {
     delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
     const config = loadPluginConfig(projectDir);
     expect(config.preset).toBe('config-preset');
-    expect(config.agents?.oracle?.model).toBe('config-model');
+    expect(selectedPresetAgent(config)?.model).toBe('config-model');
   });
 
   test('Env var with nonexistent preset warns and falls back', () => {
@@ -1596,7 +1671,7 @@ describe('environment variable preset override', () => {
       JSON.stringify({
         preset: 'config-preset',
         presets: {
-          'config-preset': { oracle: { model: 'config-model' } },
+          'config-preset': { agents: { oracle: { model: 'config-model' } } },
         },
         agents: { oracle: { model: 'fallback' } },
       }),
@@ -1784,8 +1859,10 @@ describe('JSONC config support', () => {
         "presets": {
           "dev": {
             // Development agents
-            "oracle": { "model": "dev-oracle", },
-            "explorer": { "model": "dev-explorer", },
+            "agents": {
+              "oracle": { "model": "dev-oracle", },
+              "explorer": { "model": "dev-explorer", },
+            },
           },
         },
       }`,
@@ -1793,8 +1870,8 @@ describe('JSONC config support', () => {
 
     const config = loadPluginConfig(projectDir);
     expect(config.preset).toBe('dev');
-    expect(config.agents?.oracle?.model).toBe('dev-oracle');
-    expect(config.agents?.explorer?.model).toBe('dev-explorer');
+    expect(selectedPresetAgent(config)?.model).toBe('dev-oracle');
+    expect(config.presets?.dev?.agents?.explorer?.model).toBe('dev-explorer');
   });
 });
 

+ 132 - 8
src/config/loader.ts

@@ -2,13 +2,16 @@ import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
-import { DEFAULT_DISABLED_AGENTS } from './constants';
+import { AGENT_ALIASES, DEFAULT_DISABLED_AGENTS } from './constants';
 import {
+  type AgentOverrideConfig,
   BackgroundJobsConfigSchema,
   InterviewConfigSchema,
   LEGACY_FALLBACK_KEYS,
+  type MarketplaceActivation,
   type PluginConfig,
   PluginConfigSchema,
+  type Preset,
   WebfetchConfigSchema,
 } from './schema';
 
@@ -269,6 +272,13 @@ function retainExplicitBackgroundJobsFields(
  * @param onWarning - Optional callback for warnings
  * @returns Validated config object, or null if loading failed
  */
+export function loadPluginConfigFile(
+  configPath: string,
+  options?: LoadPluginConfigOptions,
+): PluginConfig | null {
+  return loadConfigFromPath(configPath, options);
+}
+
 function loadConfigFromPath(
   configPath: string,
   options?: LoadPluginConfigOptions,
@@ -593,8 +603,8 @@ export function mergePluginConfigs(
   return {
     ...base,
     ...override,
-    agents: deepMerge(base.agents, override.agents),
-    presets: deepMerge(base.presets, override.presets),
+    agents: mergeAgentOverrides(base.agents, override.agents),
+    presets: mergePresets(base.presets, override.presets),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
@@ -612,6 +622,121 @@ export function mergePluginConfigs(
   };
 }
 
+/** Typed merge for agent override records. Object-valued options are merged;
+ * scalar values and arrays are replaced by the higher-precedence layer. */
+export function mergeAgentOverrides(
+  base: Record<string, AgentOverrideConfig> | undefined,
+  override: Record<string, AgentOverrideConfig> | undefined,
+): Record<string, AgentOverrideConfig> | undefined {
+  const result: Record<string, AgentOverrideConfig> = {};
+  const addLayer = (layer: Record<string, AgentOverrideConfig> | undefined) => {
+    if (!layer) return;
+    const entries = Object.entries(layer).sort(([left], [right]) => {
+      const leftCanonical = AGENT_ALIASES[left] ?? left;
+      const rightCanonical = AGENT_ALIASES[right] ?? right;
+      if (leftCanonical !== rightCanonical) {
+        return leftCanonical < rightCanonical ? -1 : 1;
+      }
+      return left === leftCanonical ? 1 : right === rightCanonical ? -1 : 0;
+    });
+    for (const [rawName, value] of entries) {
+      const name = AGENT_ALIASES[rawName] ?? rawName;
+      const previous = result[name];
+      const merged = previous
+        ? {
+            ...previous,
+            ...value,
+            options:
+              previous.options || value.options
+                ? { ...previous.options, ...value.options }
+                : undefined,
+          }
+        : { ...value };
+      if (value.model !== undefined) {
+        delete merged.inheritModelFrom;
+      } else if (value.inheritModelFrom !== undefined) {
+        delete merged.model;
+      }
+      result[name] = merged;
+    }
+  };
+
+  addLayer(base);
+  addLayer(override);
+  if (Object.keys(result).length === 0) return base || override;
+  return result;
+}
+
+/** Typed merge for the structured preset model. */
+function normalizePreset(preset: Preset): Preset {
+  return {
+    ...preset,
+    agents: mergeAgentOverrides(undefined, preset.agents) ?? {},
+  };
+}
+
+export function mergePreset(
+  base: Preset | undefined,
+  override: Preset | undefined,
+): Preset | undefined {
+  if (!base) {
+    return override ? normalizePreset(override) : undefined;
+  }
+  if (!override) {
+    return normalizePreset(base);
+  }
+  const marketplace: MarketplaceActivation | undefined = override.marketplace
+    ? {
+        ...(base.marketplace ?? {}),
+        ...override.marketplace,
+        ...(Object.hasOwn(override.marketplace, 'agents')
+          ? { agents: override.marketplace.agents }
+          : {}),
+        ...(Object.hasOwn(override.marketplace, 'profiles')
+          ? {
+              profiles: {
+                ...(base.marketplace?.profiles ?? {}),
+                ...(override.marketplace.profiles ?? {}),
+              },
+            }
+          : {}),
+      }
+    : base.marketplace;
+  return {
+    agents: mergeAgentOverrides(base.agents, override.agents) ?? {},
+    ...(marketplace ? { marketplace } : {}),
+  };
+}
+
+export function mergePresets(
+  base: Record<string, Preset> | undefined,
+  override: Record<string, Preset> | undefined,
+): Record<string, Preset> | undefined {
+  if (!base) {
+    return override
+      ? Object.fromEntries(
+          Object.entries(override).map(([name, preset]) => [
+            name,
+            normalizePreset(preset),
+          ]),
+        )
+      : undefined;
+  }
+  if (!override) {
+    return Object.fromEntries(
+      Object.entries(base).map(([name, preset]) => [
+        name,
+        normalizePreset(preset),
+      ]),
+    );
+  }
+  const result: Record<string, Preset> = { ...base };
+  for (const [name, preset] of Object.entries(override)) {
+    result[name] = mergePreset(result[name], preset) ?? preset;
+  }
+  return result;
+}
+
 /**
  * Recursively merge two objects, with override values taking precedence.
  * For nested objects, merges recursively. For arrays and primitives, override replaces base.
@@ -703,13 +828,12 @@ export function loadPluginConfig(
     config.preset = envPreset;
   }
 
-  // Resolve preset and merge with root agents
+  // Validate the selected preset without projecting it into root agents.
+  // RuntimeConfig/ResolvedAgentRegistry perform the single structural
+  // preset + root + host resolution later.
   if (config.preset) {
     const preset = config.presets?.[config.preset];
-    if (preset) {
-      // Merge preset agents with root agents (root overrides)
-      config.agents = deepMerge(preset, config.agents);
-    } else {
+    if (!preset) {
       // Preset name specified but doesn't exist - warn user
       const presetSource =
         envPreset === config.preset ? 'environment variable' : 'config file';

+ 32 - 20
src/config/project-local-customization.test.ts

@@ -118,7 +118,9 @@ describe('Project-local customization - 15 core cases', () => {
       projectDirectory: projectDir,
     });
     const oracle = agents.find((a) => a.name === 'oracle');
-    expect(oracle?.config.prompt).toBe('replacement prompt\n\nappend prompt');
+    expect(oracle?.config.prompt).toBe(
+      'replacement prompt\n\nappend prompt\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
+    );
   });
 
   // Test Case 5: Inline built-in prompt is accepted and used
@@ -135,7 +137,7 @@ describe('Project-local customization - 15 core cases', () => {
     const agents = createAgents(runtimeFor(config));
     const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle?.config.prompt).toBe(
-      'You are the inline oracle prompt override.',
+      'You are the inline oracle prompt override.\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
     );
   });
 
@@ -161,7 +163,7 @@ describe('Project-local customization - 15 core cases', () => {
     const agents = createAgents(runtimeFor(config));
     const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle?.config.prompt).toBe(
-      'You are the inline oracle prompt override.',
+      'You are the inline oracle prompt override.\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
     );
   });
 
@@ -184,12 +186,12 @@ describe('Project-local customization - 15 core cases', () => {
     const agents = createAgents(runtimeFor(config));
     const oracle = agents.find((a) => a.name === 'oracle');
     expect(oracle?.config.prompt).toBe(
-      'You are the inline oracle prompt override.\n\nappend content',
+      'You are the inline oracle prompt override.\n\nappend content\n\nIf a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
     );
   });
 
-  // Test Case 8: Built-in orchestratorPrompt is injected into orchestrator prompt
-  test('8. Built-in orchestratorPrompt is injected into orchestrator prompt', () => {
+  // Test Case 8: Built-in orchestratorPrompt is part of the agent route
+  test('8. Built-in orchestratorPrompt is part of the agent route', () => {
     const config = {
       agents: {
         oracle: {
@@ -202,12 +204,14 @@ describe('Project-local customization - 15 core cases', () => {
 
     const agents = createAgents(runtimeFor(config));
     const orchestrator = agents.find((a) => a.name === 'orchestrator');
-    expect(orchestrator?.config.prompt).toContain(
-      '# Project-specific routing guidance',
-    );
     expect(orchestrator?.config.prompt).toContain(
       'Please routing to @oracle when architecture is queried.',
     );
+    expect(
+      orchestrator?.config.prompt?.match(
+        /Please routing to @oracle when architecture is queried\./g,
+      ),
+    ).toHaveLength(1);
   });
 
   // Test Case 9: Disabled built-in agent\'s orchestratorPrompt is not injected
@@ -250,7 +254,9 @@ describe('Project-local customization - 15 core cases', () => {
     const userConfig = {
       presets: {
         presetA: {
-          oracle: { model: 'model-a' },
+          agents: {
+            oracle: { model: 'model-a' },
+          },
         },
       },
     };
@@ -258,7 +264,9 @@ describe('Project-local customization - 15 core cases', () => {
     const projectConfig = {
       presets: {
         presetB: {
-          explorer: { model: 'model-b' },
+          agents: {
+            explorer: { model: 'model-b' },
+          },
         },
       },
     };
@@ -266,8 +274,8 @@ describe('Project-local customization - 15 core cases', () => {
     const merged = mergePluginConfigs(userConfig, projectConfig);
     expect(merged.presets?.presetA).toBeDefined();
     expect(merged.presets?.presetB).toBeDefined();
-    expect(merged.presets?.presetA.oracle?.model).toBe('model-a');
-    expect(merged.presets?.presetB.explorer?.model).toBe('model-b');
+    expect(merged.presets?.presetA.agents.oracle?.model).toBe('model-a');
+    expect(merged.presets?.presetB.agents.explorer?.model).toBe('model-b');
   });
 
   // Test Case 12: Same-name preset deep-merges by agent and nested options
@@ -275,9 +283,11 @@ describe('Project-local customization - 15 core cases', () => {
     const userConfig = {
       presets: {
         myPreset: {
-          oracle: {
-            model: 'model-a',
-            options: { tokenLimit: 1000, debug: true },
+          agents: {
+            oracle: {
+              model: 'model-a',
+              options: { tokenLimit: 1000, debug: true },
+            },
           },
         },
       },
@@ -286,16 +296,18 @@ describe('Project-local customization - 15 core cases', () => {
     const projectConfig = {
       presets: {
         myPreset: {
-          oracle: {
-            temperature: 0.7,
-            options: { debug: false, maxSearch: 5 },
+          agents: {
+            oracle: {
+              temperature: 0.7,
+              options: { debug: false, maxSearch: 5 },
+            },
           },
         },
       },
     };
 
     const merged = mergePluginConfigs(userConfig, projectConfig);
-    const oraclePreset = merged.presets?.myPreset?.oracle;
+    const oraclePreset = merged.presets?.myPreset?.agents.oracle;
     expect(oraclePreset?.model).toBe('model-a');
     expect(oraclePreset?.temperature).toBe(0.7);
     expect(oraclePreset?.options).toEqual({

+ 74 - 21
src/config/runtime.test.ts

@@ -34,13 +34,13 @@ describe('RuntimeConfig', () => {
     expect(runtime.agent('explorer')?.model).toBe('original');
   });
 
-  test('seed precedence: host override > runtime override > plugin file', () => {
+  test('seed precedence: host override > root > selected preset', () => {
     resetRegistry();
     const plugin: PluginConfig = {
       preset: 'file',
       presets: {
-        file: { explorer: { model: 'preset-model' } },
-        runtime: { explorer: { model: 'runtime-model' } },
+        file: { agents: { explorer: { model: 'preset-model' } } },
+        runtime: { agents: { explorer: { model: 'runtime-model' } } },
       },
       agents: { explorer: { model: 'plugin-file-model' } },
     };
@@ -53,11 +53,11 @@ describe('RuntimeConfig', () => {
     // host wins over runtime override
     expect(runtime.agent('explorer')?.model).toBe('host-model');
 
-    // without host, runtime override wins over plugin file
+    // without host, root wins over the selected runtime preset
     runtime.captureHostConfig({});
-    expect(runtime.agent('explorer')?.model).toBe('runtime-model');
+    expect(runtime.agent('explorer')?.model).toBe('plugin-file-model');
 
-    // without runtime override, plugin file wins over preset agents
+    // without runtime override, the config-file preset remains below root
     runtime.setRuntimePreset(null);
     expect(runtime.agent('explorer')?.model).toBe('plugin-file-model');
   });
@@ -218,8 +218,10 @@ describe('RuntimeConfig', () => {
       preset: 'fast',
       presets: {
         fast: {
-          explorer: { model: 'provider/explorer' },
-          oracle: { model: 'provider/oracle' },
+          agents: {
+            explorer: { model: 'provider/explorer' },
+            oracle: { model: 'provider/oracle' },
+          },
         },
       },
     });
@@ -229,19 +231,50 @@ describe('RuntimeConfig', () => {
     const withOrch = RuntimeConfig.init(DIRECTORY, {
       preset: 'orch',
       presets: {
-        orch: { orchestrator: { model: 'provider/orch' } },
+        orch: { agents: { orchestrator: { model: 'provider/orch' } } },
       },
     });
     expect(withOrch.primaryModel).toBe('provider/orch');
   });
 
-  test('setRuntimePreset re-applies preset agents into agents() and preset()', () => {
+  test('active preset and root layers clear model inheritance in either direction', () => {
+    resetRegistry();
+    const presetWinsWithInheritance = RuntimeConfig.init(DIRECTORY, {
+      preset: 'lower',
+      presets: {
+        session: {
+          agents: { explorer: { inheritModelFrom: 'session' } },
+        },
+        lower: { agents: { explorer: { model: 'provider/lower' } } },
+      },
+    });
+    presetWinsWithInheritance.setRuntimePreset('session');
+    expect(presetWinsWithInheritance.agent('explorer')).toEqual({
+      inheritModelFrom: 'session',
+    });
+
+    resetRegistry();
+    const rootWinsWithModel = RuntimeConfig.init(DIRECTORY, {
+      preset: 'lower',
+      presets: {
+        lower: {
+          agents: { explorer: { inheritModelFrom: 'session' } },
+        },
+      },
+      agents: { explorer: { model: 'provider/higher' } },
+    });
+    expect(rootWinsWithModel.agent('explorer')).toEqual({
+      model: 'provider/higher',
+    });
+  });
+
+  test('setRuntimePreset selects preset agents below root agents()', () => {
     resetRegistry();
     const runtime = RuntimeConfig.init(DIRECTORY, {
       preset: 'file',
       presets: {
-        file: { explorer: { model: 'file-preset-model' } },
-        runtime: { explorer: { model: 'runtime-preset-model' } },
+        file: { agents: { explorer: { model: 'file-preset-model' } } },
+        runtime: { agents: { explorer: { model: 'runtime-preset-model' } } },
       },
       agents: { explorer: { temperature: 0.2 } },
     });
@@ -292,6 +325,24 @@ describe('RuntimeConfig', () => {
     expect(runtime.smallModel()).toBe('host/small');
   });
 
+  test('host snapshot is deeply immutable and isolated from the source', () => {
+    resetRegistry();
+    const hostConfig = {
+      agent: { explorer: { options: { nested: { value: 1 } } } },
+    };
+    const runtime = RuntimeConfig.init(DIRECTORY, {});
+    runtime.captureHostConfig(hostConfig);
+
+    expect(Object.isFrozen(runtime.host())).toBe(true);
+    expect(Object.isFrozen(runtime.hostAgent('explorer'))).toBe(true);
+    expect(Object.isFrozen(runtime.hostAgent('explorer')?.options)).toBe(true);
+    hostConfig.agent.explorer.options.nested.value = 2;
+    const nested = runtime.hostAgent('explorer')?.options?.nested as
+      | { value: number }
+      | undefined;
+    expect(nested?.value).toBe(1);
+  });
+
   test('agent() is alias-aware for legacy names', () => {
     resetRegistry();
     const runtime = RuntimeConfig.init(DIRECTORY, {
@@ -379,15 +430,17 @@ describe('RuntimeConfig', () => {
       preset: 'spaced',
       presets: {
         spaced: {
-          explorer: {
-            model: [
-              {
-                id: 'opencode-omniroute-live/of/MiniMax M3',
-                variant: 'fast',
-              },
-              { id: 'of/Kimi K2.6', variant: 'balanced' },
-              'opencode-omniroute-live/of/Qwen3.8 27b',
-            ],
+          agents: {
+            explorer: {
+              model: [
+                {
+                  id: 'opencode-omniroute-live/of/MiniMax M3',
+                  variant: 'fast',
+                },
+                { id: 'of/Kimi K2.6', variant: 'balanced' },
+                'opencode-omniroute-live/of/Qwen3.8 27b',
+              ],
+            },
           },
         },
       },

+ 27 - 63
src/config/runtime.ts

@@ -30,7 +30,7 @@ import {
   SUBAGENT_NAMES,
 } from './constants';
 import type { CouncilConfig } from './council-schema';
-import { deepMerge } from './loader';
+import { mergeAgentOverrides as mergeAgentOverrideRecords } from './loader';
 import type {
   AcpAgentsConfig,
   AgentOverrideConfig,
@@ -118,36 +118,11 @@ function primaryModelFromOverride(
  * means "keep the lower layer", but `inheritModelFrom` is an intentional
  * request to use another source instead.
  */
-function mergeAgentOverrides(
+function mergeAgentLayers(
   base: Record<string, AgentOverrideConfig>,
   override: Record<string, AgentOverrideConfig>,
 ): Record<string, AgentOverrideConfig> {
-  const merged = deepMerge(base, override) ?? base;
-  for (const [name, agentOverride] of Object.entries(override)) {
-    if (
-      agentOverride.model !== undefined ||
-      agentOverride.inheritModelFrom === undefined
-    ) {
-      continue;
-    }
-    const resolvedName = AGENT_ALIASES[name] ?? name;
-    // A canonical key in the same layer remains authoritative over its
-    // legacy alias. Otherwise, apply the alias directive to the canonical
-    // lower-layer entry so getOverrideFromAgents sees the effective policy.
-    if (resolvedName !== name && Object.hasOwn(override, resolvedName)) {
-      continue;
-    }
-    const entry = merged[resolvedName] ?? merged[name];
-    if (entry) {
-      const updatedEntry = { ...entry };
-      delete updatedEntry.model;
-      if (resolvedName !== name) {
-        updatedEntry.inheritModelFrom = agentOverride.inheritModelFrom;
-      }
-      merged[resolvedName] = updatedEntry;
-    }
-  }
-  return merged;
+  return mergeAgentOverrideRecords(base, override) ?? base;
 }
 
 /** Recursive clone of plain JSON data (drops prototypes, no functions). */
@@ -237,7 +212,9 @@ export class RuntimeConfig {
    * post-merge state (double-application risk).
    */
   captureHostConfig(opencodeConfig: Record<string, unknown>): void {
-    this.hostSnapshot = clonePlain(opencodeConfig) as HostConfigSnapshot;
+    this.hostSnapshot = deepFreeze(
+      clonePlain(opencodeConfig) as HostConfigSnapshot,
+    );
   }
 
   // ---------------------------------------------------------------------
@@ -249,35 +226,32 @@ export class RuntimeConfig {
     return this.pluginConfig;
   }
 
-  /** Active preset name: runtime override wins over the config-file preset. */
+  /** Active preset name: runtime state selects over the config-file preset. */
   get preset(): string | undefined {
     return this.runtimePresetName ?? this.pluginConfig?.preset;
   }
 
   /**
-   * Merged agent overrides with seed precedence: runtime preset override >
-   * plugin file (root agents override the config-file preset). Mirrors the
-   * loader's preset merge so this is correct even when seeded with a raw
-   * (not yet loader-merged) plugin config.
+   * Merged agent overrides with seed precedence: selected preset < root agent
+   * overrides. Runtime state selects the preset but never becomes a second
+   * higher-precedence agent layer.
    */
   agents(): Record<string, AgentOverrideConfig> {
-    let base = this.pluginConfig?.agents ?? {};
-    const filePreset = this.pluginConfig?.preset
-      ? this.pluginConfig.presets?.[this.pluginConfig.preset]
+    const activePreset = this.preset;
+    const presetAgents = activePreset
+      ? this.pluginConfig?.presets?.[activePreset]?.agents
       : undefined;
-    if (filePreset) {
-      base = mergeAgentOverrides(filePreset, base);
-    }
-    const runtimePreset = this.runtimePresetAgents();
-    if (!runtimePreset) {
-      return base;
-    }
-    return mergeAgentOverrides(base, runtimePreset);
+    // A runtime preset selects the preset layer; it is not a higher
+    // precedence override. Root agents always remain authoritative.
+    return (
+      mergeAgentLayers(presetAgents ?? {}, this.pluginConfig?.agents ?? {}) ??
+      {}
+    );
   }
 
   /**
    * Effective override for one agent, alias-aware, with seed precedence
-   * host override > runtime override > plugin file.
+   * host override > root agent override > selected preset.
    */
   agent(name: string): AgentOverrideConfig | undefined {
     const merged = this.aliasAwareOverride(this.agents(), name);
@@ -285,10 +259,10 @@ export class RuntimeConfig {
     if (!hostLayer) {
       return merged;
     }
-    return deepMerge(
-      merged as Record<string, unknown> | undefined,
-      hostLayer as Record<string, unknown> | undefined,
-    ) as AgentOverrideConfig | undefined;
+    return mergeAgentOverrideRecords(
+      merged ? { __agent: merged } : undefined,
+      hostLayer ? { __agent: hostLayer as AgentOverrideConfig } : undefined,
+    )?.__agent;
   }
 
   /** Disabled agent names, minus protected agents. */
@@ -318,7 +292,7 @@ export class RuntimeConfig {
 
   /** Custom agent names declared in config.agents (was getCustomAgentNames). */
   get customAgentNames(): string[] {
-    return getCustomAgentNames(this.pluginConfig);
+    return getCustomAgentNames({ agents: this.agents() });
   }
 
   get disabledMcps(): readonly string[] {
@@ -389,7 +363,7 @@ export class RuntimeConfig {
     const agents = this.agents();
     const names = new Set<string>([
       ...ALL_AGENT_NAMES,
-      ...getCustomAgentNames(this.pluginConfig),
+      ...this.customAgentNames,
     ]);
     for (const name of names) {
       if (disabled.has(name)) {
@@ -444,7 +418,7 @@ export class RuntimeConfig {
    */
   get primaryModel(): string | undefined {
     const activePreset = this.preset
-      ? this.pluginConfig?.presets?.[this.preset]
+      ? this.pluginConfig?.presets?.[this.preset]?.agents
       : undefined;
     if (!activePreset) {
       return undefined;
@@ -523,16 +497,6 @@ export class RuntimeConfig {
   // private helpers
   // ---------------------------------------------------------------------
 
-  private runtimePresetAgents():
-    | Record<string, AgentOverrideConfig>
-    | undefined {
-    const name = this.runtimePresetName;
-    if (!name) {
-      return undefined;
-    }
-    return this.pluginConfig?.presets?.[name];
-  }
-
   /** Alias-aware override lookup inside a merged agents record. */
   private aliasAwareOverride(
     agents: Record<string, AgentOverrideConfig>,

+ 51 - 0
src/config/schema.test.ts

@@ -2,9 +2,60 @@ import { describe, expect, it } from 'bun:test';
 import {
   InterviewConfigSchema,
   PluginConfigSchema,
+  PresetSchema,
   ProviderModelIdSchema,
 } from './schema';
 
+describe('structured preset schema', () => {
+  it('keeps agent overrides separate from reserved marketplace activation', () => {
+    const result = PresetSchema.safeParse({
+      agents: { explorer: { model: 'provider/explorer' } },
+      marketplace: {
+        agents: ['community/example'],
+        profiles: { librarian: null },
+      },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.agents.explorer.model).toBe('provider/explorer');
+      expect(result.data.marketplace?.agents).toEqual(['community/example']);
+    }
+  });
+
+  it('rejects the obsolete flat preset agent map', () => {
+    expect(
+      PresetSchema.safeParse({ explorer: { model: 'provider/explorer' } })
+        .success,
+    ).toBe(false);
+  });
+
+  it('normalizes package IDs and validates profile targets', () => {
+    const result = PresetSchema.safeParse({
+      marketplace: {
+        agents: ['  community/one  '],
+        profiles: { oracle: '  community/oracle  ' },
+      },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.marketplace?.agents).toEqual(['community/one']);
+      expect(result.data.marketplace?.profiles).toEqual({
+        oracle: 'community/oracle',
+      });
+    }
+  });
+
+  it('rejects duplicate package IDs and unsupported profile targets', () => {
+    const result = PresetSchema.safeParse({
+      marketplace: {
+        agents: ['community/one', ' community/one '],
+        profiles: { orchestrator: 'community/one' },
+      },
+    });
+    expect(result.success).toBe(false);
+  });
+});
+
 describe('ProviderModelIdSchema', () => {
   it('accepts and preserves model remainders with spaces and nested segments', () => {
     const ids = [

+ 84 - 2
src/config/schema.ts

@@ -1,4 +1,5 @@
 import { z } from 'zod';
+import { SUPPORTED_SPECIALIST_ROLES } from './agent-roles';
 import {
   AGENT_THEME_COLORS,
   DEFAULT_MAX_RETAINED_SNAPSHOTS,
@@ -57,6 +58,8 @@ export const AgentColorSchema = z.union([
 
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const ModelInheritanceSourceSchema = z.enum(['session', 'orchestrator']);
+export const AgentBaseRoleSchema = z.enum(SUPPORTED_SPECIALIST_ROLES);
+export type AgentBaseRole = z.infer<typeof AgentBaseRoleSchema>;
 export type ModelInheritanceSource = z.infer<
   typeof ModelInheritanceSourceSchema
 >;
@@ -80,6 +83,7 @@ export const AgentOverrideConfigSchema = z
       ])
       .optional(),
     inheritModelFrom: ModelInheritanceSourceSchema.optional(),
+    baseRole: AgentBaseRoleSchema.optional(),
     temperature: z.number().min(0).max(2).optional(),
     variant: z.string().optional().catch(undefined),
     skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
@@ -138,7 +142,84 @@ export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 /** Normalized model entry with optional per-model variant. */
 export type ModelEntry = { id: string; variant?: string };
 
-export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
+export const MarketplaceProfileTargetSchema = AgentBaseRoleSchema;
+export type MarketplaceProfileTarget = z.infer<
+  typeof MarketplaceProfileTargetSchema
+>;
+
+const MarketplacePackageIdSchema = z.string().trim().min(1);
+const MarketplacePackageIdsSchema = z
+  .array(MarketplacePackageIdSchema)
+  .superRefine((ids, ctx) => {
+    if (new Set(ids).size !== ids.length) {
+      ctx.addIssue({
+        code: 'custom',
+        message: 'Package IDs must be unique',
+      });
+    }
+  });
+
+/**
+ * User-owned preset configuration.  Agent overrides deliberately live below
+ * `agents`; reserved marketplace activation state must never be interpreted as
+ * an agent override.
+ *
+ * The activation shape is reserved in Phase 1 so the config contract is
+ * forward-compatible, but no package state or marketplace resolution is
+ * performed by this phase.
+ */
+export const MarketplaceActivationSchema = z
+  .object({
+    agents: MarketplacePackageIdsSchema.optional(),
+    profiles: z
+      .record(z.string(), MarketplacePackageIdSchema.nullable())
+      .superRefine((profiles, ctx) => {
+        const packageIds = Object.values(profiles).filter(
+          (packageId): packageId is string => packageId !== null,
+        );
+        if (new Set(packageIds).size !== packageIds.length) {
+          ctx.addIssue({
+            code: 'custom',
+            message: 'Profile package IDs must be unique',
+          });
+        }
+        for (const target of Object.keys(profiles)) {
+          if (!MarketplaceProfileTargetSchema.safeParse(target).success) {
+            ctx.addIssue({
+              code: 'custom',
+              path: [target],
+              message: `Unsupported profile target '${target}'`,
+            });
+          }
+        }
+      })
+      .optional(),
+  })
+  .superRefine((activation, ctx) => {
+    const packageIds = [
+      ...(activation.agents ?? []),
+      ...Object.values(activation.profiles ?? {}).filter(
+        (packageId): packageId is string => packageId !== null,
+      ),
+    ];
+    if (new Set(packageIds).size !== packageIds.length) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['agents'],
+        message: 'Activation package IDs must be unique',
+      });
+    }
+  })
+  .strict();
+
+export type MarketplaceActivation = z.infer<typeof MarketplaceActivationSchema>;
+
+export const PresetSchema = z
+  .object({
+    agents: z.record(z.string(), AgentOverrideConfigSchema).default({}),
+    marketplace: MarketplaceActivationSchema.optional(),
+  })
+  .strict();
 
 export type Preset = z.infer<typeof PresetSchema>;
 
@@ -537,9 +618,10 @@ export const PluginConfigSchema = z
 
     if (value.presets) {
       for (const [presetName, preset] of Object.entries(value.presets)) {
-        rejectOrchestratorPromptOnOrchestrator(preset, ctx, [
+        rejectOrchestratorPromptOnOrchestrator(preset.agents, ctx, [
           'presets',
           presetName,
+          'agents',
         ]);
       }
     }

+ 11 - 5
src/config/strip-orchestrator-model.test.ts

@@ -37,7 +37,9 @@ describe('applyOrchestratorModelConfig', () => {
       agents: presetOverride,
       enabled: true,
       presets: {
-        file: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
+        file: {
+          agents: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
+        },
       },
       configPreset: 'file',
       runtimePreset: null,
@@ -62,8 +64,10 @@ describe('applyOrchestratorModelConfig', () => {
       agents,
       enabled: true,
       presets: {
-        file: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
-        runtime: { explorer: { model: 'openai/gpt-5-mini' } },
+        file: {
+          agents: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
+        },
+        runtime: { agents: { explorer: { model: 'openai/gpt-5-mini' } } },
       },
       configPreset: 'file',
       runtimePreset: 'runtime',
@@ -81,8 +85,10 @@ describe('applyOrchestratorModelConfig', () => {
       agents,
       enabled: true,
       presets: {
-        file: { explorer: { model: 'openai/gpt-5-mini' } },
-        runtime: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
+        file: { agents: { explorer: { model: 'openai/gpt-5-mini' } } },
+        runtime: {
+          agents: { orchestrator: { model: 'anthropic/claude-sonnet-4' } },
+        },
       },
       configPreset: 'file',
       runtimePreset: 'runtime',

+ 2 - 1
src/config/strip-orchestrator-model.ts

@@ -9,7 +9,8 @@ export function stripOrchestratorModel(
   enabled: boolean | undefined,
   preset: Preset | undefined,
 ): void {
-  if (enabled !== true || preset?.orchestrator?.model !== undefined) return;
+  if (enabled !== true || preset?.agents.orchestrator?.model !== undefined)
+    return;
 
   const orchestrator = agents.orchestrator;
   if (!isRecord(orchestrator)) return;

+ 1 - 1
src/config/utils.ts

@@ -29,7 +29,7 @@ export function getAgentOverride(
  * legacy aliases.
  */
 export function getCustomAgentNames(
-  config: PluginConfig | undefined,
+  config: Pick<PluginConfig, 'agents'> | undefined,
 ): string[] {
   const overrides = config?.agents ?? {};
   return Object.keys(overrides).filter((name) => {

+ 11 - 11
src/health-check.test.ts

@@ -3,25 +3,25 @@ import { minimumExpectedToolCount } from './health-check';
 
 describe('plugin health thresholds', () => {
   test('accounts only for intentionally disabled baseline tools', () => {
-    expect(minimumExpectedToolCount()).toBe(9);
-    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(8);
+    expect(minimumExpectedToolCount()).toBe(10);
+    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(9);
     expect(minimumExpectedToolCount(['wait_for_user', 'wait_for_user'])).toBe(
-      8,
+      9,
     );
-    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(9);
-    expect(minimumExpectedToolCount([], false)).toBe(8);
-    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(7);
-    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(8);
+    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(10);
+    expect(minimumExpectedToolCount([], false)).toBe(9);
+    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(8);
+    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(9);
     expect(
       minimumExpectedToolCount(['task_cancel', 'task_message', 'task_revive']),
-    ).toBe(6);
+    ).toBe(7);
   });
 
   test('never throws when disabledTools is not an array', () => {
     // Regression test: a malformed/non-array config.disabled_tools value
     // must degrade to "nothing disabled" instead of crashing plugin init.
-    expect(minimumExpectedToolCount('' as any)).toBe(9);
-    expect(minimumExpectedToolCount(null as any)).toBe(9);
-    expect(minimumExpectedToolCount({} as any)).toBe(9);
+    expect(minimumExpectedToolCount('' as any)).toBe(10);
+    expect(minimumExpectedToolCount(null as any)).toBe(10);
+    expect(minimumExpectedToolCount({} as any)).toBe(10);
   });
 });

+ 3 - 2
src/health-check.ts

@@ -17,8 +17,8 @@ export const HEALTH_CHECK = {
   minAgents: 5,
   // Default tool set when council and ACP agents are not configured:
   // task_cancel, task_message, task_revive, task_status, task_result,
-  // wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
-  minTools: 9,
+  // wait_for_user, marketplace, webfetch, ast_grep_search, ast_grep_replace.
+  minTools: 10,
   minMcps: 1,
 } as const;
 
@@ -29,6 +29,7 @@ const BASELINE_TOOL_NAMES = new Set([
   'task_status',
   'task_result',
   'wait_for_user',
+  'marketplace',
   'webfetch',
   'ast_grep_search',
   'ast_grep_replace',

+ 38 - 38
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -20,32 +20,17 @@ You have perfect understanding of agent's context management, understand well th
 
 <Agents>
 
-@explorer
-- Lane: Fast codebase recon that returns compressed context
-- Permissions: read_files
-- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
-- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
-- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
-- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file
-
-@librarian
-- Lane: External knowledge and library research, fast web research
-- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
-- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
-- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
-- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
-- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. "How do others solve or workaround this tricky issue?" → @librarian.
-
-@oracle
-- Lane: Architecture, risk, debugging strategy, and review
-- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
-- Permissions: read_files
-- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
-- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
-- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
-- **Review use:** @oracle is an escalation, not a default verification step. Request independent @oracle review only when its analysis is expected to materially reduce risk or uncertainty.
-- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
-- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.
+@council
+- Lane: High-stakes multi-model decision support
+- Role: Multi-LLM consensus engine that receives raw councillor responses and synthesizes them into a structured council report.
+- Permissions: Read files
+- Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
+- Capabilities: Synthesizes responses from independently-dispatched councillors, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
+- **Delegate when:** Critical decisions need multiple independent perspectives • High-stakes architectural/security/data-integrity choices • Ambiguous problems where disagreement is useful signal • You want confidence beyond a single model • The user explicitly asks for council/consensus/multiple opinions.
+- **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
+- **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
+- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Per-Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.
 
 @designer
 - Lane: UI/UX design, related edits, design polish and review
@@ -57,7 +42,14 @@ You have perfect understanding of agent's context management, understand well th
 - Avoid: "Let me ask @designer how it should look and implement yourself" → instead: "Let me ask @designer to design and implement the UI/UX changes for me"
 - **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
 - **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.
-- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.
+
+@explorer
+- Lane: Fast codebase recon that returns compressed context
+- Permissions: read_files
+- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
+- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
+- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
+- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file
 
 @fixer
 - Lane: Bounded implementation and executioner
@@ -70,17 +62,13 @@ You have perfect understanding of agent's context management, understand well th
 - **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to @fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
 - **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.
 
-@council
-- Lane: High-stakes multi-model decision support
-- Role: Multi-LLM consensus engine that receives raw councillor responses and synthesizes them into a structured council report.
-- Permissions: Read files
-- Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
-- Capabilities: Synthesizes responses from independently-dispatched councillors, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
-- **Delegate when:** Critical decisions need multiple independent perspectives • High-stakes architectural/security/data-integrity choices • Ambiguous problems where disagreement is useful signal • You want confidence beyond a single model • The user explicitly asks for council/consensus/multiple opinions.
-- **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
-- **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
-- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Per-Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
-- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.
+@librarian
+- Lane: External knowledge and library research, fast web research
+- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
+- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
+- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
+- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. "How do others solve or workaround this tricky issue?" → @librarian.
 
 @observer
 - Lane: Visual/media analysis isolated from orchestrator context
@@ -93,6 +81,17 @@ You have perfect understanding of agent's context management, understand well th
 - **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
 - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."
 
+@oracle
+- Lane: Architecture, risk, debugging strategy, and review
+- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
+- Permissions: read_files
+- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
+- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
+- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
+- **Review use:** @oracle is an escalation, not a default verification step. Request independent @oracle review only when its analysis is expected to materially reduce risk or uncertainty.
+- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.
+
 </Agents>
 
 <Workflow>
@@ -207,6 +206,7 @@ After spawning all independent background tasks and any remaining non-overlappin
 - Do make reasonable assumptions for minor details and state them briefly
 - When user input is required before work can continue and the user can answer immediately—including clarification, permission, a choice, or pasted command output—use the \`question\` tool. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
 - When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then call \`wait_for_user\` as your final tool action and end the turn. Do not rely on ordinary text alone to mark this waiting state, and do not call more tools after \`wait_for_user\`. Background tasks are not external manual work — never use \`wait_for_user\` to await them; the system resumes automatically via the Background Job Board and orchestrator wake scheduler.
+- Use the \`marketplace\` tool for local offline package lifecycle and status. list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write store/config only and never hot-swap the live registry; they report reload_required only when disk activation differs from this session. Do not shell out to the CLI for these actions.
 - For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution

+ 22 - 0
src/hooks/filter-available-skills/index.test.ts

@@ -163,6 +163,28 @@ describe('createFilterAvailableSkillsHook', () => {
     expect(resultText).toContain('<name>skill2</name>');
   });
 
+  test('denies skills for an unknown agent', async () => {
+    const hook = createFilterAvailableSkillsHook(mockCtx, runtimeFor());
+    const output = {
+      messages: [
+        {
+          info: { role: 'system' },
+          parts: [{ type: 'text', text: availableSkillsBlock('skill1') }],
+        },
+        {
+          info: { role: 'user', agent: 'missing-agent' },
+          parts: [{ type: 'text', text: 'check skills' }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output);
+
+    const resultText = output.messages[0]?.parts[0]?.text ?? '';
+    expect(resultText).toContain('No skills available.');
+    expect(resultText).not.toContain('<name>skill1</name>');
+  });
+
   test('supports wildcard allow with explicit exclusions', async () => {
     const config: PluginConfig = {
       agents: {

+ 22 - 24
src/hooks/filter-available-skills/index.ts

@@ -4,8 +4,10 @@
  * block before the prompt is sent.
  */
 import type { PluginInput } from '@opencode-ai/plugin';
-import { getSkillPermissionsForAgent } from '../../cli/skills';
-import { AGENT_ALIASES, type AgentOverrideConfig } from '../../config';
+import {
+  buildResolvedAgentRegistry,
+  type ResolvedAgentRegistry,
+} from '../../agents';
 import type { RuntimeConfig } from '../../config/runtime';
 import {
   isMessageWithParts,
@@ -95,31 +97,21 @@ function filterAvailableSkillsText(
  */
 export function createFilterAvailableSkillsHook(
   _ctx: PluginInput,
-  runtime: RuntimeConfig,
+  source: ResolvedAgentRegistry | RuntimeConfig | (() => ResolvedAgentRegistry),
 ) {
-  const permissionRulesByAgent = new Map<string, Record<string, SkillRule>>();
+  const getRegistry = (): ResolvedAgentRegistry => {
+    if (typeof source === 'function') return source();
+    return isResolvedAgentRegistry(source)
+      ? source
+      : buildResolvedAgentRegistry(source);
+  };
 
   const getPermissionRules = (agentName: string): Record<string, SkillRule> => {
-    const cached = permissionRulesByAgent.get(agentName);
-    if (cached) {
-      return cached;
-    }
-
-    const agents = runtime.agents();
-    const agentConfig: AgentOverrideConfig | undefined =
-      agents[agentName] ??
-      agents[
-        Object.keys(AGENT_ALIASES).find(
-          (key) => AGENT_ALIASES[key] === agentName,
-        ) ?? ''
-      ];
-    const permissionRules = getSkillPermissionsForAgent(
-      agentName,
-      agentConfig?.skills,
-      runtime.disabledSkills,
-    );
-    permissionRulesByAgent.set(agentName, permissionRules);
-    return permissionRules;
+    const resolved = getRegistry();
+    return (resolved.skillPermissions[agentName] ?? { '*': 'deny' }) as Record<
+      string,
+      SkillRule
+    >;
   };
 
   return {
@@ -154,4 +146,10 @@ export function createFilterAvailableSkillsHook(
   };
 }
 
+function isResolvedAgentRegistry(
+  source: ResolvedAgentRegistry | RuntimeConfig,
+): source is ResolvedAgentRegistry {
+  return 'skillPermissions' in source;
+}
+
 export { filterAvailableSkillsText };

+ 5 - 2
src/index.test.ts

@@ -146,6 +146,7 @@ describe('plugin tool registration', () => {
     expect(hooks.tool?.task_cancel).toBeDefined();
     expect(hooks.tool?.task_revive).toBeDefined();
     expect(hooks.tool?.wait_for_user).toBeDefined();
+    expect(hooks.tool?.marketplace).toBeDefined();
     await expect(
       hooks.tool?.wait_for_user?.execute(
         { reason: 'Complete the external approval.' },
@@ -725,8 +726,10 @@ describe('plugin config model inheritance', () => {
       preset: 'split',
       presets: {
         split: {
-          orchestrator: { model: 'preset/orchestrator' },
-          fixer: { inheritModelFrom: 'session' },
+          agents: {
+            orchestrator: { model: 'preset/orchestrator' },
+            fixer: { inheritModelFrom: 'session' },
+          },
         },
       },
     });

+ 149 - 238
src/index.ts

@@ -4,24 +4,23 @@ import {
   acquireAdmissionRuntime,
 } from './admission-runtime';
 import {
-  applyModelInheritanceToConfig,
-  createAgents,
-  getAgentConfigs,
+  buildResolvedAgentRegistry,
+  cloneAgentConfigs,
+  isMarketplacePermissionDenied,
   isSubagent,
+  type ResolvedAgentRegistry,
+  resolveDesiredMarketplaceLiveFromDisk,
   resolvePrimaryModelValue,
 } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
 import { ensureCompanionVersion } from './companion/updater';
-import { deepMerge, loadPluginConfig, type MultiplexerConfig } from './config';
-import { parseList } from './config/agent-mcps';
+import { loadPluginConfig, type MultiplexerConfig } from './config';
 import {
-  AGENT_ALIASES,
   DEFAULT_MAX_SESSION_METADATA_ENTRIES,
   TOAST_DURATION_MS,
 } from './config/constants';
 import { RuntimeConfig } from './config/runtime';
-import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
 import { HEALTH_CHECK, minimumExpectedToolCount } from './health-check';
 import {
   createApplyPatchHook,
@@ -48,6 +47,8 @@ import type { ToolLoopGuardHook } from './hooks/tool-loop-guard/hook';
 import { isMessageWithParts, type MessageWithParts } from './hooks/types';
 import { handleTaskSessionEvent } from './index-event';
 import { createInterviewManager } from './interview';
+import { discoverPreflightMcps } from './marketplace/activation';
+import { MarketplaceService } from './marketplace/service';
 import { createBuiltinMcps } from './mcp';
 import {
   getMultiplexer,
@@ -59,6 +60,7 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
+  createMarketplaceTool,
   createTaskMessageTool,
   createTaskResultTool,
   createTaskReviveTool,
@@ -167,6 +169,16 @@ export function sessionManagerMultiplexerConfig(
   return hostFlavor === 'v2' ? { ...config, type: 'none' } : config;
 }
 
+function replaceRecord<T>(
+  target: Record<string, T>,
+  source: Readonly<Record<string, T>>,
+): void {
+  for (const key of Object.keys(target)) {
+    delete target[key];
+  }
+  Object.assign(target, source);
+}
+
 export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const sessionId = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
   initLogger(sessionId);
@@ -184,8 +196,14 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   // closure. These are set inside the try block.
   let config: ReturnType<typeof loadPluginConfig>;
   let runtime: RuntimeConfig;
-  let agentDefs: ReturnType<typeof createAgents>;
-  let agents: ReturnType<typeof getAgentConfigs>;
+  let agentRegistry: ResolvedAgentRegistry;
+  let agentDefs: readonly ResolvedAgentRegistry['agents'][number][];
+  let agents: ResolvedAgentRegistry['sdkConfigs'];
+  const agentRegistration: Record<
+    string,
+    ResolvedAgentRegistry['sdkConfigs'][string]
+  > = {};
+  const fallbackChains: Record<string, string[]> = {};
   let mcps: ReturnType<typeof createBuiltinMcps>;
   let multiplexerConfig: MultiplexerConfig;
   let multiplexerEnabled: boolean;
@@ -263,6 +281,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskStatusTools: ReturnType<typeof createTaskStatusTool>;
   const taskActivityTracker = new TaskActivityTracker();
   let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
+  let marketplaceTools: ReturnType<typeof createMarketplaceTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
   let tools: Record<string, ToolDefinition>;
@@ -284,39 +303,31 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     const directModel = readModel(finalHostAgentConfig?.[agentType]);
     if (directModel) return directModel;
 
-    const resolvedName = resolveRuntimeAgentName(runtime, agentType);
+    const resolvedName = resolveRuntimeAgentName(agentRegistry, agentType);
     return readModel(finalHostAgentConfig?.[resolvedName]);
   };
 
   try {
     config = loadPluginConfig(ctx.directory);
     // Seed the per-directory runtime registry with the raw plugin file
-    // config. The runtime preset reapplication below mutates `config` for
-    // legacy consumers; RuntimeConfig keeps the pre-mutation snapshot and
-    // derives preset/runtime state through its own getters.
+    // config. The first registry is a catalogue only; config() replaces it
+    // after capturing the host's untouched configuration.
     RuntimeConfig.init(ctx.directory, config);
 
-    // Safety net: instance disposal reruns the plugin factory and rebuilds
-    // factory-local state, while module-level runtime preset state may persist.
-    // Reapply that persisted preset so each fresh generation creates agents
-    // with the correct models.
-    const runtimePreset = RuntimeConfig.get(ctx.directory).getRuntimePreset();
-    if (runtimePreset && config.presets?.[runtimePreset]) {
-      config.preset = runtimePreset;
-      // Re-merge runtime preset into config.agents (loadPluginConfig
-      // already merged the config-file preset, not the runtime one).
-      // Runtime preset is override so it wins over config-file preset.
-      const presetAgents = config.presets[runtimePreset];
-      config.agents = deepMerge(config.agents, presetAgents);
-    } else if (runtimePreset) {
-      // Preset was deleted from config since last switch - clear stale state
-      RuntimeConfig.get(ctx.directory).setRuntimePreset(null);
-    }
-
     runtime = RuntimeConfig.get(ctx.directory);
-    rewriteDisplayNameMentions = createDisplayNameMentionRewriter(runtime);
-    agentDefs = createAgents(runtime, { projectDirectory: ctx.directory });
-    agents = getAgentConfigs(runtime, { projectDirectory: ctx.directory });
+    mcps = createBuiltinMcps(runtime.disabledMcps);
+    agentRegistry = buildResolvedAgentRegistry(runtime, {
+      projectDirectory: ctx.directory,
+      preflightMcpNames: discoverPreflightMcps(runtime, ctx.directory),
+    });
+    rewriteDisplayNameMentions =
+      createDisplayNameMentionRewriter(agentRegistry);
+    agentDefs = agentRegistry.agents;
+    Object.assign(
+      agentRegistration,
+      cloneAgentConfigs(agentRegistry.sdkConfigs),
+    );
+    agents = agentRegistration;
 
     // Parse multiplexer config with defaults
     multiplexerConfig = runtime.multiplexer;
@@ -340,7 +351,6 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       startAvailabilityCheck(multiplexerConfig);
     }
 
-    mcps = createBuiltinMcps(runtime.disabledMcps);
     acpRunTools =
       Object.keys(runtime.acpAgents ?? {}).length > 0
         ? { acp_run: createAcpRunTool(runtime.acpAgents) }
@@ -447,8 +457,17 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Initialize foreground fallback manager for runtime model switching.
     // Agents without a chain (e.g. councillor, owned by CouncilManager) are
     // left alone — FG only aborts/re-prompts when it has a model to switch to.
+    replaceRecord(
+      fallbackChains,
+      Object.fromEntries(
+        Object.entries(agentRegistry.modelChains).map(([name, chain]) => [
+          name,
+          [...chain],
+        ]),
+      ),
+    );
     foregroundFallback = new ForegroundFallbackManager(
-      runtime.runtimeChains,
+      fallbackChains,
       runtime.fallback.enabled !== false,
       ctx,
       runtime.fallback.maxRetries,
@@ -557,7 +576,10 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       shouldInject: shouldInjectOrchestratorReminder,
     });
 
-    filterAvailableSkills = createFilterAvailableSkillsHook(ctx, runtime);
+    filterAvailableSkills = createFilterAvailableSkillsHook(
+      ctx,
+      () => agentRegistry,
+    );
 
     postFileToolNudge = createPostFileToolNudgeHook({
       shouldInject: shouldInjectOrchestratorReminder,
@@ -617,7 +639,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     waitForUserTools = createWaitForUserTool({
       shouldManageSession: (sessionID) =>
         sessionMetadata.getAgent(sessionID) === 'orchestrator',
-      resolveAgentName: (agent) => resolveRuntimeAgentName(runtime, agent),
+      resolveAgentName: (agent) =>
+        resolveRuntimeAgentName(agentRegistry, agent),
       registerSessionAsOrchestrator: (sessionID) => {
         sessionMetadata.setAgent(sessionID, 'orchestrator');
       },
@@ -630,6 +653,34 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         runtime.backgroundJobs.orchestratorWake.enabled &&
         backgroundJobCoordinator.hasRunning(sessionID),
     });
+    const marketplaceService = new MarketplaceService({
+      projectDir: ctx.directory,
+    });
+    marketplaceTools = createMarketplaceTool({
+      service: marketplaceService,
+      projectDir: ctx.directory,
+      getLiveSnapshot: () => ({
+        packages: agentRegistry.marketplaceLive,
+        diagnostics: agentRegistry.diagnostics,
+      }),
+      getDesiredLive: () =>
+        resolveDesiredMarketplaceLiveFromDisk(
+          ctx.directory,
+          marketplaceService.store,
+          runtime.host(),
+        ),
+      shouldManageSession: (sessionID) =>
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
+      resolveAgentName: (agent) =>
+        resolveRuntimeAgentName(agentRegistry, agent),
+      registerSessionAsOrchestrator: (sessionID) => {
+        sessionMetadata.setAgent(sessionID, 'orchestrator');
+      },
+      isMarketplaceDenied: () =>
+        isMarketplacePermissionDenied(
+          agentRegistry.sdkConfigs.orchestrator?.permission,
+        ),
+    });
 
     const shouldRegisterWebfetch = runtime.webfetch.enabled !== false;
     tools = {
@@ -639,6 +690,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ...taskReviveTools,
       ...taskStatusTools,
       ...waitForUserTools,
+      ...marketplaceTools,
       ...acpRunTools,
       ...(shouldRegisterWebfetch ? { webfetch } : {}),
       ast_grep_search,
@@ -733,12 +785,16 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     agentName: string,
     model: string,
   ): string | undefined {
-    const configEntry = runtime.agents()[agentName];
+    const canonicalName =
+      agentRegistry.canonicalIdByRuntimeName[agentName] ?? agentName;
+    const configEntry =
+      agentRegistry.sdkConfigs[agentName] ??
+      agentRegistry.sdkConfigs[canonicalName];
     const defaultVariant =
       typeof configEntry?.variant === 'string'
         ? configEntry.variant
         : undefined;
-    const chainMatches = runtime.modelArrays[agentName]?.filter(
+    const chainMatches = agentRegistry.modelArrays[canonicalName]?.filter(
       (entry) => entry.id === model,
     );
     if (chainMatches) {
@@ -774,6 +830,34 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // ones (host override > runtime override > plugin file).
       RuntimeConfig.get(ctx.directory).captureHostConfig(opencodeConfig);
 
+      // Finalize the one registry snapshot only after host capture. Every
+      // runtime consumer below uses this host-resolved snapshot; the mutable
+      // registration object is only a host-owned projection of it.
+      agentRegistry = buildResolvedAgentRegistry(runtime, {
+        projectDirectory: ctx.directory,
+        availableMcpNames: Object.keys({
+          ...(runtime.hostMcp() ?? {}),
+          ...mcps,
+        }),
+        preflightMcpNames: discoverPreflightMcps(runtime, ctx.directory),
+      });
+      rewriteDisplayNameMentions =
+        createDisplayNameMentionRewriter(agentRegistry);
+      agentDefs = agentRegistry.agents;
+      replaceRecord(
+        agentRegistration,
+        cloneAgentConfigs(agentRegistry.sdkConfigs),
+      );
+      replaceRecord(
+        fallbackChains,
+        Object.fromEntries(
+          Object.entries(agentRegistry.modelChains).map(([name, chain]) => [
+            name,
+            [...chain],
+          ]),
+        ),
+      );
+
       // Force default_agent to the orchestrator's visible entry when unset,
       // and also when the user pointed it at an omos subagent name (opencode
       // rejects subagent names as default_agent with "default agent must be a
@@ -800,149 +884,21 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
-      // Merge Agent configs - per-agent shallow merge to preserve
-      // user-supplied fields (e.g. tools, permission) from opencode.json
+      // The registry already merged captured host fields. Copy its final
+      // projections into the mutable host config while preserving unrelated
+      // host agents.
       if (!opencodeConfig.agent) {
-        opencodeConfig.agent = { ...agents };
-      } else {
-        for (const [name, pluginAgent] of Object.entries(agents)) {
-          const existing = (opencodeConfig.agent as Record<string, unknown>)[
-            name
-          ] as Record<string, unknown> | undefined;
-          // User explicitly picked a model via /model → disable fallback.
-          // Only marks the agent if the model differs from the chain primary.
-          // Once marked, stays disabled even if user switches back to chain[0].
-          if (existing && typeof existing.model === 'string') {
-            const primary = runtime.modelArrays[name]?.[0]?.id;
-            if (primary && existing.model !== primary) {
-              runtime.everModelSwitched(name);
-            }
-            if (runtime.hasModelSwitched(name)) {
-              foregroundFallback.disableChain(name);
-            }
-          }
-          if (existing) {
-            // Shallow merge: plugin defaults first, user overrides win
-            (opencodeConfig.agent as Record<string, unknown>)[name] = {
-              ...pluginAgent,
-              ...existing,
-            };
-          } else {
-            (opencodeConfig.agent as Record<string, unknown>)[name] = {
-              ...pluginAgent,
-            };
-          }
-        }
+        opencodeConfig.agent = {};
       }
-      const configAgent = opencodeConfig.agent as Record<string, unknown>;
-      applyModelInheritanceToConfig(configAgent, runtime);
-
-      // Model resolution for foreground agents: use _modelArray entries
-      // to pick the first model for startup-time selection.
-      //
-      // Runtime failover on API errors (e.g. rate limits
-      // mid-conversation) is handled separately by
-      // ForegroundFallbackManager via the event hook.
-      if (Object.keys(runtime.modelArrays).length > 0) {
-        for (const [agentName, models] of Object.entries(runtime.modelArrays)) {
-          if (models.length === 0) continue;
-
-          // Use the first model in the model array. Not all providers
-          // require entries in opencodeConfig.provider - some are loaded
-          // automatically by opencode (e.g. github-copilot, openrouter).
-          // We cannot distinguish these from truly unconfigured providers
-          // at config-hook time, so we cannot gate on the provider config
-          // keys. Runtime failover is handled separately by
-          // ForegroundFallbackManager.
-          const chosen = models[0];
-          const entry = configAgent[agentName] as
-            | Record<string, unknown>
-            | undefined;
-          if (entry) {
-            // Only apply model array resolution if no user-selected model
-            // exists. A user-selected model (via /model command) takes
-            // precedence over the config's fallback chain to preserve
-            // runtime selections and avoid breaking provider cache.
-            if (entry.model === undefined) {
-              entry.model = chosen.id;
-              if (chosen.variant) {
-                entry.variant = chosen.variant;
-              }
-            }
-          } else {
-            // Agent exists in slim but not in opencodeConfig.agent -
-            // create entry
-            (configAgent as Record<string, unknown>)[agentName] = {
-              model: chosen.id,
-              ...(chosen.variant ? { variant: chosen.variant } : {}),
-            };
-          }
-          log('[plugin] resolved model from array', {
-            agent: agentName,
-            model: chosen.id,
-            variant: chosen.variant,
-          });
-        }
-      }
-
-      // Runtime preset override: instance disposal recreates the plugin
-      // factory and its factory-local state, while module-level runtime
-      // preset data may persist. Apply that persisted selection after normal
-      // model resolution for the current generation.
-      const runtimePresetName = runtime.getRuntimePreset();
-      if (runtimePresetName && config.presets?.[runtimePresetName]) {
-        const runtimePreset = config.presets[runtimePresetName];
-        for (const [agentName, override] of Object.entries(runtimePreset)) {
-          // Resolve legacy alias keys (e.g. "explore" → "explorer")
-          // so presets using aliases work in this path.
-          const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
-          const entry = configAgent[resolvedName] as
-            | Record<string, unknown>
-            | undefined;
-          if (!entry) continue;
-
-          if (typeof override.model === 'string') {
-            entry.model = override.model;
-          } else if (
-            Array.isArray(override.model) &&
-            override.model.length > 0
-          ) {
-            const first = override.model[0];
-            entry.model = typeof first === 'string' ? first : first.id;
-            // Extract inline variant from array-form model entry
-            if (typeof first !== 'string' && first.variant) {
-              entry.variant = first.variant;
-            }
-          }
-          // Explicitly set or clear scalar fields so switching from
-          // Preset A (which sets a field) to Preset B (which doesn't)
-          // doesn't leave stale values behind.
-          if (typeof override.variant === 'string') {
-            entry.variant = override.variant;
-          } else if ('variant' in override) {
-            delete entry.variant;
-          }
-          if (typeof override.temperature === 'number') {
-            entry.temperature = override.temperature;
-          } else if ('temperature' in override) {
-            delete entry.temperature;
-          }
-          if (
-            override.options &&
-            typeof override.options === 'object' &&
-            !Array.isArray(override.options)
-          ) {
-            entry.options = override.options;
-          } else if ('options' in override) {
-            delete entry.options;
-          }
-          log('[plugin] runtime preset override', {
-            preset: runtimePresetName,
-            agent: agentName,
-            model: entry.model as string,
-          });
-        }
+      for (const [name, pluginAgent] of Object.entries(
+        agentRegistry.sdkConfigs,
+      )) {
+        // The registry is the only resolution path. This is a deep-owned
+        // projection so later host mutations cannot change the snapshot.
+        (opencodeConfig.agent as Record<string, unknown>)[name] =
+          cloneAgentConfigs({ [name]: pluginAgent })[name];
       }
+      const configAgent = opencodeConfig.agent as Record<string, unknown>;
 
       // Capture the resolved model state before optionally removing the
       // orchestrator model from the SDK config, so the TUI keeps showing the
@@ -957,14 +913,14 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         )
           continue;
 
-        const entry = configAgent[agentDef.name] as
+        const entry = agentRegistry.sdkConfigs[agentDef.name] as
           | Record<string, unknown>
           | undefined;
         const resolvedModel =
           typeof entry?.model === 'string'
             ? entry.model
-            : runtime.runtimeChains[agentDef.name]?.[0]
-              ? runtime.runtimeChains[agentDef.name][0]
+            : agentRegistry.modelChains[agentDef.name]?.[0]
+              ? agentRegistry.modelChains[agentDef.name][0]
               : typeof agentDef.config.model === 'string'
                 ? agentDef.config.model
                 : undefined;
@@ -988,16 +944,9 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         ctx.directory,
       );
 
-      applyOrchestratorModelConfig({
-        agents: configAgent,
-        enabled: runtime.stripOrchestratorModel,
-        presets: runtime.plugin?.presets,
-        configPreset: runtime.preset,
-        runtimePreset: runtimePresetName,
-      });
       // This is the source of truth for admission. It is intentionally
-      // captured only after every host/plugin merge and the final model
-      // inheritance, array-primary, preset, and orchestrator-model passes.
+      // captured only after the host-owned projection has been produced from
+      // the final registry snapshot.
       finalHostAgentConfig = configAgent;
 
       // Merge MCP configs
@@ -1010,50 +959,6 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         Object.assign(configMcp, mcps);
       }
 
-      // Get all MCP names from the merged config (built-in + custom)
-      const mergedMcpConfig = opencodeConfig.mcp as
-        | Record<string, unknown>
-        | undefined;
-      const allMcpNames = Object.keys(mergedMcpConfig ?? mcps);
-
-      // For each agent, create permission rules based on their mcps list
-      for (const [agentName, agentConfig] of Object.entries(agents)) {
-        const agentMcps = (agentConfig as { mcps?: string[] })?.mcps;
-        if (!agentMcps) continue;
-
-        // Get or create agent permission config
-        if (!configAgent[agentName]) {
-          configAgent[agentName] = { ...agentConfig };
-        }
-        const agentConfigEntry = configAgent[agentName] as Record<
-          string,
-          unknown
-        >;
-        const agentPermission = (agentConfigEntry.permission ?? {}) as Record<
-          string,
-          unknown
-        >;
-
-        // Parse mcps list with wildcard and exclusion support
-        const allowedMcps = parseList(agentMcps, allMcpNames);
-
-        // Create permission rules for each MCP
-        // MCP tools are named as <server>_<tool>, so we use <server>_*
-        for (const mcpName of allMcpNames) {
-          const sanitizedMcpName = mcpName.replace(/[^a-zA-Z0-9_-]/g, '_');
-          const permissionKey = `${sanitizedMcpName}_*`;
-          const action = allowedMcps.includes(mcpName) ? 'allow' : 'deny';
-
-          // Only set if not already defined by user
-          if (!(permissionKey in agentPermission)) {
-            agentPermission[permissionKey] = action;
-          }
-        }
-
-        // Update agent config with permissions
-        agentConfigEntry.permission = agentPermission;
-      }
-
       interviewManager.registerCommand(opencodeConfig);
       deepworkCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
@@ -1152,7 +1057,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           backgroundTaskConcurrency.migrateTask(info.sessionID, model);
         }
         if (typeof info?.agent === 'string' && providerID && modelID) {
-          const agentName = resolveRuntimeAgentName(runtime, info.agent);
+          const agentName = resolveRuntimeAgentName(agentRegistry, info.agent);
           const model = `${providerID}/${modelID}`;
           const variant = resolveTuiVariantForModel(agentName, model);
           recordTuiAgentModel(
@@ -1378,7 +1283,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
     ) => {
       const rawAgent = input.agent ?? output?.message?.agent;
       const agent = rawAgent
-        ? resolveRuntimeAgentName(runtime, rawAgent)
+        ? resolveRuntimeAgentName(agentRegistry, rawAgent)
         : undefined;
 
       if (
@@ -1460,7 +1365,13 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
           const orchestratorPrompt =
             typeof orchestratorDef?.config?.prompt === 'string'
               ? orchestratorDef.config.prompt
-              : buildOrchestratorPrompt(runtime.disabledAgents);
+              : buildOrchestratorPrompt(
+                  runtime.disabledAgents,
+                  undefined,
+                  true,
+                  true,
+                  agentRegistry.routing,
+                );
           output.system[0] = `${output.system[0] || ''}\n\n${orchestratorPrompt}`;
         }
       }

+ 233 - 0
src/marketplace/activation-config.ts

@@ -0,0 +1,233 @@
+import { mutateJsonFile } from '../cli/config-io';
+import {
+  type SpecialistRole,
+  SUPPORTED_SPECIALIST_ROLES,
+} from '../config/agent-roles';
+import {
+  findPluginConfigPaths,
+  loadPluginConfig,
+  loadPluginConfigFile,
+  mergePreset,
+} from '../config/loader';
+import type {
+  MarketplaceActivation,
+  PluginConfig,
+  Preset,
+} from '../config/schema';
+import { MarketplaceActivationError } from './errors';
+import { normalizeMarketplacePackageId } from './ids';
+import { MarketplaceStore } from './store';
+
+function writeConfigFile(
+  filePath: string,
+  mutate: (current: Record<string, unknown>) => Record<string, unknown>,
+): void {
+  mutateJsonFile(filePath, (current) => {
+    const persisted =
+      current && typeof current === 'object' && !Array.isArray(current)
+        ? { ...(current as Record<string, unknown>) }
+        : {};
+    return mutate(persisted);
+  });
+}
+
+function configWritePath(directory: string): string {
+  const paths = findPluginConfigPaths(directory);
+  const filePath = paths.projectConfigPath ?? paths.userConfigPath;
+  if (!filePath) {
+    throw new MarketplaceActivationError(
+      'No plugin config file found to persist marketplace activation',
+    );
+  }
+  return filePath;
+}
+
+function activePresetName(config: PluginConfig): string {
+  if (typeof config.preset === 'string' && config.preset.length > 0) {
+    return config.preset;
+  }
+  throw new MarketplaceActivationError(
+    'Select an active preset before enabling marketplace packages',
+  );
+}
+
+function cloneActivation(
+  activation: MarketplaceActivation | undefined,
+): MarketplaceActivation {
+  return {
+    ...(activation?.agents ? { agents: [...activation.agents] } : {}),
+    ...(activation?.profiles ? { profiles: { ...activation.profiles } } : {}),
+  };
+}
+
+function samePackageIds(
+  left: readonly string[] | undefined,
+  right: readonly string[] | undefined,
+): boolean {
+  const a = [...(left ?? [])].map((id) => id.trim().toLowerCase()).sort();
+  const b = [...(right ?? [])].map((id) => id.trim().toLowerCase()).sort();
+  return a.length === b.length && a.every((id, index) => id === b[index]);
+}
+
+function activationOverlay(
+  parent: MarketplaceActivation | undefined,
+  desired: MarketplaceActivation,
+): MarketplaceActivation {
+  const overlay: MarketplaceActivation = {};
+  if (parent === undefined || !samePackageIds(parent.agents, desired.agents)) {
+    overlay.agents = [...(desired.agents ?? [])];
+  }
+  const parentProfiles = parent?.profiles ?? {};
+  const desiredProfiles = desired.profiles ?? {};
+  const profiles: NonNullable<MarketplaceActivation['profiles']> = {
+    ...parentProfiles,
+  };
+  let profilesChanged = false;
+  for (const key of new Set([
+    ...Object.keys(parentProfiles),
+    ...Object.keys(desiredProfiles),
+  ])) {
+    const role = key as SpecialistRole;
+    const next = Object.hasOwn(desiredProfiles, key)
+      ? desiredProfiles[role]
+      : undefined;
+    const previous = parentProfiles[role];
+    if (next !== previous) {
+      profiles[role] = next ?? null;
+      profilesChanged = true;
+    }
+  }
+  if (profilesChanged) overlay.profiles = profiles;
+  return overlay;
+}
+
+function persistActivation(
+  directory: string,
+  presetName: string,
+  mutate: (activation: MarketplaceActivation) => MarketplaceActivation,
+): void {
+  const paths = findPluginConfigPaths(directory);
+  const filePath = configWritePath(directory);
+  const parentConfig =
+    filePath === paths.projectConfigPath && paths.userConfigPath
+      ? (loadPluginConfigFile(paths.userConfigPath, { silent: true }) ?? {})
+      : {};
+  writeConfigFile(filePath, (persisted) => {
+    const presets = {
+      ...((persisted.presets as Record<string, unknown> | undefined) ?? {}),
+    };
+    const current = {
+      ...((presets[presetName] as Record<string, unknown> | undefined) ?? {}),
+    };
+    const parentPreset = parentConfig.presets?.[presetName];
+    const effective = mergePreset(parentPreset, current as Preset);
+    const desired = mutate(cloneActivation(effective?.marketplace));
+    const overlay = activationOverlay(parentPreset?.marketplace, desired);
+    current.agents = current.agents ?? {};
+    if (Object.keys(overlay).length === 0) {
+      delete current.marketplace;
+    } else {
+      current.marketplace = overlay;
+    }
+    presets[presetName] = current;
+    persisted.presets = presets;
+    if (persisted.preset === undefined) persisted.preset = presetName;
+    return persisted;
+  });
+}
+
+export function enableMarketplaceAgent(
+  directory: string,
+  packageId: string,
+  store = new MarketplaceStore(),
+): void {
+  const id = normalizeMarketplacePackageId(packageId);
+  const pkg = store.show(id);
+  if (pkg.manifest.kind !== 'agent') {
+    throw new MarketplaceActivationError(
+      `${id} is a ${pkg.manifest.kind} package; use profile to activate it`,
+    );
+  }
+  const config = loadPluginConfig(directory, { silent: true });
+  const presetName = activePresetName(config);
+  persistActivation(directory, presetName, (activation) => {
+    const agents = [...(activation.agents ?? [])];
+    if (!agents.includes(id)) agents.push(id);
+    const profiles = { ...(activation.profiles ?? {}) };
+    for (const [role, value] of Object.entries(profiles)) {
+      if (value === id) {
+        throw new MarketplaceActivationError(
+          `${id} is already selected as the ${role} profile`,
+        );
+      }
+    }
+    return { agents, profiles };
+  });
+}
+
+export function disableMarketplacePackage(
+  directory: string,
+  packageId: string,
+): void {
+  const id = normalizeMarketplacePackageId(packageId);
+  const config = loadPluginConfig(directory, { silent: true });
+  const presetName = activePresetName(config);
+  persistActivation(directory, presetName, (activation) => ({
+    agents: (activation.agents ?? []).filter((value) => value !== id),
+    profiles: Object.fromEntries(
+      Object.entries(activation.profiles ?? {}).map(([role, value]) => [
+        role,
+        value === id ? null : value,
+      ]),
+    ),
+  }));
+}
+
+export function setMarketplaceProfile(
+  directory: string,
+  role: string,
+  packageId: string | null,
+  store = new MarketplaceStore(),
+): void {
+  const parsedRole = SUPPORTED_SPECIALIST_ROLES.find((name) => name === role);
+  if (!parsedRole) {
+    throw new MarketplaceActivationError(
+      `Unsupported profile target '${role}'`,
+    );
+  }
+  const config = loadPluginConfig(directory, { silent: true });
+  const presetName = activePresetName(config);
+  if (packageId === null) {
+    persistActivation(directory, presetName, (activation) => ({
+      agents: [...(activation.agents ?? [])],
+      profiles: { ...(activation.profiles ?? {}), [parsedRole]: null },
+    }));
+    return;
+  }
+  const id = normalizeMarketplacePackageId(packageId);
+  const pkg = store.show(id);
+  if (pkg.manifest.kind !== 'profile') {
+    throw new MarketplaceActivationError(
+      `${id} is a ${pkg.manifest.kind} package; use enable to activate it`,
+    );
+  }
+  if (pkg.manifest.targetRole !== parsedRole) {
+    throw new MarketplaceActivationError(
+      `${id} targets ${pkg.manifest.targetRole}, not ${parsedRole}`,
+    );
+  }
+  persistActivation(directory, presetName, (activation) => {
+    const agents = [...(activation.agents ?? [])];
+    if (agents.includes(id)) {
+      throw new MarketplaceActivationError(
+        `${id} is already enabled as a marketplace agent`,
+      );
+    }
+    return {
+      agents,
+      profiles: { ...(activation.profiles ?? {}), [parsedRole]: id },
+    };
+  });
+}
+
+export type { SpecialistRole };

+ 1669 - 0
src/marketplace/activation.test.ts

@@ -0,0 +1,1669 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  symlinkSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+  buildResolvedAgentRegistry,
+  resolveDesiredMarketplaceLiveFromDisk,
+} from '../agents';
+import { ROLE_DEFINITIONS } from '../agents/role-definitions';
+import type { PluginConfig } from '../config';
+import { RuntimeConfig } from '../config/runtime';
+import { resolveRuntimeAgentName } from '../utils/agent-variant';
+import {
+  disableMarketplacePackage,
+  enableMarketplaceAgent,
+  setMarketplaceProfile,
+} from './activation-config';
+import type { MarketplacePackageBundle } from './schemas';
+import { MarketplaceStore } from './store';
+
+const previousConfigHome = process.env.XDG_CONFIG_HOME;
+
+afterEach(() => {
+  if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
+  else process.env.XDG_CONFIG_HOME = previousConfigHome;
+});
+
+function agentBundle(
+  overrides: Partial<MarketplacePackageBundle['manifest']> = {},
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/docs-researcher',
+      version: '1.0.0',
+      kind: 'agent',
+      displayName: 'Docs researcher',
+      description: 'A derived explorer.',
+      instructions: 'Prefer documentation paths first.',
+      author: { name: 'Community' },
+      tags: ['docs'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Research docs and examples.',
+        keywords: ['docs'],
+        delegation: {
+          when: 'When docs research is needed.',
+          preferredRoles: [],
+        },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      baseRole: 'explorer',
+      agentName: 'docsresearcher',
+      overrides: {},
+      ...overrides,
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function profileBundle(
+  overrides: Partial<MarketplacePackageBundle['manifest']> = {},
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/deep-explorer',
+      version: '1.0.0',
+      kind: 'profile',
+      displayName: 'Deep explorer',
+      description: 'A specialist profile.',
+      instructions: 'Search more exhaustively than usual.',
+      author: { name: 'Community' },
+      tags: ['profile'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Deep exploration profile.',
+        keywords: ['deep'],
+        delegation: {
+          when: 'When exhaustive search is needed.',
+          preferredRoles: [],
+        },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      targetRole: 'explorer',
+      instructionMode: 'append',
+      overrides: {},
+      ...overrides,
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function writeSkill(directory: string, name: string): void {
+  mkdirSync(join(directory, name), { recursive: true });
+  writeFileSync(
+    join(directory, name, 'SKILL.md'),
+    `---
+name: ${name}
+description: Test skill.
+---
+
+# ${name}
+`,
+  );
+}
+
+function registryFor(
+  config: PluginConfig,
+  store: MarketplaceStore,
+  directory = 'marketplace-activation-test',
+) {
+  RuntimeConfig.reset(directory);
+  const runtime = RuntimeConfig.init(directory, config);
+  return buildResolvedAgentRegistry(runtime, {
+    marketplaceStore: store,
+    availableMcpNames: ['context7', 'gh_grep'],
+  });
+}
+
+describe('marketplace runtime activation', () => {
+  test('activates a derived agent from the effective preset', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle());
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      const derived = registry.agents.find(
+        (agent) => agent.name === 'docsresearcher',
+      );
+      expect(derived?.baseRole).toBe('explorer');
+      expect(derived?.config.prompt).toContain(
+        ROLE_DEFINITIONS.explorer.basePrompt,
+      );
+      expect(derived?.config.prompt).toContain(
+        'Prefer documentation paths first.',
+      );
+      expect(registry.mcpLists.docsresearcher).toEqual([]);
+      expect(
+        registry.routing.some((entry) => entry.agentName === 'docsresearcher'),
+      ).toBe(true);
+      expect(registry.provenance.docsresearcher).toBe(
+        'marketplace-agent:community/docs-researcher@1.0.0',
+      );
+      expect(registry.diagnostics).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('user overrides win over package configuration', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          overrides: { model: 'package/model', description: 'Package desc' },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {
+                docsresearcher: {
+                  model: 'user/model',
+                  description: 'User desc',
+                },
+              },
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      const derived = registry.agents.find(
+        (agent) => agent.name === 'docsresearcher',
+      );
+      expect(derived?.config.model).toBe('user/model');
+      expect(derived?.description).toBe('User desc');
+      expect(derived?.config.prompt).toContain(
+        'Prefer documentation paths first.',
+      );
+      expect(registry.provenance.docsresearcher).toBe(
+        'marketplace-agent:community/docs-researcher@1.0.0',
+      );
+      expect(registry.packageIdByRuntimeName.docsresearcher).toBe(
+        'community/docs-researcher',
+      );
+      expect(registry.runtimeNameByPackageId['community/docs-researcher']).toBe(
+        'docsresearcher',
+      );
+      expect(
+        registry.routing.find((entry) => entry.agentName === 'docsresearcher')
+          ?.routingBlock,
+      ).toContain('Research docs and examples.');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('appends and replaces specialist profile instructions', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(profileBundle());
+      const appended = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                profiles: { explorer: 'community/deep-explorer' },
+              },
+            },
+          },
+        },
+        store,
+      );
+      const explorer = appended.agents.find(
+        (agent) => agent.name === 'explorer',
+      );
+      expect(explorer?.config.prompt).toContain(
+        ROLE_DEFINITIONS.explorer.basePrompt,
+      );
+      expect(explorer?.config.prompt).toContain(
+        'Search more exhaustively than usual.',
+      );
+      expect(appended.provenance.explorer).toBe(
+        'marketplace-profile:community/deep-explorer@1.0.0',
+      );
+
+      store.remove('community/deep-explorer');
+      store.install(
+        profileBundle({
+          id: 'community/replace-explorer',
+          instructionMode: 'replace',
+          instructions: 'Replacement explorer instructions.',
+        }),
+      );
+      const replaced = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                profiles: { explorer: 'community/replace-explorer' },
+              },
+            },
+          },
+        },
+        store,
+        'marketplace-replace-test',
+      );
+      const replacedExplorer = replaced.agents.find(
+        (agent) => agent.name === 'explorer',
+      );
+      expect(replacedExplorer?.config.prompt).toContain(
+        'Replacement explorer instructions.',
+      );
+      expect(replacedExplorer?.config.prompt).not.toContain(
+        'You are Explorer - a fast codebase navigation specialist.',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('respects profile tombstones and does not create a second agent', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(profileBundle());
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { profiles: { explorer: null } },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.filter((agent) => agent.baseRole === 'explorer'),
+      ).toHaveLength(1);
+      expect(registry.provenance.explorer).toBe('builtin:explorer');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects colliding runtime names', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle({ agentName: 'explorer' }));
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(false);
+      expect(registry.diagnostics[0]?.code).toBe('collision');
+      expect(registry.marketplaceLive).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('disables packages with missing required dependencies', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['not-a-real-skill'], optional: ['simplify'] },
+            mcps: { required: [], optional: [] },
+          },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(false);
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('keeps optional missing dependencies from disabling a package', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: ['not-a-real-skill'] },
+            mcps: { required: [], optional: ['unknown-mcp'] },
+          },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+      expect(
+        registry.skillPermissions.docsresearcher?.['not-a-real-skill'],
+      ).not.toBe('allow');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('classifies unreadable selected packages as operational', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const installed = store.install(agentBundle());
+      chmodSync(join(installed.path, 'package.json'), 0);
+      try {
+        const registry = registryFor(
+          {
+            preset: 'work',
+            presets: {
+              work: {
+                agents: {},
+                marketplace: { agents: ['community/docs-researcher'] },
+              },
+            },
+          },
+          store,
+        );
+        expect(
+          registry.diagnostics.some((entry) => entry.code === 'operational'),
+        ).toBe(true);
+        expect(registry.marketplaceLive).toEqual([]);
+      } finally {
+        chmodSync(join(installed.path, 'package.json'), 0o644);
+      }
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('records missing and corrupt packages without creating agents', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const missing = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/missing'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(missing.diagnostics[0]?.code).toBe('missing');
+      expect(
+        missing.routing.some((entry) => entry.agentName === 'missing'),
+      ).toBe(false);
+
+      writeFileSync(store.paths.lockfilePath, '{bad json');
+      const corrupt = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+        'marketplace-corrupt-test',
+      );
+      expect(corrupt.diagnostics[0]?.code).toBe('corrupt');
+      expect(
+        corrupt.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not contact the network while resolving activation', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const originalFetch = globalThis.fetch;
+    globalThis.fetch = (() => {
+      throw new Error('network should not be used');
+    }) as typeof fetch;
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle());
+      expect(() =>
+        registryFor(
+          {
+            preset: 'work',
+            presets: {
+              work: {
+                agents: {},
+                marketplace: { agents: ['community/docs-researcher'] },
+              },
+            },
+          },
+          store,
+        ),
+      ).not.toThrow();
+    } finally {
+      globalThis.fetch = originalFetch;
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('puts required package MCPs and routing in the final registry', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['context7'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset('marketplace-mcp-projection');
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-mcp-projection', {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          preflightMcpNames: ['context7', 'gh_grep'],
+          availableMcpNames: ['context7', 'gh_grep'],
+        },
+      );
+      expect(registry.mcpLists.docsresearcher).toContain('context7');
+      expect(
+        (
+          registry.sdkConfigs.docsresearcher.permission as Record<
+            string,
+            string
+          >
+        )['context7_*'],
+      ).toBe('allow');
+      expect(
+        registry.routing.find((entry) => entry.agentName === 'docsresearcher')
+          ?.routingBlock,
+      ).toContain('When docs research is needed.');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not treat plugin-injected MCPs as preflight capabilities', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['injected-plugin-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset('marketplace-injected-mcp');
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-injected-mcp', {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          availableMcpNames: ['injected-plugin-mcp'],
+        },
+      );
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('preflights on-disk enabled skill directories', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const skillRoot = join(root, 'skills');
+    const bareRoot = join(root, 'bare');
+    const nestedRoot = join(skillRoot, 'nested');
+    const linkedRoot = join(root, 'linked-skills');
+    try {
+      mkdirSync(join(bareRoot, 'on-disk-skill'), { recursive: true });
+      writeFileSync(
+        join(bareRoot, 'on-disk-skill', 'README.md'),
+        '# not a skill',
+      );
+      writeSkill(nestedRoot, 'on-disk-skill');
+      mkdirSync(linkedRoot, { recursive: true });
+      symlinkSync(
+        join(nestedRoot, 'on-disk-skill'),
+        join(linkedRoot, 'on-disk-skill'),
+      );
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['on-disk-skill'], optional: [] },
+            mcps: { required: [], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset('marketplace-disk-skill');
+      const missing = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-disk-skill', {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        { marketplaceStore: store, extraSkillDirectories: [bareRoot] },
+      );
+      expect(missing.diagnostics[0]?.code).toBe('missing-required-dependency');
+
+      RuntimeConfig.reset('marketplace-disk-skill-ok');
+      const present = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-disk-skill-ok', {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          extraSkillDirectories: [skillRoot],
+        },
+      );
+      expect(
+        present.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+
+      RuntimeConfig.reset('marketplace-disk-skill-link');
+      const linked = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-disk-skill-link', {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          extraSkillDirectories: [linkedRoot],
+        },
+      );
+      expect(
+        linked.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not treat host-snapshot MCPs as preflight capabilities', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['injected-host-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset('marketplace-host-mcp');
+      const runtime = RuntimeConfig.init('marketplace-host-mcp', {
+        preset: 'work',
+        presets: {
+          work: {
+            agents: {},
+            marketplace: { agents: ['community/docs-researcher'] },
+          },
+        },
+      });
+      runtime.captureHostConfig({
+        mcp: {
+          'injected-host-mcp': {
+            type: 'remote',
+            url: 'http://127.0.0.1/mcp',
+          },
+        },
+      });
+      const registry = buildResolvedAgentRegistry(runtime, {
+        marketplaceStore: store,
+        projectDirectory: root,
+      });
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not treat disabled on-disk OpenCode MCPs as available', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'project-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+              enabled: false,
+            },
+          },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['project-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(project);
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init(project, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        { marketplaceStore: store, projectDirectory: project },
+      );
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('treats enabled on-disk OpenCode MCPs as available', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'project-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+            },
+          },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['project-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(project);
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init(project, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          projectDirectory: project,
+          availableMcpNames: ['project-mcp'],
+        },
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+      expect(registry.mcpLists.docsresearcher).toContain('project-mcp');
+      expect(
+        (
+          registry.sdkConfigs.docsresearcher.permission as Record<
+            string,
+            string
+          >
+        )['project-mcp_*'],
+      ).toBe('allow');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('filters disabled on-disk skills and MCPs', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    const skillRoot = join(root, 'skills');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeSkill(skillRoot, 'on-disk-skill');
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'project-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+            },
+          },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['on-disk-skill'], optional: [] },
+            mcps: { required: ['project-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(project);
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init(project, {
+          preset: 'work',
+          disabled_skills: ['on-disk-skill'],
+          disabled_mcps: ['project-mcp'],
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          projectDirectory: project,
+          extraSkillDirectories: [skillRoot],
+        },
+      );
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('ignores invalid empty MCP definitions and reads config.json', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(configHome, 'opencode'), { recursive: true });
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(configHome, 'opencode', 'config.json'),
+        JSON.stringify({
+          mcp: {
+            'config-json-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+            },
+          },
+        }),
+      );
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'empty-mcp': {},
+          },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['empty-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(project);
+      const missing = buildResolvedAgentRegistry(
+        RuntimeConfig.init(project, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        { marketplaceStore: store, projectDirectory: project },
+      );
+      expect(missing.diagnostics[0]?.code).toBe('missing-required-dependency');
+
+      store.install(
+        agentBundle({
+          id: 'community/config-json',
+          agentName: 'configjson',
+          requirements: {
+            skills: { required: [], optional: [] },
+            mcps: { required: ['config-json-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(`${project}-ok`);
+      const present = buildResolvedAgentRegistry(
+        RuntimeConfig.init(`${project}-ok`, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/config-json'] },
+            },
+          },
+        }),
+        { marketplaceStore: store, projectDirectory: project },
+      );
+      expect(present.agents.some((agent) => agent.name === 'configjson')).toBe(
+        true,
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('discovers hidden external skill directories', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const previousHome = process.env.OPENCODE_TEST_HOME;
+    try {
+      process.env.OPENCODE_TEST_HOME = root;
+      writeSkill(join(root, '.claude', 'skills', '.hidden'), 'hidden-skill');
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['hidden-skill'], optional: [] },
+            mcps: { required: [], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(root);
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init(root, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        { marketplaceStore: store, projectDirectory: root },
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+    } finally {
+      if (previousHome === undefined) delete process.env.OPENCODE_TEST_HOME;
+      else process.env.OPENCODE_TEST_HOME = previousHome;
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('filters explicit preflight skill and MCP names by disabled sets', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['on-disk-skill'], optional: [] },
+            mcps: { required: ['project-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset('marketplace-explicit-disabled');
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init('marketplace-explicit-disabled', {
+          preset: 'work',
+          disabled_skills: ['on-disk-skill'],
+          disabled_mcps: ['project-mcp'],
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          preflightSkillNames: ['on-disk-skill'],
+          preflightMcpNames: ['project-mcp'],
+        },
+      );
+      expect(registry.diagnostics[0]?.code).toBe('missing-required-dependency');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('reserves custom names except the package override key', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          overrides: { displayName: 'janitor' },
+        }),
+      );
+      const colliding = registryFor(
+        {
+          preset: 'work',
+          agents: { janitor: { model: 'custom/model' } },
+          presets: {
+            work: {
+              agents: { janitor: { model: 'custom/model' } },
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(colliding.diagnostics[0]?.code).toBe('collision');
+
+      store.install(
+        agentBundle({
+          id: 'community/janitor',
+          agentName: 'janitor',
+        }),
+      );
+      const ownKey = registryFor(
+        {
+          preset: 'work',
+          agents: { janitor: { model: 'custom/model' } },
+          presets: {
+            work: {
+              agents: { janitor: { model: 'custom/model' } },
+              marketplace: { agents: ['community/janitor'] },
+            },
+          },
+        },
+        store,
+        'marketplace-own-override',
+      );
+      const derived = ownKey.agents.find((agent) => agent.name === 'janitor');
+      expect(derived?.config.model).toBe('custom/model');
+      expect(ownKey.provenance.janitor).toBe(
+        'marketplace-agent:community/janitor@1.0.0',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('treats a self-alias as a no-op', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          overrides: { displayName: 'docsresearcher' },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+      expect(registry.runtimeNameByPackageId['community/docs-researcher']).toBe(
+        'docsresearcher',
+      );
+      expect(registry.canonicalIdByRuntimeName.docsresearcher).toBe(
+        'docsresearcher',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('discovers skills from .claude and OPENCODE_CONFIG MCPs', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const previousHome = process.env.OPENCODE_TEST_HOME;
+    const previousConfig = process.env.OPENCODE_CONFIG;
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      process.env.OPENCODE_TEST_HOME = root;
+      writeSkill(join(root, '.claude', 'skills'), 'on-disk-skill');
+      const customConfig = join(root, 'custom-opencode.json');
+      writeFileSync(
+        customConfig,
+        JSON.stringify({
+          mcp: {
+            'config-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+            },
+          },
+        }),
+      );
+      process.env.OPENCODE_CONFIG = customConfig;
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          requirements: {
+            skills: { required: ['on-disk-skill'], optional: [] },
+            mcps: { required: ['config-mcp'], optional: [] },
+          },
+        }),
+      );
+      RuntimeConfig.reset(root);
+      const registry = buildResolvedAgentRegistry(
+        RuntimeConfig.init(root, {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+        {
+          marketplaceStore: store,
+          projectDirectory: root,
+          availableMcpNames: ['config-mcp'],
+        },
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+    } finally {
+      if (previousHome === undefined) delete process.env.OPENCODE_TEST_HOME;
+      else process.env.OPENCODE_TEST_HOME = previousHome;
+      if (previousConfig === undefined) delete process.env.OPENCODE_CONFIG;
+      else process.env.OPENCODE_CONFIG = previousConfig;
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('disables unsafe profile aliases without affecting other packages', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle());
+      store.install(
+        profileBundle({
+          overrides: { displayName: 'Docs Researcher' },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                agents: ['community/docs-researcher'],
+                profiles: { explorer: 'community/deep-explorer' },
+              },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+      expect(registry.diagnostics[0]?.code).toBe('invalid-alias');
+      expect(registry.diagnostics[0]?.packageId).toBe(
+        'community/deep-explorer',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('disables unsafe display aliases without throwing', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          overrides: { displayName: 'Docs Researcher' },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(false);
+      expect(registry.diagnostics[0]?.code).toBe('invalid-alias');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('isolates inactive package corruption from selected loads', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle());
+      store.install(
+        agentBundle({
+          id: 'community/other',
+          agentName: 'otheragent',
+        }),
+      );
+      writeFileSync(
+        join(root, 'packages', 'community', 'other', '1.0.0', 'package.json'),
+        '{"not":"a package"}',
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'docsresearcher'),
+      ).toBe(true);
+      expect(registry.diagnostics).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('resolves marketplace aliases from the final registry maps', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(
+        agentBundle({
+          overrides: { displayName: 'docsalias', model: 'package/model' },
+        }),
+      );
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(resolveRuntimeAgentName(registry, '@docsalias')).toBe(
+        'docsresearcher',
+      );
+      expect(resolveRuntimeAgentName(registry, 'explore')).toBe('explorer');
+      expect(registry.canonicalIdByRuntimeName.docsalias).toBe(
+        'docsresearcher',
+      );
+      expect(registry.runtimeNameByPackageId['community/docs-researcher']).toBe(
+        'docsalias',
+      );
+      expect(registry.marketplaceLive).toEqual([
+        expect.objectContaining({
+          packageId: 'community/docs-researcher',
+          runtimeName: 'docsalias',
+          kind: 'agent',
+        }),
+      ]);
+      expect(
+        (registry.sdkConfigs.docsalias.permission as Record<string, unknown>)
+          .marketplace,
+      ).toBe('deny');
+      expect(
+        (
+          registry.sdkConfigs.docsresearcher.permission as Record<
+            string,
+            unknown
+          >
+        ).marketplace,
+      ).toBe('deny');
+      expect(registry.sdkConfigs.docsalias.model).toBe('package/model');
+      expect(registry.sdkConfigs.docsresearcher.model).toBe('package/model');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('live identity uses final registry runtime name after user display override', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(agentBundle());
+      const registry = registryFor(
+        {
+          preset: 'work',
+          agents: { docsresearcher: { displayName: 'fieldscout' } },
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        },
+        store,
+      );
+      expect(registry.runtimeNameByPackageId['community/docs-researcher']).toBe(
+        'fieldscout',
+      );
+      expect(registry.marketplaceLive).toEqual([
+        expect.objectContaining({
+          packageId: 'community/docs-researcher',
+          runtimeName: 'fieldscout',
+          kind: 'agent',
+        }),
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('desired live from disk does not mutate the session RuntimeConfig', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    mkdirSync(join(configHome, 'opencode'), { recursive: true });
+    mkdirSync(join(project, '.opencode'), { recursive: true });
+    process.env.XDG_CONFIG_HOME = configHome;
+    writeFileSync(
+      join(configHome, 'opencode', 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'work',
+        presets: {
+          work: {
+            agents: {},
+            marketplace: { agents: ['community/docs-researcher'] },
+          },
+        },
+      }),
+    );
+    const sessionDir = `${project}\0session`;
+    try {
+      const store = new MarketplaceStore({ rootDir: join(root, 'store') });
+      store.install(agentBundle({ overrides: { displayName: 'docsalias' } }));
+      RuntimeConfig.reset(sessionDir);
+      const session = RuntimeConfig.init(sessionDir, {
+        preset: 'session-preset',
+      });
+      session.captureHostConfig({ default_agent: 'build' });
+      const desired = resolveDesiredMarketplaceLiveFromDisk(project, store);
+      expect(desired.packages).toEqual([
+        expect.objectContaining({
+          packageId: 'community/docs-researcher',
+          runtimeName: 'docsalias',
+        }),
+      ]);
+      expect(RuntimeConfig.get(sessionDir).host()?.default_agent).toBe('build');
+      expect(RuntimeConfig.get(sessionDir).plugin?.preset).toBe(
+        'session-preset',
+      );
+    } finally {
+      RuntimeConfig.reset(sessionDir);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('marketplace activation persistence', () => {
+  test('enables and disables an installed agent on the active preset', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(configHome, 'opencode'), { recursive: true });
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      const userConfig = join(
+        configHome,
+        'opencode',
+        'oh-my-opencode-slim.json',
+      );
+      writeFileSync(
+        userConfig,
+        JSON.stringify({
+          preset: 'work',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: join(root, 'store') });
+      store.install(agentBundle());
+      enableMarketplaceAgent(project, 'community/docs-researcher', store);
+      expect(existsSync(`${userConfig}.bak`)).toBe(true);
+      expect(JSON.parse(readFileSync(userConfig, 'utf8'))).toEqual({
+        preset: 'work',
+        presets: {
+          work: {
+            agents: {},
+            marketplace: {
+              agents: ['community/docs-researcher'],
+            },
+          },
+        },
+      });
+      disableMarketplacePackage(project, 'community/docs-researcher');
+      expect(
+        JSON.parse(readFileSync(userConfig, 'utf8')).presets.work.marketplace
+          .agents,
+      ).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('project enable keeps inherited user marketplace agents', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(configHome, 'opencode'), { recursive: true });
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      const userConfig = join(
+        configHome,
+        'opencode',
+        'oh-my-opencode-slim.json',
+      );
+      const projectConfig = join(
+        project,
+        '.opencode',
+        'oh-my-opencode-slim.json',
+      );
+      writeFileSync(
+        userConfig,
+        JSON.stringify({
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/docs-researcher'] },
+            },
+          },
+        }),
+      );
+      writeFileSync(
+        projectConfig,
+        JSON.stringify({
+          preset: 'work',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: join(root, 'store') });
+      store.install(agentBundle());
+      store.install(
+        agentBundle({
+          id: 'community/other',
+          agentName: 'otheragent',
+        }),
+      );
+      enableMarketplaceAgent(project, 'community/other', store);
+      const merged = JSON.parse(readFileSync(projectConfig, 'utf8')) as {
+        presets: {
+          work: { marketplace: { agents: string[] } };
+        };
+      };
+      expect(merged.presets.work.marketplace.agents).toEqual([
+        'community/docs-researcher',
+        'community/other',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('selects and clears a profile for a supported role', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(configHome, 'opencode'), { recursive: true });
+      const userConfig = join(
+        configHome,
+        'opencode',
+        'oh-my-opencode-slim.json',
+      );
+      writeFileSync(
+        userConfig,
+        JSON.stringify({
+          preset: 'work',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: join(root, 'store') });
+      store.install(profileBundle());
+      setMarketplaceProfile(
+        project,
+        'explorer',
+        'community/deep-explorer',
+        store,
+      );
+      expect(
+        JSON.parse(readFileSync(userConfig, 'utf8')).presets.work.marketplace
+          .profiles.explorer,
+      ).toBe('community/deep-explorer');
+      setMarketplaceProfile(project, 'explorer', null, store);
+      expect(
+        JSON.parse(readFileSync(userConfig, 'utf8')).presets.work.marketplace
+          .profiles.explorer,
+      ).toBeNull();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('competing activation writers keep both package enables', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    const barrierDir = join(root, 'barriers');
+    const storeRoot = join(root, 'store');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(configHome, 'opencode'), { recursive: true });
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      mkdirSync(barrierDir, { recursive: true });
+      writeFileSync(join(barrierDir, 'config-rmw-read.wait'), 'wait');
+      const userConfig = join(
+        configHome,
+        'opencode',
+        'oh-my-opencode-slim.json',
+      );
+      writeFileSync(
+        userConfig,
+        JSON.stringify({
+          preset: 'work',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      const store = new MarketplaceStore({ rootDir: storeRoot });
+      store.install(agentBundle());
+      store.install(
+        agentBundle({
+          id: 'community/other',
+          agentName: 'otheragent',
+        }),
+      );
+      const spawnEnable = (packageId: string) =>
+        Bun.spawn(
+          [
+            'bun',
+            '-e',
+            `import { enableMarketplaceAgent } from './src/marketplace/activation-config.ts';
+import { MarketplaceStore } from './src/marketplace/store.ts';
+const { directory, packageId, storeRoot } = JSON.parse(process.argv[1]);
+enableMarketplaceAgent(directory, packageId, new MarketplaceStore({ rootDir: storeRoot }));`,
+            JSON.stringify({
+              directory: project,
+              packageId,
+              storeRoot,
+            }),
+          ],
+          {
+            stdout: 'pipe',
+            stderr: 'pipe',
+            env: {
+              ...process.env,
+              XDG_CONFIG_HOME: configHome,
+              CONFIG_MUTATION_BARRIER_DIR: barrierDir,
+            },
+          },
+        );
+      const first = spawnEnable('community/docs-researcher');
+      const started = Date.now();
+      const reached = join(barrierDir, 'config-rmw-read.reached');
+      while (!existsSync(reached)) {
+        if (Date.now() - started > 8_000) {
+          throw new Error('Timed out waiting for first writer barrier');
+        }
+        await Bun.sleep(10);
+      }
+      const second = spawnEnable('community/other');
+      await Bun.sleep(80);
+      writeFileSync(join(barrierDir, 'config-rmw-read.go'), 'go');
+      const [firstCode, secondCode] = await Promise.all([
+        first.exited,
+        second.exited,
+      ]);
+      expect(firstCode).toBe(0);
+      expect(secondCode).toBe(0);
+      expect(
+        JSON.parse(readFileSync(userConfig, 'utf8')).presets.work.marketplace
+          .agents,
+      ).toEqual(['community/docs-researcher', 'community/other']);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 474 - 0
src/marketplace/activation.ts

@@ -0,0 +1,474 @@
+import type { SpecialistRole } from '../config/agent-roles';
+import { AGENT_ALIASES, ALL_AGENT_NAMES } from '../config/constants';
+import type { RuntimeConfig } from '../config/runtime';
+import type {
+  AgentOverrideConfig,
+  MarketplaceActivation,
+} from '../config/schema';
+import { isSafeAgentAlias, normalizeAgentName } from '../utils/agent-variant';
+import { validateMarketplaceCompatibility } from './compatibility';
+import {
+  MarketplaceCompatibilityError,
+  MarketplaceIntegrityError,
+  MarketplaceLockfileError,
+} from './errors';
+import { normalizeMarketplacePackageId } from './ids';
+import {
+  discoverOnDiskSkillNames,
+  discoverPreflightMcps,
+  discoverPreflightSkills,
+} from './preflight';
+import type {
+  MarketplaceAgentManifest,
+  MarketplacePackageManifest,
+  MarketplaceProfileManifest,
+} from './schemas';
+import { MarketplaceStore, type StoredMarketplacePackage } from './store';
+
+export {
+  discoverOnDiskSkillNames,
+  discoverPreflightMcps,
+  discoverPreflightSkills,
+};
+
+export type MarketplaceDiagnosticCode =
+  | 'missing'
+  | 'corrupt'
+  | 'operational'
+  | 'incompatible'
+  | 'collision'
+  | 'missing-required-dependency'
+  | 'kind-mismatch'
+  | 'target-mismatch'
+  | 'target-disabled'
+  | 'prompt-masked'
+  | 'invalid-alias';
+
+export interface MarketplaceDiagnostic {
+  packageId: string;
+  code: MarketplaceDiagnosticCode;
+  message: string;
+}
+
+export interface ActivatedMarketplaceAgent {
+  packageId: string;
+  version: string;
+  digest: string;
+  manifest: MarketplaceAgentManifest;
+  requiredSkills: readonly string[];
+  requiredMcps: readonly string[];
+}
+
+export interface ActivatedMarketplaceProfile {
+  packageId: string;
+  version: string;
+  digest: string;
+  manifest: MarketplaceProfileManifest;
+  requiredSkills: readonly string[];
+  requiredMcps: readonly string[];
+}
+
+export interface MarketplaceActivationPlan {
+  agents: ActivatedMarketplaceAgent[];
+  profiles: Map<SpecialistRole, ActivatedMarketplaceProfile>;
+  diagnostics: MarketplaceDiagnostic[];
+}
+
+export interface ResolveMarketplaceActivationOptions {
+  runtime: RuntimeConfig;
+  store?: MarketplaceStore;
+  projectDirectory?: string;
+  availableSkillNames?: readonly string[];
+  availableMcpNames?: readonly string[];
+  extraSkillDirectories?: readonly string[];
+}
+
+export function emptyMarketplaceActivationPlan(): MarketplaceActivationPlan {
+  return { agents: [], profiles: new Map(), diagnostics: [] };
+}
+
+export function marketplaceActivationFromRuntime(
+  runtime: RuntimeConfig,
+): MarketplaceActivation | undefined {
+  const presetName = runtime.preset;
+  if (!presetName) return undefined;
+  return runtime.plugin?.presets?.[presetName]?.marketplace;
+}
+
+export function composePackagePrompt(
+  rolePrompt: string,
+  instructions: string,
+  mode: 'append' | 'replace',
+): string {
+  return mode === 'replace' ? instructions : `${rolePrompt}\n\n${instructions}`;
+}
+
+export function boundedPackageOverride(
+  manifest: MarketplacePackageManifest,
+): AgentOverrideConfig {
+  const overrides = manifest.overrides;
+  const result: AgentOverrideConfig = {};
+  if (overrides.model) result.model = overrides.model;
+  if (overrides.variant) result.variant = overrides.variant;
+  if (overrides.temperature !== undefined) {
+    result.temperature = overrides.temperature;
+  }
+  if (overrides.displayName) {
+    const displayName = normalizeAgentName(overrides.displayName);
+    const selfAlias =
+      (manifest.kind === 'agent' && displayName === manifest.agentName) ||
+      (manifest.kind === 'profile' && displayName === manifest.targetRole);
+    if (displayName && !selfAlias) result.displayName = displayName;
+  }
+  if (overrides.description) result.description = overrides.description;
+  return result;
+}
+
+export function reservedRuntimeNames(
+  runtime: RuntimeConfig,
+  ownOverrideKey?: string,
+): Set<string> {
+  const reserved = new Set<string>(ALL_AGENT_NAMES);
+  for (const [alias, canonical] of Object.entries(AGENT_ALIASES)) {
+    reserved.add(alias);
+    reserved.add(canonical);
+  }
+  for (const name of Object.keys(runtime.acpAgents)) reserved.add(name);
+  reserved.add('councillor');
+  const councilPreset =
+    runtime.council?.presets?.[runtime.council.default_preset ?? 'default'];
+  if (councilPreset) {
+    for (const seat of Object.keys(councilPreset)) {
+      if (seat !== 'master') reserved.add(`councillor-${seat}`);
+    }
+  }
+  for (const name of runtime.customAgentNames) {
+    if (name === ownOverrideKey) continue;
+    reserved.add(name);
+  }
+  for (const [name, override] of Object.entries(runtime.agents())) {
+    if (name === ownOverrideKey) continue;
+    if (override.displayName) {
+      reserved.add(normalizeAgentName(override.displayName));
+    }
+  }
+  return reserved;
+}
+
+function diagnostic(
+  packageId: string,
+  code: MarketplaceDiagnosticCode,
+  message: string,
+): MarketplaceDiagnostic {
+  return { packageId, code, message };
+}
+
+function missingRequired(
+  required: readonly string[],
+  available: ReadonlySet<string>,
+): string[] {
+  return required.filter((name) => !available.has(name));
+}
+
+function marketplaceLoadFailureCode(error: unknown): MarketplaceDiagnosticCode {
+  if (
+    error instanceof MarketplaceIntegrityError &&
+    error.message.includes('is not installed')
+  ) {
+    return 'missing';
+  }
+  if (
+    error instanceof MarketplaceLockfileError ||
+    error instanceof MarketplaceIntegrityError
+  ) {
+    return 'corrupt';
+  }
+  return 'operational';
+}
+
+function claimDisplayAlias(
+  packageId: string,
+  runtimeName: string,
+  rawDisplayName: string | undefined,
+  reserved: ReadonlySet<string>,
+  claimedNames: Set<string>,
+): MarketplaceDiagnostic | undefined {
+  if (!rawDisplayName) return undefined;
+  const displayName = normalizeAgentName(rawDisplayName);
+  if (!displayName || displayName === runtimeName) return undefined;
+  if (!isSafeAgentAlias(displayName)) {
+    return diagnostic(
+      packageId,
+      'invalid-alias',
+      `${packageId} display alias '${rawDisplayName}' is not a valid agent alias`,
+    );
+  }
+  if (reserved.has(displayName) || claimedNames.has(displayName)) {
+    return diagnostic(
+      packageId,
+      'collision',
+      `${packageId} display alias '${displayName}' collides with an existing agent name`,
+    );
+  }
+  claimedNames.add(displayName);
+  return undefined;
+}
+
+export function resolveMarketplaceActivation(
+  options: ResolveMarketplaceActivationOptions,
+): MarketplaceActivationPlan {
+  const activation = marketplaceActivationFromRuntime(options.runtime);
+  if (!activation) return emptyMarketplaceActivationPlan();
+
+  const agentIds = [...(activation.agents ?? [])].map((id) =>
+    id.trim().toLowerCase(),
+  );
+  const profileEntries = Object.entries(activation.profiles ?? {})
+    .filter((entry): entry is [SpecialistRole, string] => {
+      return entry[1] !== null && entry[1].trim().length > 0;
+    })
+    .map(
+      ([role, packageId]) => [role, packageId.trim().toLowerCase()] as const,
+    );
+
+  if (agentIds.length === 0 && profileEntries.length === 0) {
+    return emptyMarketplaceActivationPlan();
+  }
+
+  const diagnostics: MarketplaceDiagnostic[] = [];
+  const store = options.store ?? new MarketplaceStore();
+  const disabledSkills = new Set(options.runtime.disabledSkills);
+  const disabledMcps = new Set(options.runtime.disabledMcps);
+  const skills = new Set(
+    (
+      options.availableSkillNames ??
+      discoverPreflightSkills(
+        options.runtime,
+        options.projectDirectory,
+        options.extraSkillDirectories,
+      )
+    ).filter((name) => !disabledSkills.has(name)),
+  );
+  const mcps = new Set(
+    (
+      options.availableMcpNames ??
+      discoverPreflightMcps(options.runtime, options.projectDirectory)
+    ).filter((name) => !disabledMcps.has(name)),
+  );
+  const claimedNames = new Set<string>();
+  const agents: ActivatedMarketplaceAgent[] = [];
+  const profiles = new Map<SpecialistRole, ActivatedMarketplaceProfile>();
+
+  const selectedIds: string[] = [];
+  for (const packageId of new Set([
+    ...agentIds,
+    ...profileEntries.map(([, packageId]) => packageId),
+  ])) {
+    try {
+      selectedIds.push(normalizeMarketplacePackageId(packageId));
+    } catch {
+      diagnostics.push(
+        diagnostic(packageId, 'missing', `${packageId} is not installed`),
+      );
+    }
+  }
+
+  let selectedPackages = new Map<string, StoredMarketplacePackage>();
+  let selectedErrors = new Map<string, Error>();
+  let selectedLoadFailed = false;
+  try {
+    const selected = store.loadSelected(selectedIds);
+    selectedPackages = selected.packages;
+    selectedErrors = selected.errors;
+  } catch (error) {
+    selectedLoadFailed = true;
+    diagnostics.push(
+      diagnostic(
+        '(store)',
+        marketplaceLoadFailureCode(error),
+        error instanceof Error ? error.message : String(error),
+      ),
+    );
+  }
+
+  const load = (packageId: string): StoredMarketplacePackage | undefined => {
+    if (selectedLoadFailed) return undefined;
+    let normalized: string;
+    try {
+      normalized = normalizeMarketplacePackageId(packageId);
+    } catch {
+      return undefined;
+    }
+    const error = selectedErrors.get(normalized);
+    if (error) {
+      diagnostics.push(
+        diagnostic(
+          normalized,
+          marketplaceLoadFailureCode(error),
+          error.message,
+        ),
+      );
+      return undefined;
+    }
+    const pkg = selectedPackages.get(normalized);
+    if (!pkg) {
+      diagnostics.push(
+        diagnostic(normalized, 'missing', `${normalized} is not installed`),
+      );
+      return undefined;
+    }
+    try {
+      validateMarketplaceCompatibility(pkg.manifest);
+    } catch (compatError) {
+      const message =
+        compatError instanceof MarketplaceCompatibilityError
+          ? compatError.message
+          : compatError instanceof Error
+            ? compatError.message
+            : String(compatError);
+      diagnostics.push(diagnostic(normalized, 'incompatible', message));
+      return undefined;
+    }
+    return pkg;
+  };
+
+  const preflight = (
+    pkg: StoredMarketplacePackage,
+  ): { skills: string[]; mcps: string[] } | undefined => {
+    const missingSkills = missingRequired(
+      pkg.manifest.requirements.skills.required,
+      skills,
+    );
+    const missingMcps = missingRequired(
+      pkg.manifest.requirements.mcps.required,
+      mcps,
+    );
+    if (missingSkills.length > 0 || missingMcps.length > 0) {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'missing-required-dependency',
+          `${pkg.manifest.id} is disabled: missing required ${[
+            ...missingSkills.map((name) => `skill ${name}`),
+            ...missingMcps.map((name) => `mcp ${name}`),
+          ].join(', ')}`,
+        ),
+      );
+      return undefined;
+    }
+    return {
+      skills: pkg.manifest.requirements.skills.required,
+      mcps: pkg.manifest.requirements.mcps.required,
+    };
+  };
+
+  for (const packageId of [...agentIds].sort()) {
+    const pkg = load(packageId);
+    if (!pkg) continue;
+    if (pkg.manifest.kind !== 'agent') {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'kind-mismatch',
+          `${pkg.manifest.id} is a ${pkg.manifest.kind} package and cannot be activated as an agent`,
+        ),
+      );
+      continue;
+    }
+    const deps = preflight(pkg);
+    if (!deps) continue;
+    const runtimeName = pkg.manifest.agentName;
+    const reserved = reservedRuntimeNames(options.runtime, runtimeName);
+    if (reserved.has(runtimeName) || claimedNames.has(runtimeName)) {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'collision',
+          `${pkg.manifest.id} runtime name '${runtimeName}' collides with an existing agent, alias, or display name`,
+        ),
+      );
+      continue;
+    }
+    const aliasError = claimDisplayAlias(
+      pkg.manifest.id,
+      runtimeName,
+      pkg.manifest.overrides.displayName,
+      reserved,
+      claimedNames,
+    );
+    if (aliasError) {
+      diagnostics.push(aliasError);
+      continue;
+    }
+    claimedNames.add(runtimeName);
+    agents.push({
+      packageId: pkg.manifest.id,
+      version: pkg.manifest.version,
+      digest: pkg.digest,
+      manifest: pkg.manifest,
+      requiredSkills: deps.skills,
+      requiredMcps: deps.mcps,
+    });
+  }
+
+  for (const [role, packageId] of profileEntries.sort(([left], [right]) =>
+    left.localeCompare(right),
+  )) {
+    const pkg = load(packageId);
+    if (!pkg) continue;
+    if (pkg.manifest.kind !== 'profile') {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'kind-mismatch',
+          `${pkg.manifest.id} is a ${pkg.manifest.kind} package and cannot be activated as a profile`,
+        ),
+      );
+      continue;
+    }
+    if (pkg.manifest.targetRole !== role) {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'target-mismatch',
+          `${pkg.manifest.id} targets ${pkg.manifest.targetRole}, not ${role}`,
+        ),
+      );
+      continue;
+    }
+    if (options.runtime.disabledAgents.has(role)) {
+      diagnostics.push(
+        diagnostic(
+          pkg.manifest.id,
+          'target-disabled',
+          `${pkg.manifest.id} is disabled because @${role} is disabled`,
+        ),
+      );
+      continue;
+    }
+    const deps = preflight(pkg);
+    if (!deps) continue;
+    const reserved = reservedRuntimeNames(options.runtime, role);
+    const aliasError = claimDisplayAlias(
+      pkg.manifest.id,
+      role,
+      pkg.manifest.overrides.displayName,
+      reserved,
+      claimedNames,
+    );
+    if (aliasError) {
+      diagnostics.push(aliasError);
+      continue;
+    }
+    profiles.set(role, {
+      packageId: pkg.manifest.id,
+      version: pkg.manifest.version,
+      digest: pkg.digest,
+      manifest: pkg.manifest,
+      requiredSkills: deps.skills,
+      requiredMcps: deps.mcps,
+    });
+  }
+
+  return { agents, profiles, diagnostics };
+}

+ 54 - 0
src/marketplace/compatibility.test.ts

@@ -0,0 +1,54 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  MarketplaceCompatibilityError,
+  MarketplaceService,
+  satisfiesPluginCompatibility,
+} from './index';
+import type { MarketplacePackageBundle } from './schemas';
+
+const bundle: MarketplacePackageBundle = {
+  manifest: {
+    schemaVersion: 1,
+    id: 'community/compatible',
+    version: '1.0.0',
+    kind: 'profile',
+    displayName: 'Compatible profile',
+    description: 'A compatible profile.',
+    instructions: 'Append these instructions.',
+    author: { name: 'Community' },
+    tags: ['test'],
+    license: 'MIT',
+    compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+    routing: {
+      description: 'Use for compatibility tests.',
+      keywords: ['test'],
+      delegation: { when: 'When testing.', preferredRoles: [] },
+    },
+    requirements: {
+      skills: { required: ['one'], optional: ['two'] },
+      mcps: { required: [], optional: ['three'] },
+    },
+    capabilities: { tools: ['read'], permissions: ['filesystem.read'] },
+    targetRole: 'oracle',
+    instructionMode: 'append',
+    overrides: {},
+  },
+};
+
+describe('marketplace compatibility', () => {
+  test('supports bounded exact and comparator plugin ranges', () => {
+    expect(satisfiesPluginCompatibility('2.2.18', '>=2.2.0 <3.0.0')).toBe(true);
+    expect(satisfiesPluginCompatibility('3.0.0', '>=2.2.0 <3.0.0')).toBe(false);
+    expect(satisfiesPluginCompatibility('2.2.18', '^2.0.0')).toBe(true);
+  });
+
+  test('rejects incompatible packages before persistence', () => {
+    const service = new MarketplaceService({
+      rootDir: '/tmp/marketplace-compatibility-test',
+      pluginVersion: '1.0.0',
+    });
+    expect(() => service.install(bundle)).toThrow(
+      MarketplaceCompatibilityError,
+    );
+  });
+});

+ 45 - 0
src/marketplace/compatibility.ts

@@ -0,0 +1,45 @@
+import { satisfies, valid } from 'semver';
+import { readPluginPackageVersion } from '../utils/package-metadata';
+import { MarketplaceCompatibilityError } from './errors';
+import {
+  MARKETPLACE_ROLE_CONTRACT_VERSION,
+  type MarketplacePackageManifest,
+} from './schemas';
+
+export interface MarketplaceCompatibilityOptions {
+  pluginVersion?: string;
+  roleContractVersion?: string;
+}
+
+export function satisfiesPluginCompatibility(
+  version: string,
+  range: string,
+): boolean {
+  return valid(version) !== null && satisfies(version, range);
+}
+
+export function validateMarketplaceCompatibility(
+  manifest: MarketplacePackageManifest,
+  options: MarketplaceCompatibilityOptions = {},
+): void {
+  const pluginVersion = options.pluginVersion ?? readPluginPackageVersion();
+  if (!pluginVersion) {
+    throw new MarketplaceCompatibilityError(
+      'Cannot determine the installed plugin version for marketplace compatibility',
+    );
+  }
+  const roleContractVersion =
+    options.roleContractVersion ?? MARKETPLACE_ROLE_CONTRACT_VERSION;
+  if (
+    !satisfiesPluginCompatibility(pluginVersion, manifest.compatibility.plugin)
+  ) {
+    throw new MarketplaceCompatibilityError(
+      `${manifest.id}@${manifest.version} requires plugin ${manifest.compatibility.plugin}; current plugin is ${pluginVersion}`,
+    );
+  }
+  if (!satisfies(roleContractVersion, manifest.compatibility.roleContract)) {
+    throw new MarketplaceCompatibilityError(
+      `${manifest.id}@${manifest.version} requires role contract ${manifest.compatibility.roleContract}; current contract is ${roleContractVersion}`,
+    );
+  }
+}

+ 131 - 0
src/marketplace/config-references.test.ts

@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+  MarketplaceActivationReferenceError,
+  MarketplaceService,
+  readMarketplaceConfigReferences,
+} from './index';
+import type { MarketplacePackageBundle } from './schemas';
+
+const previousConfigHome = process.env.XDG_CONFIG_HOME;
+
+const bundle: MarketplacePackageBundle = {
+  manifest: {
+    schemaVersion: 1,
+    id: 'community/referenced',
+    version: '1.0.0',
+    kind: 'agent',
+    displayName: 'Referenced agent',
+    description: 'An agent used by a preset.',
+    instructions: 'Follow the role instructions.',
+    author: { name: 'Community' },
+    tags: ['test'],
+    license: 'MIT',
+    compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+    routing: {
+      description: 'Route referenced work here.',
+      keywords: ['reference'],
+      delegation: { when: 'When referenced.', preferredRoles: [] },
+    },
+    requirements: {
+      skills: { required: [], optional: [] },
+      mcps: { required: [], optional: [] },
+    },
+    capabilities: { tools: [], permissions: [] },
+    baseRole: 'explorer',
+    agentName: 'referenced',
+    overrides: {},
+  },
+};
+
+afterEach(() => {
+  if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
+  else process.env.XDG_CONFIG_HOME = previousConfigHome;
+});
+
+describe('marketplace activation references', () => {
+  test('reads user/project preset references instead of CLI claims', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      const configPath = join(project, '.opencode', 'oh-my-opencode-slim.json');
+      writeFileSync(
+        configPath,
+        JSON.stringify({
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: { agents: ['community/referenced'] },
+            },
+          },
+        }),
+      );
+      expect(readMarketplaceConfigReferences(project)).toEqual([
+        {
+          packageId: 'community/referenced',
+          configPath,
+          presetName: 'work',
+          target: 'agent',
+        },
+      ]);
+
+      const service = new MarketplaceService({
+        rootDir: join(root, 'store'),
+        projectDir: project,
+      });
+      service.install(bundle);
+      expect(() => service.remove('community/referenced')).toThrow(
+        MarketplaceActivationReferenceError,
+      );
+      expect(() =>
+        service.remove('community/referenced', { force: true }),
+      ).not.toThrow();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('resolves a project-selected preset defined in user config', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-config-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    const userConfigPath = join(configHome, 'opencode');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(userConfigPath, { recursive: true });
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(userConfigPath, 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          presets: {
+            shared: {
+              agents: {},
+              marketplace: { agents: ['community/referenced'] },
+            },
+          },
+        }),
+      );
+      writeFileSync(
+        join(project, '.opencode', 'oh-my-opencode-slim.json'),
+        JSON.stringify({ preset: 'shared' }),
+      );
+
+      expect(readMarketplaceConfigReferences(project)).toEqual([
+        {
+          packageId: 'community/referenced',
+          configPath: join(project, '.opencode', 'oh-my-opencode-slim.json'),
+          presetName: 'shared',
+          target: 'agent',
+        },
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 71 - 0
src/marketplace/config-references.ts

@@ -0,0 +1,71 @@
+import { findPluginConfigPaths, loadPluginConfig } from '../config/loader';
+
+export interface MarketplaceConfigReference {
+  packageId: string;
+  configPath: string;
+  presetName: string;
+  target: 'agent' | 'profile';
+}
+
+interface UnknownRecord {
+  [key: string]: unknown;
+}
+
+function isRecord(value: unknown): value is UnknownRecord {
+  return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function addReference(
+  references: MarketplaceConfigReference[],
+  value: unknown,
+  configPath: string,
+  presetName: string,
+  target: 'agent' | 'profile',
+): void {
+  if (typeof value !== 'string' || value.trim().length === 0) return;
+  references.push({
+    packageId: value.trim().toLowerCase(),
+    configPath,
+    presetName,
+    target,
+  });
+}
+
+function readReferencesFromMergedConfig(
+  config: unknown,
+  configPath: string,
+): MarketplaceConfigReference[] {
+  if (!isRecord(config)) return [];
+  const presetName = config.preset;
+  if (typeof presetName !== 'string') return [];
+  const presets = config.presets;
+  if (!isRecord(presets)) return [];
+
+  const references: MarketplaceConfigReference[] = [];
+  const presetValue = presets[presetName];
+  if (!isRecord(presetValue) || !isRecord(presetValue.marketplace)) return [];
+  const marketplace = presetValue.marketplace;
+  if (Array.isArray(marketplace.agents)) {
+    for (const packageId of marketplace.agents) {
+      addReference(references, packageId, configPath, presetName, 'agent');
+    }
+  }
+  if (isRecord(marketplace.profiles)) {
+    for (const packageId of Object.values(marketplace.profiles)) {
+      if (packageId !== null) {
+        addReference(references, packageId, configPath, presetName, 'profile');
+      }
+    }
+  }
+  return references;
+}
+
+/** Read persisted references from both user and project plugin configs. */
+export function readMarketplaceConfigReferences(
+  directory: string,
+): MarketplaceConfigReference[] {
+  const paths = findPluginConfigPaths(directory);
+  const configPath = paths.projectConfigPath ?? paths.userConfigPath;
+  const config = loadPluginConfig(directory, { silent: true });
+  return readReferencesFromMergedConfig(config, configPath ?? directory);
+}

+ 72 - 0
src/marketplace/errors.ts

@@ -0,0 +1,72 @@
+export class MarketplaceError extends Error {
+  constructor(
+    message: string,
+    readonly code: string,
+  ) {
+    super(message);
+    this.name = 'MarketplaceError';
+  }
+}
+
+export class MarketplaceValidationError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'validation');
+    this.name = 'MarketplaceValidationError';
+  }
+}
+
+export class MarketplaceLockfileError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'lockfile');
+    this.name = 'MarketplaceLockfileError';
+  }
+}
+
+export class MarketplaceIntegrityError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'integrity');
+    this.name = 'MarketplaceIntegrityError';
+  }
+}
+
+export class MarketplaceConflictError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'conflict');
+    this.name = 'MarketplaceConflictError';
+  }
+}
+
+export class MarketplaceBusyError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'busy');
+    this.name = 'MarketplaceBusyError';
+  }
+}
+
+export class MarketplaceLockOwnershipError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'lock-ownership');
+    this.name = 'MarketplaceLockOwnershipError';
+  }
+}
+
+export class MarketplaceCompatibilityError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'compatibility');
+    this.name = 'MarketplaceCompatibilityError';
+  }
+}
+
+export class MarketplaceActivationReferenceError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'activation-reference');
+    this.name = 'MarketplaceActivationReferenceError';
+  }
+}
+
+export class MarketplaceActivationError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'activation');
+    this.name = 'MarketplaceActivationError';
+  }
+}

+ 10 - 0
src/marketplace/ids.ts

@@ -0,0 +1,10 @@
+import { MarketplacePackageIdSchema } from './schemas';
+
+export function normalizeMarketplacePackageId(value: string): string {
+  const normalized = value.trim().toLowerCase();
+  const result = MarketplacePackageIdSchema.safeParse(normalized);
+  if (!result.success) {
+    throw new Error(`Invalid marketplace package ID: ${value}`);
+  }
+  return normalized;
+}

+ 11 - 0
src/marketplace/index.ts

@@ -0,0 +1,11 @@
+export * from './activation';
+export * from './activation-config';
+export * from './compatibility';
+export * from './config-references';
+export * from './errors';
+export * from './ids';
+export * from './paths';
+export * from './schemas';
+export * from './service';
+export * from './status';
+export * from './store';

+ 548 - 0
src/marketplace/lease.test.ts

@@ -0,0 +1,548 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  readFileSync,
+  rmSync,
+  utimesSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { MarketplaceBusyError, MarketplaceLockOwnershipError } from './errors';
+import {
+  acquireMarketplaceLease,
+  acquireMarketplaceLeaseForTests,
+  generationPath,
+  heartbeatPath,
+  isHeartbeatStale,
+  type LeaseIdentity,
+  listValidGenerations,
+  publishGeneration,
+  readGenerationState,
+  readLeaseState,
+} from './lease';
+import { getMarketplacePaths } from './paths';
+
+const SHORT_LOCK = {
+  staleMs: 80,
+  heartbeatMs: 20,
+  timeoutMs: 400,
+  retryMs: 10,
+} as const;
+
+function tempRoot(): string {
+  return mkdtempSync(join(tmpdir(), 'marketplace-lease-'));
+}
+
+function expireHeartbeat(directory: string, generation: string): void {
+  const old = new Date(Date.now() - 2_000);
+  const file = heartbeatPath(directory, generation);
+  utimesSync(generationPath(directory, generation), old, old);
+  utimesSync(file, old, old);
+}
+
+function writeIncompleteGeneration(
+  directory: string,
+  generation: string,
+): void {
+  const target = generationPath(directory, generation);
+  mkdirSync(target, { recursive: true });
+  writeFileSync(
+    join(target, 'meta.json'),
+    `${JSON.stringify({ generation })}\n`,
+  );
+}
+
+function creatingDirs(directory: string): string[] {
+  try {
+    return readdirSync(directory).filter((name) =>
+      name.startsWith('.creating.'),
+    );
+  } catch {
+    return [];
+  }
+}
+
+async function waitForFile(filePath: string, timeoutMs = 3_000): Promise<void> {
+  const started = Date.now();
+  while (!existsSync(filePath)) {
+    if (Date.now() - started > timeoutMs) {
+      throw new Error(`Timed out waiting for ${filePath}`);
+    }
+    await Bun.sleep(10);
+  }
+}
+
+function spawnLeaseWorker(
+  body: string,
+  payload: Record<string, unknown>,
+  env: Record<string, string | undefined> = {},
+): Bun.Subprocess {
+  return Bun.spawn(
+    [
+      'bun',
+      '-e',
+      `import { existsSync, writeFileSync } from 'node:fs';
+import { acquireMarketplaceLease } from './src/marketplace/lease.ts';
+${body}`,
+      JSON.stringify(payload),
+    ],
+    {
+      stdout: 'pipe',
+      stderr: 'pipe',
+      env: { ...process.env, ...env },
+    },
+  );
+}
+
+async function workerOutput(
+  worker: Bun.Subprocess,
+): Promise<{ code: number; stdout: string; stderr: string }> {
+  const [stdout, stderr, code] = await Promise.all([
+    new Response(worker.stdout).text(),
+    new Response(worker.stderr).text(),
+    worker.exited,
+  ]);
+  return { code, stdout, stderr };
+}
+
+describe('marketplace lease generations', () => {
+  test('stale cleanup does not claim a replacement generation', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    try {
+      mkdirSync(paths.rootDir, { recursive: true });
+      const replacement: LeaseIdentity = {
+        generation: 'replacement-generation',
+        token: 'replacement-token',
+      };
+      expect(publishGeneration(paths.lockDir, replacement)).toBe(true);
+      writeIncompleteGeneration(paths.lockDir, 'stale-generation');
+      expect(() =>
+        acquireMarketplaceLease(paths, {
+          staleMs: 2_000,
+          heartbeatMs: 100,
+          timeoutMs: 120,
+          retryMs: 10,
+        }),
+      ).toThrow(MarketplaceBusyError);
+      expect(readLeaseState(paths.lockDir)?.metadata).toEqual({
+        ...replacement,
+        pid: process.pid,
+      });
+      expect(
+        existsSync(generationPath(paths.lockDir, 'stale-generation')),
+      ).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('missing metadata is not ownership and is never deleted', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    try {
+      writeIncompleteGeneration(paths.lockDir, 'partial-generation');
+      const owner = acquireMarketplaceLease(paths, SHORT_LOCK);
+      expect(
+        existsSync(
+          join(
+            generationPath(paths.lockDir, 'partial-generation'),
+            'meta.json',
+          ),
+        ),
+      ).toBe(true);
+      expect(
+        JSON.parse(
+          readFileSync(
+            join(
+              generationPath(paths.lockDir, 'partial-generation'),
+              'meta.json',
+            ),
+            'utf8',
+          ),
+        ),
+      ).toEqual({ generation: 'partial-generation' });
+      expect(owner.generation).not.toBe('partial-generation');
+      owner.release();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('heartbeat equal to the stale interval is still fresh', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    try {
+      const identity: LeaseIdentity = {
+        generation: 'boundary-generation',
+        token: 'boundary-token',
+      };
+      expect(publishGeneration(paths.lockDir, identity)).toBe(true);
+      const staleMs = 200;
+      const state = readGenerationState(
+        generationPath(paths.lockDir, identity.generation),
+      );
+      if (!state) throw new Error('expected published generation state');
+      expect(
+        isHeartbeatStale(state, staleMs, state.heartbeatMtimeMs + staleMs),
+      ).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('heartbeat older than the stale interval is stale', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    try {
+      const identity: LeaseIdentity = {
+        generation: 'stale-boundary-generation',
+        token: 'stale-boundary-token',
+      };
+      expect(publishGeneration(paths.lockDir, identity)).toBe(true);
+      const staleMs = 200;
+      const state = readGenerationState(
+        generationPath(paths.lockDir, identity.generation),
+      );
+      if (!state) throw new Error('expected published generation state');
+      expect(
+        isHeartbeatStale(state, staleMs, state.heartbeatMtimeMs + staleMs + 1),
+      ).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('synchronous commit remains owned after the stale interval', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    let nowMs = Date.now();
+    try {
+      const owner = acquireMarketplaceLeaseForTests(
+        paths,
+        {
+          staleMs: 40,
+          heartbeatMs: 10,
+          timeoutMs: 500,
+          retryMs: 5,
+        },
+        () => nowMs,
+      );
+      const acquired = readGenerationState(
+        generationPath(paths.lockDir, owner.generation),
+      );
+      if (!acquired) throw new Error('expected owned generation');
+      nowMs = acquired.heartbeatMtimeMs;
+      const result = owner.commit(() => {
+        const during = readGenerationState(
+          generationPath(paths.lockDir, owner.generation),
+        );
+        if (!during) {
+          throw new Error('expected owned generation during commit');
+        }
+        nowMs = during.heartbeatMtimeMs + 80;
+        return 'published';
+      });
+      expect(result).toBe('published');
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        owner.generation,
+      );
+      owner.release();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('expired owner cannot commit before a replacement exists', () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    try {
+      const owner = acquireMarketplaceLease(paths, SHORT_LOCK);
+      expireHeartbeat(paths.lockDir, owner.generation);
+      let published = false;
+      expect(() =>
+        owner.commit(() => {
+          published = true;
+        }),
+      ).toThrow(MarketplaceLockOwnershipError);
+      expect(published).toBe(false);
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        owner.generation,
+      );
+      expect(() => owner.release()).not.toThrow();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('marketplace lease subprocess barriers', () => {
+  test('active breaker blocks main lock acquire', async () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    const readyPath = join(root, 'ready');
+    const holdingPath = join(root, 'holding');
+    const goPath = join(root, 'go');
+    const worker = spawnLeaseWorker(
+      `const { paths, readyPath, holdingPath, goPath, lock } = JSON.parse(process.argv[1]);
+const lease = acquireMarketplaceLease(paths, lock);
+writeFileSync(readyPath, lease.generation);
+lease.commit(() => {
+  writeFileSync(holdingPath, 'holding');
+  while (!existsSync(goPath)) {
+    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
+  }
+});
+lease.release();`,
+      { paths, readyPath, holdingPath, goPath, lock: SHORT_LOCK },
+    );
+    try {
+      await waitForFile(holdingPath);
+      const generation = readFileSync(readyPath, 'utf8');
+      expireHeartbeat(paths.lockDir, generation);
+      expect(() =>
+        acquireMarketplaceLease(paths, { ...SHORT_LOCK, timeoutMs: 120 }),
+      ).toThrow(MarketplaceBusyError);
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        generation,
+      );
+      expect(listValidGenerations(paths.breakerDir).length).toBeGreaterThan(0);
+      writeFileSync(goPath, 'go');
+      const result = await workerOutput(worker);
+      expect(result.code).toBe(0);
+    } finally {
+      worker.kill();
+      await worker.exited.catch(() => undefined);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('kills a worker during private generation initialization', async () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    const barrierDir = join(root, 'barriers');
+    mkdirSync(barrierDir, { recursive: true });
+    writeFileSync(join(barrierDir, 'prepare-generation.wait'), 'wait');
+    const worker = spawnLeaseWorker(
+      `const { paths, lock } = JSON.parse(process.argv[1]);
+acquireMarketplaceLease(paths, lock);`,
+      { paths, lock: SHORT_LOCK },
+      { MARKETPLACE_LEASE_BARRIER_DIR: barrierDir },
+    );
+    try {
+      await waitForFile(join(barrierDir, 'prepare-generation.reached'));
+      expect(creatingDirs(paths.breakerDir).length).toBeGreaterThan(0);
+      worker.kill('SIGKILL');
+      await worker.exited;
+      rmSync(join(barrierDir, 'prepare-generation.wait'), { force: true });
+      const owner = acquireMarketplaceLease(paths, SHORT_LOCK);
+      expect(creatingDirs(paths.breakerDir)).toEqual([]);
+      expect(creatingDirs(paths.lockDir)).toEqual([]);
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        owner.generation,
+      );
+      owner.release();
+    } finally {
+      worker.kill();
+      await worker.exited.catch(() => undefined);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('expired owner commit fails before replacement', async () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    const readyPath = join(root, 'ready');
+    const expirePath = join(root, 'expire');
+    const logPath = join(root, 'commit.log');
+    const publishedPath = join(root, 'published');
+    const worker = spawnLeaseWorker(
+      `const { paths, readyPath, expirePath, logPath, publishedPath, lock } = JSON.parse(process.argv[1]);
+const lease = acquireMarketplaceLease(paths, lock);
+writeFileSync(readyPath, lease.generation);
+while (!existsSync(expirePath)) {
+  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
+}
+try {
+  lease.commit(() => writeFileSync(publishedPath, 'published'));
+  writeFileSync(logPath, 'committed');
+} catch (error) {
+  writeFileSync(logPath, error instanceof Error ? error.constructor.name : 'error');
+}`,
+      {
+        paths,
+        readyPath,
+        expirePath,
+        logPath,
+        publishedPath,
+        lock: SHORT_LOCK,
+      },
+    );
+    try {
+      await waitForFile(readyPath);
+      const generation = readFileSync(readyPath, 'utf8');
+      expireHeartbeat(paths.lockDir, generation);
+      writeFileSync(expirePath, 'expired');
+      const result = await workerOutput(worker);
+      expect(result.code).toBe(0);
+      expect(readFileSync(logPath, 'utf8')).toBe(
+        'MarketplaceLockOwnershipError',
+      );
+      expect(existsSync(publishedPath)).toBe(false);
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        generation,
+      );
+    } finally {
+      worker.kill();
+      await worker.exited.catch(() => undefined);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('owner release during takeover leaves the replacement', async () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    const readyPath = join(root, 'ready');
+    const takeoverPath = join(root, 'takeover');
+    const logPath = join(root, 'release.log');
+    const worker = spawnLeaseWorker(
+      `const { paths, readyPath, takeoverPath, logPath, lock } = JSON.parse(process.argv[1]);
+const lease = acquireMarketplaceLease(paths, lock);
+writeFileSync(readyPath, JSON.stringify({ generation: lease.generation, token: lease.token }));
+while (!existsSync(takeoverPath)) {
+  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
+}
+try {
+  lease.release();
+  writeFileSync(logPath, 'released');
+} catch (error) {
+  writeFileSync(logPath, error instanceof Error ? error.constructor.name : 'error');
+}`,
+      { paths, readyPath, takeoverPath, logPath, lock: SHORT_LOCK },
+    );
+    try {
+      await waitForFile(readyPath);
+      const owner = JSON.parse(
+        readFileSync(readyPath, 'utf8'),
+      ) as LeaseIdentity;
+      expireHeartbeat(paths.lockDir, owner.generation);
+      const replacement = acquireMarketplaceLease(paths, SHORT_LOCK);
+      expect(replacement.generation).not.toBe(owner.generation);
+      writeFileSync(takeoverPath, 'taken');
+      const result = await workerOutput(worker);
+      expect(result.code).toBe(0);
+      expect(readFileSync(logPath, 'utf8')).toBe(
+        'MarketplaceLockOwnershipError',
+      );
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        replacement.generation,
+      );
+      expect(() => replacement.assertCurrent()).not.toThrow();
+      replacement.release();
+    } finally {
+      worker.kill();
+      await worker.exited.catch(() => undefined);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('three parties cannot overlap critical sections or displace a generation', async () => {
+    const root = tempRoot();
+    const paths = getMarketplacePaths(root);
+    const barrierDir = join(root, 'barriers');
+    mkdirSync(barrierDir, { recursive: true });
+    writeFileSync(join(barrierDir, 'breaker-critical.wait'), 'wait');
+    const ownerReady = join(root, 'owner-ready');
+    const ownerGo = join(root, 'owner-go');
+    const staleReady = join(root, 'stale-ready');
+    const owner = spawnLeaseWorker(
+      `const { paths, ownerReady, ownerGo, lock } = JSON.parse(process.argv[1]);
+const lease = acquireMarketplaceLease(paths, lock);
+writeFileSync(ownerReady, lease.generation);
+while (!existsSync(ownerGo)) {
+  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
+}
+lease.release();`,
+      {
+        paths,
+        ownerReady,
+        ownerGo,
+        lock: {
+          staleMs: 2_000,
+          heartbeatMs: 100,
+          timeoutMs: 3_000,
+          retryMs: 20,
+        },
+      },
+    );
+    let staleClaim: Bun.Subprocess | undefined;
+    try {
+      await waitForFile(ownerReady);
+    } catch (error) {
+      owner.kill();
+      await owner.exited.catch(() => undefined);
+      throw error;
+    }
+    staleClaim = spawnLeaseWorker(
+      `const { paths, staleReady, lock } = JSON.parse(process.argv[1]);
+writeFileSync(staleReady, 'started');
+try {
+  acquireMarketplaceLease(paths, lock);
+  writeFileSync(staleReady + '.won', 'won');
+} catch (error) {
+  writeFileSync(staleReady + '.busy', error instanceof Error ? error.constructor.name : 'error');
+}`,
+      {
+        paths,
+        staleReady,
+        lock: {
+          staleMs: 2_000,
+          heartbeatMs: 100,
+          timeoutMs: 2_000,
+          retryMs: 20,
+        },
+      },
+      { MARKETPLACE_LEASE_BARRIER_DIR: barrierDir },
+    );
+    try {
+      const ownerGeneration = readFileSync(ownerReady, 'utf8');
+      await waitForFile(staleReady);
+      await waitForFile(join(barrierDir, 'breaker-critical.reached'));
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        ownerGeneration,
+      );
+      expect(() =>
+        acquireMarketplaceLease(paths, {
+          staleMs: 2_000,
+          heartbeatMs: 100,
+          timeoutMs: 120,
+          retryMs: 10,
+        }),
+      ).toThrow(MarketplaceBusyError);
+      expect(readLeaseState(paths.lockDir)?.metadata.generation).toBe(
+        ownerGeneration,
+      );
+      expect(existsSync(`${staleReady}.won`)).toBe(false);
+      writeFileSync(join(barrierDir, 'breaker-critical.go'), 'go');
+      writeFileSync(ownerGo, 'go');
+      const [ownerResult, staleResult] = await Promise.all([
+        workerOutput(owner),
+        workerOutput(staleClaim),
+      ]);
+      expect(ownerResult.code).toBe(0);
+      expect(staleResult.code).toBe(0);
+    } finally {
+      owner.kill();
+      staleClaim?.kill();
+      await Promise.all([
+        owner.exited.catch(() => undefined),
+        staleClaim?.exited.catch(() => undefined),
+      ]);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 678 - 0
src/marketplace/lease.ts

@@ -0,0 +1,678 @@
+import { randomUUID } from 'node:crypto';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import {
+  MarketplaceBusyError,
+  MarketplaceLockfileError,
+  MarketplaceLockOwnershipError,
+  MarketplaceValidationError,
+} from './errors';
+import type { MarketplacePaths } from './paths';
+
+export interface MarketplaceLockOptions {
+  staleMs: number;
+  timeoutMs: number;
+  heartbeatMs: number;
+  retryMs: number;
+}
+
+export interface MarketplaceLease {
+  generation: string;
+  token: string;
+  assertCurrent(): void;
+  commit<T>(operation: () => T): T;
+  release(): void;
+}
+
+export interface LeaseIdentity {
+  generation: string;
+  token: string;
+}
+
+export interface LeaseMetadata extends LeaseIdentity {
+  pid: number;
+}
+
+export interface LeaseState {
+  metadata: LeaseMetadata;
+  heartbeatMtimeMs: number;
+}
+
+const DEFAULT_LOCK_OPTIONS: MarketplaceLockOptions = {
+  staleMs: 60_000,
+  timeoutMs: 300_000,
+  heartbeatMs: 20_000,
+  retryMs: 20,
+};
+
+function lockNow(now?: () => number): number {
+  return now?.() ?? Date.now();
+}
+
+const OWNERSHIP_MESSAGE =
+  'Marketplace lease generation is no longer fresh and current';
+
+const CREATING_PREFIX = '.creating.';
+
+interface BreakerLease {
+  release(): void;
+}
+
+function errnoCode(error: unknown): string | undefined {
+  if (!error || typeof error !== 'object') return undefined;
+  const code = (error as { code?: unknown }).code;
+  return typeof code === 'string' ? code : undefined;
+}
+
+export function syncDirectory(directory: string): void {
+  try {
+    const fd = fs.openSync(directory, 'r');
+    try {
+      fs.fsyncSync(fd);
+    } finally {
+      fs.closeSync(fd);
+    }
+  } catch {
+    // Directory fsync is unavailable on some supported filesystems.
+  }
+}
+
+export function writeAtomic(filePath: string, content: string): void {
+  const parent = path.dirname(filePath);
+  fs.mkdirSync(parent, { recursive: true });
+  const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
+  try {
+    const fd = fs.openSync(temporaryPath, 'wx');
+    try {
+      fs.writeFileSync(fd, content, 'utf8');
+      fs.fsyncSync(fd);
+    } finally {
+      fs.closeSync(fd);
+    }
+    fs.renameSync(temporaryPath, filePath);
+    syncDirectory(parent);
+  } finally {
+    try {
+      if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath);
+    } catch {
+      // A failed cleanup cannot make the published file invalid.
+    }
+  }
+}
+
+function sleepSync(milliseconds: number): void {
+  const sleeper = new Int32Array(new SharedArrayBuffer(4));
+  Atomics.wait(sleeper, 0, 0, milliseconds);
+}
+
+function testBarrier(point: string): void {
+  const dir = process.env.MARKETPLACE_LEASE_BARRIER_DIR;
+  if (!dir) return;
+  const waitPath = path.join(dir, `${point}.wait`);
+  try {
+    fs.statSync(waitPath);
+  } catch {
+    return;
+  }
+  writeAtomic(path.join(dir, `${point}.reached`), `${process.pid}\n`);
+  const goPath = path.join(dir, `${point}.go`);
+  while (true) {
+    try {
+      fs.statSync(goPath);
+      return;
+    } catch {
+      sleepSync(10);
+    }
+  }
+}
+
+export function normalizeLockOptions(
+  partialOptions: Partial<MarketplaceLockOptions>,
+): MarketplaceLockOptions {
+  const options = { ...DEFAULT_LOCK_OPTIONS, ...partialOptions };
+  if (
+    !Number.isFinite(options.staleMs) ||
+    !Number.isFinite(options.timeoutMs) ||
+    !Number.isFinite(options.heartbeatMs) ||
+    !Number.isFinite(options.retryMs) ||
+    options.staleMs <= 0 ||
+    options.timeoutMs < 0 ||
+    options.heartbeatMs <= 0 ||
+    options.retryMs <= 0 ||
+    options.heartbeatMs >= options.staleMs
+  ) {
+    throw new MarketplaceValidationError(
+      'Marketplace lock timing requires finite positive stale, heartbeat, and retry intervals; timeout must be non-negative and heartbeat must be shorter than stale timeout',
+    );
+  }
+  return {
+    staleMs: options.staleMs,
+    timeoutMs: options.timeoutMs,
+    heartbeatMs: options.heartbeatMs,
+    retryMs: options.retryMs,
+  };
+}
+
+export function generationsRoot(directory: string): string {
+  return path.join(directory, 'gen');
+}
+
+export function generationPath(directory: string, generation: string): string {
+  return path.join(directory, 'gen', generation);
+}
+
+export function heartbeatPath(directory: string, generation: string): string {
+  return path.join(generationPath(directory, generation), 'heartbeat');
+}
+
+function readLeaseMetadata(directory: string): LeaseMetadata | undefined {
+  try {
+    const value = JSON.parse(
+      fs.readFileSync(path.join(directory, 'meta.json'), 'utf8'),
+    ) as Partial<LeaseMetadata>;
+    if (
+      !Number.isInteger(value.pid) ||
+      typeof value.generation !== 'string' ||
+      value.generation.length === 0 ||
+      typeof value.token !== 'string' ||
+      value.token.length === 0
+    ) {
+      return undefined;
+    }
+    return value as LeaseMetadata;
+  } catch {
+    return undefined;
+  }
+}
+
+export function readGenerationState(
+  generationDirectory: string,
+): LeaseState | undefined {
+  const metadata = readLeaseMetadata(generationDirectory);
+  if (!metadata) return undefined;
+  try {
+    const heartbeat = fs.statSync(path.join(generationDirectory, 'heartbeat'));
+    if (!heartbeat.isFile()) return undefined;
+    if (path.basename(generationDirectory) !== metadata.generation) {
+      return undefined;
+    }
+    return { metadata, heartbeatMtimeMs: heartbeat.mtimeMs };
+  } catch {
+    return undefined;
+  }
+}
+
+export function listValidGenerations(directory: string): LeaseState[] {
+  const root = generationsRoot(directory);
+  let entries: fs.Dirent[];
+  try {
+    entries = fs.readdirSync(root, { withFileTypes: true });
+  } catch (error) {
+    if (errnoCode(error) === 'ENOENT') return [];
+    throw error;
+  }
+  const states: LeaseState[] = [];
+  for (const entry of entries) {
+    if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
+    const state = readGenerationState(path.join(root, entry.name));
+    if (state) states.push(state);
+  }
+  return states;
+}
+
+export function readLeaseState(directory: string): LeaseState | undefined {
+  const gens = listValidGenerations(directory);
+  if (gens.length !== 1) return undefined;
+  return gens[0];
+}
+
+function sameLease(
+  left: LeaseIdentity | undefined,
+  right: LeaseIdentity,
+): boolean {
+  return Boolean(
+    left && left.generation === right.generation && left.token === right.token,
+  );
+}
+
+function processIsAlive(pid: number): boolean {
+  if (pid <= 0) return false;
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+export function isHeartbeatStale(
+  state: LeaseState,
+  staleMs: number,
+  nowMs = Date.now(),
+): boolean {
+  return nowMs - state.heartbeatMtimeMs > staleMs;
+}
+
+function breakerIsAbandoned(
+  state: LeaseState,
+  staleMs: number,
+  nowMs: number,
+): boolean {
+  return (
+    isHeartbeatStale(state, staleMs, nowMs) &&
+    !processIsAlive(state.metadata.pid)
+  );
+}
+
+function creatingPid(name: string): number | undefined {
+  if (!name.startsWith(CREATING_PREFIX)) return undefined;
+  const pid = Number(name.slice(CREATING_PREFIX.length).split('.')[0]);
+  return Number.isInteger(pid) ? pid : undefined;
+}
+
+function isAbandonedCreating(
+  fullPath: string,
+  name: string,
+  staleMs: number,
+  nowMs: number,
+): boolean {
+  const pid = creatingPid(name);
+  if (pid === undefined) return false;
+  if (!processIsAlive(pid)) return true;
+  try {
+    return nowMs - fs.statSync(fullPath).mtimeMs > staleMs;
+  } catch {
+    return false;
+  }
+}
+
+function cleanAbandonedCreating(
+  directory: string,
+  staleMs: number,
+  nowMs: number,
+): void {
+  let entries: fs.Dirent[];
+  try {
+    entries = fs.readdirSync(directory, { withFileTypes: true });
+  } catch (error) {
+    if (errnoCode(error) === 'ENOENT') return;
+    throw error;
+  }
+  for (const entry of entries) {
+    if (!entry.isDirectory()) continue;
+    const fullPath = path.join(directory, entry.name);
+    if (isAbandonedCreating(fullPath, entry.name, staleMs, nowMs)) {
+      fs.rmSync(fullPath, { recursive: true, force: true });
+    }
+  }
+}
+
+function cleanAbandonedArtifacts(
+  directory: string,
+  staleMs: number,
+  nowMs: number,
+): void {
+  fs.mkdirSync(directory, { recursive: true });
+  cleanAbandonedCreating(directory, staleMs, nowMs);
+  cleanAbandonedCreating(generationsRoot(directory), staleMs, nowMs);
+}
+
+/**
+ * Delete only `gen/<expected.generation>` after an in-place identity check.
+ * A replacement lives at a different path and cannot be targeted.
+ */
+function deleteExpectedGeneration(
+  directory: string,
+  expected: LeaseIdentity,
+): boolean {
+  const target = generationPath(directory, expected.generation);
+  if (!sameLease(readGenerationState(target)?.metadata, expected)) {
+    return false;
+  }
+  fs.rmSync(target, { recursive: true, force: true });
+  return true;
+}
+
+function deleteStaleGeneration(
+  directory: string,
+  expected: LeaseIdentity,
+  staleMs: number,
+  requireDeadPid: boolean,
+  nowMs: number,
+): boolean {
+  const target = generationPath(directory, expected.generation);
+  const state = readGenerationState(target);
+  if (!sameLease(state?.metadata, expected) || !state) return false;
+  if (!isHeartbeatStale(state, staleMs, nowMs)) return false;
+  if (requireDeadPid && processIsAlive(state.metadata.pid)) return false;
+  const again = readGenerationState(target);
+  if (!sameLease(again?.metadata, expected) || !again) return false;
+  if (!isHeartbeatStale(again, staleMs, nowMs)) return false;
+  if (requireDeadPid && processIsAlive(again.metadata.pid)) return false;
+  fs.rmSync(target, { recursive: true, force: true });
+  return true;
+}
+
+function prepareGeneration(directory: string, identity: LeaseIdentity): string {
+  fs.mkdirSync(generationsRoot(directory), { recursive: true });
+  const privateDirectory = path.join(
+    directory,
+    `${CREATING_PREFIX}${process.pid}.${randomUUID()}`,
+  );
+  fs.mkdirSync(privateDirectory, { recursive: false, mode: 0o700 });
+  testBarrier('prepare-generation');
+  try {
+    writeAtomic(
+      path.join(privateDirectory, 'meta.json'),
+      `${JSON.stringify({ ...identity, pid: process.pid })}\n`,
+    );
+    writeAtomic(path.join(privateDirectory, 'heartbeat'), '');
+    syncDirectory(privateDirectory);
+    return privateDirectory;
+  } catch {
+    fs.rmSync(privateDirectory, { recursive: true, force: true });
+    throw new MarketplaceLockfileError(
+      'Failed to initialize a private marketplace lease generation',
+    );
+  }
+}
+
+function publishPreparedGeneration(
+  privateDirectory: string,
+  directory: string,
+  generation: string,
+): boolean {
+  const dest = generationPath(directory, generation);
+  try {
+    fs.renameSync(privateDirectory, dest);
+    syncDirectory(generationsRoot(directory));
+    return true;
+  } catch (error) {
+    const code = errnoCode(error);
+    if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EISDIR') {
+      return false;
+    }
+    throw error;
+  } finally {
+    try {
+      if (fs.existsSync(privateDirectory)) {
+        fs.rmSync(privateDirectory, { recursive: true, force: true });
+      }
+    } catch {
+      // An unpublished private generation is never an active lease.
+    }
+  }
+}
+
+export function publishGeneration(
+  directory: string,
+  identity: LeaseIdentity,
+): boolean {
+  const privateDirectory = prepareGeneration(directory, identity);
+  return publishPreparedGeneration(
+    privateDirectory,
+    directory,
+    identity.generation,
+  );
+}
+
+function assertFreshAndCurrent(
+  directory: string,
+  identity: LeaseIdentity,
+  staleMs: number,
+  nowMs: number,
+): void {
+  const state = readGenerationState(
+    generationPath(directory, identity.generation),
+  );
+  if (
+    !sameLease(state?.metadata, identity) ||
+    !state ||
+    isHeartbeatStale(state, staleMs, nowMs)
+  ) {
+    throw new MarketplaceLockOwnershipError(OWNERSHIP_MESSAGE);
+  }
+}
+
+function assertCurrentIdentity(
+  directory: string,
+  identity: LeaseIdentity,
+): void {
+  if (
+    !sameLease(
+      readGenerationState(generationPath(directory, identity.generation))
+        ?.metadata,
+      identity,
+    )
+  ) {
+    throw new MarketplaceLockOwnershipError(OWNERSHIP_MESSAGE);
+  }
+}
+
+function touchHeartbeat(directory: string, identity: LeaseIdentity): void {
+  try {
+    const now = new Date();
+    fs.utimesSync(heartbeatPath(directory, identity.generation), now, now);
+  } catch {
+    throw new MarketplaceLockOwnershipError(OWNERSHIP_MESSAGE);
+  }
+}
+
+function startHeartbeat(
+  directory: string,
+  identity: LeaseIdentity,
+  heartbeatMs: number,
+): ReturnType<typeof setInterval> {
+  const beat = () => {
+    try {
+      const now = new Date();
+      fs.utimesSync(heartbeatPath(directory, identity.generation), now, now);
+    } catch {
+      // Release and commit perform authoritative ownership checks.
+    }
+  };
+  beat();
+  const timer = setInterval(beat, heartbeatMs);
+  timer.unref?.();
+  return timer;
+}
+
+function newIdentity(): LeaseIdentity {
+  return {
+    generation: randomUUID(),
+    token: `${process.pid}:${randomUUID()}`,
+  };
+}
+
+function acquireBreakerLease(
+  paths: MarketplacePaths,
+  options: MarketplaceLockOptions,
+  now?: () => number,
+): BreakerLease {
+  const deadline = Date.now() + options.timeoutMs;
+  while (Date.now() <= deadline) {
+    const nowMs = lockNow(now);
+    cleanAbandonedArtifacts(paths.breakerDir, options.staleMs, nowMs);
+    for (const state of listValidGenerations(paths.breakerDir)) {
+      if (breakerIsAbandoned(state, options.staleMs, nowMs)) {
+        deleteStaleGeneration(
+          paths.breakerDir,
+          state.metadata,
+          options.staleMs,
+          true,
+          nowMs,
+        );
+      }
+    }
+    const live = listValidGenerations(paths.breakerDir).filter(
+      (state) => !breakerIsAbandoned(state, options.staleMs, nowMs),
+    );
+    if (live.length > 0) {
+      sleepSync(options.retryMs);
+      continue;
+    }
+    const identity = newIdentity();
+    if (!publishGeneration(paths.breakerDir, identity)) {
+      sleepSync(options.retryMs);
+      continue;
+    }
+    testBarrier('breaker-critical');
+    const others = listValidGenerations(paths.breakerDir).filter(
+      (state) => state.metadata.generation !== identity.generation,
+    );
+    if (others.length > 0) {
+      deleteExpectedGeneration(paths.breakerDir, identity);
+      sleepSync(options.retryMs);
+      continue;
+    }
+    const heartbeatTimer = startHeartbeat(
+      paths.breakerDir,
+      identity,
+      options.heartbeatMs,
+    );
+    return {
+      release: () => {
+        clearInterval(heartbeatTimer);
+        if (!deleteExpectedGeneration(paths.breakerDir, identity)) {
+          throw new MarketplaceLockOwnershipError(
+            'Refusing to release a compromised marketplace breaker',
+          );
+        }
+      },
+    };
+  }
+  throw new MarketplaceBusyError('Timed out waiting for marketplace breaker');
+}
+
+function createMarketplaceLease(
+  paths: MarketplacePaths,
+  identity: LeaseIdentity,
+  options: MarketplaceLockOptions,
+  now?: () => number,
+): MarketplaceLease {
+  let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
+  const assertCurrent = () => {
+    assertFreshAndCurrent(
+      paths.lockDir,
+      identity,
+      options.staleMs,
+      lockNow(now),
+    );
+  };
+  const release = () => {
+    if (heartbeatTimer) clearInterval(heartbeatTimer);
+    const breaker = acquireBreakerLease(paths, options, now);
+    try {
+      if (!deleteExpectedGeneration(paths.lockDir, identity)) {
+        throw new MarketplaceLockOwnershipError(
+          'Refusing to release a compromised marketplace lock',
+        );
+      }
+    } finally {
+      breaker.release();
+    }
+  };
+  heartbeatTimer = startHeartbeat(paths.lockDir, identity, options.heartbeatMs);
+  return {
+    ...identity,
+    assertCurrent,
+    commit: <T>(operation: () => T): T => {
+      const breaker = acquireBreakerLease(paths, options, now);
+      try {
+        assertCurrent();
+        touchHeartbeat(paths.lockDir, identity);
+        const result = operation();
+        assertCurrentIdentity(paths.lockDir, identity);
+        touchHeartbeat(paths.lockDir, identity);
+        return result;
+      } finally {
+        breaker.release();
+      }
+    },
+    release,
+  };
+}
+
+function acquireMarketplaceLeaseInternal(
+  paths: MarketplacePaths,
+  partialOptions: Partial<MarketplaceLockOptions> = {},
+  now?: () => number,
+): MarketplaceLease {
+  const options = normalizeLockOptions(partialOptions);
+  fs.mkdirSync(paths.rootDir, { recursive: true });
+  const deadline = Date.now() + options.timeoutMs;
+  while (Date.now() <= deadline) {
+    let breaker: BreakerLease | undefined;
+    try {
+      breaker = acquireBreakerLease(
+        paths,
+        {
+          ...options,
+          timeoutMs: Math.max(0, deadline - Date.now()),
+        },
+        now,
+      );
+      const nowMs = lockNow(now);
+      cleanAbandonedArtifacts(paths.lockDir, options.staleMs, nowMs);
+      for (const state of listValidGenerations(paths.lockDir)) {
+        if (isHeartbeatStale(state, options.staleMs, nowMs)) {
+          deleteStaleGeneration(
+            paths.lockDir,
+            state.metadata,
+            options.staleMs,
+            false,
+            nowMs,
+          );
+        }
+      }
+      const live = listValidGenerations(paths.lockDir).filter(
+        (state) => !isHeartbeatStale(state, options.staleMs, nowMs),
+      );
+      if (live.length === 0) {
+        const identity = newIdentity();
+        if (publishGeneration(paths.lockDir, identity)) {
+          const lease = createMarketplaceLease(paths, identity, options, now);
+          breaker.release();
+          breaker = undefined;
+          return lease;
+        }
+      }
+    } catch (error) {
+      if (!(error instanceof MarketplaceBusyError)) throw error;
+    } finally {
+      breaker?.release();
+    }
+    if (Date.now() + options.retryMs > deadline) break;
+    sleepSync(options.retryMs);
+  }
+  throw new MarketplaceBusyError('Timed out waiting for marketplace lock');
+}
+
+export function acquireMarketplaceLease(
+  paths: MarketplacePaths,
+  partialOptions: Partial<MarketplaceLockOptions> = {},
+): MarketplaceLease {
+  return acquireMarketplaceLeaseInternal(paths, partialOptions);
+}
+
+/** Test-only clock seam. Production store/lock options cannot pass `now`. */
+export function acquireMarketplaceLeaseForTests(
+  paths: MarketplacePaths,
+  partialOptions: Partial<MarketplaceLockOptions>,
+  now: () => number,
+): MarketplaceLease {
+  return acquireMarketplaceLeaseInternal(paths, partialOptions, now);
+}
+
+export function withMarketplaceLease<T>(
+  paths: MarketplacePaths,
+  operation: (lease: MarketplaceLease) => T,
+  options: Partial<MarketplaceLockOptions>,
+): T {
+  const lock = acquireMarketplaceLease(paths, options);
+  try {
+    return operation(lock);
+  } finally {
+    lock.release();
+  }
+}

+ 31 - 0
src/marketplace/paths.ts

@@ -0,0 +1,31 @@
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+export interface MarketplacePaths {
+  rootDir: string;
+  packagesDir: string;
+  lockfilePath: string;
+  lockDir: string;
+  breakerDir: string;
+  stagingDir: string;
+}
+
+export function getMarketplacePaths(rootDir?: string): MarketplacePaths {
+  const root =
+    rootDir ??
+    join(
+      process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'),
+      'opencode',
+      'storage',
+      'oh-my-opencode-slim',
+      'marketplace',
+    );
+  return {
+    rootDir: root,
+    packagesDir: join(root, 'packages'),
+    lockfilePath: join(root, 'lock.json'),
+    lockDir: join(root, 'marketplace.lock'),
+    breakerDir: join(root, 'marketplace.lock.breaker'),
+    stagingDir: join(root, '.staging'),
+  };
+}

+ 256 - 0
src/marketplace/preflight.test.ts

@@ -0,0 +1,256 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { RuntimeConfig } from '../config/runtime';
+import { discoverOnDiskSkillNames, discoverPreflightMcps } from './preflight';
+import { parseSkillFrontmatterName } from './skill-frontmatter';
+
+const previousConfigHome = process.env.XDG_CONFIG_HOME;
+const previousConfigContent = process.env.OPENCODE_CONFIG_CONTENT;
+const previousTestHome = process.env.OPENCODE_TEST_HOME;
+
+afterEach(() => {
+  if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
+  else process.env.XDG_CONFIG_HOME = previousConfigHome;
+  if (previousConfigContent === undefined) {
+    delete process.env.OPENCODE_CONFIG_CONTENT;
+  } else process.env.OPENCODE_CONFIG_CONTENT = previousConfigContent;
+  if (previousTestHome === undefined) delete process.env.OPENCODE_TEST_HOME;
+  else process.env.OPENCODE_TEST_HOME = previousTestHome;
+});
+
+function writeSkillFile(path: string, body: string): void {
+  mkdirSync(join(path, '..'), { recursive: true });
+  writeFileSync(path, body);
+}
+
+describe('parseSkillFrontmatterName', () => {
+  test('accepts quoted names and YAML comments', () => {
+    expect(
+      parseSkillFrontmatterName(`---
+name: "quoted-skill"
+description: Test skill. # comment
+---
+
+# body
+`),
+    ).toBe('quoted-skill');
+  });
+
+  test('accepts block scalar names', () => {
+    expect(
+      parseSkillFrontmatterName(`---
+name: |-
+  block-skill
+description: Test skill.
+---
+
+# body
+`),
+    ).toBe('block-skill');
+  });
+
+  test('rejects non-string names', () => {
+    expect(
+      parseSkillFrontmatterName(`---
+name: 123
+description: Test skill.
+---
+
+# body
+`),
+    ).toBeUndefined();
+  });
+
+  test('rejects non-string descriptions', () => {
+    expect(
+      parseSkillFrontmatterName(`---
+name: typed-skill
+description: true
+---
+
+# body
+`),
+    ).toBeUndefined();
+  });
+});
+
+describe('OpenCode preflight discovery parity', () => {
+  test('JSONC wins over JSON in the same project directory', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-preflight-'));
+    const configHome = join(root, 'config');
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = configHome;
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'json-mcp': { type: 'remote', url: 'http://127.0.0.1/json' },
+            'shared-mcp': { type: 'remote', url: 'http://127.0.0.1/json' },
+          },
+        }),
+      );
+      writeFileSync(
+        join(project, '.opencode', 'opencode.jsonc'),
+        `{
+          // jsonc should win
+          "mcp": {
+            "jsonc-mcp": { "type": "remote", "url": "http://127.0.0.1/jsonc" },
+            "shared-mcp": { "type": "remote", "url": "http://127.0.0.1/jsonc" }
+          }
+        }`,
+      );
+      RuntimeConfig.reset(project);
+      const names = discoverPreflightMcps(
+        RuntimeConfig.init(project, {}),
+        project,
+      );
+      expect(names).toContain('jsonc-mcp');
+      expect(names).toContain('json-mcp');
+      expect(names).toContain('shared-mcp');
+      expect(names).not.toContain('http://127.0.0.1/json');
+      const projectRoot = mkdtempSync(join(tmpdir(), 'marketplace-root-'));
+      writeFileSync(
+        join(projectRoot, 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'root-json': { type: 'remote', url: 'http://127.0.0.1/json' },
+          },
+        }),
+      );
+      writeFileSync(
+        join(projectRoot, 'opencode.jsonc'),
+        `{
+          "mcp": {
+            "root-jsonc": { "type": "remote", "url": "http://127.0.0.1/jsonc" },
+            "root-json": { "enabled": false }
+          }
+        }`,
+      );
+      RuntimeConfig.reset(projectRoot);
+      const rootNames = discoverPreflightMcps(
+        RuntimeConfig.init(projectRoot, {}),
+        projectRoot,
+      );
+      expect(rootNames).toContain('root-jsonc');
+      expect(rootNames).not.toContain('root-json');
+      rmSync(projectRoot, { recursive: true, force: true });
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('parses OPENCODE_CONFIG_CONTENT as JSONC', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-preflight-'));
+    try {
+      process.env.XDG_CONFIG_HOME = join(root, 'config');
+      process.env.OPENCODE_CONFIG_CONTENT = `{
+        // comment
+        "mcp": {
+          "content-mcp": { "type": "remote", "url": "http://127.0.0.1/mcp", },
+        }
+      }`;
+      RuntimeConfig.reset(root);
+      expect(
+        discoverPreflightMcps(RuntimeConfig.init(root, {}), root),
+      ).toContain('content-mcp');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects MCP definitions that fail the host schema', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-preflight-'));
+    const project = join(root, 'project');
+    try {
+      process.env.XDG_CONFIG_HOME = join(root, 'config');
+      mkdirSync(join(project, '.opencode'), { recursive: true });
+      writeFileSync(
+        join(project, '.opencode', 'opencode.json'),
+        JSON.stringify({
+          mcp: {
+            'timeout-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+              timeout: 0,
+            },
+            'env-mcp': {
+              type: 'local',
+              command: ['npx', 'server'],
+              environment: { TOKEN: 1 },
+            },
+            'header-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+              headers: { Authorization: 1 },
+            },
+            'oauth-mcp': {
+              type: 'remote',
+              url: 'http://127.0.0.1/mcp',
+              oauth: true,
+            },
+          },
+        }),
+      );
+      RuntimeConfig.reset(project);
+      const names = discoverPreflightMcps(
+        RuntimeConfig.init(project, {}),
+        project,
+      );
+      expect(names).not.toContain('timeout-mcp');
+      expect(names).not.toContain('env-mcp');
+      expect(names).not.toContain('header-mcp');
+      expect(names).not.toContain('oauth-mcp');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('discovers YAML block-scalar skills and rejects typed frontmatter', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-preflight-'));
+    const skillRoot = join(root, 'skills');
+    try {
+      process.env.XDG_CONFIG_HOME = join(root, 'config');
+      process.env.OPENCODE_TEST_HOME = root;
+      writeSkillFile(
+        join(skillRoot, 'block', 'SKILL.md'),
+        `---
+name: |-
+  block-skill
+description: Test skill.
+---
+
+# body
+`,
+      );
+      writeSkillFile(
+        join(skillRoot, 'bad-name', 'SKILL.md'),
+        `---
+name: 123
+description: Test skill.
+---
+
+# body
+`,
+      );
+      writeSkillFile(
+        join(skillRoot, 'bad-desc', 'SKILL.md'),
+        `---
+name: desc-skill
+description: ["not", "a", "string"]
+---
+
+# body
+`,
+      );
+      expect(discoverOnDiskSkillNames(root, [skillRoot])).toEqual([
+        'block-skill',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 400 - 0
src/marketplace/preflight.ts

@@ -0,0 +1,400 @@
+import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, isAbsolute, join } from 'node:path';
+import { z } from 'zod';
+import { parseJsonConfigFile, parseJsoncText } from '../config/jsonc';
+import { deepMerge } from '../config/loader';
+import type { RuntimeConfig } from '../config/runtime';
+import { parseSkillFrontmatterName } from './skill-frontmatter';
+
+const BUILTIN_MCPS = ['context7', 'gh_grep'] as const;
+const CLAUDE_EXTERNAL_DIR = '.claude';
+const AGENTS_EXTERNAL_DIR = '.agents';
+
+function truthyEnv(name: string): boolean {
+  const value = process.env[name]?.toLowerCase();
+  return value === 'true' || value === '1';
+}
+
+function opencodeHome(): string {
+  const override = process.env.OPENCODE_TEST_HOME?.trim();
+  return override && override.length > 0 ? override : homedir();
+}
+
+function xdgConfigHome(): string {
+  const xdg = process.env.XDG_CONFIG_HOME?.trim();
+  return xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
+}
+
+function isDirectory(path: string): boolean {
+  try {
+    return statSync(path).isDirectory();
+  } catch {
+    return false;
+  }
+}
+
+function walkUp(start: string, stop?: string): string[] {
+  const directories: string[] = [];
+  let current = start;
+  while (true) {
+    directories.push(current);
+    if (stop && current === stop) break;
+    const parent = dirname(current);
+    if (parent === current) break;
+    current = parent;
+  }
+  return directories;
+}
+
+function gitWorktree(directory: string): string | undefined {
+  for (const current of walkUp(directory)) {
+    try {
+      statSync(join(current, '.git'));
+      return current;
+    } catch {
+      // Continue walking toward the filesystem root.
+    }
+  }
+  return undefined;
+}
+
+function existingNamedDirectories(
+  start: string,
+  name: string,
+  stop?: string,
+): string[] {
+  const found: string[] = [];
+  for (const current of walkUp(start, stop)) {
+    const candidate = join(current, name);
+    if (isDirectory(candidate)) found.push(candidate);
+  }
+  return found;
+}
+
+function unique(paths: readonly string[]): string[] {
+  return [...new Set(paths)];
+}
+
+function opencodeConfigDirectories(projectDirectory?: string): string[] {
+  const directories = [join(xdgConfigHome(), 'opencode')];
+  if (projectDirectory && !truthyEnv('OPENCODE_DISABLE_PROJECT_CONFIG')) {
+    directories.push(
+      ...existingNamedDirectories(
+        projectDirectory,
+        '.opencode',
+        gitWorktree(projectDirectory),
+      ),
+    );
+  }
+  const homeOpencode = join(opencodeHome(), '.opencode');
+  if (isDirectory(homeOpencode)) directories.push(homeOpencode);
+  const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
+  if (configDir) directories.push(configDir);
+  return unique(directories);
+}
+
+function projectConfigFiles(projectDirectory?: string): string[] {
+  if (!projectDirectory || truthyEnv('OPENCODE_DISABLE_PROJECT_CONFIG')) {
+    return [];
+  }
+  const files: string[] = [];
+  for (const current of walkUp(
+    projectDirectory,
+    gitWorktree(projectDirectory),
+  ).reverse()) {
+    files.push(join(current, 'opencode.json'), join(current, 'opencode.jsonc'));
+  }
+  return files;
+}
+
+function directoryConfigFiles(projectDirectory?: string): string[] {
+  const files: string[] = [];
+  const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
+  for (const directory of opencodeConfigDirectories(projectDirectory)) {
+    if (directory.endsWith('.opencode') || directory === configDir) {
+      files.push(
+        join(directory, 'opencode.json'),
+        join(directory, 'opencode.jsonc'),
+      );
+    }
+  }
+  return files;
+}
+
+function parseConfigObject(filePath: string): Record<string, unknown> | null {
+  const parsed = parseJsonConfigFile(filePath).config;
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    return null;
+  }
+  return parsed as Record<string, unknown>;
+}
+
+function mergeConfigLayer(
+  target: Record<string, unknown>,
+  source: Record<string, unknown>,
+): void {
+  const mcp = source.mcp;
+  if (mcp && typeof mcp === 'object' && !Array.isArray(mcp)) {
+    target.mcp = deepMerge(
+      (target.mcp as Record<string, unknown> | undefined) ?? {},
+      mcp as Record<string, unknown>,
+    );
+  }
+  const skills = source.skills;
+  if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
+    const current =
+      (target.skills as Record<string, unknown> | undefined) ?? {};
+    const next = skills as { paths?: unknown; urls?: unknown };
+    target.skills = {
+      ...current,
+      ...(Array.isArray(next.paths) ? { paths: next.paths } : {}),
+      ...(Array.isArray(next.urls) ? { urls: next.urls } : {}),
+    };
+  }
+}
+
+export function loadMergedOpenCodeConfig(
+  projectDirectory?: string,
+): Record<string, unknown> {
+  const merged: Record<string, unknown> = {};
+  const globalDir = join(xdgConfigHome(), 'opencode');
+  for (const filePath of [
+    join(globalDir, 'config.json'),
+    join(globalDir, 'opencode.json'),
+    join(globalDir, 'opencode.jsonc'),
+  ]) {
+    const parsed = parseConfigObject(filePath);
+    if (parsed) mergeConfigLayer(merged, parsed);
+  }
+  const customFile = process.env.OPENCODE_CONFIG?.trim();
+  if (customFile) {
+    const parsed = parseConfigObject(customFile);
+    if (parsed) mergeConfigLayer(merged, parsed);
+  }
+  for (const filePath of projectConfigFiles(projectDirectory)) {
+    const parsed = parseConfigObject(filePath);
+    if (parsed) mergeConfigLayer(merged, parsed);
+  }
+  for (const filePath of directoryConfigFiles(projectDirectory)) {
+    const parsed = parseConfigObject(filePath);
+    if (parsed) mergeConfigLayer(merged, parsed);
+  }
+  const content = process.env.OPENCODE_CONFIG_CONTENT?.trim();
+  if (content) {
+    try {
+      const parsed = parseJsoncText(content);
+      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+        mergeConfigLayer(merged, parsed as Record<string, unknown>);
+      }
+    } catch {
+      // Invalid OPENCODE_CONFIG_CONTENT is ignored for preflight.
+    }
+  }
+  return merged;
+}
+
+function entryKind(
+  fullPath: string,
+  isSymbolicLink: boolean,
+  isDirectoryEntry: boolean,
+  isFile: boolean,
+): 'dir' | 'file' | 'other' {
+  if (isSymbolicLink) {
+    try {
+      const target = statSync(fullPath);
+      if (target.isDirectory()) return 'dir';
+      if (target.isFile()) return 'file';
+    } catch {
+      return 'other';
+    }
+    return 'other';
+  }
+  if (isDirectoryEntry) return 'dir';
+  if (isFile) return 'file';
+  return 'other';
+}
+
+function addSkillName(skillFile: string, names: Set<string>): void {
+  try {
+    const name = parseSkillFrontmatterName(readFileSync(skillFile, 'utf8'));
+    if (name) names.add(name);
+  } catch {
+    // Unreadable or invalid SKILL.md files are not available skills.
+  }
+}
+
+function walkSkillTree(
+  directory: string,
+  names: Set<string>,
+  includeHidden = false,
+): void {
+  const visited = new Set<string>();
+  const walk = (current: string): void => {
+    let real: string;
+    try {
+      real = realpathSync(current);
+    } catch {
+      return;
+    }
+    if (visited.has(real)) return;
+    visited.add(real);
+    try {
+      for (const entry of readdirSync(current, { withFileTypes: true })) {
+        if (!includeHidden && entry.name.startsWith('.')) continue;
+        const fullPath = join(current, entry.name);
+        const kind = entryKind(
+          fullPath,
+          entry.isSymbolicLink(),
+          entry.isDirectory(),
+          entry.isFile(),
+        );
+        if (kind === 'dir') {
+          walk(fullPath);
+          continue;
+        }
+        if (kind === 'file' && entry.name === 'SKILL.md') {
+          addSkillName(fullPath, names);
+        }
+      }
+    } catch {
+      return;
+    }
+  };
+  if (isDirectory(directory)) walk(directory);
+}
+
+function configuredSkillPaths(
+  projectDirectory: string | undefined,
+  merged: Record<string, unknown>,
+): string[] {
+  const skills = merged.skills as { paths?: unknown } | undefined;
+  if (!Array.isArray(skills?.paths)) return [];
+  const home = opencodeHome();
+  const paths: string[] = [];
+  for (const item of skills.paths) {
+    if (typeof item !== 'string' || item.trim().length === 0) continue;
+    const expanded = item.startsWith('~/') ? join(home, item.slice(2)) : item;
+    const resolved = isAbsolute(expanded)
+      ? expanded
+      : join(projectDirectory ?? process.cwd(), expanded);
+    if (isDirectory(resolved)) paths.push(resolved);
+  }
+  return paths;
+}
+
+function externalSkillRoots(projectDirectory?: string): string[] {
+  if (truthyEnv('OPENCODE_DISABLE_EXTERNAL_SKILLS')) return [];
+  const roots: string[] = [];
+  const names: string[] = [AGENTS_EXTERNAL_DIR];
+  if (
+    !truthyEnv('OPENCODE_DISABLE_CLAUDE_CODE') &&
+    !truthyEnv('OPENCODE_DISABLE_CLAUDE_CODE_SKILLS')
+  ) {
+    names.unshift(CLAUDE_EXTERNAL_DIR);
+  }
+  const home = opencodeHome();
+  for (const name of names) {
+    const globalRoot = join(home, name);
+    if (isDirectory(globalRoot)) roots.push(globalRoot);
+  }
+  if (projectDirectory) {
+    const stop = gitWorktree(projectDirectory);
+    for (const name of names) {
+      roots.push(...existingNamedDirectories(projectDirectory, name, stop));
+    }
+  }
+  return unique(roots);
+}
+
+export function discoverOnDiskSkillNames(
+  projectDirectory?: string,
+  extraDirectories: readonly string[] = [],
+): string[] {
+  const names = new Set<string>();
+  const merged = loadMergedOpenCodeConfig(projectDirectory);
+  for (const root of externalSkillRoots(projectDirectory)) {
+    walkSkillTree(join(root, 'skills'), names, true);
+  }
+  for (const directory of opencodeConfigDirectories(projectDirectory)) {
+    walkSkillTree(join(directory, 'skill'), names);
+    walkSkillTree(join(directory, 'skills'), names);
+  }
+  for (const directory of configuredSkillPaths(projectDirectory, merged)) {
+    walkSkillTree(directory, names);
+  }
+  for (const directory of extraDirectories) {
+    walkSkillTree(directory, names);
+  }
+  return [...names];
+}
+
+export function discoverPreflightSkills(
+  runtime: RuntimeConfig,
+  projectDirectory?: string,
+  extraDirectories: readonly string[] = [],
+): string[] {
+  const disabled = new Set(runtime.disabledSkills);
+  return discoverOnDiskSkillNames(projectDirectory, extraDirectories).filter(
+    (name) => !disabled.has(name),
+  );
+}
+
+const PositiveTimeoutSchema = z.number().int().positive();
+const StringMapSchema = z.record(z.string(), z.string());
+const McpOAuthSchema = z.object({
+  clientId: z.string().optional(),
+  clientSecret: z.string().optional(),
+  scope: z.string().optional(),
+  redirectUri: z.string().optional(),
+});
+const McpLocalSchema = z.object({
+  type: z.literal('local'),
+  command: z.array(z.string()),
+  environment: StringMapSchema.optional(),
+  enabled: z.boolean().optional(),
+  timeout: PositiveTimeoutSchema.optional(),
+});
+const McpRemoteSchema = z.object({
+  type: z.literal('remote'),
+  url: z.string(),
+  enabled: z.boolean().optional(),
+  headers: StringMapSchema.optional(),
+  oauth: z.union([McpOAuthSchema, z.literal(false)]).optional(),
+  timeout: PositiveTimeoutSchema.optional(),
+});
+const McpDefinitionSchema = z.discriminatedUnion('type', [
+  McpLocalSchema,
+  McpRemoteSchema,
+]);
+
+function isValidMcpDefinition(value: unknown): boolean {
+  const parsed = McpDefinitionSchema.safeParse(value);
+  return parsed.success && parsed.data.enabled !== false;
+}
+
+export function discoverOnDiskOpenCodeMcps(
+  projectDirectory?: string,
+): Record<string, unknown> {
+  const mcp = loadMergedOpenCodeConfig(projectDirectory).mcp;
+  if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)) return {};
+  return mcp as Record<string, unknown>;
+}
+
+export function discoverPreflightMcps(
+  runtime: RuntimeConfig,
+  projectDirectory?: string,
+): string[] {
+  const disabled = new Set(runtime.disabledMcps);
+  const names = new Set<string>();
+  for (const name of BUILTIN_MCPS) {
+    if (!disabled.has(name)) names.add(name);
+  }
+  for (const [name, definition] of Object.entries(
+    discoverOnDiskOpenCodeMcps(projectDirectory),
+  )) {
+    if (disabled.has(name) || !isValidMcpDefinition(definition)) continue;
+    names.add(name);
+  }
+  return [...names];
+}

+ 158 - 0
src/marketplace/schemas.test.ts

@@ -0,0 +1,158 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  MarketplaceAgentManifestSchema,
+  MarketplacePackageManifestSchema,
+  MarketplaceProfileManifestSchema,
+} from './schemas';
+
+const common = {
+  schemaVersion: 1 as const,
+  id: 'community/example',
+  version: '1.0.0',
+  displayName: 'Example',
+  description: 'An example package',
+  instructions: 'Follow these instructions.',
+  author: { name: 'Example Community' },
+  tags: ['example'],
+  license: 'MIT',
+  compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+  routing: {
+    description: 'Explore example code.',
+    keywords: ['example'],
+    delegation: { when: 'When exploration is needed.', preferredRoles: [] },
+  },
+  requirements: {
+    skills: { required: [], optional: [] },
+    mcps: { required: [], optional: [] },
+  },
+  capabilities: { tools: [], permissions: [] },
+};
+
+describe('marketplace manifest schemas', () => {
+  test('accept only data-only agent and profile manifests', () => {
+    expect(
+      MarketplaceAgentManifestSchema.safeParse({
+        ...common,
+        kind: 'agent',
+        baseRole: 'fixer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(true);
+    expect(
+      MarketplaceProfileManifestSchema.safeParse({
+        ...common,
+        kind: 'profile',
+        targetRole: 'oracle',
+        instructionMode: 'append',
+        overrides: {},
+      }).success,
+    ).toBe(true);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        kind: 'agent',
+        baseRole: 'orchestrator',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+  });
+
+  test('rejects executable or non-canonical manifest fields', () => {
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+        scripts: { install: 'rm -rf /' },
+      }).success,
+    ).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        id: 'Community/Example',
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+        version: '^1.0.0',
+      }).success,
+    ).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: { promptSuffix: 'not canonical' },
+      }).success,
+    ).toBe(false);
+  });
+
+  test('rejects duplicate capabilities', () => {
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        capabilities: { tools: ['read', 'read'], permissions: [] },
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        capabilities: {
+          tools: [],
+          permissions: ['network.fetch', 'network.fetch'],
+        },
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+  });
+
+  test('rejects overlapping required and optional requirements', () => {
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        requirements: {
+          skills: { required: ['code-search'], optional: ['code-search'] },
+          mcps: { required: [], optional: [] },
+        },
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        requirements: {
+          skills: { required: [], optional: [] },
+          mcps: { required: ['crawl4ai'], optional: ['crawl4ai'] },
+        },
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+  });
+});

+ 283 - 0
src/marketplace/schemas.ts

@@ -0,0 +1,283 @@
+import { isAbsolute } from 'node:path';
+import { valid, validRange } from 'semver';
+import { z } from 'zod';
+import { SUPPORTED_SPECIALIST_ROLES } from '../config/agent-roles';
+
+export const MARKETPLACE_MANIFEST_SCHEMA_VERSION = 1 as const;
+export const MARKETPLACE_LOCKFILE_SCHEMA_VERSION = 1 as const;
+export const MARKETPLACE_ROLE_CONTRACT_VERSION = '1.0.0' as const;
+export const MARKETPLACE_DIGEST_DOMAIN = 'marketplace-bundle-v1' as const;
+
+const packageIdPattern =
+  /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/;
+
+export const MarketplacePackageIdSchema = z
+  .string()
+  .trim()
+  .regex(
+    packageIdPattern,
+    'Expected a canonical package ID such as author/name',
+  );
+
+export const MarketplaceVersionSchema = z
+  .string()
+  .refine(
+    (version) => valid(version) !== null,
+    'Expected an exact semantic version',
+  );
+
+export const MarketplaceRoleSchema = z.enum(SUPPORTED_SPECIALIST_ROLES);
+
+const BoundedTextSchema = (max: number) => z.string().trim().min(1).max(max);
+
+export const MarketplaceAuthorSchema = z
+  .object({
+    name: BoundedTextSchema(120),
+    email: z.string().email().optional(),
+    url: z.string().url().optional(),
+  })
+  .strict();
+
+const UniqueBoundedStringArraySchema: z.ZodType<string[]> = z
+  .array(z.string().trim().min(1).max(64))
+  .max(32)
+  .refine((values) => new Set(values).size === values.length, {
+    message: 'Values must be unique',
+  });
+
+function uniqueCapabilityArray<T extends z.ZodTypeAny>(
+  itemSchema: T,
+  max: number,
+) {
+  return z
+    .array(itemSchema)
+    .max(max)
+    .refine((values) => new Set(values).size === values.length, {
+      message: 'Values must be unique',
+    });
+}
+
+const RequirementSetSchema = z
+  .object({
+    required: UniqueBoundedStringArraySchema.default([]),
+    optional: UniqueBoundedStringArraySchema.default([]),
+  })
+  .strict();
+
+export const MarketplaceRequirementsSchema = z
+  .object({
+    skills: RequirementSetSchema.default({ required: [], optional: [] }),
+    mcps: RequirementSetSchema.default({ required: [], optional: [] }),
+  })
+  .strict()
+  .superRefine((requirements, ctx) => {
+    if (
+      requirements.skills.required.some((skill) =>
+        requirements.skills.optional.includes(skill),
+      )
+    ) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['skills'],
+        message: 'Required and optional skills must not overlap',
+      });
+    }
+    if (
+      requirements.mcps.required.some((mcp) =>
+        requirements.mcps.optional.includes(mcp),
+      )
+    ) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['mcps'],
+        message: 'Required and optional MCPs must not overlap',
+      });
+    }
+  });
+
+export const MarketplaceCapabilityRequestSchema = z
+  .object({
+    tools: uniqueCapabilityArray(
+      z.enum([
+        'read',
+        'glob',
+        'grep',
+        'ast_grep_search',
+        'webfetch',
+        'websearch',
+      ]),
+      6,
+    ).default([]),
+    permissions: uniqueCapabilityArray(
+      z.enum([
+        'filesystem.read',
+        'filesystem.write',
+        'network.fetch',
+        'session.delegate',
+      ]),
+      4,
+    ).default([]),
+  })
+  .strict()
+  .default({ tools: [], permissions: [] });
+
+export const MarketplaceCompatibilitySchema = z
+  .object({
+    plugin: BoundedTextSchema(64).refine(
+      (range) => validRange(range) !== null,
+      'Expected a valid plugin semantic-version range',
+    ),
+    roleContract: BoundedTextSchema(64).refine(
+      (range) => validRange(range) !== null,
+      'Expected a valid role-contract semantic-version range',
+    ),
+  })
+  .strict();
+
+export const MarketplaceRoutingSchema = z
+  .object({
+    description: BoundedTextSchema(500),
+    keywords: UniqueBoundedStringArraySchema.default([]),
+    delegation: z
+      .object({
+        when: BoundedTextSchema(500),
+        preferredRoles: z.array(MarketplaceRoleSchema).max(6).default([]),
+      })
+      .strict(),
+  })
+  .strict();
+
+export const MarketplaceAgentOverridesSchema = z
+  .object({
+    model: z.string().trim().min(1).max(200).optional(),
+    variant: z.string().trim().min(1).max(100).optional(),
+    temperature: z.number().min(0).max(2).optional(),
+    displayName: BoundedTextSchema(120).optional(),
+    description: BoundedTextSchema(500).optional(),
+  })
+  .strict()
+  .default({});
+
+const ManifestBaseSchema = z.object({
+  schemaVersion: z
+    .literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION)
+    .default(MARKETPLACE_MANIFEST_SCHEMA_VERSION),
+  id: MarketplacePackageIdSchema,
+  version: MarketplaceVersionSchema,
+  displayName: BoundedTextSchema(120),
+  description: BoundedTextSchema(1000),
+  instructions: BoundedTextSchema(100_000),
+  author: MarketplaceAuthorSchema,
+  tags: UniqueBoundedStringArraySchema,
+  license: BoundedTextSchema(64),
+  compatibility: MarketplaceCompatibilitySchema,
+  routing: MarketplaceRoutingSchema,
+  requirements: MarketplaceRequirementsSchema,
+  capabilities: MarketplaceCapabilityRequestSchema,
+});
+
+export const MarketplaceAgentManifestSchema = ManifestBaseSchema.extend({
+  kind: z.literal('agent'),
+  baseRole: MarketplaceRoleSchema,
+  agentName: z
+    .string()
+    .trim()
+    .regex(/^[a-z][a-z0-9_-]{0,63}$/, 'Expected a valid agent name'),
+  overrides: MarketplaceAgentOverridesSchema,
+}).strict();
+
+export const MarketplaceProfileManifestSchema = ManifestBaseSchema.extend({
+  kind: z.literal('profile'),
+  targetRole: MarketplaceRoleSchema,
+  instructionMode: z.enum(['append', 'replace']),
+  overrides: MarketplaceAgentOverridesSchema,
+}).strict();
+
+export const MarketplacePackageManifestSchema = z.discriminatedUnion('kind', [
+  MarketplaceAgentManifestSchema,
+  MarketplaceProfileManifestSchema,
+]);
+
+/**
+ * A package bundle deliberately contains only its manifest. There is no
+ * executable entrypoint, hook, script, or arbitrary file payload in V3.
+ */
+export const MarketplacePackageBundleSchema = z
+  .object({ manifest: MarketplacePackageManifestSchema })
+  .strict();
+
+export const MarketplaceLocalSourceSchema = z
+  .object({
+    kind: z.literal('local'),
+    path: z
+      .string()
+      .trim()
+      .min(1)
+      .refine(isAbsolute, 'Local marketplace source paths must be absolute'),
+  })
+  .strict();
+
+export const MarketplaceRegistrySourceSchema = z
+  .object({
+    kind: z.literal('registry'),
+    registry: z.string().trim().min(1).max(200),
+    packageUrl: z.string().url().optional(),
+  })
+  .strict();
+
+export const MarketplaceInMemorySourceSchema = z
+  .object({
+    kind: z.literal('in-memory'),
+    label: BoundedTextSchema(120),
+  })
+  .strict();
+
+export const MarketplaceSourceSchema = z.discriminatedUnion('kind', [
+  MarketplaceLocalSourceSchema,
+  MarketplaceRegistrySourceSchema,
+  MarketplaceInMemorySourceSchema,
+]);
+
+export const MarketplaceDigestSchema = z
+  .object({
+    algorithm: z.literal('sha256'),
+    domain: z.literal(MARKETPLACE_DIGEST_DOMAIN),
+    value: z.string().regex(/^[0-9a-f]{64}$/),
+  })
+  .strict();
+
+export const MarketplaceLockEntrySchema = z
+  .object({
+    manifestSchemaVersion: z.literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION),
+    manifestVersion: MarketplaceVersionSchema,
+    source: MarketplaceSourceSchema,
+    digest: MarketplaceDigestSchema,
+  })
+  .strict();
+
+export const MarketplaceLockfileSchema = z
+  .object({
+    schemaVersion: z.literal(MARKETPLACE_LOCKFILE_SCHEMA_VERSION),
+    packages: z.record(MarketplacePackageIdSchema, MarketplaceLockEntrySchema),
+  })
+  .strict();
+
+export type MarketplacePackageId = z.infer<typeof MarketplacePackageIdSchema>;
+export type MarketplaceVersion = z.infer<typeof MarketplaceVersionSchema>;
+export type MarketplaceRole = z.infer<typeof MarketplaceRoleSchema>;
+export type MarketplaceAgentManifest = z.infer<
+  typeof MarketplaceAgentManifestSchema
+>;
+export type MarketplaceProfileManifest = z.infer<
+  typeof MarketplaceProfileManifestSchema
+>;
+export type MarketplacePackageManifest = z.infer<
+  typeof MarketplacePackageManifestSchema
+>;
+export type MarketplacePackageBundle = z.infer<
+  typeof MarketplacePackageBundleSchema
+>;
+export type MarketplaceSource = z.infer<typeof MarketplaceSourceSchema>;
+export type MarketplaceDigest = z.infer<typeof MarketplaceDigestSchema>;
+export type MarketplaceLockEntry = z.infer<typeof MarketplaceLockEntrySchema>;
+export type MarketplaceLockfile = z.infer<typeof MarketplaceLockfileSchema>;

+ 160 - 0
src/marketplace/service.ts

@@ -0,0 +1,160 @@
+import { readFileSync, realpathSync } from 'node:fs';
+import type { MarketplaceCompatibilityOptions } from './compatibility';
+import {
+  type MarketplaceConfigReference,
+  readMarketplaceConfigReferences,
+} from './config-references';
+import {
+  MarketplaceActivationReferenceError,
+  MarketplaceValidationError,
+} from './errors';
+import { normalizeMarketplacePackageId } from './ids';
+import {
+  type MarketplacePackageBundle,
+  MarketplacePackageBundleSchema,
+  MarketplacePackageManifestSchema,
+  type MarketplaceSource,
+} from './schemas';
+import {
+  MarketplaceStore,
+  type MarketplaceStoreOptions,
+  type MarketplaceVerification,
+  type StoredMarketplacePackage,
+} from './store';
+
+export interface MarketplaceServiceOptions
+  extends MarketplaceStoreOptions,
+    MarketplaceCompatibilityOptions {
+  projectDir?: string;
+}
+
+export interface MarketplaceRemoveOptions {
+  force?: boolean;
+}
+
+function parseBundle(value: unknown): MarketplacePackageBundle {
+  const candidate =
+    typeof value === 'object' && value !== null && 'manifest' in value
+      ? value
+      : { manifest: value };
+  const withDefaults =
+    'manifest' in candidate ? candidate : { manifest: candidate };
+  const result = MarketplacePackageBundleSchema.safeParse(withDefaults);
+  if (!result.success) {
+    throw new MarketplaceValidationError(result.error.message);
+  }
+  return result.data;
+}
+
+function sourceForImport(filePath: string): MarketplaceSource {
+  return { kind: 'local', path: realpathSync(filePath) };
+}
+
+function referenceMessage(
+  packageId: string,
+  references: MarketplaceConfigReference[],
+): string {
+  const locations = references
+    .filter((reference) => reference.packageId === packageId)
+    .map(
+      (reference) =>
+        `${reference.configPath} (preset ${reference.presetName}, ${reference.target})`,
+    );
+  return `Cannot remove referenced package ${packageId}: ${locations.join('; ')}. Deactivate it first or use --force.`;
+}
+
+export class MarketplaceService {
+  readonly store: MarketplaceStore;
+  readonly projectDir: string;
+
+  constructor(options: MarketplaceServiceOptions = {}) {
+    this.store = new MarketplaceStore(options);
+    this.projectDir = options.projectDir ?? process.cwd();
+  }
+
+  install(
+    input: MarketplacePackageBundle,
+    source?: MarketplaceSource,
+  ): StoredMarketplacePackage {
+    const bundle = parseBundle(input);
+    return this.store.install(bundle, source);
+  }
+
+  installFile(filePath: string): StoredMarketplacePackage {
+    return this.install(
+      parseBundleFromFile(filePath),
+      sourceForImport(filePath),
+    );
+  }
+
+  update(
+    input: MarketplacePackageBundle,
+    source?: MarketplaceSource,
+  ): StoredMarketplacePackage {
+    const bundle = parseBundle(input);
+    return this.store.update(bundle, source);
+  }
+
+  updateFile(filePath: string): StoredMarketplacePackage {
+    return this.update(
+      parseBundleFromFile(filePath),
+      sourceForImport(filePath),
+    );
+  }
+
+  list(): StoredMarketplacePackage[] {
+    return this.store.list();
+  }
+
+  show(id: string): StoredMarketplacePackage {
+    return this.store.show(normalizeMarketplacePackageId(id));
+  }
+
+  verify(id?: string): MarketplaceVerification[] {
+    if (id) return [this.store.verify(normalizeMarketplacePackageId(id))];
+    return this.store.verifyAll();
+  }
+
+  remove(id: string, options: MarketplaceRemoveOptions = {}): void {
+    const normalizedId = normalizeMarketplacePackageId(id);
+    this.store.remove(
+      normalizedId,
+      options.force
+        ? undefined
+        : () => {
+            const references = readMarketplaceConfigReferences(this.projectDir);
+            if (
+              references.some(
+                (reference) => reference.packageId === normalizedId,
+              )
+            ) {
+              throw new MarketplaceActivationReferenceError(
+                referenceMessage(normalizedId, references),
+              );
+            }
+          },
+    );
+  }
+}
+
+export function parseBundleFromFile(
+  filePath: string,
+): MarketplacePackageBundle {
+  let value: unknown;
+  try {
+    value = JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
+  } catch (error) {
+    throw new MarketplaceValidationError(
+      `Cannot read package file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
+    );
+  }
+  return parseBundle(value);
+}
+
+export function validateMarketplaceManifest(value: unknown) {
+  const result = MarketplacePackageManifestSchema.safeParse(value);
+  if (!result.success) {
+    throw new MarketplaceValidationError(result.error.message);
+  }
+  return result.data;
+}

+ 81 - 0
src/marketplace/skill-frontmatter.ts

@@ -0,0 +1,81 @@
+import matter from 'gray-matter';
+
+/**
+ * OpenCode-compatible YAML frontmatter sanitization for skills whose YAML is
+ * invalid because an unquoted value contains a colon (Claude-style).
+ */
+function fallbackSanitization(content: string): string {
+  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+  if (!match) return content;
+
+  const frontmatter = match[1];
+  const lines = frontmatter.split(/\r?\n/);
+  const result: string[] = [];
+
+  for (const line of lines) {
+    if (line.trim().startsWith('#') || line.trim() === '') {
+      result.push(line);
+      continue;
+    }
+    if (line.match(/^\s+/)) {
+      result.push(line);
+      continue;
+    }
+    const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
+    if (!kvMatch) {
+      result.push(line);
+      continue;
+    }
+    const key = kvMatch[1];
+    const value = kvMatch[2].trim();
+    if (
+      value === '' ||
+      value === '>' ||
+      value === '|' ||
+      value.startsWith('"') ||
+      value.startsWith("'")
+    ) {
+      result.push(line);
+      continue;
+    }
+    if (value.includes(':')) {
+      result.push(`${key}: |-`);
+      result.push(`  ${value}`);
+      continue;
+    }
+    result.push(line);
+  }
+
+  const processed = result.join('\n');
+  return content.replace(frontmatter, () => processed);
+}
+
+function isSkillFrontmatter(
+  data: unknown,
+): data is { name: string; description?: string } {
+  return (
+    typeof data === 'object' &&
+    data !== null &&
+    !Array.isArray(data) &&
+    typeof (data as { name?: unknown }).name === 'string' &&
+    ((data as { description?: unknown }).description === undefined ||
+      typeof (data as { description?: unknown }).description === 'string')
+  );
+}
+
+/** Parse SKILL.md using OpenCode's gray-matter + frontmatter schema. */
+export function parseSkillFrontmatterName(content: string): string | undefined {
+  try {
+    const parsed = matter(content);
+    if (isSkillFrontmatter(parsed.data)) return parsed.data.name;
+    return undefined;
+  } catch {
+    try {
+      const parsed = matter(fallbackSanitization(content));
+      if (isSkillFrontmatter(parsed.data)) return parsed.data.name;
+      return undefined;
+    } catch {
+      return undefined;
+    }
+  }
+}

+ 381 - 0
src/marketplace/status.test.ts

@@ -0,0 +1,381 @@
+import { afterEach, describe, expect, spyOn, test } from 'bun:test';
+import {
+  chmodSync,
+  mkdirSync,
+  mkdtempSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { resolveDesiredMarketplaceLiveFromDisk } from '../agents';
+import type { MarketplacePackageBundle } from './schemas';
+import { MarketplaceService } from './service';
+import { collectMarketplaceStatus } from './status';
+
+const previousConfigHome = process.env.XDG_CONFIG_HOME;
+
+afterEach(() => {
+  if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
+  else process.env.XDG_CONFIG_HOME = previousConfigHome;
+});
+
+function agentBundle(
+  id = 'community/docs-researcher',
+  version = '1.0.0',
+  overrides: Partial<MarketplacePackageBundle['manifest']> = {},
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id,
+      version,
+      kind: 'agent',
+      displayName: 'Docs researcher',
+      description: 'A derived explorer.',
+      instructions: 'Prefer documentation paths first.',
+      author: { name: 'Community' },
+      tags: ['docs'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Research docs.',
+        keywords: ['docs'],
+        delegation: { when: 'When needed.', preferredRoles: [] },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      baseRole: 'explorer',
+      agentName: id.split('/')[1]?.replaceAll('-', '') ?? 'docsresearcher',
+      overrides: {},
+      ...overrides,
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function setup() {
+  const root = mkdtempSync(join(tmpdir(), 'marketplace-status-'));
+  const configHome = join(root, 'config');
+  const project = join(root, 'project');
+  mkdirSync(join(configHome, 'opencode'), { recursive: true });
+  mkdirSync(join(project, '.opencode'), { recursive: true });
+  process.env.XDG_CONFIG_HOME = configHome;
+  writeFileSync(
+    join(configHome, 'opencode', 'oh-my-opencode-slim.json'),
+    JSON.stringify({
+      preset: 'work',
+      presets: {
+        work: {
+          agents: {},
+          marketplace: { agents: ['community/docs-researcher'] },
+        },
+      },
+    }),
+  );
+  const service = new MarketplaceService({
+    rootDir: join(root, 'store'),
+    projectDir: project,
+  });
+  return { root, project, service };
+}
+
+describe('marketplace status', () => {
+  test('CLI without a live registry reports reload status unknown', () => {
+    const { root, project, service } = setup();
+    try {
+      service.install(agentBundle());
+      const report = collectMarketplaceStatus({ service, projectDir: project });
+      expect(report.reloadRequired).toBe('unknown');
+      expect(report.live).toBeUndefined();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('survives corrupt packages and labels disk diagnostics', () => {
+    const { root, project, service } = setup();
+    try {
+      const installed = service.install(agentBundle());
+      service.install(agentBundle('community/other', '1.0.0'));
+      writeFileSync(join(installed.path, 'package.json'), '{broken');
+      const report = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+      });
+      expect(report.installed.some((pkg) => pkg.id === 'community/other')).toBe(
+        true,
+      );
+      expect(
+        report.installed.find((pkg) => pkg.id === 'community/docs-researcher')
+          ?.valid,
+      ).toBe(false);
+      const corrupt = report.diagnostics.find(
+        (entry) => entry.packageId === 'community/docs-researcher',
+      );
+      expect(corrupt?.code).toBe('corrupt');
+      expect(corrupt?.provenance).toBe('disk');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('keeps disk and live diagnostics of the same class when provenance differs', () => {
+    const { root, project, service } = setup();
+    try {
+      const report = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+        live: {
+          packages: [],
+          diagnostics: [
+            {
+              packageId: 'community/docs-researcher',
+              code: 'missing',
+              message: 'live wording differs',
+            },
+            {
+              packageId: 'community/docs-researcher',
+              code: 'missing',
+              message: 'duplicate live wording',
+            },
+          ],
+        },
+      });
+      const missing = report.diagnostics.filter(
+        (entry) =>
+          entry.packageId === 'community/docs-researcher' &&
+          entry.code === 'missing',
+      );
+      expect(missing.map((entry) => entry.provenance).sort()).toEqual([
+        'disk',
+        'live',
+      ]);
+      expect(
+        report.live?.diagnostics.filter(
+          (entry) =>
+            entry.packageId === 'community/docs-researcher' &&
+            entry.code === 'missing',
+        ),
+      ).toHaveLength(1);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('classifies inspect operational errors separately from corruption', () => {
+    const { root, project, service } = setup();
+    try {
+      const inspect = spyOn(service.store, 'inspectAll');
+      inspect.mockImplementation(() => {
+        throw new Error('lease timed out');
+      });
+      const report = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+      });
+      expect(
+        report.diagnostics.some(
+          (entry) =>
+            entry.code === 'operational' && entry.message === 'lease timed out',
+        ),
+      ).toBe(true);
+      expect(report.reloadRequired).toBe('unknown');
+      inspect.mockRestore();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('operational inspect leaves reload unknown even with live identities', () => {
+    const { root, project, service } = setup();
+    try {
+      const inspect = spyOn(service.store, 'inspectAll');
+      inspect.mockImplementation(() => ({
+        packages: [],
+        verifications: [],
+        operationalError: 'EACCES',
+      }));
+      const report = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+        live: { packages: [] },
+        desiredLive: { packages: [] },
+      });
+      expect(report.reloadRequired).toBe('unknown');
+      inspect.mockRestore();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('compares desired registry identities so aliases match and collisions are excluded', () => {
+    const { root, project, service } = setup();
+    try {
+      service.install(
+        agentBundle('community/docs-researcher', '1.0.0', {
+          overrides: { displayName: 'docsalias' },
+        }),
+      );
+      service.install(
+        agentBundle('community/reserved-explorer', '1.0.0', {
+          agentName: 'explorer',
+          displayName: 'Reserved explorer',
+        }),
+      );
+      writeFileSync(
+        join(root, 'config', 'opencode', 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                agents: [
+                  'community/docs-researcher',
+                  'community/reserved-explorer',
+                ],
+              },
+            },
+          },
+        }),
+      );
+      const desiredLive = resolveDesiredMarketplaceLiveFromDisk(
+        project,
+        service.store,
+      );
+      expect(desiredLive.packages).toEqual([
+        expect.objectContaining({
+          packageId: 'community/docs-researcher',
+          runtimeName: 'docsalias',
+        }),
+      ]);
+      expect(
+        desiredLive.diagnostics?.some(
+          (entry) =>
+            entry.packageId === 'community/reserved-explorer' &&
+            entry.code === 'collision',
+        ),
+      ).toBe(true);
+      const matching = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+        live: { packages: desiredLive.packages },
+        desiredLive,
+      });
+      expect(matching.reloadRequired).toBe(false);
+      expect(
+        matching.diagnostics.some(
+          (entry) =>
+            entry.packageId === 'community/reserved-explorer' &&
+            entry.code === 'collision' &&
+            entry.provenance === 'disk',
+        ),
+      ).toBe(true);
+      const staleName = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+        live: {
+          packages: desiredLive.packages.map((entry) => ({
+            ...entry,
+            runtimeName: 'docsresearcher',
+          })),
+        },
+        desiredLive,
+      });
+      expect(staleName.reloadRequired).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('keeps desired rejection diagnostics in status', () => {
+    const { root, project, service } = setup();
+    try {
+      service.install(
+        agentBundle('community/docs-researcher', '1.0.0', {
+          overrides: { displayName: 'Docs Researcher' },
+        }),
+      );
+      service.install(
+        agentBundle('community/needs-skill', '1.0.0', {
+          agentName: 'needsskill',
+          displayName: 'Needs skill',
+          requirements: {
+            skills: { required: ['not-a-real-skill'], optional: [] },
+            mcps: { required: [], optional: [] },
+          },
+        }),
+      );
+      writeFileSync(
+        join(root, 'config', 'opencode', 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                agents: ['community/docs-researcher', 'community/needs-skill'],
+              },
+            },
+          },
+        }),
+      );
+      const desiredLive = resolveDesiredMarketplaceLiveFromDisk(
+        project,
+        service.store,
+      );
+      expect(desiredLive.packages).toEqual([]);
+      expect(
+        desiredLive.diagnostics?.map((entry) => entry.code).sort(),
+      ).toEqual(['invalid-alias', 'missing-required-dependency']);
+      const report = collectMarketplaceStatus({
+        service,
+        projectDir: project,
+        live: { packages: [] },
+        desiredLive,
+      });
+      expect(report.reloadRequired).toBe(false);
+      expect(report.diagnostics.map((entry) => entry.code).sort()).toEqual([
+        'invalid-alias',
+        'missing-required-dependency',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('unreadable lockfile is operational and leaves in-session reload unknown', () => {
+    const { root, project, service } = setup();
+    try {
+      service.install(agentBundle());
+      chmodSync(service.store.paths.lockfilePath, 0);
+      try {
+        const desiredLive = resolveDesiredMarketplaceLiveFromDisk(
+          project,
+          service.store,
+        );
+        const report = collectMarketplaceStatus({
+          service,
+          projectDir: project,
+          live: { packages: [] },
+          desiredLive,
+        });
+        expect(
+          report.diagnostics.some(
+            (entry) =>
+              entry.code === 'operational' &&
+              /EACCES|permission/i.test(entry.message),
+          ),
+        ).toBe(true);
+        expect(report.reloadRequired).toBe('unknown');
+      } finally {
+        chmodSync(service.store.paths.lockfilePath, 0o644);
+      }
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 367 - 0
src/marketplace/status.ts

@@ -0,0 +1,367 @@
+import { loadPluginConfig } from '../config/loader';
+import type { MarketplaceActivation } from '../config/schema';
+import type { MarketplaceDiagnostic } from './activation';
+import { MarketplaceLockfileError } from './errors';
+import type { MarketplaceService } from './service';
+import type {
+  MarketplaceStoreInspection,
+  StoredMarketplacePackage,
+} from './store';
+
+export const MARKETPLACE_RELOAD_NOTICE =
+  'Applies after OpenCode reload or a new session. The live agent registry is not hot-swapped.';
+
+export type MarketplaceReloadRequired = boolean | 'unknown';
+export type MarketplaceDiagnosticProvenance = 'disk' | 'live';
+
+export interface MarketplaceLivePackage {
+  packageId: string;
+  version: string;
+  digest: string;
+  runtimeName: string;
+  kind: 'agent' | 'profile';
+}
+
+export interface MarketplaceLiveSnapshot {
+  packages: readonly MarketplaceLivePackage[];
+  diagnostics?: readonly MarketplaceDiagnostic[];
+}
+
+export interface MarketplaceInstalledSummary {
+  id: string;
+  version?: string;
+  kind?: 'agent' | 'profile';
+  displayName?: string;
+  role?: string;
+  digest?: string;
+  valid: boolean;
+}
+
+export interface MarketplaceStatusDiagnostic extends MarketplaceDiagnostic {
+  provenance: MarketplaceDiagnosticProvenance;
+}
+
+export interface MarketplaceStatusReport {
+  installed: MarketplaceInstalledSummary[];
+  configured: {
+    preset?: string;
+    agents: string[];
+    profiles: Record<string, string | null>;
+  };
+  live?: {
+    packages: MarketplaceLivePackage[];
+    diagnostics: MarketplaceStatusDiagnostic[];
+  };
+  diagnostics: MarketplaceStatusDiagnostic[];
+  reloadRequired: MarketplaceReloadRequired;
+  note: string;
+}
+
+export interface CollectMarketplaceStatusOptions {
+  service: MarketplaceService;
+  projectDir: string;
+  live?: MarketplaceLiveSnapshot;
+  desiredLive?: MarketplaceLiveSnapshot;
+}
+
+function packageRole(pkg: StoredMarketplacePackage): string {
+  return pkg.manifest.kind === 'agent'
+    ? pkg.manifest.baseRole
+    : pkg.manifest.targetRole;
+}
+
+function summarize(pkg: StoredMarketplacePackage): MarketplaceInstalledSummary {
+  return {
+    id: pkg.manifest.id,
+    version: pkg.manifest.version,
+    kind: pkg.manifest.kind,
+    displayName: pkg.manifest.displayName,
+    role: packageRole(pkg),
+    digest: pkg.digest,
+    valid: true,
+  };
+}
+
+function configuredIds(
+  activation: MarketplaceActivation | undefined,
+): string[] {
+  const agents = activation?.agents ?? [];
+  const profiles = Object.values(activation?.profiles ?? {}).filter(
+    (value): value is string => typeof value === 'string' && value.length > 0,
+  );
+  return [...new Set([...agents, ...profiles])];
+}
+
+function identityKey(entry: {
+  packageId: string;
+  version: string;
+  digest: string;
+  runtimeName: string;
+}): string {
+  return `${entry.packageId}@${entry.version}#${entry.digest}->${entry.runtimeName}`;
+}
+
+function sameIdentitySet(
+  left: readonly string[],
+  right: readonly string[],
+): boolean {
+  const a = [...left].sort();
+  const b = [...right].sort();
+  return a.length === b.length && a.every((id, index) => id === b[index]);
+}
+
+function uniqueLabeled(
+  diagnostics: readonly MarketplaceStatusDiagnostic[],
+): MarketplaceStatusDiagnostic[] {
+  const labeled: MarketplaceStatusDiagnostic[] = [];
+  const seen = new Set<string>();
+  for (const diagnostic of diagnostics) {
+    const key = diagnosticClassKey(diagnostic, diagnostic.provenance);
+    if (seen.has(key)) continue;
+    seen.add(key);
+    labeled.push(diagnostic);
+  }
+  return labeled;
+}
+
+function labelDisk(
+  diagnostic: MarketplaceDiagnostic,
+): MarketplaceStatusDiagnostic {
+  return { ...diagnostic, provenance: 'disk' };
+}
+
+function labelLive(
+  diagnostic: MarketplaceDiagnostic,
+): MarketplaceStatusDiagnostic {
+  return { ...diagnostic, provenance: 'live' };
+}
+
+function diagnosticClassKey(
+  diagnostic: Pick<MarketplaceDiagnostic, 'packageId' | 'code'>,
+  provenance: MarketplaceDiagnosticProvenance,
+): string {
+  return `${diagnostic.packageId}\0${diagnostic.code}\0${provenance}`;
+}
+
+function mergeDiagnostics(
+  disk: readonly MarketplaceDiagnostic[],
+  live: readonly MarketplaceDiagnostic[],
+): MarketplaceStatusDiagnostic[] {
+  const labeled: MarketplaceStatusDiagnostic[] = [];
+  const seen = new Set<string>();
+  for (const diagnostic of disk) {
+    const key = diagnosticClassKey(diagnostic, 'disk');
+    if (seen.has(key)) continue;
+    seen.add(key);
+    labeled.push(labelDisk(diagnostic));
+  }
+  for (const diagnostic of live) {
+    const key = diagnosticClassKey(diagnostic, 'live');
+    if (seen.has(key)) continue;
+    seen.add(key);
+    labeled.push(labelLive(diagnostic));
+  }
+  return labeled;
+}
+
+function inspectionDiagnostics(
+  inspection: MarketplaceStoreInspection,
+  pendingIds: readonly string[],
+  installedIds: ReadonlySet<string>,
+): MarketplaceDiagnostic[] {
+  const diagnostics: MarketplaceDiagnostic[] = [];
+  if (inspection.operationalError) {
+    diagnostics.push({
+      packageId: '(store)',
+      code: 'operational',
+      message: inspection.operationalError,
+    });
+  }
+  if (inspection.lockfileError) {
+    diagnostics.push({
+      packageId: '(store)',
+      code: 'corrupt',
+      message: inspection.lockfileError,
+    });
+  }
+  for (const id of pendingIds) {
+    if (installedIds.has(id)) continue;
+    const verification = inspection.verifications.find(
+      (entry) => entry.id === id,
+    );
+    if (verification && !verification.valid) {
+      diagnostics.push({
+        packageId: id,
+        code: 'corrupt',
+        message: verification.message,
+      });
+      continue;
+    }
+    diagnostics.push({
+      packageId: id,
+      code: 'missing',
+      message: `${id} is configured but not installed`,
+    });
+  }
+  for (const verification of inspection.verifications) {
+    if (verification.valid) continue;
+    if (pendingIds.includes(verification.id)) continue;
+    diagnostics.push({
+      packageId: verification.id,
+      code: 'corrupt',
+      message: verification.message,
+    });
+  }
+  return diagnostics;
+}
+
+export function collectMarketplaceStatus(
+  options: CollectMarketplaceStatusOptions,
+): MarketplaceStatusReport {
+  let inspection: MarketplaceStoreInspection;
+  try {
+    inspection = options.service.store.inspectAll();
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    inspection = {
+      packages: [],
+      verifications: [],
+      ...(error instanceof MarketplaceLockfileError
+        ? { lockfileError: message }
+        : { operationalError: message }),
+    };
+  }
+  const installedIds = new Set(
+    inspection.packages.map((pkg) => pkg.manifest.id),
+  );
+  const config = loadPluginConfig(options.projectDir, { silent: true });
+  const preset =
+    typeof config.preset === 'string' && config.preset.length > 0
+      ? config.preset
+      : undefined;
+  const activation = preset ? config.presets?.[preset]?.marketplace : undefined;
+  const pendingIds = configuredIds(activation);
+  const desiredDiagnostics = options.desiredLive?.diagnostics ?? [];
+  const disk = [
+    ...inspectionDiagnostics(inspection, pendingIds, installedIds),
+    ...desiredDiagnostics,
+  ];
+  const liveDiagnostics = options.live?.diagnostics ?? [];
+  const installed: MarketplaceInstalledSummary[] = [
+    ...inspection.packages.map(summarize),
+    ...inspection.verifications
+      .filter((entry) => !entry.valid)
+      .map((entry) => ({
+        id: entry.id,
+        version: entry.version,
+        digest: entry.expectedDigest,
+        valid: false,
+      })),
+  ];
+  const livePackages = options.live ? [...options.live.packages] : undefined;
+  const operational =
+    Boolean(inspection.operationalError) ||
+    desiredDiagnostics.some((entry) => entry.code === 'operational');
+  const reloadRequired: MarketplaceReloadRequired =
+    operational || !options.live || !options.desiredLive
+      ? 'unknown'
+      : !sameIdentitySet(
+          options.desiredLive.packages.map(identityKey),
+          options.live.packages.map(identityKey),
+        );
+  return {
+    installed,
+    configured: {
+      ...(preset ? { preset } : {}),
+      agents: [...(activation?.agents ?? [])],
+      profiles: { ...(activation?.profiles ?? {}) },
+    },
+    ...(livePackages
+      ? {
+          live: {
+            packages: livePackages,
+            diagnostics: uniqueLabeled(liveDiagnostics.map(labelLive)),
+          },
+        }
+      : {}),
+    diagnostics: mergeDiagnostics(disk, liveDiagnostics),
+    reloadRequired,
+    note: reloadRequired === false ? '' : MARKETPLACE_RELOAD_NOTICE,
+  };
+}
+
+export function formatMarketplaceStatus(
+  report: MarketplaceStatusReport,
+): string {
+  const lines = [
+    `preset: ${report.configured.preset ?? '(none)'}`,
+    `reload_required: ${report.reloadRequired}`,
+  ];
+  if (report.note) lines.push(`note: ${report.note}`);
+  lines.push('', 'installed:');
+  if (report.installed.length === 0) {
+    lines.push('  (none)');
+  } else {
+    for (const pkg of report.installed) {
+      const version = pkg.version ? `@${pkg.version}` : '';
+      const kind = pkg.kind ? ` ${pkg.kind}` : '';
+      const role = pkg.role ? ` ${pkg.role}` : '';
+      const name = pkg.displayName ? ` ${pkg.displayName}` : '';
+      const valid = pkg.valid ? '' : ' corrupt';
+      lines.push(`  ${pkg.id}${version}${kind}${role}${name}${valid}`);
+    }
+  }
+  lines.push('', 'configured_agents:');
+  if (report.configured.agents.length === 0) {
+    lines.push('  (none)');
+  } else {
+    for (const id of report.configured.agents) lines.push(`  ${id}`);
+  }
+  lines.push('', 'configured_profiles:');
+  const profileEntries = Object.entries(report.configured.profiles);
+  if (profileEntries.length === 0) {
+    lines.push('  (none)');
+  } else {
+    for (const [role, packageId] of profileEntries) {
+      lines.push(`  ${role}: ${packageId ?? '(cleared)'}`);
+    }
+  }
+  if (report.live) {
+    lines.push('', 'live_packages:');
+    if (report.live.packages.length === 0) {
+      lines.push('  (none)');
+    } else {
+      for (const entry of report.live.packages) {
+        lines.push(
+          `  ${entry.packageId}@${entry.version} ${entry.kind} ${entry.runtimeName}`,
+        );
+      }
+    }
+  }
+  lines.push('', 'diagnostics:');
+  if (report.diagnostics.length === 0) {
+    lines.push('  (none)');
+  } else {
+    for (const diagnostic of report.diagnostics) {
+      lines.push(
+        `  ${diagnostic.provenance} ${diagnostic.packageId} ${diagnostic.code}: ${diagnostic.message}`,
+      );
+    }
+  }
+  return lines.join('\n');
+}
+
+export function mutationReloadNotice(
+  message: string,
+  reloadRequired: MarketplaceReloadRequired,
+): string {
+  const lines = [message, `reload_required: ${reloadRequired}`];
+  if (reloadRequired !== false) lines.push(MARKETPLACE_RELOAD_NOTICE);
+  return lines.join('\n');
+}
+
+export function reloadRequiredAfterMutation(
+  options: CollectMarketplaceStatusOptions,
+): MarketplaceReloadRequired {
+  return collectMarketplaceStatus(options).reloadRequired;
+}

+ 608 - 0
src/marketplace/store.test.ts

@@ -0,0 +1,608 @@
+import { describe, expect, spyOn, test } from 'bun:test';
+import * as fsModule from 'node:fs';
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+  acquireMarketplaceLeaseForPaths,
+  MarketplaceBusyError,
+  MarketplaceConflictError,
+  MarketplaceIntegrityError,
+  MarketplaceLockfileError,
+  MarketplaceLockOwnershipError,
+  type MarketplacePackageBundle,
+  MarketplaceService,
+  MarketplaceStore,
+} from './index';
+import { heartbeatPath, publishGeneration, readLeaseState } from './lease';
+import type { MarketplacePaths } from './paths';
+
+function bundle(
+  version = '1.0.0',
+  overrides: Partial<MarketplacePackageBundle['manifest']> = {},
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/example',
+      version,
+      kind: 'agent',
+      displayName: 'Example',
+      description: 'An example package',
+      instructions: 'Use the example role carefully.',
+      author: { name: 'Example Community' },
+      tags: ['example'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Explore example code.',
+        keywords: ['example'],
+        delegation: {
+          when: 'When repository exploration is needed.',
+          preferredRoles: [],
+        },
+      },
+      baseRole: 'explorer',
+      agentName: 'example',
+      overrides: {},
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      ...overrides,
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function tempRoot(): string {
+  return mkdtempSync(join(tmpdir(), 'marketplace-store-'));
+}
+
+function markStaleGeneration(directory: string, generation: string): void {
+  const target = join(directory, 'gen', generation);
+  mkdirSync(target, { recursive: true });
+  writeFileSync(
+    join(target, 'meta.json'),
+    JSON.stringify({ generation, token: 'stale-token', pid: -1 }),
+  );
+  writeFileSync(join(target, 'heartbeat'), '');
+  const old = new Date(Date.now() - 2_000);
+  fsModule.utimesSync(target, old, old);
+  fsModule.utimesSync(join(target, 'meta.json'), old, old);
+  fsModule.utimesSync(join(target, 'heartbeat'), old, old);
+}
+
+function spawnLockWorker(
+  paths: MarketplacePaths,
+  logPath: string,
+  body: string,
+): Bun.Subprocess {
+  return Bun.spawn(
+    [
+      'bun',
+      '-e',
+      `import { acquireMarketplaceLeaseForPaths } from './src/marketplace/store.ts';
+${body}`,
+      JSON.stringify({ paths, logPath }),
+    ],
+    { stdout: 'ignore', stderr: 'ignore' },
+  );
+}
+
+describe('MarketplaceStore', () => {
+  test('installs, verifies, and lists an immutable exact version', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const installed = store.install(bundle());
+      expect(installed.manifest.version).toBe('1.0.0');
+      expect(store.verify('community/example').valid).toBe(true);
+      expect(store.list().map((pkg) => pkg.manifest.id)).toEqual([
+        'community/example',
+      ]);
+      expect(store.getLockfile().packages['community/example']).toEqual({
+        manifestSchemaVersion: 1,
+        manifestVersion: '1.0.0',
+        source: { kind: 'in-memory', label: 'programmatic' },
+        digest: {
+          algorithm: 'sha256',
+          domain: 'marketplace-bundle-v1',
+          value: installed.digest,
+        },
+      });
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('inspectAll reports corrupt packages without failing the snapshot', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const first = store.install(bundle());
+      store.install(
+        bundle('1.0.0', {
+          id: 'community/other',
+          agentName: 'other',
+          displayName: 'Other',
+        }),
+      );
+      writeFileSync(join(first.path, 'package.json'), '{not-valid-json');
+      const firstInspect = store.inspectAll();
+      const secondInspect = store.inspectAll();
+      expect(firstInspect.lockfileError).toBeUndefined();
+      expect(firstInspect.packages.map((pkg) => pkg.manifest.id)).toEqual([
+        'community/other',
+      ]);
+      expect(
+        firstInspect.verifications.find(
+          (entry) => entry.id === 'community/example',
+        )?.valid,
+      ).toBe(false);
+      expect(
+        firstInspect.verifications.find(
+          (entry) => entry.id === 'community/other',
+        )?.valid,
+      ).toBe(true);
+      expect(secondInspect).toEqual(firstInspect);
+      expect(() => store.list()).toThrow();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('inspectAll classifies missing package files as operational errors', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const first = store.install(bundle());
+      store.install(
+        bundle('1.0.0', {
+          id: 'community/other',
+          agentName: 'other',
+          displayName: 'Other',
+        }),
+      );
+      rmSync(join(first.path, 'package.json'));
+      const inspection = store.inspectAll();
+      expect(inspection.packages).toEqual([]);
+      expect(inspection.verifications).toEqual([]);
+      expect(inspection.lockfileError).toBeUndefined();
+      expect(inspection.operationalError).toMatch(/ENOENT|no such file/i);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('lockfile read failures stay operational instead of lockfile corruption', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      chmodSync(store.paths.lockfilePath, 0);
+      try {
+        try {
+          store.getLockfile();
+          throw new Error('expected lockfile read to fail');
+        } catch (error) {
+          expect(error).not.toBeInstanceOf(MarketplaceLockfileError);
+          expect((error as NodeJS.ErrnoException).code).toBe('EACCES');
+        }
+        const inspection = store.inspectAll();
+        expect(inspection.lockfileError).toBeUndefined();
+        expect(inspection.operationalError).toMatch(/EACCES|permission/i);
+      } finally {
+        chmodSync(store.paths.lockfilePath, 0o644);
+      }
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('inspectAll classifies invalid lockfiles as lockfile errors', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      mkdirSync(root, { recursive: true });
+      writeFileSync(store.paths.lockfilePath, '{not-a-lockfile');
+      const inspection = store.inspectAll();
+      expect(inspection.packages).toEqual([]);
+      expect(inspection.lockfileError).toContain('Marketplace lockfile');
+      expect(inspection.operationalError).toBeUndefined();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects replacing an immutable package version with different data', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      expect(() =>
+        store.install(
+          bundle('1.0.0', { instructions: 'Changed instructions.' }),
+        ),
+      ).toThrow(MarketplaceConflictError);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('loads a selected package when another installed package is corrupt', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      store.install(
+        bundle('1.0.0', {
+          id: 'community/other',
+          agentName: 'other',
+        }),
+      );
+      writeFileSync(
+        join(root, 'packages', 'community', 'other', '1.0.0', 'package.json'),
+        '{"not":"a package"}',
+      );
+      const selected = store.loadSelected([
+        'community/example',
+        'community/other',
+      ]);
+      expect(selected.packages.get('community/example')?.manifest.id).toBe(
+        'community/example',
+      );
+      expect(selected.errors.get('community/other')).toBeInstanceOf(
+        MarketplaceIntegrityError,
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('detects tampering without loading corrupted package data', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      writeFileSync(
+        join(root, 'packages', 'community', 'example', '1.0.0', 'package.json'),
+        '{"not":"a package"}',
+      );
+      expect(() => store.list()).toThrow(MarketplaceIntegrityError);
+      expect(store.verify('community/example').valid).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not overwrite a corrupt lockfile', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      mkdirSync(root, { recursive: true });
+      writeFileSync(store.paths.lockfilePath, '{bad json');
+      expect(() => store.getLockfile()).toThrow(MarketplaceLockfileError);
+      expect(() => store.install(bundle())).toThrow(MarketplaceLockfileError);
+      expect(existsSync(store.paths.lockfilePath)).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('fails rather than racing another lifecycle operation', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({
+        rootDir: root,
+        lock: { timeoutMs: 10, retryMs: 1 },
+      });
+      mkdirSync(root, { recursive: true });
+      expect(
+        publishGeneration(store.paths.lockDir, {
+          generation: 'blocking-generation',
+          token: 'blocking-token',
+        }),
+      ).toBe(true);
+      expect(() => store.install(bundle())).toThrow(MarketplaceBusyError);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('recovers a lock left by a dead process', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({
+        rootDir: root,
+        lock: { staleMs: 200, heartbeatMs: 50, retryMs: 1 },
+      });
+      mkdirSync(root, { recursive: true });
+      markStaleGeneration(store.paths.lockDir, 'dead-lock-generation');
+      expect(store.install(bundle()).manifest.id).toBe('community/example');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('blocks a concurrent worker and recovers after the worker exits', async () => {
+    const root = tempRoot();
+    const readyPath = join(root, 'worker-ready');
+    const store = new MarketplaceStore({
+      rootDir: root,
+      lock: { staleMs: 100, heartbeatMs: 10, timeoutMs: 30, retryMs: 1 },
+    });
+    const worker = Bun.spawn(
+      [
+        'bun',
+        '-e',
+        `import { writeFileSync } from 'node:fs';
+import { acquireMarketplaceLeaseForPaths } from './src/marketplace/store.ts';
+const { paths, readyPath } = JSON.parse(process.argv[1]);
+acquireMarketplaceLeaseForPaths(paths, { staleMs: 100, timeoutMs: 5000, heartbeatMs: 10 });
+writeFileSync(readyPath, 'ready');
+await new Promise(() => {});`,
+        JSON.stringify({ paths: store.paths, readyPath }),
+      ],
+      { stdout: 'ignore', stderr: 'ignore' },
+    );
+    try {
+      for (
+        let attempt = 0;
+        attempt < 200 && !existsSync(readyPath);
+        attempt++
+      ) {
+        await Bun.sleep(10);
+      }
+      expect(existsSync(readyPath)).toBe(true);
+      expect(() => store.list()).toThrow(MarketplaceBusyError);
+      worker.kill();
+      await worker.exited;
+      await Bun.sleep(250);
+      expect(store.list()).toEqual([]);
+    } finally {
+      worker.kill();
+      await worker.exited.catch(() => undefined);
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('serializes simultaneous stale reclaimers across generations', async () => {
+    const root = tempRoot();
+    const store = new MarketplaceStore({
+      rootDir: root,
+      lock: { staleMs: 1000, heartbeatMs: 100, timeoutMs: 2000, retryMs: 5 },
+    });
+    const logPath = join(root, 'reclaim.log');
+    try {
+      markStaleGeneration(store.paths.lockDir, 'stale-lock-generation');
+      markStaleGeneration(store.paths.breakerDir, 'stale-breaker-generation');
+      const body = `import { appendFileSync, closeSync, openSync, rmSync } from 'node:fs';
+const { paths, logPath } = JSON.parse(process.argv[1]);
+const lease = acquireMarketplaceLeaseForPaths(paths, { staleMs: 1000, heartbeatMs: 100, timeoutMs: 2000, retryMs: 5 });
+const activePath = logPath + '.active';
+try {
+  lease.commit(() => {
+    const fd = openSync(activePath, 'wx');
+    closeSync(fd);
+    appendFileSync(logPath, 'acquired\\n');
+    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40);
+    rmSync(activePath, { force: true });
+    appendFileSync(logPath, 'released\\n');
+  });
+} finally {
+  lease.release();
+}`;
+      const workers = [
+        spawnLockWorker(store.paths, logPath, body),
+        spawnLockWorker(store.paths, logPath, body),
+      ];
+      const exitCodes = await Promise.all(
+        workers.map((worker) => worker.exited),
+      );
+      expect(exitCodes).toEqual([0, 0]);
+      expect(readFileSync(logPath, 'utf8').trim().split('\n')).toEqual([
+        'acquired',
+        'released',
+        'acquired',
+        'released',
+      ]);
+      expect(readLeaseState(store.paths.breakerDir)).toBeUndefined();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('fences an expired owner when a replacement generation takes over', () => {
+    const root = tempRoot();
+    const store = new MarketplaceStore({
+      rootDir: root,
+      lock: { staleMs: 200, heartbeatMs: 50, timeoutMs: 1000, retryMs: 10 },
+    });
+    try {
+      const owner = acquireMarketplaceLeaseForPaths(store.paths, {
+        staleMs: 200,
+        heartbeatMs: 50,
+        timeoutMs: 1000,
+        retryMs: 10,
+      });
+      const old = new Date(Date.now() - 2_000);
+      fsModule.utimesSync(
+        heartbeatPath(store.paths.lockDir, owner.generation),
+        old,
+        old,
+      );
+      const replacement = acquireMarketplaceLeaseForPaths(store.paths, {
+        staleMs: 200,
+        heartbeatMs: 50,
+        timeoutMs: 1000,
+        retryMs: 10,
+      });
+      expect(() => owner.release()).toThrow(MarketplaceLockOwnershipError);
+      expect(() => owner.commit(() => undefined)).toThrow(
+        MarketplaceLockOwnershipError,
+      );
+      expect(() => replacement.assertCurrent()).not.toThrow();
+      expect(() => replacement.commit(() => undefined)).not.toThrow();
+      replacement.release();
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('keeps the previous lifecycle state when lockfile publication fails', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const originalRename = fsModule.renameSync;
+      const rename = spyOn(fsModule, 'renameSync').mockImplementation(
+        (source, destination) => {
+          if (destination === store.paths.lockfilePath) {
+            throw new Error('injected lockfile rename failure');
+          }
+          return originalRename(source, destination);
+        },
+      );
+      expect(() => store.install(bundle())).toThrow(
+        'injected lockfile rename failure',
+      );
+      rename.mockRestore();
+      expect(store.getLockfile().packages).toEqual({});
+      expect(store.list()).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('restores an abandoned removal staging directory', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      const packagePath = join(root, 'packages', 'community', 'example');
+      const quarantinePath = join(
+        root,
+        '.staging',
+        'removed',
+        'community',
+        'example',
+      );
+      mkdirSync(join(root, '.staging', 'removed', 'community'), {
+        recursive: true,
+      });
+      fsModule.renameSync(packagePath, quarantinePath);
+      expect(store.list()[0]?.manifest.id).toBe('community/example');
+      expect(existsSync(packagePath)).toBe(true);
+      expect(existsSync(quarantinePath)).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('restores package data when removal lock publication fails', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      const originalRename = fsModule.renameSync;
+      const rename = spyOn(fsModule, 'renameSync').mockImplementation(
+        (source, destination) => {
+          if (destination === store.paths.lockfilePath) {
+            throw new Error('injected removal lock failure');
+          }
+          return originalRename(source, destination);
+        },
+      );
+      expect(() => store.remove('community/example')).toThrow(
+        'injected removal lock failure',
+      );
+      rename.mockRestore();
+      expect(store.show('community/example').manifest.id).toBe(
+        'community/example',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('reconciles a committed removal quarantine orphan', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      store.install(bundle());
+      const originalRemove = fsModule.rmSync;
+      const remove = spyOn(fsModule, 'rmSync').mockImplementation(
+        (target, options) => {
+          if (String(target).includes(`${pathSeparator()}removed`)) {
+            throw new Error('injected garbage collection failure');
+          }
+          return originalRemove(target, options);
+        },
+      );
+      store.remove('community/example');
+      remove.mockRestore();
+      expect(store.getLockfile().packages).toEqual({});
+      expect(store.list()).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});
+
+function pathSeparator(): string {
+  return '/removed/';
+}
+
+describe('MarketplaceService', () => {
+  test('requires explicit updates', () => {
+    const root = tempRoot();
+    try {
+      const service = new MarketplaceService({ rootDir: root });
+      service.install(bundle());
+      expect(() => service.install(bundle('2.0.0'))).toThrow(
+        MarketplaceConflictError,
+      );
+      service.update(bundle('2.0.0'));
+      expect(service.verify('community/example')[0]?.version).toBe('2.0.0');
+      service.remove('community/example');
+      expect(service.list()).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('preserves the canonical local import source and exposes show', () => {
+    const root = tempRoot();
+    const packageFile = join(root, 'package.json');
+    try {
+      writeFileSync(packageFile, JSON.stringify(bundle().manifest));
+      const service = new MarketplaceService({ rootDir: join(root, 'store') });
+      service.installFile(packageFile);
+      expect(service.show('community/example').source).toEqual({
+        kind: 'local',
+        path: packageFile,
+      });
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('normalizes package IDs for management commands', () => {
+    const root = tempRoot();
+    try {
+      const service = new MarketplaceService({ rootDir: root });
+      service.install(bundle());
+      expect(service.verify(' COMMUNITY/EXAMPLE ')[0]?.valid).toBe(true);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 674 - 0
src/marketplace/store.ts

@@ -0,0 +1,674 @@
+import { createHash, randomUUID } from 'node:crypto';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { validateMarketplaceCompatibility } from './compatibility';
+import {
+  MarketplaceConflictError,
+  MarketplaceIntegrityError,
+  MarketplaceLockfileError,
+  MarketplaceValidationError,
+} from './errors';
+import {
+  acquireMarketplaceLease,
+  type MarketplaceLease,
+  type MarketplaceLockOptions,
+  syncDirectory,
+  withMarketplaceLease,
+  writeAtomic,
+} from './lease';
+import { getMarketplacePaths, type MarketplacePaths } from './paths';
+import {
+  MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
+  type MarketplaceLockEntry,
+  type MarketplaceLockfile,
+  MarketplaceLockfileSchema,
+  type MarketplacePackageBundle,
+  MarketplacePackageBundleSchema,
+  type MarketplacePackageManifest,
+  type MarketplaceSource,
+  MarketplaceSourceSchema,
+} from './schemas';
+
+export type { MarketplaceLease, MarketplaceLockOptions };
+
+export interface MarketplaceStoreOptions {
+  rootDir?: string;
+  pluginVersion?: string;
+  roleContractVersion?: string;
+  lock?: Partial<MarketplaceLockOptions>;
+}
+
+export interface StoredMarketplacePackage {
+  manifest: MarketplacePackageManifest;
+  source: MarketplaceSource;
+  digest: string;
+  path: string;
+}
+
+export interface MarketplaceVerification {
+  id: string;
+  version?: string;
+  valid: boolean;
+  expectedDigest?: string;
+  actualDigest?: string;
+  message: string;
+}
+
+export interface MarketplaceStoreInspection {
+  packages: StoredMarketplacePackage[];
+  verifications: MarketplaceVerification[];
+  lockfileError?: string;
+  operationalError?: string;
+}
+
+function stableJson(value: unknown): string {
+  if (Array.isArray(value)) {
+    return `[${value.map((item) => stableJson(item)).join(',')}]`;
+  }
+  if (value !== null && typeof value === 'object') {
+    const entries = Object.entries(value as Record<string, unknown>).sort(
+      ([a], [b]) => a.localeCompare(b),
+    );
+    return `{${entries
+      .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
+      .join(',')}}`;
+  }
+  return JSON.stringify(value);
+}
+
+/** Digest input is UTF-8 bytes of canonical stable JSON for `{ manifest }`. */
+export function canonicalMarketplaceBundleBytes(
+  bundle: MarketplacePackageBundle,
+): string {
+  return stableJson(bundle);
+}
+
+export function digestMarketplaceBundle(
+  bundle: MarketplacePackageBundle,
+): string {
+  return createHash('sha256')
+    .update(canonicalMarketplaceBundleBytes(bundle))
+    .digest('hex');
+}
+
+function parseBundle(value: unknown): MarketplacePackageBundle {
+  const result = MarketplacePackageBundleSchema.safeParse(value);
+  if (!result.success) {
+    throw new MarketplaceValidationError(result.error.message);
+  }
+  return result.data;
+}
+
+function packageVersionPath(
+  paths: MarketplacePaths,
+  id: string,
+  version: string,
+): string {
+  const [namespace, name] = id.split('/');
+  return path.join(paths.packagesDir, namespace, name, version);
+}
+
+function packageIdPath(paths: MarketplacePaths, id: string): string {
+  const [namespace, name] = id.split('/');
+  return path.join(paths.packagesDir, namespace, name);
+}
+
+function readJson(filePath: string): unknown {
+  return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
+}
+
+export function acquireMarketplaceLeaseForPaths(
+  paths: MarketplacePaths,
+  options: Partial<MarketplaceLockOptions> = {},
+): MarketplaceLease {
+  return acquireMarketplaceLease(paths, options);
+}
+
+function packageEntry(
+  bundle: MarketplacePackageBundle,
+  source: MarketplaceSource,
+  digest: string,
+): MarketplaceLockEntry {
+  return {
+    manifestSchemaVersion: bundle.manifest.schemaVersion,
+    manifestVersion: bundle.manifest.version,
+    source,
+    digest: {
+      algorithm: 'sha256',
+      domain: MARKETPLACE_DIGEST_DOMAIN,
+      value: digest,
+    },
+  };
+}
+
+export class MarketplaceStore {
+  readonly paths: MarketplacePaths;
+  private readonly compatibility: MarketplaceStoreOptions;
+  private readonly lockOptions: Partial<MarketplaceLockOptions>;
+
+  constructor(options: MarketplaceStoreOptions = {}) {
+    this.paths = getMarketplacePaths(options.rootDir);
+    this.compatibility = options;
+    this.lockOptions = options.lock ?? {};
+  }
+
+  /** Return a consistent lockfile snapshot. */
+  getLockfile(): MarketplaceLockfile {
+    return withMarketplaceLease(
+      this.paths,
+      () => this.readLockfile(),
+      this.lockOptions,
+    );
+  }
+
+  install(
+    input: MarketplacePackageBundle,
+    source: MarketplaceSource = { kind: 'in-memory', label: 'programmatic' },
+  ): StoredMarketplacePackage {
+    return this.mutatePackage(input, source, 'install');
+  }
+
+  update(
+    input: MarketplacePackageBundle,
+    source: MarketplaceSource = { kind: 'in-memory', label: 'programmatic' },
+  ): StoredMarketplacePackage {
+    return this.mutatePackage(input, source, 'update');
+  }
+
+  list(): StoredMarketplacePackage[] {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        return Object.keys(lockfile.packages)
+          .sort()
+          .map((id) => this.loadLockedPackage(lockfile, id));
+      },
+      this.lockOptions,
+    );
+  }
+
+  show(id: string): StoredMarketplacePackage {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        return this.loadLockedPackage(lockfile, id);
+      },
+      this.lockOptions,
+    );
+  }
+
+  /**
+   * Reconcile once, snapshot the lockfile once, then integrity-load only
+   * the selected package IDs. Other installed packages are not parsed.
+   */
+  loadSelected(ids: readonly string[]): {
+    packages: Map<string, StoredMarketplacePackage>;
+    errors: Map<string, Error>;
+  } {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        const packages = new Map<string, StoredMarketplacePackage>();
+        const errors = new Map<string, Error>();
+        const unique = [...new Set(ids)].sort();
+        for (const id of unique) {
+          try {
+            packages.set(id, this.loadLockedPackage(lockfile, id));
+          } catch (error) {
+            errors.set(
+              id,
+              error instanceof Error ? error : new Error(String(error)),
+            );
+          }
+        }
+        return { packages, errors };
+      },
+      this.lockOptions,
+    );
+  }
+
+  verify(id: string): MarketplaceVerification {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        return this.verifyLocked(lockfile, id);
+      },
+      this.lockOptions,
+    );
+  }
+
+  verifyAll(): MarketplaceVerification[] {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        return Object.keys(lockfile.packages)
+          .sort()
+          .map((id) => this.verifyLocked(lockfile, id));
+      },
+      this.lockOptions,
+    );
+  }
+
+  /**
+   * Reconcile, read the lockfile, and load/verify every package under one
+   * lease. Corrupt packages are reported instead of failing the snapshot.
+   */
+  inspectAll(): MarketplaceStoreInspection {
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        try {
+          const lockfile = this.readLockfile();
+          this.reconcileLockedState(lockfile, lease);
+          const packages: StoredMarketplacePackage[] = [];
+          const verifications: MarketplaceVerification[] = [];
+          for (const id of Object.keys(lockfile.packages).sort()) {
+            const entry = lockfile.packages[id];
+            try {
+              const stored = this.loadLockedPackage(lockfile, id);
+              packages.push(stored);
+              verifications.push({
+                id,
+                version: stored.manifest.version,
+                valid: true,
+                expectedDigest: entry?.digest.value,
+                actualDigest: stored.digest,
+                message: `${id}@${stored.manifest.version} verified`,
+              });
+            } catch (error) {
+              if (error instanceof MarketplaceIntegrityError) {
+                verifications.push({
+                  id,
+                  version: entry?.manifestVersion,
+                  valid: false,
+                  expectedDigest: entry?.digest.value,
+                  message: error.message,
+                });
+                continue;
+              }
+              return {
+                packages: [],
+                verifications: [],
+                operationalError:
+                  error instanceof Error ? error.message : String(error),
+              };
+            }
+          }
+          return { packages, verifications };
+        } catch (error) {
+          const message =
+            error instanceof Error ? error.message : String(error);
+          if (error instanceof MarketplaceLockfileError) {
+            return { packages: [], verifications: [], lockfileError: message };
+          }
+          return {
+            packages: [],
+            verifications: [],
+            operationalError: message,
+          };
+        }
+      },
+      this.lockOptions,
+    );
+  }
+
+  remove(id: string, precondition?: () => void): void {
+    withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        precondition?.();
+        this.loadLockedPackage(lockfile, id);
+        const packagePath = packageIdPath(this.paths, id);
+        const [namespace, name] = id.split('/');
+        const quarantinePath = path.join(
+          this.paths.stagingDir,
+          'removed',
+          namespace,
+          name,
+        );
+        lease.commit(() => {
+          fs.mkdirSync(path.dirname(quarantinePath), { recursive: true });
+          fs.renameSync(packagePath, quarantinePath);
+          delete lockfile.packages[id];
+          try {
+            this.writeLockfileUnlocked(lockfile);
+          } catch (error) {
+            fs.renameSync(quarantinePath, packagePath);
+            throw error;
+          }
+          try {
+            fs.rmSync(quarantinePath, { recursive: true, force: true });
+          } catch {
+            // The committed lockfile makes the quarantine an orphan. The next
+            // operation reconciles it without exposing transient package state.
+          }
+        });
+      },
+      this.lockOptions,
+    );
+  }
+
+  private mutatePackage(
+    input: MarketplacePackageBundle,
+    source: MarketplaceSource,
+    mode: 'install' | 'update',
+  ): StoredMarketplacePackage {
+    const bundle = parseBundle(input);
+    validateMarketplaceCompatibility(bundle.manifest, this.compatibility);
+    const sourceResult = MarketplaceSourceSchema.safeParse(source);
+    if (!sourceResult.success) {
+      throw new MarketplaceValidationError(sourceResult.error.message);
+    }
+    const normalizedSource = sourceResult.data;
+    const digest = digestMarketplaceBundle(bundle);
+    return withMarketplaceLease(
+      this.paths,
+      (lease) => {
+        const lockfile = this.readLockfile();
+        this.reconcileLockedState(lockfile, lease);
+        const current = lockfile.packages[bundle.manifest.id];
+        if (current) {
+          const existing = this.loadLockedPackage(lockfile, bundle.manifest.id);
+          if (
+            mode === 'install' &&
+            current.manifestVersion !== bundle.manifest.version
+          ) {
+            throw new MarketplaceConflictError(
+              `${bundle.manifest.id} is installed at ${current.manifestVersion}; use update for an explicit version change`,
+            );
+          }
+          if (
+            mode === 'update' &&
+            current.manifestVersion === bundle.manifest.version
+          ) {
+            throw new MarketplaceConflictError(
+              `${bundle.manifest.id}@${bundle.manifest.version} is already selected; updates require a new exact version`,
+            );
+          }
+          if (existing.digest === digest) {
+            if (mode === 'update') {
+              throw new MarketplaceConflictError(
+                `${bundle.manifest.id}@${bundle.manifest.version} is already selected; updates require a new exact version`,
+              );
+            }
+            return existing;
+          }
+        }
+
+        const versionPath = packageVersionPath(
+          this.paths,
+          bundle.manifest.id,
+          bundle.manifest.version,
+        );
+        try {
+          return lease.commit(() => {
+            this.publishBundle(versionPath, bundle, digest);
+            lockfile.packages[bundle.manifest.id] = packageEntry(
+              bundle,
+              normalizedSource,
+              digest,
+            );
+            this.writeLockfileUnlocked(lockfile);
+            return this.toStored(versionPath, bundle, normalizedSource, digest);
+          });
+        } catch (error) {
+          try {
+            lease.commit(() => {
+              fs.rmSync(versionPath, { recursive: true, force: true });
+            });
+          } catch {
+            // A fenced owner leaves the unique version directory for reconciliation.
+          }
+          throw error;
+        }
+      },
+      this.lockOptions,
+    );
+  }
+
+  private readLockfile(): MarketplaceLockfile {
+    if (!fs.existsSync(this.paths.lockfilePath)) {
+      return {
+        schemaVersion: MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
+        packages: {},
+      };
+    }
+    let parsed: unknown;
+    try {
+      parsed = readJson(this.paths.lockfilePath);
+    } catch (error) {
+      if (error instanceof SyntaxError) {
+        throw new MarketplaceLockfileError(
+          `Marketplace lockfile is invalid: ${error.message}`,
+        );
+      }
+      throw error;
+    }
+    const result = MarketplaceLockfileSchema.safeParse(parsed);
+    if (!result.success) {
+      throw new MarketplaceLockfileError(
+        `Marketplace lockfile is invalid: ${result.error.message}`,
+      );
+    }
+    return result.data;
+  }
+
+  private verifyLocked(
+    lockfile: MarketplaceLockfile,
+    id: string,
+  ): MarketplaceVerification {
+    const entry = lockfile.packages[id];
+    if (!entry) return { id, valid: false, message: `${id} is not installed` };
+    try {
+      const stored = this.loadLockedPackage(lockfile, id);
+      return {
+        id,
+        version: stored.manifest.version,
+        valid: true,
+        expectedDigest: entry.digest.value,
+        actualDigest: stored.digest,
+        message: `${id}@${stored.manifest.version} verified`,
+      };
+    } catch (error) {
+      return {
+        id,
+        version: entry.manifestVersion,
+        valid: false,
+        expectedDigest: entry.digest.value,
+        message: error instanceof Error ? error.message : String(error),
+      };
+    }
+  }
+
+  /** Remove abandoned temporary data and unreferenced immutable versions. */
+  private reconcileLockedState(
+    lockfile: MarketplaceLockfile,
+    lease: MarketplaceLease,
+  ): void {
+    lease.commit(() => this.reconcileLockedStateUnlocked(lockfile));
+  }
+
+  private reconcileLockedStateUnlocked(lockfile: MarketplaceLockfile): void {
+    const removedRoot = path.join(this.paths.stagingDir, 'removed');
+    if (fs.existsSync(removedRoot)) {
+      for (const namespace of fs.readdirSync(removedRoot, {
+        withFileTypes: true,
+      })) {
+        if (!namespace.isDirectory()) continue;
+        for (const name of fs.readdirSync(
+          path.join(removedRoot, namespace.name),
+          {
+            withFileTypes: true,
+          },
+        )) {
+          if (!name.isDirectory()) continue;
+          const id = `${namespace.name}/${name.name}`;
+          const quarantinePath = path.join(
+            removedRoot,
+            namespace.name,
+            name.name,
+          );
+          const packagePath = packageIdPath(this.paths, id);
+          if (lockfile.packages[id] && !fs.existsSync(packagePath)) {
+            fs.mkdirSync(path.dirname(packagePath), { recursive: true });
+            fs.renameSync(quarantinePath, packagePath);
+          } else {
+            fs.rmSync(quarantinePath, { recursive: true, force: true });
+          }
+        }
+      }
+    }
+    if (fs.existsSync(this.paths.stagingDir)) {
+      for (const entry of fs.readdirSync(this.paths.stagingDir)) {
+        fs.rmSync(path.join(this.paths.stagingDir, entry), {
+          recursive: true,
+          force: true,
+        });
+      }
+    }
+    if (!fs.existsSync(this.paths.packagesDir)) return;
+    for (const namespace of fs.readdirSync(this.paths.packagesDir, {
+      withFileTypes: true,
+    })) {
+      if (!namespace.isDirectory()) continue;
+      const namespacePath = path.join(this.paths.packagesDir, namespace.name);
+      for (const name of fs.readdirSync(namespacePath, {
+        withFileTypes: true,
+      })) {
+        if (!name.isDirectory()) continue;
+        const id = `${namespace.name}/${name.name}`;
+        const packagePath = path.join(namespacePath, name.name);
+        const selected = lockfile.packages[id]?.manifestVersion;
+        for (const version of fs.readdirSync(packagePath, {
+          withFileTypes: true,
+        })) {
+          if (!version.isDirectory() || version.name === selected) continue;
+          fs.rmSync(path.join(packagePath, version.name), {
+            recursive: true,
+            force: true,
+          });
+        }
+      }
+    }
+  }
+
+  /** Every package read goes through this locked identity/integrity path. */
+  private loadLockedPackage(
+    lockfile: MarketplaceLockfile,
+    id: string,
+  ): StoredMarketplacePackage {
+    const entry = lockfile.packages[id];
+    if (!entry) throw new MarketplaceIntegrityError(`${id} is not installed`);
+    const versionPath = packageVersionPath(
+      this.paths,
+      id,
+      entry.manifestVersion,
+    );
+    const packageFile = path.join(versionPath, 'package.json');
+    const digestFile = path.join(versionPath, 'sha256');
+    try {
+      const bundle = parseBundle(readJson(packageFile));
+      const digest = digestMarketplaceBundle(bundle);
+      const sidecar = fs.readFileSync(digestFile, 'utf8').trim();
+      if (
+        bundle.manifest.id !== id ||
+        bundle.manifest.version !== entry.manifestVersion ||
+        bundle.manifest.schemaVersion !== entry.manifestSchemaVersion ||
+        sidecar !== digest ||
+        entry.digest.algorithm !== 'sha256' ||
+        entry.digest.domain !== MARKETPLACE_DIGEST_DOMAIN ||
+        entry.digest.value !== digest
+      ) {
+        throw new MarketplaceIntegrityError(
+          `Integrity or identity mismatch for ${id}@${entry.manifestVersion}`,
+        );
+      }
+      return this.toStored(versionPath, bundle, entry.source, digest);
+    } catch (error) {
+      if (error instanceof MarketplaceIntegrityError) throw error;
+      if (
+        error instanceof MarketplaceValidationError ||
+        error instanceof SyntaxError
+      ) {
+        throw new MarketplaceIntegrityError(
+          `Cannot load ${id}@${entry.manifestVersion}: ${error instanceof Error ? error.message : String(error)}`,
+        );
+      }
+      throw error;
+    }
+  }
+
+  private publishBundle(
+    versionPath: string,
+    bundle: MarketplacePackageBundle,
+    digest: string,
+  ): void {
+    if (fs.existsSync(versionPath)) {
+      throw new MarketplaceConflictError(
+        `${bundle.manifest.id}@${bundle.manifest.version} already exists in the immutable store`,
+      );
+    }
+    const temporaryPath = path.join(
+      this.paths.stagingDir,
+      `package-${process.pid}-${randomUUID()}`,
+    );
+    try {
+      fs.mkdirSync(temporaryPath, { recursive: true });
+      writeAtomic(
+        path.join(temporaryPath, 'package.json'),
+        `${stableJson(bundle)}\n`,
+      );
+      writeAtomic(path.join(temporaryPath, 'sha256'), `${digest}\n`);
+      fs.mkdirSync(path.dirname(versionPath), { recursive: true });
+      fs.renameSync(temporaryPath, versionPath);
+      syncDirectory(path.dirname(versionPath));
+    } finally {
+      try {
+        if (fs.existsSync(temporaryPath)) {
+          fs.rmSync(temporaryPath, { recursive: true, force: true });
+        }
+      } catch {
+        // A staged directory is never part of the active package set.
+      }
+    }
+  }
+
+  private writeLockfileUnlocked(lockfile: MarketplaceLockfile): void {
+    const normalized: MarketplaceLockfile = {
+      schemaVersion: MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
+      packages: Object.fromEntries(
+        Object.entries(lockfile.packages).sort(([a], [b]) =>
+          a.localeCompare(b),
+        ),
+      ),
+    };
+    writeAtomic(this.paths.lockfilePath, `${stableJson(normalized)}\n`);
+  }
+
+  private toStored(
+    packagePath: string,
+    bundle: MarketplacePackageBundle,
+    source: MarketplaceSource,
+    digest: string,
+  ): StoredMarketplacePackage {
+    return {
+      manifest: bundle.manifest,
+      source,
+      digest,
+      path: packagePath,
+    };
+  }
+}

+ 3 - 0
src/tools/codemap.md

@@ -8,6 +8,7 @@ Centralized tool factory and registry for the OpenCode plugin system. This direc
 - **Code intelligence tools**: AST-grep pattern matching and transformation across languages
 - **Web capabilities**: Smart web fetching with caching and secondary model processing
 - **ACP integration**: External agent protocol execution
+- **Marketplace**: Local offline package install, activation, and status
 - **Preset switching**: On-disk preset persistence helpers used by the TUI `/preset` manager
 
 These tools enable agents to perform file operations, manage background tasks, and interact with external systems while maintaining security boundaries through the OpenCode tool schema. Multi-LLM council orchestration is agent-level (dynamic `councillor-<name>` subagents in `src/agents/`), not a tool.
@@ -30,6 +31,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 | **Task Management** | Background task communication, cancellation, status, results, revival, and HITL continuation control | `task-message.ts`, `cancel-task.ts`, `task-status.ts`, `task-result.ts`, `task-revive.ts`, `wait-for-user.ts` |
 | **Task Policy & Activity** | Shared live-status policy and activity tracking consumed by `task_status` and event wiring | `task-policy.ts` (`summarizeTaskStatus`), `task-activity.ts` (`TaskActivityTracker`) |
 | **ACP Integration** | External agent protocol execution | `acp-run.ts`, ACP client implementation |
+| **Marketplace** | Local offline package lifecycle and status | `marketplace.ts` wrapping `MarketplaceService` |
 | **Code Intelligence** | AST-based code manipulation | `ast-grep/` directory, `tools.ts` |
 | **Web Fetching** | Intelligent web content retrieval | `smartfetch/` directory, `tool.ts` |
 | **Preset Switching** | On-disk preset persistence for the TUI `/preset` manager | `preset-switch.ts`, TUI state integration |
@@ -234,6 +236,7 @@ export { createTaskMessageTool } from './task-message';
 export { createTaskResultTool } from './task-result';
 export { createTaskReviveTool } from './task-revive';
 export { createTaskStatusTool } from './task-status';
+export { createMarketplaceTool } from './marketplace';
 export { createWaitForUserTool } from './wait-for-user';
 ```
 

+ 1 - 0
src/tools/index.ts

@@ -2,6 +2,7 @@
 export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
+export { createMarketplaceTool } from './marketplace';
 export { createWebfetchTool } from './smartfetch';
 export { createTaskMessageTool } from './task-message';
 export { createTaskResultTool } from './task-result';

+ 485 - 0
src/tools/marketplace.test.ts

@@ -0,0 +1,485 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import {
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+  isMarketplacePermissionDenied,
+  resolveDesiredMarketplaceLiveFromDisk,
+} from '../agents';
+import type { MarketplacePackageBundle } from '../marketplace/schemas';
+import { MarketplaceService } from '../marketplace/service';
+import {
+  MARKETPLACE_RELOAD_NOTICE,
+  type MarketplaceLivePackage,
+} from '../marketplace/status';
+import { createMarketplaceTool, parseMarketplaceToolArgs } from './marketplace';
+
+const previousConfigHome = process.env.XDG_CONFIG_HOME;
+
+afterEach(() => {
+  if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
+  else process.env.XDG_CONFIG_HOME = previousConfigHome;
+});
+
+function agentBundle(
+  version = '1.0.0',
+  overrides: Partial<MarketplacePackageBundle['manifest']> = {},
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/docs-researcher',
+      version,
+      kind: 'agent',
+      displayName: 'Docs researcher',
+      description: 'A derived explorer.',
+      instructions: 'Prefer documentation paths first.',
+      author: { name: 'Community' },
+      tags: ['docs'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Research docs and examples.',
+        keywords: ['docs'],
+        delegation: {
+          when: 'When docs research is needed.',
+          preferredRoles: [],
+        },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      baseRole: 'explorer',
+      agentName: 'docsresearcher',
+      overrides: {},
+      ...overrides,
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function profileBundle(): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/deep-explorer',
+      version: '1.0.0',
+      kind: 'profile',
+      displayName: 'Deep explorer',
+      description: 'A specialist profile.',
+      instructions: 'Search more exhaustively than usual.',
+      author: { name: 'Community' },
+      tags: ['profile'],
+      license: 'MIT',
+      compatibility: { plugin: '>=2.2.0 <3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Deep exploration profile.',
+        keywords: ['deep'],
+        delegation: {
+          when: 'When exhaustive search is needed.',
+          preferredRoles: [],
+        },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      targetRole: 'explorer',
+      instructionMode: 'append',
+      overrides: {},
+    } as MarketplacePackageBundle['manifest'],
+  };
+}
+
+function writeBundle(
+  directory: string,
+  bundle: MarketplacePackageBundle,
+  name = 'package.json',
+): string {
+  const filePath = join(directory, name);
+  writeFileSync(filePath, JSON.stringify(bundle));
+  return filePath;
+}
+
+function setupHarness() {
+  const root = mkdtempSync(join(tmpdir(), 'marketplace-tool-'));
+  const configHome = join(root, 'config');
+  const project = join(root, 'project');
+  mkdirSync(join(configHome, 'opencode'), { recursive: true });
+  mkdirSync(join(project, '.opencode'), { recursive: true });
+  process.env.XDG_CONFIG_HOME = configHome;
+  writeFileSync(
+    join(configHome, 'opencode', 'oh-my-opencode-slim.json'),
+    JSON.stringify({
+      preset: 'work',
+      presets: { work: { agents: {} } },
+    }),
+  );
+  const live = {
+    packages: [] as MarketplaceLivePackage[],
+  };
+  const service = new MarketplaceService({
+    rootDir: join(root, 'store'),
+    projectDir: project,
+  });
+  const marketplace = createMarketplaceTool({
+    service,
+    projectDir: project,
+    getLiveSnapshot: () => live,
+    getDesiredLive: () =>
+      resolveDesiredMarketplaceLiveFromDisk(project, service.store),
+    shouldManageSession: () => true,
+  }).marketplace;
+  const context = {
+    sessionID: 'parent-1',
+    agent: 'orchestrator',
+  } as never;
+  return { root, project, configHome, service, marketplace, context, live };
+}
+
+describe('marketplace tool args', () => {
+  test('rejects fields that do not belong to the action', () => {
+    expect(() =>
+      parseMarketplaceToolArgs({
+        action: 'install',
+        path: './package.json',
+        packageId: 'community/docs-researcher',
+      }),
+    ).toThrow();
+    expect(() =>
+      parseMarketplaceToolArgs({ action: 'list', path: './package.json' }),
+    ).toThrow();
+    expect(() =>
+      parseMarketplaceToolArgs({
+        action: 'profile',
+        role: 'explorer',
+        packageId: 'community/deep-explorer',
+        clear: true,
+      }),
+    ).toThrow();
+  });
+});
+
+describe('marketplace tool', () => {
+  test('covers local lifecycle, status, reload notice, and no live-registry mutation', async () => {
+    const { root, project, configHome, marketplace, context, live } =
+      setupHarness();
+    try {
+      const agentPath = writeBundle(project, agentBundle());
+      const profilePath = writeBundle(project, profileBundle(), 'profile.json');
+      const v2Path = writeBundle(
+        project,
+        agentBundle('1.1.0'),
+        'package-v2.json',
+      );
+
+      const installed = String(
+        await marketplace.execute(
+          { action: 'install', path: agentPath },
+          context,
+        ),
+      );
+      expect(installed).toContain('Installed community/docs-researcher@1.0.0');
+      expect(installed).toContain('reload_required: false');
+      expect(installed).not.toContain(MARKETPLACE_RELOAD_NOTICE);
+
+      const imported = String(
+        await marketplace.execute(
+          { action: 'import', path: profilePath },
+          context,
+        ),
+      );
+      expect(imported).toContain('Installed community/deep-explorer@1.0.0');
+      expect(imported).toContain('reload_required: false');
+
+      const listed = String(
+        await marketplace.execute({ action: 'list' }, context),
+      );
+      expect(listed).toContain('community/deep-explorer@1.0.0');
+      expect(listed).toContain('community/docs-researcher@1.0.0');
+
+      const shown = String(
+        await marketplace.execute(
+          {
+            action: 'show',
+            packageId: 'community/docs-researcher',
+          },
+          context,
+        ),
+      );
+      expect(shown).toContain('kind: agent');
+      expect(shown).toContain('role: explorer');
+
+      const verified = String(
+        await marketplace.execute(
+          {
+            action: 'verify',
+            packageId: 'community/docs-researcher',
+          },
+          context,
+        ),
+      );
+      expect(verified).toContain('OK');
+
+      const updated = String(
+        await marketplace.execute({ action: 'update', path: v2Path }, context),
+      );
+      expect(updated).toContain('Updated community/docs-researcher@1.1.0');
+      expect(updated).toContain('reload_required: false');
+
+      const enabled = String(
+        await marketplace.execute(
+          {
+            action: 'enable',
+            packageId: 'community/docs-researcher',
+          },
+          context,
+        ),
+      );
+      expect(enabled).toContain('Enabled community/docs-researcher');
+      expect(enabled).toContain('reload_required: true');
+      expect(enabled).toContain(MARKETPLACE_RELOAD_NOTICE);
+      expect(live.packages).toEqual([]);
+
+      const profiled = String(
+        await marketplace.execute(
+          {
+            action: 'profile',
+            role: 'explorer',
+            packageId: 'community/deep-explorer',
+          },
+          context,
+        ),
+      );
+      expect(profiled).toContain(
+        'Selected community/deep-explorer for explorer',
+      );
+      expect(profiled).toContain(MARKETPLACE_RELOAD_NOTICE);
+
+      const statusAfterMutations = String(
+        await marketplace.execute({ action: 'status' }, context),
+      );
+      expect(statusAfterMutations).toContain('reload_required: true');
+      expect(statusAfterMutations).toContain('community/docs-researcher@1.1.0');
+      expect(statusAfterMutations).toContain('configured_agents:');
+      expect(statusAfterMutations).toContain('community/docs-researcher');
+      expect(statusAfterMutations).toContain('live_packages:');
+      expect(statusAfterMutations).toContain('  (none)');
+      expect(statusAfterMutations).toContain(MARKETPLACE_RELOAD_NOTICE);
+
+      const userConfig = JSON.parse(
+        readFileSync(
+          join(configHome, 'opencode', 'oh-my-opencode-slim.json'),
+          'utf8',
+        ),
+      ) as {
+        presets: {
+          work: {
+            marketplace: {
+              agents: string[];
+              profiles: Record<string, string | null>;
+            };
+          };
+        };
+      };
+      expect(userConfig.presets.work.marketplace.agents).toEqual([
+        'community/docs-researcher',
+      ]);
+      expect(userConfig.presets.work.marketplace.profiles.explorer).toBe(
+        'community/deep-explorer',
+      );
+
+      const cleared = String(
+        await marketplace.execute(
+          { action: 'profile', role: 'explorer', clear: true },
+          context,
+        ),
+      );
+      expect(cleared).toContain('Cleared the explorer profile');
+
+      const disabled = String(
+        await marketplace.execute(
+          {
+            action: 'disable',
+            packageId: 'community/docs-researcher',
+          },
+          context,
+        ),
+      );
+      expect(disabled).toContain('Disabled community/docs-researcher');
+      expect(disabled).toContain('reload_required: false');
+
+      const removed = String(
+        await marketplace.execute(
+          {
+            action: 'remove',
+            packageId: 'community/deep-explorer',
+          },
+          context,
+        ),
+      );
+      expect(removed).toContain('Removed community/deep-explorer');
+      expect(live.packages).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('reports reload required for an active version update', async () => {
+    const { root, project, service, marketplace, context, live } =
+      setupHarness();
+    try {
+      const v1 = writeBundle(project, agentBundle());
+      await marketplace.execute({ action: 'install', path: v1 }, context);
+      await marketplace.execute(
+        { action: 'enable', packageId: 'community/docs-researcher' },
+        context,
+      );
+      const installed = service.show('community/docs-researcher');
+      live.packages = [
+        {
+          packageId: installed.manifest.id,
+          version: installed.manifest.version,
+          digest: installed.digest,
+          runtimeName: installed.manifest.agentName,
+          kind: 'agent',
+        },
+      ];
+      const v2 = writeBundle(project, agentBundle('1.1.0'), 'package-v2.json');
+      const updated = String(
+        await marketplace.execute({ action: 'update', path: v2 }, context),
+      );
+      expect(updated).toContain('reload_required: true');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('reports reload required after force-removing a live package', async () => {
+    const { root, project, service, marketplace, context, live } =
+      setupHarness();
+    try {
+      const path = writeBundle(project, agentBundle());
+      await marketplace.execute({ action: 'install', path }, context);
+      await marketplace.execute(
+        { action: 'enable', packageId: 'community/docs-researcher' },
+        context,
+      );
+      const installed = service.show('community/docs-researcher');
+      live.packages = [
+        {
+          packageId: installed.manifest.id,
+          version: installed.manifest.version,
+          digest: installed.digest,
+          runtimeName: installed.manifest.agentName,
+          kind: 'agent',
+        },
+      ];
+      const removed = String(
+        await marketplace.execute(
+          {
+            action: 'remove',
+            packageId: 'community/docs-researcher',
+            force: true,
+          },
+          context,
+        ),
+      );
+      expect(removed).toContain('reload_required: true');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not claim reload required for idempotent enable', async () => {
+    const { root, project, service, marketplace, context, live } =
+      setupHarness();
+    try {
+      const path = writeBundle(project, agentBundle());
+      await marketplace.execute({ action: 'install', path }, context);
+      await marketplace.execute(
+        { action: 'enable', packageId: 'community/docs-researcher' },
+        context,
+      );
+      const installed = service.show('community/docs-researcher');
+      live.packages = [
+        {
+          packageId: installed.manifest.id,
+          version: installed.manifest.version,
+          digest: installed.digest,
+          runtimeName: installed.manifest.agentName,
+          kind: 'agent',
+        },
+      ];
+      const again = String(
+        await marketplace.execute(
+          { action: 'enable', packageId: 'community/docs-researcher' },
+          context,
+        ),
+      );
+      expect(again).toContain('reload_required: false');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('enforces nested marketplace permission deny at execution', async () => {
+    const { root, context } = setupHarness();
+    const permission = { marketplace: { '*': 'deny' } };
+    const denied = createMarketplaceTool({
+      service: new MarketplaceService({
+        rootDir: join(root, 'store-denied'),
+        projectDir: root,
+      }),
+      projectDir: root,
+      shouldManageSession: () => true,
+      isMarketplaceDenied: () => isMarketplacePermissionDenied(permission),
+    }).marketplace;
+    try {
+      expect(isMarketplacePermissionDenied(permission)).toBe(true);
+      await expect(denied.execute({ action: 'list' }, context)).rejects.toThrow(
+        'denied by permission',
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects marketplace-derived, ACP, and alias callers at execution', async () => {
+    const { root, marketplace } = setupHarness();
+    try {
+      for (const agent of ['docsresearcher', 'bridge', 'Scout']) {
+        await expect(
+          marketplace.execute({ action: 'status' }, {
+            sessionID: 'parent-1',
+            agent,
+          } as never),
+        ).rejects.toThrow('orchestrator');
+      }
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects non-orchestrator callers', async () => {
+    const { root, marketplace } = setupHarness();
+    try {
+      await expect(
+        marketplace.execute({ action: 'list' }, {
+          sessionID: 'child-1',
+          agent: 'fixer',
+        } as never),
+      ).rejects.toThrow('orchestrator');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 326 - 0
src/tools/marketplace.ts

@@ -0,0 +1,326 @@
+import { isAbsolute, join } from 'node:path';
+import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+import { z } from 'zod';
+import {
+  disableMarketplacePackage,
+  enableMarketplaceAgent,
+  setMarketplaceProfile,
+} from '../marketplace/activation-config';
+import type { MarketplaceService } from '../marketplace/service';
+import {
+  collectMarketplaceStatus,
+  formatMarketplaceStatus,
+  type MarketplaceLiveSnapshot,
+  mutationReloadNotice,
+  reloadRequiredAfterMutation,
+} from '../marketplace/status';
+
+const toolZ = tool.schema;
+
+export const MARKETPLACE_TOOL_ACTIONS = [
+  'install',
+  'import',
+  'list',
+  'show',
+  'verify',
+  'update',
+  'enable',
+  'disable',
+  'profile',
+  'remove',
+  'status',
+] as const;
+
+export type MarketplaceToolAction = (typeof MARKETPLACE_TOOL_ACTIONS)[number];
+
+const MarketplaceToolRequestSchema = z.discriminatedUnion('action', [
+  z.object({ action: z.literal('install'), path: z.string().min(1) }).strict(),
+  z.object({ action: z.literal('import'), path: z.string().min(1) }).strict(),
+  z.object({ action: z.literal('update'), path: z.string().min(1) }).strict(),
+  z
+    .object({ action: z.literal('show'), packageId: z.string().min(1) })
+    .strict(),
+  z
+    .object({ action: z.literal('enable'), packageId: z.string().min(1) })
+    .strict(),
+  z
+    .object({ action: z.literal('disable'), packageId: z.string().min(1) })
+    .strict(),
+  z
+    .object({
+      action: z.literal('verify'),
+      packageId: z.string().min(1).optional(),
+    })
+    .strict(),
+  z
+    .object({
+      action: z.literal('remove'),
+      packageId: z.string().min(1),
+      force: z.boolean().optional(),
+    })
+    .strict(),
+  z
+    .object({
+      action: z.literal('profile'),
+      role: z.string().min(1),
+      packageId: z.string().min(1).optional(),
+      clear: z.literal(true).optional(),
+    })
+    .strict(),
+  z.object({ action: z.literal('list') }).strict(),
+  z.object({ action: z.literal('status') }).strict(),
+]);
+
+export type MarketplaceToolRequest = z.infer<
+  typeof MarketplaceToolRequestSchema
+>;
+
+export interface MarketplaceToolOptions {
+  service: MarketplaceService;
+  projectDir: string;
+  getLiveSnapshot?: () => MarketplaceLiveSnapshot | undefined;
+  getDesiredLive?: () => MarketplaceLiveSnapshot;
+  shouldManageSession: (sessionID: string) => boolean;
+  resolveAgentName?: (agent: string) => string;
+  registerSessionAsOrchestrator?: (sessionID: string) => void;
+  isMarketplaceDenied?: () => boolean;
+}
+
+function definedArgs(args: Record<string, unknown>): Record<string, unknown> {
+  return Object.fromEntries(
+    Object.entries(args).filter(([, value]) => value !== undefined),
+  );
+}
+
+export function parseMarketplaceToolArgs(
+  args: Record<string, unknown>,
+): MarketplaceToolRequest {
+  const result = MarketplaceToolRequestSchema.safeParse(definedArgs(args));
+  if (!result.success) {
+    throw new Error(result.error.message);
+  }
+  const request = result.data;
+  if (request.action === 'profile') {
+    if (request.clear === true) {
+      if (request.packageId !== undefined) {
+        throw new Error('profile --clear accepts only a role');
+      }
+    } else if (!request.packageId) {
+      throw new Error('profile requires a role and package ID');
+    }
+  }
+  return request;
+}
+
+function resolvePackagePath(projectDir: string, filePath: string): string {
+  return isAbsolute(filePath) ? filePath : join(projectDir, filePath);
+}
+
+function mutationResult(
+  options: MarketplaceToolOptions,
+  message: string,
+): string {
+  return mutationReloadNotice(
+    message,
+    reloadRequiredAfterMutation({
+      service: options.service,
+      projectDir: options.projectDir,
+      live: options.getLiveSnapshot?.(),
+      desiredLive: options.getDesiredLive?.(),
+    }),
+  );
+}
+
+function formatPackageLine(pkg: {
+  manifest: {
+    id: string;
+    version: string;
+    kind: string;
+    displayName: string;
+    description: string;
+    baseRole?: string;
+    targetRole?: string;
+  };
+  source: unknown;
+}): string {
+  const role =
+    pkg.manifest.kind === 'agent'
+      ? pkg.manifest.baseRole
+      : pkg.manifest.targetRole;
+  return [
+    `${pkg.manifest.id}@${pkg.manifest.version}`,
+    `kind: ${pkg.manifest.kind}`,
+    `role: ${role}`,
+    `displayName: ${pkg.manifest.displayName}`,
+    `description: ${pkg.manifest.description}`,
+    `source: ${JSON.stringify(pkg.source)}`,
+  ].join('\n');
+}
+
+export function createMarketplaceTool(
+  options: MarketplaceToolOptions,
+): Record<'marketplace', ToolDefinition> {
+  const marketplace = tool({
+    description: `Manage local offline marketplace packages.
+
+Use this tool for install, import, list, show, verify, update, enable, disable, profile, remove, and status. Do not shell out to the CLI for these actions. There is no network registry.
+
+list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write the local store and plugin config only and never hot-swap the live agent registry. Those mutations report reload_required only when disk activation differs from this session; no-op or inactive changes do not.
+
+Action-specific fields: path for install/import/update; packageId for show/enable/disable/remove and optional for verify; role plus packageId or role plus clear=true for profile; no extra fields.`,
+    args: {
+      action: toolZ
+        .enum(MARKETPLACE_TOOL_ACTIONS)
+        .describe('Marketplace action to perform'),
+      path: toolZ
+        .string()
+        .min(1)
+        .optional()
+        .describe('Local package.json path for install, import, or update'),
+      packageId: toolZ
+        .string()
+        .min(1)
+        .optional()
+        .describe(
+          'Canonical package ID for show, verify, enable, disable, profile, or remove',
+        ),
+      role: toolZ
+        .string()
+        .min(1)
+        .optional()
+        .describe('Specialist role for profile'),
+      force: toolZ
+        .boolean()
+        .optional()
+        .describe('Force remove even if the package is still referenced'),
+      clear: toolZ
+        .boolean()
+        .optional()
+        .describe('Clear the selected profile for a specialist role'),
+    },
+    async execute(args, toolContext) {
+      const sessionID = toolContext?.sessionID;
+      if (!sessionID) throw new Error('marketplace requires sessionID');
+      const rawAgent = toolContext?.agent;
+      const agent =
+        typeof rawAgent === 'string'
+          ? (options.resolveAgentName?.(rawAgent) ?? rawAgent)
+          : undefined;
+      if (agent && agent !== 'orchestrator') {
+        throw new Error('marketplace can only be used by orchestrator');
+      }
+      if (options.isMarketplaceDenied?.()) {
+        throw new Error('marketplace is denied by permission');
+      }
+      if (!options.shouldManageSession(sessionID)) {
+        if (agent === 'orchestrator') {
+          options.registerSessionAsOrchestrator?.(sessionID);
+        }
+      }
+      if (!options.shouldManageSession(sessionID)) {
+        throw new Error(
+          'marketplace can only be used in orchestrator sessions',
+        );
+      }
+
+      const request = parseMarketplaceToolArgs(args as Record<string, unknown>);
+      const service = options.service;
+      const projectDir = options.projectDir;
+
+      switch (request.action) {
+        case 'install':
+        case 'import': {
+          const pkg = service.installFile(
+            resolvePackagePath(projectDir, request.path),
+          );
+          return mutationResult(
+            options,
+            `Installed ${pkg.manifest.id}@${pkg.manifest.version}`,
+          );
+        }
+        case 'update': {
+          const pkg = service.updateFile(
+            resolvePackagePath(projectDir, request.path),
+          );
+          return mutationResult(
+            options,
+            `Updated ${pkg.manifest.id}@${pkg.manifest.version}`,
+          );
+        }
+        case 'list':
+          return service
+            .list()
+            .map((pkg) => {
+              const role =
+                pkg.manifest.kind === 'agent'
+                  ? pkg.manifest.baseRole
+                  : pkg.manifest.targetRole;
+              return `${pkg.manifest.id}@${pkg.manifest.version}\t${pkg.manifest.kind}\t${role}\t${pkg.manifest.displayName}`;
+            })
+            .join('\n');
+        case 'show':
+          return formatPackageLine(service.show(request.packageId));
+        case 'verify': {
+          const results = service.verify(request.packageId);
+          return results
+            .map(
+              (result) => `${result.valid ? 'OK' : 'FAIL'} ${result.message}`,
+            )
+            .join('\n');
+        }
+        case 'remove':
+          service.remove(request.packageId, { force: request.force });
+          return mutationResult(options, `Removed ${request.packageId}`);
+        case 'enable':
+          enableMarketplaceAgent(projectDir, request.packageId, service.store);
+          return mutationResult(
+            options,
+            `Enabled ${request.packageId} in the active preset`,
+          );
+        case 'disable':
+          disableMarketplacePackage(projectDir, request.packageId);
+          return mutationResult(
+            options,
+            `Disabled ${request.packageId} in the active preset`,
+          );
+        case 'profile':
+          if (request.clear === true) {
+            setMarketplaceProfile(
+              projectDir,
+              request.role,
+              null,
+              service.store,
+            );
+            return mutationResult(
+              options,
+              `Cleared the ${request.role} profile`,
+            );
+          }
+          if (!request.packageId) {
+            throw new Error('profile requires a role and package ID');
+          }
+          setMarketplaceProfile(
+            projectDir,
+            request.role,
+            request.packageId,
+            service.store,
+          );
+          return mutationResult(
+            options,
+            `Selected ${request.packageId} for ${request.role}`,
+          );
+        case 'status':
+          return formatMarketplaceStatus(
+            collectMarketplaceStatus({
+              service,
+              projectDir,
+              live: options.getLiveSnapshot?.(),
+              desiredLive: options.getDesiredLive?.(),
+            }),
+          );
+      }
+    },
+  });
+
+  return { marketplace };
+}

+ 102 - 50
src/tools/preset-switch.test.ts

@@ -53,7 +53,9 @@ describe('switchPresetOnDisk', () => {
   test('returns a not-found result for an unknown preset', () => {
     const config: PluginConfig = {
       presets: {
-        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        cheap: {
+          agents: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
       },
     };
 
@@ -79,7 +81,7 @@ describe('switchPresetOnDisk', () => {
   test('returns an empty result when the preset has no valid overrides', () => {
     const config: PluginConfig = {
       presets: {
-        empty: { orchestrator: {} },
+        empty: { agents: { orchestrator: {} } },
       },
     };
 
@@ -90,12 +92,32 @@ describe('switchPresetOnDisk', () => {
     expect(result.message).toContain('no agent overrides');
   });
 
+  test('allows profile-only preset activation', () => {
+    const config: PluginConfig = {
+      presets: {
+        profiles: {
+          agents: {},
+          marketplace: {
+            profiles: { oracle: 'community/oracle-profile' },
+          },
+        },
+      },
+    };
+
+    const result = switchPresetOnDisk(tempDir, 'profiles', config);
+
+    expect(result.ok).toBe(true);
+    expect(result.presetName).toBe('profiles');
+  });
+
   test('switches preset and reports a reload-to-apply message', () => {
     const config: PluginConfig = {
       presets: {
         cheap: {
-          orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          explorer: { model: 'openai/gpt-5.6-luna' },
+          agents: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: { model: 'openai/gpt-5.6-luna' },
+          },
         },
       },
     };
@@ -134,7 +156,9 @@ describe('switchPresetOnDisk', () => {
 
     const config: PluginConfig = {
       presets: {
-        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        cheap: {
+          agents: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
       },
     };
 
@@ -166,7 +190,9 @@ describe('switchPresetOnDisk', () => {
 
     const config: PluginConfig = {
       presets: {
-        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        cheap: {
+          agents: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
       },
     };
 
@@ -186,7 +212,7 @@ describe('switchPresetOnDisk', () => {
   test('resolves legacy alias keys (explore → explorer)', () => {
     const config: PluginConfig = {
       presets: {
-        scout: { explore: { model: 'openai/gpt-5.6-luna' } },
+        scout: { agents: { explore: { model: 'openai/gpt-5.6-luna' } } },
       },
     };
 
@@ -200,9 +226,11 @@ describe('switchPresetOnDisk', () => {
     const config: PluginConfig = {
       presets: {
         mixed: {
-          orchestrator: { model: 'anthropic/claude-3.5-haiku' },
-          explorer: {},
-          oracle: { temperature: 0.3 },
+          agents: {
+            orchestrator: { model: 'anthropic/claude-3.5-haiku' },
+            explorer: {},
+            oracle: { temperature: 0.3 },
+          },
         },
       },
     };
@@ -222,8 +250,10 @@ describe('switchPresetOnDisk', () => {
     const config: PluginConfig = {
       presets: {
         fallback: {
-          orchestrator: {
-            model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.6'],
+          agents: {
+            orchestrator: {
+              model: ['anthropic/claude-3.5-haiku', 'openai/gpt-5.6'],
+            },
           },
         },
       },
@@ -241,11 +271,13 @@ describe('switchPresetOnDisk', () => {
     const config: PluginConfig = {
       presets: {
         thinker: {
-          oracle: {
-            model: [
-              { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
-              { id: 'openai/o3' },
-            ],
+          agents: {
+            oracle: {
+              model: [
+                { id: 'anthropic/claude-sonnet-4-6', variant: 'thinking' },
+                { id: 'openai/o3' },
+              ],
+            },
           },
         },
       },
@@ -263,10 +295,12 @@ describe('switchPresetOnDisk', () => {
     const config: PluginConfig = {
       presets: {
         precise: {
-          orchestrator: {
-            model: 'openai/o3',
-            temperature: 0.1,
-            options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
+          agents: {
+            orchestrator: {
+              model: 'openai/o3',
+              temperature: 0.1,
+              options: { thinking: { type: 'enabled', budgetTokens: 10000 } },
+            },
           },
         },
       },
@@ -284,7 +318,9 @@ describe('switchPresetOnDisk', () => {
     // No config file on disk; persistPresetName is best-effort.
     const config: PluginConfig = {
       presets: {
-        cheap: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        cheap: {
+          agents: { orchestrator: { model: 'anthropic/claude-3.5-haiku' } },
+        },
       },
     };
 
@@ -303,7 +339,7 @@ describe('writePreset', () => {
     );
 
     const ok = writePreset(tempDir, 'scout', {
-      explorer: { model: 'openai/gpt-5.6-luna' },
+      agents: { explorer: { model: 'openai/gpt-5.6-luna' } },
     });
 
     expect(ok).toBe(true);
@@ -314,7 +350,7 @@ describe('writePreset', () => {
       ),
     ) as { presets?: Record<string, unknown> };
     expect(persisted.presets?.scout).toEqual({
-      explorer: { model: 'openai/gpt-5.6-luna' },
+      agents: { explorer: { model: 'openai/gpt-5.6-luna' } },
     });
     // existing fields preserved
     expect(persisted.preset).toBe('old');
@@ -329,12 +365,12 @@ describe('writePreset', () => {
       configPath,
       `\uFEFF${JSON.stringify({
         preset: 'old',
-        presets: { existing: { oracle: { model: 'a' } } },
+        presets: { existing: { agents: { oracle: { model: 'a' } } } },
       })}`,
     );
 
     const ok = writePreset(tempDir, 'scout', {
-      explorer: { model: 'openai/gpt-5.6-luna' },
+      agents: { explorer: { model: 'openai/gpt-5.6-luna' } },
     });
 
     expect(ok).toBe(true);
@@ -344,9 +380,11 @@ describe('writePreset', () => {
     };
     // Existing fields survived, proving the BOM-prefixed file was parsed
     expect(persisted.preset).toBe('old');
-    expect(persisted.presets?.existing).toEqual({ oracle: { model: 'a' } });
+    expect(persisted.presets?.existing).toEqual({
+      agents: { oracle: { model: 'a' } },
+    });
     expect(persisted.presets?.scout).toEqual({
-      explorer: { model: 'openai/gpt-5.6-luna' },
+      agents: { explorer: { model: 'openai/gpt-5.6-luna' } },
     });
   });
 
@@ -357,12 +395,12 @@ describe('writePreset', () => {
     fs.writeFileSync(
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        presets: { scout: { orchestrator: { model: 'old' } } },
+        presets: { scout: { agents: { orchestrator: { model: 'old' } } } },
       }),
     );
 
     writePreset(tempDir, 'scout', {
-      oracle: { model: 'new' },
+      agents: { oracle: { model: 'new' } },
     });
 
     const persisted = JSON.parse(
@@ -371,7 +409,9 @@ describe('writePreset', () => {
         'utf-8',
       ),
     ) as { presets?: Record<string, unknown> };
-    expect(persisted.presets?.scout).toEqual({ oracle: { model: 'new' } });
+    expect(persisted.presets?.scout).toEqual({
+      agents: { oracle: { model: 'new' } },
+    });
   });
 
   test('writes into a freshly empty user config', () => {
@@ -381,7 +421,7 @@ describe('writePreset', () => {
     fs.writeFileSync(path.join(configDir, 'oh-my-opencode-slim.json'), '{}');
 
     const ok = writePreset(tempDir, 'solo', {
-      orchestrator: { model: 'x' },
+      agents: { orchestrator: { model: 'x' } },
     });
 
     expect(ok).toBe(true);
@@ -391,7 +431,9 @@ describe('writePreset', () => {
         'utf-8',
       ),
     ) as { presets?: Record<string, unknown> };
-    expect(persisted.presets?.solo).toEqual({ orchestrator: { model: 'x' } });
+    expect(persisted.presets?.solo).toEqual({
+      agents: { orchestrator: { model: 'x' } },
+    });
   });
 });
 
@@ -404,8 +446,8 @@ describe('deletePreset', () => {
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         presets: {
-          scout: { orchestrator: { model: 'a' } },
-          keep: { oracle: { model: 'b' } },
+          scout: { agents: { orchestrator: { model: 'a' } } },
+          keep: { agents: { oracle: { model: 'b' } } },
         },
       }),
     );
@@ -419,7 +461,9 @@ describe('deletePreset', () => {
         'utf-8',
       ),
     ) as { presets?: Record<string, unknown> };
-    expect(persisted.presets).toEqual({ keep: { oracle: { model: 'b' } } });
+    expect(persisted.presets).toEqual({
+      keep: { agents: { oracle: { model: 'b' } } },
+    });
   });
 
   test('clears the active preset field when deleting the active preset', () => {
@@ -430,7 +474,7 @@ describe('deletePreset', () => {
       path.join(configDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         preset: 'scout',
-        presets: { scout: { orchestrator: { model: 'a' } } },
+        presets: { scout: { agents: { orchestrator: { model: 'a' } } } },
       }),
     );
 
@@ -452,7 +496,9 @@ describe('deletePreset', () => {
     process.env.OPENCODE_CONFIG_DIR = configDir;
     fs.writeFileSync(
       path.join(configDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({ presets: { keep: { orchestrator: { model: 'a' } } } }),
+      JSON.stringify({
+        presets: { keep: { agents: { orchestrator: { model: 'a' } } } },
+      }),
     );
 
     expect(deletePreset(tempDir, 'missing')).toBe(false);
@@ -465,41 +511,47 @@ describe('deletePreset', () => {
 
 describe('setAgentOverride / removeAgentFromPreset', () => {
   test('setAgentOverride adds a new agent immutably', () => {
-    const preset = { orchestrator: { model: 'a' } };
+    const preset = { agents: { orchestrator: { model: 'a' } } };
     const next = setAgentOverride(preset, 'oracle', { model: 'b' });
     expect(next).toEqual({
-      orchestrator: { model: 'a' },
-      oracle: { model: 'b' },
+      agents: {
+        orchestrator: { model: 'a' },
+        oracle: { model: 'b' },
+      },
     });
-    expect(preset).toEqual({ orchestrator: { model: 'a' } });
+    expect(preset).toEqual({ agents: { orchestrator: { model: 'a' } } });
   });
 
   test('setAgentOverride replaces an existing agent', () => {
-    const preset = { orchestrator: { model: 'a' } };
+    const preset = { agents: { orchestrator: { model: 'a' } } };
     const next = setAgentOverride(preset, 'orchestrator', {
       model: 'b',
       variant: 'thinking',
     });
     expect(next).toEqual({
-      orchestrator: { model: 'b', variant: 'thinking' },
+      agents: { orchestrator: { model: 'b', variant: 'thinking' } },
     });
   });
 
   test('removeAgentFromPreset removes an agent immutably', () => {
     const preset = {
-      orchestrator: { model: 'a' },
-      oracle: { model: 'b' },
+      agents: {
+        orchestrator: { model: 'a' },
+        oracle: { model: 'b' },
+      },
     };
     const next = removeAgentFromPreset(preset, 'oracle');
-    expect(next).toEqual({ orchestrator: { model: 'a' } });
+    expect(next).toEqual({ agents: { orchestrator: { model: 'a' } } });
     expect(preset).toEqual({
-      orchestrator: { model: 'a' },
-      oracle: { model: 'b' },
+      agents: {
+        orchestrator: { model: 'a' },
+        oracle: { model: 'b' },
+      },
     });
   });
 
   test('removeAgentFromPreset is a no-op for absent agents', () => {
-    const preset = { orchestrator: { model: 'a' } };
+    const preset = { agents: { orchestrator: { model: 'a' } } };
     expect(removeAgentFromPreset(preset, 'oracle')).toBe(preset);
   });
 });

+ 10 - 7
src/tools/preset-switch.ts

@@ -61,7 +61,10 @@ export function switchPresetOnDisk(
   }
 
   const agentUpdates = buildAgentUpdates(preset);
-  if (Object.keys(agentUpdates).length === 0) {
+  const hasMarketplaceActivation =
+    preset.marketplace?.agents !== undefined ||
+    preset.marketplace?.profiles !== undefined;
+  if (Object.keys(agentUpdates).length === 0 && !hasMarketplaceActivation) {
     return {
       ok: false,
       presetName,
@@ -86,7 +89,7 @@ export function switchPresetOnDisk(
  */
 export function buildAgentUpdates(preset: Preset): Record<string, AgentUpdate> {
   const agentUpdates: Record<string, AgentUpdate> = {};
-  for (const [agentName, override] of Object.entries(preset)) {
+  for (const [agentName, override] of Object.entries(preset.agents)) {
     const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
     const agentConfig = mapOverrideToAgentConfig(override);
     if (Object.keys(agentConfig).length > 0) {
@@ -257,7 +260,7 @@ export function setAgentOverride(
   agentName: string,
   override: AgentOverrideConfig,
 ): Preset {
-  return { ...preset, [agentName]: override };
+  return { ...preset, agents: { ...preset.agents, [agentName]: override } };
 }
 
 /**
@@ -268,8 +271,8 @@ export function removeAgentFromPreset(
   preset: Preset,
   agentName: string,
 ): Preset {
-  if (!(agentName in preset)) return preset;
-  const next = { ...preset };
-  delete next[agentName];
-  return next;
+  if (!(agentName in preset.agents)) return preset;
+  const agents = { ...preset.agents };
+  delete agents[agentName];
+  return { ...preset, agents };
 }

+ 13 - 13
src/tui-preset.ts

@@ -239,7 +239,7 @@ function promptAndCreatePreset(
             confirmOverwritePreset(state, name, onCancel);
             return;
           }
-          editPresetWorkingCopy(state, name, {});
+          editPresetWorkingCopy(state, name, { agents: {} });
         },
         onCancel,
       }),
@@ -264,7 +264,7 @@ function confirmOverwritePreset(
         title: 'Preset exists',
         message: `A preset named "${name}" already exists. Overwrite it with a new empty preset?`,
         onConfirm: () => {
-          editPresetWorkingCopy(state, name, {});
+          editPresetWorkingCopy(state, name, { agents: {} });
         },
         onCancel: () => promptAndCreatePreset(state, onCancel),
       }),
@@ -274,7 +274,7 @@ function confirmOverwritePreset(
 
 function editPreset(state: ManagerState, presetName: string): void {
   const config = loadPluginConfig(state.directory, { silent: true });
-  const preset = config.presets?.[presetName] ?? {};
+  const preset = config.presets?.[presetName] ?? { agents: {} };
   // Work on a shallow copy so in-memory edits don't mutate the loaded config.
   editPresetWorkingCopy(state, presetName, { ...preset });
 }
@@ -284,11 +284,11 @@ function editPresetWorkingCopy(
   presetName: string,
   working: Preset,
 ): void {
-  const agentNames = Object.keys(working);
+  const agentNames = Object.keys(working.agents);
   const options: TuiDialogSelectOption<string>[] = agentNames.map((name) => ({
     title: name,
     value: name,
-    description: describeOverride(working[name]),
+    description: describeOverride(working.agents[name]),
   }));
   options.push({ title: '+ Add agent', value: ACTION_ADD_AGENT });
   options.push({ title: '− Remove agent', value: '__omo_remove_agent__' });
@@ -356,7 +356,7 @@ function promptAddAgent(
   presetName: string,
   working: Preset,
 ): void {
-  const present = new Set(Object.keys(working));
+  const present = new Set(Object.keys(working.agents));
   const available = ALL_AGENT_NAMES.filter((n) => !present.has(n));
   if (available.length === 0) {
     state.api.ui.toast({
@@ -399,7 +399,7 @@ function promptRemoveAgent(
   presetName: string,
   working: Preset,
 ): void {
-  const agentNames = Object.keys(working);
+  const agentNames = Object.keys(working.agents);
   if (agentNames.length === 0) {
     state.api.ui.toast({
       variant: 'info',
@@ -412,7 +412,7 @@ function promptRemoveAgent(
   const options: TuiDialogSelectOption<string>[] = agentNames.map((n) => ({
     title: n,
     value: n,
-    description: describeOverride(working[n]),
+    description: describeOverride(working.agents[n]),
   }));
   options.push({ title: '← Back', value: ACTION_BACK });
 
@@ -449,10 +449,10 @@ function savePreset(
   silent = false,
 ): boolean {
   // Strip agents whose override is empty — they add nothing to the preset.
-  const cleaned: Preset = {};
-  for (const [agent, override] of Object.entries(working)) {
+  const cleaned: Preset = { ...working, agents: {} };
+  for (const [agent, override] of Object.entries(working.agents)) {
     if (Object.keys(override).length > 0) {
-      cleaned[agent] = override;
+      cleaned.agents[agent] = override;
     }
   }
   const ok = writePreset(state.directory, presetName, cleaned);
@@ -481,7 +481,7 @@ function editAgent(
   working: Preset,
   agentName: string,
 ): void {
-  const current = working[agentName] ?? {};
+  const current = working.agents[agentName] ?? {};
   pickModel(state, presetName, working, agentName, current);
 }
 
@@ -773,7 +773,7 @@ function pickOptions(
 // --- formatting helpers (also used by the simple list view if needed) ---
 
 function describePreset(preset: Preset): string {
-  const parts = Object.entries(preset).map(
+  const parts = Object.entries(preset.agents).map(
     ([agent, override]) => `${agent}: ${describeOverride(override)}`,
   );
   return parts.length > 0 ? parts.join(', ') : '(empty)';

+ 20 - 0
src/utils/agent-variant.test.ts

@@ -88,6 +88,26 @@ describe('resolveRuntimeAgentName', () => {
     );
   });
 
+  test('resolves aliases from the final registry mappings', () => {
+    expect(
+      resolveRuntimeAgentName(
+        {
+          canonicalIdByRuntimeName: {
+            docsresearcher: 'docsresearcher',
+            docsalias: 'docsresearcher',
+            explore: 'explorer',
+            explorer: 'explorer',
+          },
+          runtimeNameByCanonicalId: {
+            docsresearcher: 'docsalias',
+            explorer: 'explorer',
+          },
+        },
+        '@docsalias',
+      ),
+    ).toBe('docsresearcher');
+  });
+
   test('returns normalized name when no displayName match exists', () => {
     const config = {
       agents: {

+ 53 - 20
src/utils/agent-variant.ts

@@ -5,6 +5,23 @@ import {
 } from '../config';
 import type { RuntimeConfig } from '../config/runtime';
 
+export const SAFE_AGENT_ALIAS_RE = /^[a-z][a-z0-9_-]*$/i;
+
+export function isSafeAgentAlias(name: string): boolean {
+  return SAFE_AGENT_ALIAS_RE.test(name);
+}
+
+export interface AgentAliasRegistry {
+  readonly canonicalIdByRuntimeName: Readonly<Record<string, string>>;
+  readonly runtimeNameByCanonicalId: Readonly<Record<string, string>>;
+}
+
+function isAliasRegistry(
+  source: RuntimeConfig | AgentAliasRegistry,
+): source is AgentAliasRegistry {
+  return 'canonicalIdByRuntimeName' in source;
+}
+
 /**
  * Normalizes an agent name by trimming whitespace and removing the optional @ prefix.
  *
@@ -52,7 +69,7 @@ function getPluginOverride(
  * - displayName aliases (e.g. "advisor" -> "oracle")
  */
 export function resolveRuntimeAgentName(
-  runtime: RuntimeConfig,
+  source: RuntimeConfig | AgentAliasRegistry,
   agentName: string,
 ): string {
   const normalized = normalizeAgentName(agentName);
@@ -60,12 +77,16 @@ export function resolveRuntimeAgentName(
     return normalized;
   }
 
+  if (isAliasRegistry(source)) {
+    return source.canonicalIdByRuntimeName[normalized] ?? normalized;
+  }
+
   if ((ALL_AGENT_NAMES as readonly string[]).includes(normalized)) {
     return normalized;
   }
 
-  for (const internalName of getRuntimeAgentNames(runtime)) {
-    const displayName = getPluginOverride(runtime, internalName)?.displayName;
+  for (const internalName of getRuntimeAgentNames(source)) {
+    const displayName = getPluginOverride(source, internalName)?.displayName;
     if (!displayName) {
       continue;
     }
@@ -85,28 +106,40 @@ export function escapeRegExp(value: string): string {
 export type DisplayNameMentionRewriter = (text: string) => string;
 
 export function createDisplayNameMentionRewriter(
-  runtime: RuntimeConfig,
+  source: RuntimeConfig | AgentAliasRegistry,
 ): DisplayNameMentionRewriter {
   const replacements: Array<{ regex: RegExp; internalName: string }> = [];
 
-  for (const internalName of getRuntimeAgentNames(runtime)) {
-    const displayName = getPluginOverride(runtime, internalName)?.displayName;
-    if (!displayName) {
-      continue;
+  if (isAliasRegistry(source)) {
+    for (const [canonical, runtimeName] of Object.entries(
+      source.runtimeNameByCanonicalId,
+    )) {
+      if (!runtimeName || runtimeName === canonical) continue;
+      replacements.push({
+        regex: new RegExp(`(^|[^\\w.])@${escapeRegExp(runtimeName)}\\b`, 'g'),
+        internalName: canonical,
+      });
     }
-
-    const normalizedDisplayName = normalizeAgentName(displayName);
-    if (!normalizedDisplayName || normalizedDisplayName === internalName) {
-      continue;
+  } else {
+    for (const internalName of getRuntimeAgentNames(source)) {
+      const displayName = getPluginOverride(source, internalName)?.displayName;
+      if (!displayName) {
+        continue;
+      }
+
+      const normalizedDisplayName = normalizeAgentName(displayName);
+      if (!normalizedDisplayName || normalizedDisplayName === internalName) {
+        continue;
+      }
+
+      replacements.push({
+        regex: new RegExp(
+          `(^|[^\\w.])@${escapeRegExp(normalizedDisplayName)}\\b`,
+          'g',
+        ),
+        internalName,
+      });
     }
-
-    replacements.push({
-      regex: new RegExp(
-        `(^|[^\\w.])@${escapeRegExp(normalizedDisplayName)}\\b`,
-        'g',
-      ),
-      internalName,
-    });
   }
 
   if (replacements.length === 0) {

+ 23 - 0
src/utils/package-metadata.ts

@@ -0,0 +1,23 @@
+import { readFileSync } from 'node:fs';
+
+interface PackageMetadata {
+  version?: unknown;
+}
+
+/** Read the installed plugin version from the package shipped with this code. */
+export function readPluginPackageVersion(): string | undefined {
+  for (const packageUrl of [
+    new URL('../../package.json', import.meta.url),
+    new URL('../package.json', import.meta.url),
+  ]) {
+    try {
+      const metadata = JSON.parse(
+        readFileSync(packageUrl, 'utf8'),
+      ) as PackageMetadata;
+      if (typeof metadata.version === 'string') return metadata.version;
+    } catch {
+      // Try the next package location for source and bundled execution.
+    }
+  }
+  return undefined;
+}

+ 22 - 10
src/v2/tui.test.ts

@@ -16,11 +16,15 @@ function makeConfig(): PluginConfig {
     preset: 'balanced',
     presets: {
       balanced: {
-        orchestrator: { model: 'anthropic/claude-sonnet-4-5' },
-        explorer: { model: 'openai/gpt-5-mini' },
+        agents: {
+          orchestrator: { model: 'anthropic/claude-sonnet-4-5' },
+          explorer: { model: 'openai/gpt-5-mini' },
+        },
       },
       cheap: {
-        orchestrator: { model: 'openai/gpt-5-mini', temperature: 0.4 },
+        agents: {
+          orchestrator: { model: 'openai/gpt-5-mini', temperature: 0.4 },
+        },
       },
     },
   } as PluginConfig;
@@ -89,8 +93,10 @@ describe('v2 tui preset plugin', () => {
     test('persists the preset name and returns a success message', () => {
       writeUserConfig({
         presets: {
-          balanced: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
-          cheap: { orchestrator: { model: 'openai/gpt-5-mini' } },
+          balanced: {
+            agents: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
+          },
+          cheap: { agents: { orchestrator: { model: 'openai/gpt-5-mini' } } },
         },
       });
 
@@ -153,8 +159,10 @@ describe('v2 tui preset plugin', () => {
       writeUserConfig({
         preset: 'balanced',
         presets: {
-          balanced: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
-          cheap: { orchestrator: { model: 'openai/gpt-5-mini' } },
+          balanced: {
+            agents: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
+          },
+          cheap: { agents: { orchestrator: { model: 'openai/gpt-5-mini' } } },
         },
       });
       const stub = makeStubCtx('cheap');
@@ -181,8 +189,10 @@ describe('v2 tui preset plugin', () => {
       writeUserConfig({
         preset: 'balanced',
         presets: {
-          balanced: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
-          cheap: { orchestrator: { model: 'openai/gpt-5-mini' } },
+          balanced: {
+            agents: { orchestrator: { model: 'anthropic/claude-sonnet-4-5' } },
+          },
+          cheap: { agents: { orchestrator: { model: 'openai/gpt-5-mini' } } },
         },
       });
       const stub = makeStubCtx('cheap');
@@ -196,7 +206,9 @@ describe('v2 tui preset plugin', () => {
 
     test('cancels silently when the dialog is dismissed', async () => {
       writeUserConfig({
-        presets: { cheap: { orchestrator: { model: 'openai/gpt-5-mini' } } },
+        presets: {
+          cheap: { agents: { orchestrator: { model: 'openai/gpt-5-mini' } } },
+        },
       });
       const stub = makeStubCtx(undefined);
 

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio