Browse Source

fix(background-jobs): resolve PR 886 merge conflicts

Alvin Unreal 6 days ago
parent
commit
86119551cd
89 changed files with 5836 additions and 800 deletions
  1. 63 0
      .all-contributorsrc
  2. 10 10
      README.ja-JP.md
  3. 51 29
      README.ko-KR.md
  4. 22 11
      README.md
  5. 10 10
      README.zh-CN.md
  6. 1 4
      docs/authors-preset.md
  7. 7 1
      docs/background-orchestration.md
  8. 92 2
      docs/configuration.md
  9. 20 7
      docs/mcps.md
  10. 1 1
      docs/openai-preset.md
  11. 10 12
      docs/opencode-go-preset.md
  12. 1 1
      docs/opencode-zen-free-preset.md
  13. 35 17
      docs/project-local-customization.md
  14. 1 1
      docs/quick-reference.md
  15. 2 2
      docs/thirty-dollars-preset.md
  16. 8 2
      docs/tools.md
  17. 281 0
      docs/webfetch.md
  18. 58 13
      oh-my-opencode-slim.schema.json
  19. 1 1
      src/agents/codemap.md
  20. 7 2
      src/agents/council.ts
  21. 3 1
      src/agents/councillor.ts
  22. 1 1
      src/agents/fixer.ts
  23. 130 3
      src/agents/index.test.ts
  24. 25 13
      src/agents/index.ts
  25. 0 1
      src/agents/librarian.ts
  26. 23 7
      src/agents/orchestrator.ts
  27. 67 0
      src/agents/resolve-prompt-warn.test.ts
  28. 2 2
      src/cli/config-io.test.ts
  29. 5 4
      src/cli/providers.test.ts
  30. 4 4
      src/cli/providers.ts
  31. 36 0
      src/cli/skills.test.ts
  32. 3 1
      src/cli/skills.ts
  33. 28 0
      src/companion/manager.test.ts
  34. 3 1
      src/companion/manager.ts
  35. 1 2
      src/config/agent-mcps.test.ts
  36. 1 1
      src/config/agent-mcps.ts
  37. 1 2
      src/config/codemap.md
  38. 8 0
      src/config/constants.ts
  39. 142 1
      src/config/loader.test.ts
  40. 80 2
      src/config/loader.ts
  41. 5 3
      src/config/project-local-customization.test.ts
  42. 24 0
      src/config/schema.test.ts
  43. 22 8
      src/config/schema.ts
  44. 24 0
      src/health-check.test.ts
  45. 62 0
      src/health-check.ts
  46. 0 10
      src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap
  47. 14 5
      src/hooks/cache-safety-harness.test.ts
  48. 143 130
      src/hooks/cache-safety.property.test.ts
  49. 4 0
      src/hooks/foreground-fallback/index.ts
  50. 1 1
      src/hooks/json-error-recovery/codemap.md
  51. 0 1
      src/hooks/json-error-recovery/hook.ts
  52. 475 0
      src/hooks/task-session-manager/board-cache-breakpoint.test.ts
  53. 672 75
      src/hooks/task-session-manager/board-injection.ts
  54. 748 0
      src/hooks/task-session-manager/board-tool-pairing.test.ts
  55. 10 7
      src/hooks/task-session-manager/codemap.md
  56. 29 0
      src/hooks/task-session-manager/continuation-evaluator.ts
  57. 46 0
      src/hooks/task-session-manager/continuation-model-selection.ts
  58. 14 3
      src/hooks/task-session-manager/event-router.ts
  59. 1 5
      src/hooks/task-session-manager/idle-reconciliation.ts
  60. 981 138
      src/hooks/task-session-manager/index.test.ts
  61. 51 4
      src/hooks/task-session-manager/index.ts
  62. 1 12
      src/index.test.ts
  63. 99 62
      src/index.ts
  64. 9 24
      src/mcp/codemap.md
  65. 19 21
      src/mcp/index.test.ts
  66. 6 14
      src/mcp/index.ts
  67. 0 47
      src/mcp/websearch.ts
  68. 1 1
      src/skills/oh-my-opencode-slim/SKILL.md
  69. 2 2
      src/skills/reflect/SKILL.md
  70. 1 0
      src/tools/smartfetch/codemap.md
  71. 53 1
      src/tools/smartfetch/secondary-model.test.ts
  72. 29 11
      src/tools/smartfetch/secondary-model.ts
  73. 2 1
      src/tools/smartfetch/tool.ts
  74. 14 0
      src/tools/smartfetch/types.ts
  75. 72 1
      src/tools/smartfetch/utils.test.ts
  76. 27 2
      src/tools/smartfetch/utils.ts
  77. 330 0
      src/utils/background-job-board.test.ts
  78. 79 12
      src/utils/background-job-board.ts
  79. 26 0
      src/utils/background-job-coordinator.test.ts
  80. 17 1
      src/utils/background-job-coordinator.ts
  81. 5 0
      src/utils/background-job-store.ts
  82. 1 1
      src/utils/codemap.md
  83. 11 6
      src/utils/env.test.ts
  84. 175 1
      src/utils/logger.test.ts
  85. 82 17
      src/utils/logger.ts
  86. 63 0
      src/utils/session-metadata.test.ts
  87. 90 0
      src/utils/session-metadata.ts
  88. 45 0
      src/utils/session.test.ts
  89. 7 1
      src/utils/session.ts

+ 63 - 0
.all-contributorsrc

@@ -767,6 +767,69 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "pmolinal",
+      "name": "Patricio Molina",
+      "avatar_url": "https://avatars.githubusercontent.com/u/1817596?v=4",
+      "profile": "https://github.com/pmolinal",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "vinilouz",
+      "name": "vinilouz",
+      "avatar_url": "https://avatars.githubusercontent.com/u/20116132?v=4",
+      "profile": "https://github.com/vinilouz",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "MyGO-Mujica",
+      "name": "Homura",
+      "avatar_url": "https://avatars.githubusercontent.com/u/190353468?v=4",
+      "profile": "https://github.com/MyGO-Mujica",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "major",
+      "name": "Major Hayden",
+      "avatar_url": "https://avatars.githubusercontent.com/u/89910?v=4",
+      "profile": "https://major.io/",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "FrancoStino",
+      "name": "Davide Ladisa",
+      "avatar_url": "https://avatars.githubusercontent.com/u/32127923?v=4",
+      "profile": "https://github.com/FrancoStino",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "Max-Null",
+      "name": "Max-Null",
+      "avatar_url": "https://avatars.githubusercontent.com/u/24647158?v=4",
+      "profile": "https://github.com/Max-Null",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "brucemead",
+      "name": "Bruce",
+      "avatar_url": "https://avatars.githubusercontent.com/u/5895525?v=4",
+      "profile": "https://github.com/brucemead",
+      "contributions": [
+        "code"
+      ]
     }
   ],
   "commitConvention": "angular"

+ 10 - 10
README.ja-JP.md

@@ -37,7 +37,7 @@ oh-my-opencode-slim は OpenCode 向けのエージェントオーケストレ
 - **[Companion](docs/companion.md)** - 並列のバックグラウンド専門家を含む、稼働中のエージェントを表示する任意のフローティングデスクトップウィンドウです。
 - **[マルチプレクサー統合](docs/multiplexer-integration.md)** - Tmux、Zellij、Herdr、cmux、kitty のペインでエージェントの作業をライブ表示します。
 - **[プリセット切り替え](docs/preset-switching.md)** - `/preset` でチーム全体のモデルを実行時に切り替えます。
-- **[コードインテリジェンスツール](docs/tools.md)** - 25 言語対応の LSP、AST 対応検索、Web 検索・ドキュメント・GitHub コード検索用の組み込み MCP を提供します。
+- **[コードインテリジェンスツール](docs/tools.md)** - 25 言語対応の LSP、AST 対応検索、ドキュメント・GitHub コード検索用の組み込み MCP を提供します。
 - **[完全にカスタマイズ可能](docs/configuration.md)** - カスタムエージェント、プロンプト上書き、エージェントごとのスキル/MCP 権限、[プロジェクトローカルのカスタマイズ](docs/project-local-customization.md)に対応します。
 
 ### OpenAI GPT-5.6
@@ -155,19 +155,19 @@ bun run build
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "xhigh", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.6-sol", "variant": "xhigh", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
+      "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": "xhigh", "skills": [], "mcps": [] }
     },
     "opencode-go": {
-      "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "max", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
-      "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
-      "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "max", "skills": [], "mcps": [] },
-      "designer": { "model": "opencode-go/kimi-k2.7-code", "variant": "medium", "skills": [], "mcps": [] },
-      "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] },
-      "observer": { "model": "opencode-go/mimo-v2.5", "variant": "max", "skills": [], "mcps": [] }
+      "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" }
     }
   }
 }
@@ -625,7 +625,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[Background Orchestration](docs/background-orchestration.md)** | ネイティブのバックグラウンドサブエージェントを中心にした、スケジューラー優先の Orchestrator モデル |
 | **[Maintainer Guide](docs/maintainers.md)** | Issue のトリアージルール、ラベルの意味、サポートの振り分け、リポジトリ運用ワークフロー |
 | **[Skills](docs/skills.md)** | `simplify`、`codemap`、`clonedeps`、`deepwork`、`verification-planning`、`reflect`、`worktrees`、`oh-my-opencode-slim` などの同梱スキル |
-| **[MCPs](docs/mcps.md)** | `websearch`、`context7`、`gh_grep`、およびエージェントごとの MCP 権限の仕組み |
+| **[MCPs](docs/mcps.md)** | `context7`、`gh_grep`、およびエージェントごとの MCP 権限の仕組み |
 | **[Tools](docs/tools.md)** | `webfetch`、LSP ツール、コード検索、フォーマッターなどの組み込みツール機能 |
 
 ### 💡 プリセット

+ 51 - 29
README.ko-KR.md

@@ -48,7 +48,7 @@ oh-my-opencode-slim은 OpenCode용 에이전트 오케스트레이션 플러그
 - **[프리셋 전환](docs/preset-switching.md)** - `/preset`으로 실행 중에 팀 전체의
   모델을 교체합니다.
 - **[코드 인텔리전스 도구](docs/tools.md)** - 25개 언어를 지원하는 LSP 도구와
-  AST 인식 검색, 웹 검색·문서·GitHub 코드 검색용 내장 MCP를 제공합니다.
+  AST 인식 검색, 문서·GitHub 코드 검색용 내장 MCP를 제공합니다.
 - **[완전한 사용자 지정](docs/configuration.md)** - 커스텀 에이전트, 프롬프트
   오버라이드, 에이전트별 스킬/MCP 권한 및
   [프로젝트 로컬 사용자 지정](docs/project-local-customization.md)을 지원합니다.
@@ -101,6 +101,13 @@ Install and configure oh-my-opencode-slim: https://raw.githubusercontent.com/alv
 bunx oh-my-opencode-slim@latest install
 ```
 
+배포된 CLI는 Node.js 호환 번들이므로 Bun이 설치되어 있지 않다면 `npx`도
+사용할 수 있습니다:
+
+```bash
+npx oh-my-opencode-slim@latest install
+```
+
 ### Master 브랜치에서 실행하기
 
 최신 코드를 사용하거나, 버그를 고치거나, 로컬에서 개발하고 기여하려면 이
@@ -151,7 +158,7 @@ bun run build
 4. **각 에이전트에 사용할 모델을 업데이트합니다**
 
 > [!TIP]
-> 자동 위임이 어떻게 동작하는지 이해하는 것을 **권장**합니다. **[Orchestrator 프롬프트](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 에는 위임 규칙, 전문 에이전트 라우팅 로직, 메인 에이전트가 언제 서브에이전트로 작업을 넘겨야 하는지에 대한 임계값이 포함되어 있습니다. 수동으로 위임하려면 `@agentName <task>`로 서브에이전트를 호출하면 됩니다.
+> **백그라운드 오케스트레이션**의 작동 방식을 이해하는 것을 **권장**합니다. **[Orchestrator 프롬프트](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 에는 스케줄러 규칙, 전문 에이전트 라우팅 로직, 작업을 백그라운드 에이전트에 할당하는 시점을 결정하는 임계값이 포함되어 있습니다. `@agentName <task>`로 서브에이전트를 호출해 언제든 수동으로 위임할 수도 있습니다.
 
 > [!TIP]
 > 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux, Zellij, Herdr, cmux, 또는 kitty 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
@@ -166,19 +173,19 @@ bun run build
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "xhigh", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.6-sol", "variant": "xhigh", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
+      "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": "xhigh", "skills": [], "mcps": [] }
     },
     "opencode-go": {
-      "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "max", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
-      "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
-      "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "max", "skills": [], "mcps": [] },
-      "designer": { "model": "opencode-go/kimi-k2.7-code", "variant": "medium", "skills": [], "mcps": [] },
-      "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] },
-      "observer": { "model": "opencode-go/mimo-v2.5", "variant": "max", "skills": [], "mcps": [] }
+      "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" }
     }
   }
 }
@@ -252,7 +259,7 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>openai/gpt-5.6-terra (medium)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8</code>
+      <b>추천 모델:</b> <code>claude-fable-5</code> <code>claude-opus-4-8</code> <code>glm-5.2</code> <code>gpt-5.6-terra</code> <code>mimo-v2.5</code> <code>minimax-m3</code> <code>qwen3.7-plus</code>
     </td>
   </tr>
   <tr>
@@ -293,7 +300,7 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>openai/gpt-5.3-codex</code> <code>cerebras/zai-glm-4.7</code> <code>fireworks-ai/accounts/fireworks/routers/kimi-k2p6-turbo</code>
+      <b>추천 모델:</b> <code>deepseek-v4-flash</code> <code>gpt-5.3-codex</code>
     </td>
   </tr>
   <tr>
@@ -334,7 +341,7 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>openai/gpt-5.6-sol (xhigh)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8 (xhigh)</code>
+      <b>추천 모델:</b> <code>claude-fable-5</code> <code>claude-opus-4-8</code> <code>deepseek-v4-pro</code> <code>glm-5.2</code> <code>gpt-5.6-sol</code> <code>qwen3.7-max</code>
     </td>
   </tr>
   <tr>
@@ -424,7 +431,7 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>openai/gpt-5.3-codex</code> <code>cerebras/zai-glm-4.7</code> <code>fireworks-ai/accounts/fireworks/routers/kimi-k2p6-turbo</code>
+      <b>추천 모델:</b> <code>deepseek-v4-flash</code> <code>gpt-5.3-codex</code> <code>mimo-v2.5</code> <code>minimax-m2.7</code>
     </td>
   </tr>
   <tr>
@@ -465,7 +472,7 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>google/gemini-3.5-flash</code> <code>moonshotai/kimi-k2.7-code</code>
+      <b>추천 모델:</b> <code>gemini-3.5-flash</code> <code>kimi-k2.7-code</code> <code>minimax-m3</code>
     </td>
   </tr>
   <tr>
@@ -501,12 +508,12 @@ ping all agents
   </tr>
   <tr>
     <td colspan="2">
-      <b>기본 모델:</b> <code>openai/gpt-5.6-luna (medium)</code>
+      <b>기본 모델:</b> <code>openai/gpt-5.6-luna</code>
     </td>
   </tr>
   <tr>
     <td colspan="2">
-      <b>추천 모델:</b> <code>openai/gpt-5.6-luna (medium)</code> <code>anthropic/claude-sonnet-4-6</code>
+      <b>추천 모델:</b> <code>claude-sonnet-4-6</code> <code>deepseek-v4-flash</code> <code>gpt-5.6-luna</code> <code>kimi-k2.7-code</code>
     </td>
   </tr>
   <tr>
@@ -551,6 +558,11 @@ ping all agents
       <b>기본 모델:</b> <code>openai/gpt-5.6-luna</code> - <i>비전 지원 모델을 구성하여 활성화</i>
     </td>
   </tr>
+  <tr>
+    <td colspan="2">
+      <b>추천 모델:</b> <code>mimo-v2.5</code> <code>qwen3.5-plus</code>
+    </td>
+  </tr>
   <tr>
     <td colspan="2">
       <b>모델 가이드:</b> 에이전트가 스크린샷, 이미지, PDF 및 기타 시각 파일을 읽게 하려면 비전 지원 모델을 선택하세요.
@@ -570,11 +582,16 @@ ping all agents
 플레이북입니다. 인스톨러는 8개의 스킬을 번들로 제공하고 플러그인 자동
 업데이트 시 최신 상태로 유지하며, 로컬 사용자 지정은 보존합니다.
 
+> [!TIP]
+> 로컬에서 수정한 번들 스킬을 폐기하고 패키지 업데이트를 적용하려면
+> `bunx oh-my-opencode-slim install --skills=force`를 실행하세요. 이 명령은
+> 설치된 번들 스킬을 패키지 버전으로 의도적으로 교체합니다.
+
 | 스킬 | 용도 | 기본 에이전트 | 호출 방법 |
 |:----:|------|---------------|-----------|
 | <img src="img/skills/codemap.webp" width="120" alt="Codemap artifact"><br>[`codemap`](src/skills/codemap/SKILL.md) | 에이전트가 모든 것을 다시 읽지 않고 코드베이스를 이해하도록 돕는 계층형 저장소 지도 | `orchestrator` | `run codemap` |
 | <img src="img/skills/deepwork.webp" width="120" alt="Deepwork artifact"><br>[`deepwork`](src/skills/deepwork/SKILL.md) | 검토 게이트를 갖춘 대규모·고위험·다단계 코딩 세션용 구조화된 워크플로 | `orchestrator` | `/deepwork <task>` |
-| <img src="img/skills/verification-planning.webp" width="120" alt="Verification Planning artifact"><br>[`verification-planning`](src/skills/verification-planning/SKILL.md) | 중요하지 않은 변경이 아닌 경우, 프로젝트별 증거 경로를 미리 계획 | `orchestrator` | 중요한 작업 전 자동 |
+| <img src="img/skills/verification-planning.webp" width="120" alt="Verification Planning artifact"><br>[`verification-planning`](src/skills/verification-planning/SKILL.md) | 중요한 변경 전에 프로젝트별 증거 경로를 미리 계획 | `orchestrator` | 중요한 작업 전 자동 |
 | <img src="img/skills/simplify.webp" width="120" alt="Simplify artifact"><br>[`simplify`](src/skills/simplify/SKILL.md) | 가독성과 유지보수성을 위한 동작 보존 단순화 | `oracle` | 단순화를 요청하거나 리뷰 중 |
 | <img src="img/skills/worktrees.webp" width="120" alt="Worktrees artifact"><br>[`worktrees`](src/skills/worktrees/SKILL.md) | 고위험 또는 병렬 작업을 위한 안전하고 격리된 코딩 레인으로 Git worktree 사용 | `orchestrator` | `work in a worktree` |
 | <img src="img/skills/clonedeps.webp" width="120" alt="Clonedeps artifact"><br>[`clonedeps`](src/skills/clonedeps/SKILL.md) | 에이전트가 라이브러리 내부를 검사하도록 의존성 소스를 로컬에 복제 | `orchestrator` | `clone dependencies` |
@@ -600,7 +617,7 @@ ping all agents
 한눈에 더 쉽게 파악할 수 있습니다.
 
 <div align="center">
-  <img src="img/companion.gif" alt="Companion showing active agents" width="600">
+  <img src="img/companion.gif" alt="활성 에이전트를 보여 주는 Companion" width="600">
   <p><i>왼쪽 아래의 시각적 companion.</i></p>
 </div>
 
@@ -646,17 +663,9 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[Background Orchestration](docs/background-orchestration.md)** | 네이티브 백그라운드 서브에이전트를 기반으로 한 스케줄러 우선 Orchestrator 모델 |
 | **[Maintainer Guide](docs/maintainers.md)** | 이슈 트리아지 규칙, 라벨 의미, 지원 라우팅, 저장소 유지보수 워크플로우 |
 | **[Skills](docs/skills.md)** | `simplify`, `codemap`, `clonedeps`, `deepwork`, `verification-planning`, `reflect`, `worktrees`, `oh-my-opencode-slim` 등 번들된 스킬 |
-| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `gh_grep` 및 에이전트별 MCP 권한 동작 방식 |
+| **[MCPs](docs/mcps.md)** | `context7`, `gh_grep` 및 에이전트별 MCP 권한 동작 방식 |
 | **[Tools](docs/tools.md)** | `webfetch`, LSP 도구, 코드 검색, 포매터 등 내장 도구 기능 |
 
-### 💡 프리셋
-
-| 문서 | 내용 |
-|-----|------|
-| **[Author's Preset](docs/authors-preset.md)** | 작성자의 일상적인 혼합 프로바이더 설정 |
-| **[$30 Preset](docs/thirty-dollars-preset.md)** | 월 약 $30 예산의 혼합 프로바이더 설정 |
-| **[OpenCode Go Preset](docs/opencode-go-preset.md)** | 인스톨러가 생성하는 번들 `opencode-go` 프리셋 |
-
 ---
 
 ## 🏛️ 기여자
@@ -666,7 +675,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
   <p><sub>병합된 모든 기여는 이 영역에 흔적을 남깁니다.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-76-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-85-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -778,6 +787,19 @@ bunx oh-my-opencode-slim@latest install --companion=yes
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/umi008"><img src="https://avatars.githubusercontent.com/u/200843810?v=4?s=100" width="100px;" alt="Ulises Millán"/><br /><sub><b>Ulises Millán</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=umi008" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/HighColdHC"><img src="https://avatars.githubusercontent.com/u/35870222?v=4?s=100" width="100px;" alt="HighColdHC"/><br /><sub><b>HighColdHC</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=HighColdHC" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://hardcore.engineer/about"><img src="https://avatars.githubusercontent.com/u/401815?v=4?s=100" width="100px;" alt="Stephan Schielke"/><br /><sub><b>Stephan Schielke</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=stephanschielke" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/DanMaly"><img src="https://avatars.githubusercontent.com/u/69809112?v=4?s=100" width="100px;" alt="Daniel Maly"/><br /><sub><b>Daniel Maly</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=DanMaly" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/Chewji9875"><img src="https://avatars.githubusercontent.com/u/126886556?v=4?s=100" width="100px;" alt="Chewji"/><br /><sub><b>Chewji</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=Chewji9875" title="Code">💻</a></td>
+    </tr>
+    <tr>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/DanielMaly"><img src="https://avatars.githubusercontent.com/u/1443921?v=4?s=100" width="100px;" alt="Daniel Maly"/><br /><sub><b>Daniel Maly</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=DanielMaly" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://giuseppebellamacina.com/"><img src="https://avatars.githubusercontent.com/u/102151655?v=4?s=100" width="100px;" alt="Giuseppe Bellamacina"/><br /><sub><b>Giuseppe Bellamacina</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=GiuseppeBellamacina" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/Zhanyuanium"><img src="https://avatars.githubusercontent.com/u/92024923?v=4?s=100" width="100px;" alt="Zhanyuanium"/><br /><sub><b>Zhanyuanium</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=Zhanyuanium" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/kaze-gif"><img src="https://avatars.githubusercontent.com/u/114116466?v=4?s=100" width="100px;" alt="かぜ"/><br /><sub><b>かぜ</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=kaze-gif" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/tsankotsanev"><img src="https://avatars.githubusercontent.com/u/76694544?v=4?s=100" width="100px;" alt="Tsanko Tsanev"/><br /><sub><b>Tsanko Tsanev</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=tsankotsanev" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/shixi-li"><img src="https://avatars.githubusercontent.com/u/40780706?v=4?s=100" width="100px;" alt="cyril"/><br /><sub><b>cyril</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=shixi-li" title="Code">💻</a></td>
+    </tr>
+    <tr>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/pmolinal"><img src="https://avatars.githubusercontent.com/u/1817596?v=4?s=100" width="100px;" alt="Patricio Molina"/><br /><sub><b>Patricio Molina</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=pmolinal" title="Code">💻</a></td>
     </tr>
   </tbody>
 </table>

+ 22 - 11
README.md

@@ -47,7 +47,7 @@ The main idea is simple: instead of forcing one model to do everything, the plug
 - **[Preset switching](docs/preset-switching.md)** - swap the whole team's
   models at runtime with `/preset`.
 - **[Code intelligence tools](docs/tools.md)** - LSP tools, AST-aware search
-  across 25 languages, and built-in MCPs for web search, docs, and GitHub code
+  across 25 languages, and built-in MCPs for docs and GitHub code
   search.
 - **[Fully customizable](docs/configuration.md)** - custom agents, prompt
   overrides, per-agent skill/MCP permissions, and
@@ -174,19 +174,19 @@ The default generated configuration includes both `openai` and `opencode-go` pre
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "xhigh", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.6-sol", "variant": "xhigh", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
+      "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": "xhigh", "skills": [], "mcps": [] }
     },
     "opencode-go": {
-      "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "max", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
-      "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
-      "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "max", "skills": [], "mcps": [] },
-      "designer": { "model": "opencode-go/kimi-k2.7-code", "variant": "medium", "skills": [], "mcps": [] },
-      "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] },
-      "observer": { "model": "opencode-go/mimo-v2.5", "variant": "max", "skills": [], "mcps": [] }
+      "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" }
     }
   }
 }
@@ -666,7 +666,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Background Orchestration](docs/background-orchestration.md)** | Scheduler-first orchestrator model built around native background subagents |
 | **[Maintainer Guide](docs/maintainers.md)** | Issue triage rules, label meanings, support routing, and repo maintenance workflow |
 | **[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)** | `websearch`, `context7`, `gh_grep`, and how MCP permissions work per agent |
+| **[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 |
 
 ---
@@ -678,7 +678,7 @@ Use this section as a map: start with installation, then jump to features, confi
   <p><sub>Every merged contribution leaves a mark on the realm.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-84-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-91-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -801,6 +801,17 @@ Use this section as a map: start with installation, then jump to features, confi
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/tsankotsanev"><img src="https://avatars.githubusercontent.com/u/76694544?v=4?s=100" width="100px;" alt="Tsanko Tsanev"/><br /><sub><b>Tsanko Tsanev</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=tsankotsanev" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/shixi-li"><img src="https://avatars.githubusercontent.com/u/40780706?v=4?s=100" width="100px;" alt="cyril"/><br /><sub><b>cyril</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=shixi-li" title="Code">💻</a></td>
     </tr>
+    <tr>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/pmolinal"><img src="https://avatars.githubusercontent.com/u/1817596?v=4?s=100" width="100px;" alt="Patricio Molina"/><br /><sub><b>Patricio Molina</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=pmolinal" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/vinilouz"><img src="https://avatars.githubusercontent.com/u/20116132?v=4?s=100" width="100px;" alt="vinilouz"/><br /><sub><b>vinilouz</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=vinilouz" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/MyGO-Mujica"><img src="https://avatars.githubusercontent.com/u/190353468?v=4?s=100" width="100px;" alt="Homura"/><br /><sub><b>Homura</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=MyGO-Mujica" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://major.io/"><img src="https://avatars.githubusercontent.com/u/89910?v=4?s=100" width="100px;" alt="Major Hayden"/><br /><sub><b>Major Hayden</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=major" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/FrancoStino"><img src="https://avatars.githubusercontent.com/u/32127923?v=4?s=100" width="100px;" alt="Davide Ladisa"/><br /><sub><b>Davide Ladisa</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=FrancoStino" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/Max-Null"><img src="https://avatars.githubusercontent.com/u/24647158?v=4?s=100" width="100px;" alt="Max-Null"/><br /><sub><b>Max-Null</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=Max-Null" title="Code">💻</a></td>
+    </tr>
+    <tr>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/brucemead"><img src="https://avatars.githubusercontent.com/u/5895525?v=4?s=100" width="100px;" alt="Bruce"/><br /><sub><b>Bruce</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=brucemead" title="Code">💻</a></td>
+    </tr>
   </tbody>
 </table>
 

+ 10 - 10
README.zh-CN.md

@@ -37,7 +37,7 @@ oh-my-opencode-slim 是一个用于 OpenCode 的智能体编排插件。它内
 - **[Companion](docs/companion.md)** —— 可选的浮动桌面窗口,显示哪些智能体正在运行,包括并行后台专家。
 - **[多路复用器集成](docs/multiplexer-integration.md)** —— 在 Tmux、Zellij、Herdr 或 cmux 窗格中实时观察智能体工作。
 - **[预设切换](docs/preset-switching.md)** —— 使用 `/preset` 在运行时更换整支团队的模型。
-- **[代码智能工具](docs/tools.md)** —— LSP 工具、支持 25 种语言的 AST 感知搜索,以及用于 Web 搜索、文档和 GitHub 代码搜索的内置 MCP。
+- **[代码智能工具](docs/tools.md)** —— LSP 工具、支持 25 种语言的 AST 感知搜索,以及用于文档和 GitHub 代码搜索的内置 MCP。
 - **[完全可定制](docs/configuration.md)** —— 自定义智能体、提示词覆盖、按智能体控制的 Skill/MCP 权限,以及[项目本地定制](docs/project-local-customization.md)。
 
 ### OpenAI GPT-5.6
@@ -148,19 +148,19 @@ bun run build
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "xhigh", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.6-sol", "variant": "xhigh", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
+      "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": "xhigh", "skills": [], "mcps": [] }
     },
     "opencode-go": {
-      "orchestrator": { "model": "opencode-go/minimax-m3", "variant": "max", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
-      "oracle": { "model": "opencode-go/qwen3.7-max", "variant": "max", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
-      "explorer": { "model": "opencode-go/deepseek-v4-flash", "variant": "max", "skills": [], "mcps": [] },
-      "designer": { "model": "opencode-go/kimi-k2.7-code", "variant": "medium", "skills": [], "mcps": [] },
-      "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] },
-      "observer": { "model": "opencode-go/mimo-v2.5", "variant": "max", "skills": [], "mcps": [] }
+      "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" }
     }
   }
 }
@@ -617,7 +617,7 @@ bunx oh-my-opencode-slim@latest install --companion=yes
 | **[后台编排](docs/background-orchestration.md)** | 围绕原生后台子智能体构建的调度器优先 Orchestrator 模型 |
 | **[维护者指南](docs/maintainers.md)** | issue 分流规则、标签含义、支持路由和仓库维护工作流 |
 | **[Skills](docs/skills.md)** | `simplify`、`codemap`、`clonedeps`、`deepwork`、`verification-planning`、`reflect`、`worktrees` 和 `oh-my-opencode-slim` 等捆绑技能 |
-| **[MCPs](docs/mcps.md)** | `websearch`、`context7`、`gh_grep` 以及每个智能体的 MCP 权限机制 |
+| **[MCPs](docs/mcps.md)** | `context7`、`gh_grep` 以及每个智能体的 MCP 权限机制 |
 | **[Tools](docs/tools.md)** | `webfetch`、LSP 工具、代码搜索和格式化工具等内置工具能力 |
 
 ### 💡 预设配置

+ 1 - 4
docs/authors-preset.md

@@ -27,8 +27,7 @@ This is the exact configuration the author runs day-to-day.
         "mcps": [
           "*",
           "!context7",
-          "!gh_app",
-          "!websearch"
+          "!gh_app"
         ]
       },
       "oracle": {
@@ -52,7 +51,6 @@ This is the exact configuration the author runs day-to-day.
           "customer-research"
         ],
         "mcps": [
-          "websearch",
           "context7",
           "gh_app",
           "searxng",
@@ -146,7 +144,6 @@ Each skill is listed with a short description and its source. The config block a
 | `vite` | Vite build tool | `public` |
 | `vue` | Vue framework | `public` |
 | `web-perf` | Web performance optimization | `author` |
-| `websearch` | (MCP) web search | `public` |
 | `workers-best-practices` | Worker best practices | `author` |
 
 For the complete configuration reference, see [Configuration](configuration.md).

+ 7 - 1
docs/background-orchestration.md

@@ -164,7 +164,9 @@ changes before launching a replacement lane.
 Terminal jobs are reconciled automatically after their result is injected into
 the orchestrator session. That lifecycle state is not proof the output was used;
 the orchestrator must still verify it consumed the relevant result before
-finalizing.
+finalizing. When idle reconciliation performs that reconciliation, the opt-in
+continuation evaluator can run in the same idle cycle, subject to its existing
+guards.
 
 Specialist outputs are inputs, not final truth. The orchestrator reconciles them
 against each other and the original user goal.
@@ -350,6 +352,10 @@ unavailable or malformed. A matching reply, or a rejected question, clears its
 tool-backed wait but does not itself inject a nudge; the normal session lifecycle
 decides whether a later nudge is needed.
 
+When idle reconciliation first reconciles an injected terminal result, the
+opt-in evaluator may run in that same idle cycle; the existing liveness,
+wait, fallback, and one-attempt guards still apply.
+
 For external manual work, the orchestrator first gives the user concrete steps,
 then calls `wait_for_user` as its final tool action. This explicit signal covers
 text-only HITL turns without attempting to infer intent from assistant prose. The

+ 92 - 2
docs/configuration.md

@@ -124,6 +124,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `agents.<customAgent>.orchestratorPrompt` | string | - | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
 | `agents.<agent>.permission` | object \| string | - | Tool-level permission rules enforced by the SDK. See [Agent Permissions](#agent-permissions) |
 | `agents.<agent>.displayName` | string | - | Custom user-facing alias for the agent in the active config |
+| `agents.<agent>.description` | string | generated | Description shown to OpenCode and the orchestrator; defaults to `Custom subagent '<name>'` for custom agents |
 | `acpAgents.<name>.command` | string | - | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` See [ACP-connected agents](#acp-connected-agents). |
 | `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command See [ACP-connected agents](#acp-connected-agents). |
 | `acpAgents.<name>.env` | object | `{}` | Extra environment variables for the ACP subprocess See [ACP-connected agents](#acp-connected-agents). |
@@ -145,6 +146,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` See [Multiplexer Integration](multiplexer-integration.md). |
 | `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` See [Multiplexer Integration](multiplexer-integration.md). |
 | `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.maxContextLines` | integer | `50000` | Maximum total context lines (sum of all tracked file line counts) for a session to remain reusable. Sessions exceeding this threshold are evicted from the reusable pool on completion See [Background Job Management](#background-job-management). |
 | `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss See [Background Job Management](#background-job-management). |
@@ -347,6 +349,94 @@ Notes:
 - Display names must be unique
 - Display names cannot conflict with internal agent names like `oracle` or `explorer`
 
+### Per-preset agent configuration
+
+To get per-preset behavior for any agent, built-in (`council`, `oracle`,
+`explorer`, `librarian`, `fixer`, `designer`, `observer`) or custom, define
+the agent override inside each preset block, not in root `agents`.
+
+```jsonc
+{
+  "presets": {
+    "balanced": {
+      "council": { "model": ["opencode/mimo-v2.5-free", "opencode-go/minimax-m3", "opencode/minimax-m3"] },
+      "oracle": { "model": "opencode/big-pickle", "variant": "high" },
+      "skeptic": { "model": ["opencode/big-pickle", "opencode-go/qwen3.7-plus"], "variant": "max" }
+    },
+    "nvidia-free": {
+      "council": { "model": ["nvidia/z-ai/glm-5.2", "nvidia/moonshotai/kimi-k2.6"] },
+      "oracle": { "model": "nvidia/deepseek-ai/deepseek-v4-pro", "variant": "high" },
+      "skeptic": { "model": ["nvidia/deepseek-ai/deepseek-v4-pro", "nvidia/mistralai/mistral-large-3-675b-instruct-2512"], "variant": "max" }
+    }
+  }
+}
+```
+
+#### Root `agents` wins the merge (config-file presets)
+
+At startup, config-file presets merge into `config.agents` via
+`deepMerge(preset, config.agents)` at `src/config/loader.ts:365`. The
+second argument wins for conflicting scalars, so root `agents` overrides
+the preset. A root entry for an agent makes the config-file preset value
+for that agent ignored — the agent becomes global instead of per-preset.
+Root `agents` is the escape hatch for values that should never vary by
+preset.
+
+**Runtime presets reverse this.** When a preset is activated at runtime
+via the `/preset` command, the merge at `src/index.ts:227` is
+`deepMerge(config.agents, presetAgents)` — the runtime preset is the
+override and wins. Root `agents` only guarantees precedence for
+config-file presets resolved at startup.
+
+#### Sharing a prompt across presets (custom agents)
+
+A custom agent with a long prompt does not need the prompt duplicated into
+every preset block. Put the prompt in a file and define the agent in each
+preset with only `model` (and `variant` if needed):
+
+1. Create `<projectDir>/.opencode/oh-my-opencode-slim/<agentName>.md` with
+   the shared prompt.
+2. In each preset block, define the agent with only the model fields (no
+   `prompt`):
+
+```jsonc
+{
+  "presets": {
+    "balanced": {
+      "skeptic": { "model": ["opencode/big-pickle", "opencode-go/qwen3.7-plus"], "variant": "max" }
+    },
+    "nvidia-free": {
+      "skeptic": { "model": ["nvidia/deepseek-ai/deepseek-v4-pro", "nvidia/mistralai/mistral-large-3-675b-instruct-2512"], "variant": "max" }
+    }
+  }
+}
+```
+
+`loadAgentPrompt` (`src/config/loader.ts:418`) is preset-aware and reads
+`<agentName>.md` from the `oh-my-opencode-slim/` prompts directory. Lookup
+order:
+
+1. `<projectDir>/.opencode/oh-my-opencode-slim/<preset>/<agentName>.md` (project, preset-specific)
+2. `<projectDir>/.opencode/oh-my-opencode-slim/<agentName>.md` (project, preset-agnostic)
+3. `~/.config/opencode/oh-my-opencode-slim/<preset>/<agentName>.md` (user, preset-specific)
+4. `~/.config/opencode/oh-my-opencode-slim/<agentName>.md` (user, preset-agnostic)
+
+A preset block without `prompt` falls back to the file prompt (if one
+exists), not to a root `agents.<name>.prompt`. The project-level paths (1
+and 2) work universally and are the recommended location for shared
+prompts. User-level paths (3 and 4) can collide with a plugin install
+symlink if `~/.config/opencode/oh-my-opencode-slim/` is symlinked to the
+plugin source.
+
+> **⚠️ Known limitation (#899):** Prompt files take precedence over inline
+> prompts everywhere — not just in presets, but also in root `agents`.
+> If you set an inline `prompt` in a preset or in root `agents` and a
+> prompt file exists for that agent, the inline prompt is silently dropped
+> in favor of the file. Until #899 is fixed, the file-based shared prompt
+> pattern above is the safe path: keep the prompt in the file, and put
+> only `model`/`variant` in the config. Do not mix an inline `prompt`
+> with a prompt file for the same agent.
+
 ### Custom Agents
 
 Unknown keys under `agents` are treated as custom subagents. A custom agent needs
@@ -392,7 +482,7 @@ The field accepts either:
       "model": "openai/gpt-5.5",
       "variant": "high",
       "skills": [],
-      "mcps": ["context7", "websearch"],
+      "mcps": ["context7", "gh_grep"],
       "permission": {
         "edit": "deny",
         "bash": {
@@ -402,7 +492,7 @@ The field accepts either:
           "grep *": "allow"
         },
         "webfetch": "allow",
-        "websearch": "allow",
+        "websearch": "allow", // opencode's built-in websearch tool, not a plugin MCP
         "task": "deny"
       },
       "prompt": "You are Planner. Create implementation plans only. Do not implement code."

+ 20 - 7
docs/mcps.md

@@ -1,6 +1,20 @@
 # MCP Servers
 
-Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and give agents access to external tools - web search, library documentation, and code search.
+Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and give agents access to external tools - library documentation and code search.
+
+---
+
+## Built-in websearch (recommended)
+
+The plugin no longer ships a websearch MCP. OpenCode has a built-in `websearch` tool that replaces it, so you do not need an API key or an extra MCP server.
+
+Enable the built-in tool by setting these environment variables in your shell profile or launch command:
+
+```sh
+env OPENCODE_ENABLE_EXA=true OPENCODE_ENABLE_PARALLEL=true opencode
+```
+
+The built-in tool is Exa-backed (optionally Parallel), needs no API key, and is only available when using the `opencode` provider OR when those flags are set. Control access per agent with `permission: { "websearch": "allow" }` (all tools are allowed by default).
 
 ---
 
@@ -8,7 +22,6 @@ Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and
 
 | MCP | Purpose | Endpoint |
 |-----|---------|----------|
-| `websearch` | Real-time web search via Exa AI | `https://mcp.exa.ai/mcp` |
 | `context7` | Official library documentation (up-to-date) | `https://mcp.context7.com/mcp` |
 | `gh_grep` | GitHub code search via grep.app | `https://mcp.grep.app` |
 
@@ -19,7 +32,7 @@ Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and
 | Agent | Default MCPs |
 |-------|-------------|
 | `orchestrator` | `*`, `!context7` |
-| `librarian` | `websearch`, `context7`, `gh_grep` |
+| `librarian` | `context7`, `gh_grep` |
 | `designer` | none |
 | `oracle` | none |
 | `explorer` | none |
@@ -36,7 +49,7 @@ Control which MCPs each agent can use via the `mcps` array in your preset config
 |--------|---------|
 | `["*"]` | All MCPs |
 | `["*", "!context7"]` | All MCPs except `context7` |
-| `["websearch", "context7"]` | Only listed MCPs |
+| `["context7", "gh_grep"]` | Only listed MCPs |
 | `[]` | No MCPs |
 | `["!*"]` | Deny all MCPs |
 
@@ -55,10 +68,10 @@ Control which MCPs each agent can use via the `mcps` array in your preset config
         "mcps": ["*", "!context7"]
       },
       "librarian": {
-        "mcps": ["websearch", "context7", "gh_grep"]
+        "mcps": ["context7", "gh_grep"]
       },
       "oracle": {
-        "mcps": ["*", "!websearch"]
+        "mcps": ["*", "!gh_grep"]
       },
       "fixer": {
         "mcps": []
@@ -76,7 +89,7 @@ To disable specific MCPs for all agents regardless of preset, add them to `disab
 
 ```json
 {
-  "disabled_mcps": ["websearch"]
+  "disabled_mcps": ["gh_grep"]
 }
 ```
 

+ 1 - 1
docs/openai-preset.md

@@ -66,7 +66,7 @@ setting the top-level `preset` field:
         "model": "openai/gpt-5.6-luna",
         "variant": "low",
         "skills": [],
-        "mcps": ["websearch", "context7", "gh_grep"]
+        "mcps": ["context7", "gh_grep"]
       },
       "explorer": {
         "model": "openai/gpt-5.6-luna",

+ 10 - 12
docs/opencode-go-preset.md

@@ -48,13 +48,13 @@ role:
 
 | Agent | Model |
 |-------|-------|
-| Orchestrator | `opencode-go/minimax-m3` (`max`) |
+| Orchestrator | `opencode-go/minimax-m3` (`thinking`) |
 | Oracle | `opencode-go/qwen3.7-max` (`max`) |
 | Librarian | `opencode-go/deepseek-v4-flash` (`high`) + MCPs |
-| Explorer | `opencode-go/deepseek-v4-flash` (`max`) |
-| Designer | `opencode-go/kimi-k2.7-code` (`medium`) |
+| Explorer | `opencode-go/deepseek-v4-flash` (`high`) |
+| Designer | `opencode-go/kimi-k2.7-code` |
 | Fixer | `opencode-go/deepseek-v4-flash` (`high`) |
-| Observer | `opencode-go/mimo-v2.5` (`max`) |
+| Observer | `opencode-go/mimo-v2.5` |
 
 ## Generated Config Shape
 
@@ -69,7 +69,7 @@ setting the top-level `preset` field:
     "opencode-go": {
       "orchestrator": {
         "model": "opencode-go/minimax-m3",
-        "variant": "max"
+        "variant": "thinking"
       },
       "oracle": {
         "model": "opencode-go/qwen3.7-max",
@@ -78,23 +78,21 @@ setting the top-level `preset` field:
       "librarian": {
         "model": "opencode-go/deepseek-v4-flash",
         "variant": "high",
-        "mcps": ["websearch", "context7", "gh_grep"]
+        "mcps": ["context7", "gh_grep"]
       },
       "explorer": {
         "model": "opencode-go/deepseek-v4-flash",
-        "variant": "max"
+        "variant": "high"
       },
       "designer": {
-        "model": "opencode-go/kimi-k2.7-code",
-        "variant": "medium"
+        "model": "opencode-go/kimi-k2.7-code"
       },
       "fixer": {
         "model": "opencode-go/deepseek-v4-flash",
         "variant": "high"
       },
       "observer": {
-        "model": "opencode-go/mimo-v2.5",
-        "variant": "max"
+        "model": "opencode-go/mimo-v2.5"
       }
     }
   }
@@ -103,7 +101,7 @@ setting the top-level `preset` field:
 
 ## Skill Reference
 
-This preset defines per-agent `skills` and `mcps` via `generateLiteConfig`. The generated config includes `skills: ["*"]` for Orchestrator and agent-specific MCP lists (e.g., Librarian gets `websearch`, `context7`, `gh_grep`).
+This preset defines per-agent `skills` and `mcps` via `generateLiteConfig`. The generated config includes `skills: ["*"]` for Orchestrator and agent-specific MCP lists (e.g., Librarian gets `context7`, `gh_grep`).
 
 | Skill | Description | Source |
 | --- | --- | --- |

+ 1 - 1
docs/opencode-zen-free-preset.md

@@ -36,7 +36,7 @@ You need an API key for the `opencode` provider. Sign up at [OpenCode Zen](https
         "model": "opencode/deepseek-v4-flash-free",
         "temperature": 0.2,
         "skills": [],
-        "mcps": ["websearch", "context7", "gh_grep"]
+        "mcps": ["context7", "gh_grep"]
       },
       "designer": {
         "model": "opencode/mimo-v2.5-free",

+ 35 - 17
docs/project-local-customization.md

@@ -1,6 +1,6 @@
 # Project-local Customization
 
-This document describes how to configure and customize oh-my-opencode-slim on a per-project (repository-specific) basis. Project-local customization allows teams and repositories to define custom agents, override systemic prompts, restrict skills, and orchestrate MCP configurations without polluting global user configurations.
+This document describes how to configure and customize oh-my-opencode-slim on a per-project (repository-specific) basis. Project-local customization lets teams and repositories define custom agents, override systemic prompts, restrict skills, and set MCP configurations without affecting global user configurations.
 
 ## Security & Trust Boundary Warning
 
@@ -16,10 +16,10 @@ This document describes how to configure and customize oh-my-opencode-slim on a
 |---|---|---|
 | **Configuration file** | `.opencode/oh-my-opencode-slim.json[c]` | Project-level configuration file that overrides global user settings, merging presets, agent profiles, and multiplexer integration. |
 | **Custom agents** | `agents` configuration block | Define new specialized agents by keying them under `agents.<custom-name>` with required `model`, custom system `prompt`, and optional routing guidance. |
-| **Built-in prompt overrides** | `.opencode/oh-my-opencode-slim/<agent>.md` | Completely override the built-in system prompt for any agent (e.g. `oracle.md`, `explorer.md`, `orchestrator.md`, or custom agents). |
-| **Append prompts** | `.opencode/oh-my-opencode-slim/<agent>_append.md` | Append additional rules or guidelines to the existing base (inline or default built-in) prompt without overriding it completely. |
+| **Built-in prompt overrides** | `.opencode/oh-my-opencode-slim/<agent>.md` | Override the built-in system prompt for any agent (e.g. `oracle.md`, `explorer.md`, `orchestrator.md`, or custom agents). Acts as the default when no inline `prompt` is set in config. |
+| **Append prompts** | `.opencode/oh-my-opencode-slim/<agent>_append.md` | Append additional rules or guidelines to the existing base (inline, file, or default built-in) prompt without overriding it completely. |
 | **Per-agent skills** | `agents.<agent>.skills` | Explicitly restrict or authorize specific local codebase skills/scripts that this agent is allowed to execute. |
-| **Per-agent MCPs** | `agents.<agent>.mcps` | Assign, restrict, or authorize specific Model Context Protocol (MCP) servers (like `websearch` or `context7`) to specific agents. |
+| **Per-agent MCPs** | `agents.<agent>.mcps` | Assign, restrict, or authorize specific Model Context Protocol (MCP) servers (like `context7` or `gh_grep`) to specific agents. |
 | **Presets** | `presets` configuration block | Bundle named agent environments. User and project preset definitions deep-merge; the active preset then merges into `agents`. |
 | **Precedence** | User config, project config, presets, prompt files | Project-local settings take precedence over user-global settings, while root `agents.*` entries beat active preset entries. |
 
@@ -65,24 +65,38 @@ When looking up markdown prompt template files (such as `<agent>.md` or `<agent>
 
 ## Prompt Composition Rules
 
-For any agent, the final system prompt is computed dynamically using the following formula:
+For any agent, the final system prompt is computed dynamically. Precedence
+is **inline > file > built-in default**:
 
-1. **Calculate Base Prompt:**
+1. **Resolve the effective base prompt:**
    ```
-   base = inlinePrompt ?? defaultBuiltInPrompt
+   effectiveBase = inlinePrompt ?? filePrompt ?? defaultBuiltInPrompt
    ```
-   - For built-in agents, `defaultBuiltInPrompt` is their factory template.
-   - For custom agents, `defaultBuiltInPrompt` defaults to `"You are the <name> specialist."`.
-   - `inlinePrompt` is the inline `prompt` string configured directly inside the `agents.<agent>.prompt` object.
-
-2. **Calculate Effective Base:**
+   - `inlinePrompt` is the `prompt` string set directly in
+     `agents.<agent>.prompt` (config or preset).
+   - `filePrompt` is the content of the resolved `<agent>.md` replacement
+     file (located according to the Prompt Lookup Precedence).
+   - `defaultBuiltInPrompt` is the agent's factory template (built-in
+     agents) or `"You are the <name> specialist."` (custom agents).
+
+   An explicit inline `prompt` always wins over a prompt file. The file
+   acts as a shared default — useful for the "shared prompt, per-preset
+   model" pattern where the file holds the common base and each preset
+   only overrides `model` and `variant`.
+
+2. **Conflict warning:**
+   When both an inline `prompt` and a `<agent>.md` file exist, a
+   `console.warn` is emitted at agent construction:
    ```
-   effectiveBase = filePrompt ?? base
+   [oh-my-opencode] Agent '<name>': inline prompt overrides prompt file
+   (<name>.md). Remove the inline prompt to use the file.
    ```
-   - `filePrompt` is the content of the resolved `<agent>.md` replacement file (located according to the Prompt Lookup Precedence).
+   This is informational — the inline prompt takes effect as expected. The
+   warning surfaces the conflict so you know the file is being ignored.
 
-3. **Append Append Prompt:**
-   - If an append file `<agent>_append.md` is resolved, it is appended to the `effectiveBase` separated by two newlines:
+3. **Append prompt:**
+   - If an append file `<agent>_append.md` is resolved, it is appended to
+     the `effectiveBase` separated by two newlines:
      ```
      finalPrompt = effectiveBase + "\n\n" + appendPrompt
      ```
@@ -128,4 +142,8 @@ If you also place a file under `.opencode/oh-my-opencode-slim/backend-preset/ora
 ```
 Your primary focus is auditing backend security and performance.
 ```
-According to prompt composition rules, the markdown file prompt overrides the inline preset prompt, so the effective base prompt becomes `"Your primary focus is auditing backend security and performance."`.
+The inline preset `prompt` takes precedence over the file, so the effective
+base prompt becomes `"You are the project senior backend oracle. Focus
+strictly on NestJS."`. A `console.warn` fires noting the file is being
+overridden. To use the file prompt instead, remove the inline `prompt` from
+the preset config.

+ 1 - 1
docs/quick-reference.md

@@ -24,7 +24,7 @@
 | Doc | Contents |
 |-----|----------|
 | [Skills](skills.md) | `simplify`, `codemap`, `clonedeps` - skills assignment syntax |
-| [MCPs](mcps.md) | `websearch`, `context7`, `gh_grep` - permissions per agent, global disable |
+| [MCPs](mcps.md) | `context7`, `gh_grep` - permissions per agent, global disable |
 | [Tools](tools.md) | Background tasks, LSP, code search (`ast_grep`), formatters |
 | [Configuration](configuration.md) | Config files, prompt overriding, JSONC, full option reference table |
 

+ 2 - 2
docs/thirty-dollars-preset.md

@@ -12,9 +12,9 @@ Codex Plus covers the OpenAI models and Copilot covers the design models, so you
 {
     "preset": "thirtydollars",
     "presets": {
-      "thirtydollars": { "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": [ "*" ], "mcps": [ "*", "websearch"] },
+      "thirtydollars": { "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": [ "*" ], "mcps": [ "*" ] },
         "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": [], "mcps": [] },
-        "librarian": { "model": "openai/gpt-5.6-luna", "variant": "low", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
+        "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": "github-copilot/gemini-3.5-flash", "skills": [], "mcps": [] },
         "fixer": { "model": "openai/gpt-5.6-luna", "variant": "medium", "skills": [], "mcps": [] }

+ 8 - 2
docs/tools.md

@@ -10,11 +10,17 @@ Slim only intercepts `apply_patch` before the native tool runs. It rewrites reco
 
 ## Web Fetch
 
-Fetch remote pages with content extraction tuned for docs/static sites.
+Enhanced version of OpenCode's built-in `webfetch`. Overrides the default when
+this plugin is active. Fetch remote pages with content extraction tuned for
+docs/static sites.
 
 | Tool | Description |
 |------|-------------|
-| `webfetch` | Fetch a URL, optionally prefer `llms.txt`, extract main content from HTML, include metadata, and optionally save binary responses |
+| `webfetch` | Fetch a URL, optionally prefer `llms.txt`, extract main content from HTML, include metadata, optionally save binary responses, and optionally run secondary-model extraction |
+
+See the full [Webfetch documentation](webfetch.md) for parameters, output
+format, caching, llms.txt probing, redirect policy, secondary-model
+summarization, binary detection, and implementation details.
 
 `webfetch` blocks cross-origin redirects unless the requested URL or derived permission patterns explicitly allow them, and it can fall back to the raw fetched content when secondary-model summarization is unavailable.
 

+ 281 - 0
docs/webfetch.md

@@ -0,0 +1,281 @@
+# Webfetch (smartfetch)
+
+The `webfetch` tool fetches remote URLs and returns their content with intelligent
+extraction designed for documentation, static pages, and structured text. It
+provides caching, `llms.txt` probing, binary content handling, and optional
+secondary-model summarization.
+
+`webfetch` is already a built-in tool in OpenCode. This plugin replaces it with
+an enhanced version — the implementation lives in `src/tools/smartfetch/`, and
+the tool is registered under the same `webfetch` name to override the default.
+
+## Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | URL (string) | **required** | The URL to fetch. Must be a valid HTTP/HTTPS URL. |
+| `format` | `"text"` \| `"markdown"` \| `"html"` | `"markdown"` | Output format for the fetched content. |
+| `timeout` | number | `30` | Timeout in seconds (max `120`). |
+| `prompt` | string | optional | An extraction task for the secondary model to run against the fetched content (see [Secondary Model](#secondary-model)). |
+| `extract_main` | boolean | `true` | Extract main content from HTML using Mozilla Readability. When disabled, returns the full page body. |
+| `prefer_llms_txt` | `"auto"` \| `"always"` \| `"never"` | `"auto"` | Prefer `/llms.txt` or `/llms-full.txt` over the page itself. `"auto"` probes only for docs-like domains (readthedocs, gitbook, netlify, vercel, etc.). |
+| `include_metadata` | boolean | `true` | Include YAML frontmatter with fetch metadata (status code, content type, charset, redirect chain, cache info, etc.). |
+| `save_binary` | boolean | `false` | Save binary payloads (images, PDFs, audio, video) to disk under the system temp dir. When disabled, binary content reports metadata-only. |
+
+## Output
+
+### Text content (HTML, plain text, llms.txt)
+
+Returns the fetched content in the requested `format`. When `include_metadata`
+is enabled (default), the response is prefixed with YAML frontmatter containing
+metadata about the fetch:
+
+```yaml
+---
+requested_url: "https://example.com/docs"
+final_url: "https://example.com/docs"
+canonical_url: "https://example.com/docs"
+status_code: 200
+source_content_type: "text/html"
+source_kind: "html"
+title: "Documentation"
+headings:
+  - "Getting Started"
+  - "API Reference"
+used_llms_txt: false
+extracted_main: true
+redirect_chain: []
+upgraded_to_https: true
+cache_hit: false
+word_count: 1420
+quality_signals: []
+truncated: false
+---
+```
+
+The `quality_signals` field flags potential issues:
+- `very_short_content` — fewer than 60 words
+- `possible_paywall` — content matches paywall/login keywords
+- `high_boilerplate_ratio` — large HTML-to-text ratio without Readability extraction
+
+### Binary content
+
+Binary responses (images, PDFs, audio, video) return metadata about the file:
+
+- Content type and size
+- Filename (from `Content-Disposition` or URL path)
+- Binary kind (`image`, `audio`, `video`, `pdf`, `binary`)
+
+Two modes:
+
+1. **Metadata-only** — content exceeds the download limit (2 MiB without
+   `save_binary`, 10 MiB with it). Reports size and type without the body.
+2. **Saved to disk** — when `save_binary=true`, the binary is written to
+   `<tmpdir>/opencode-smartfetch/<filename>` and the response includes the
+   filesystem path.
+
+### Blocked redirects
+
+When a cross-origin redirect is blocked by policy, the response explains which
+URL was attempted and provides the redirect URL so you can fetch it directly.
+
+## Secondary Model
+
+When a `prompt` parameter is supplied, `webfetch` can route the fetched content
+through a secondary (cheaper) model for focused extraction. This lets you ask
+questions like "summarize this page" or "extract the code examples" in one step.
+
+**How it works:**
+
+1. Content is fetched and cached normally.
+2. A temporary OpenCode session is created with all tools disabled.
+3. The fetched content and your prompt are sent to a secondary model.
+4. The session is cleaned up after the response.
+
+**Which model is used** (in priority order):
+
+1. `webfetch.model` (dedicated — highest priority, supports array for fallback)
+2. `small_model` from the OpenCode configuration (`opencode.json` / `opencode.jsonc`)
+3. The configured `explorer` agent model
+4. The configured `librarian` agent model
+
+The secondary model is called only when all of these are true:
+- A `prompt` parameter is provided
+- A secondary model is configured
+- The fetched content has at least 25 words
+
+If the secondary model fails (timeout, error, empty response), `webfetch`
+returns the raw fetched content as a graceful fallback.
+
+## Caching
+
+Fetches are cached in memory with an LRU cache (50 MiB max, 15-minute TTL).
+The cache key includes the URL plus behavior-affecting options (`extract_main`,
+`prefer_llms_txt`, `save_binary`), so changing these re-fetches the URL.
+
+**Revalidation:** Cache entries with `ETag` or `Last-Modified` headers support
+conditional revalidation. When a stale entry exists, `webfetch` sends
+`If-None-Match` / `If-Modified-Since` headers. A `304 Not Modified` response
+refreshes the TTL without re-downloading.
+
+**llms.txt validation:** Cached `llms.txt` results are validated — if the
+cached entry doesn't actually look like an llms.txt response (wrong path,
+HTML content, login page), it is evicted and re-fetched.
+
+## llms.txt Probing
+
+For documentation sites, `webfetch` probes for `/llms-full.txt` then `/llms.txt`
+before falling back to the page itself.
+
+**Probing behavior** depends on the `prefer_llms_txt` parameter:
+
+- `"auto"` (default) — probes only when the domain looks documentation-adjacent
+  (suffixes like `.readthedocs.io`, `.gitbook.io`, `docs.rs`; prefixes like
+  `docs.`, `developer.`, `dev.`, `wiki.`)
+- `"always"` — always probes; fails with a message if neither llms.txt variant
+  exists
+- `"never"` — skips probing entirely
+
+The probe respects cross-origin redirect policy (same origin only). If the
+`llms.txt` response is HTML or a login page, the probe is rejected.
+
+## Redirect Policy
+
+`webfetch` follows up to 10 redirects per request, but only within same-origin
+scopes. Cross-origin redirects are blocked and the caller is instructed to
+fetch the new URL directly.
+
+For URLs entered as `http://`, `webfetch` first tries `https://` and falls
+back to `http://` if the HTTPS attempt fails (connection error, blocked
+redirect, or non-2xx status).
+
+## Binary Detection
+
+Content type detection follows this flow:
+
+1. Explicit binary MIME types (`image/*`, `audio/*`, `video/*`,
+   `application/pdf`, `application/zip`, `application/octet-stream`) are
+   treated as binary.
+2. `application/octet-stream` and known text types are re-examined — the
+   first 2 KiB is scanned for null bytes and non-printable characters to
+   distinguish text from binary.
+3. Content declared as text/plain that looks like HTML is upgraded to
+   `text/html` for better content extraction.
+
+## Tool Timeouts
+
+- Default timeout: 30 seconds
+- Maximum timeout: 120 seconds
+- llms.txt probe timeout: capped at 8 seconds within the overall timeout
+- Multiple scoped timeouts run in parallel (llms.txt probing and page fetch
+  are independent within a single call)
+
+## Configuration
+
+### Disabling
+
+Set `webfetch.enabled` to `false` to skip registering the enhanced version and
+use OpenCode's built-in `webfetch` instead:
+
+```jsonc
+{
+  "webfetch": {
+    "enabled": false
+  }
+}
+```
+
+### Dedicated secondary model
+
+The `webfetch.model` option sets a dedicated model (or array of fallback
+models) for secondary-model summarization. Takes priority over all other model
+resolution sources. Accepts the same format as agent model configs:
+
+```jsonc
+{
+  "webfetch": {
+    "model": "openai/gpt-4o-mini"
+  }
+}
+```
+
+Multiple fallback models in priority order:
+
+```jsonc
+{
+  "webfetch": {
+    "model": ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"]
+  }
+}
+```
+
+With optional variant:
+
+```jsonc
+{
+  "webfetch": {
+    "model": [
+      "openai/gpt-4o-mini",
+      { "id": "anthropic/claude-3-haiku", "variant": "low-latency" }
+    ]
+  }
+}
+```
+
+Each entry is tried in turn; the first to return usable text is used.
+
+### Secondary model fallback chain
+
+The [secondary model](#secondary-model) is resolved from these sources (in
+priority order):
+
+1. `webfetch.model` (dedicated — highest priority, supports array for fallback)
+2. `small_model` in the OpenCode config (`opencode.json` / `opencode.jsonc` at
+   project or user level)
+3. The plugin's `agents.explorer.model` config
+4. The plugin's `agents.librarian.model` config
+
+Example `opencode.jsonc`:
+
+```jsonc
+{
+  "small_model": "openai/gpt-4o-mini"
+}
+```
+
+Or in the plugin's `opencode.json` preset or project config:
+
+```jsonc
+{
+  "agents": {
+    "explorer": { "model": "anthropic/claude-3-haiku" },
+    "librarian": { "model": "openai/gpt-4o-mini" }
+  }
+}
+```
+
+### Permissions
+
+The `webfetch` permission can be configured in the plugin's permission rules.
+See [Configuration](configuration.md) for details.
+
+## Registration
+
+The tool is registered under the name `webfetch` in `src/index.ts`, which
+overrides OpenCode's built-in `webfetch` when this plugin is active.
+
+## Implementation
+
+The enhanced `webfetch` lives in `src/tools/smartfetch/` (the internal module is
+named "smartfetch", while the public tool name is `webfetch`). It is composed of
+these modules:
+
+| Module | Responsibility |
+|--------|---------------|
+| `tool.ts` | Entry point — permission prompts, cache lookup, llms.txt preference logic, binary-vs-text branching, metadata emission, secondary-model integration |
+| `network.ts` | URL normalization, redirect policy, charset/body decoding, header extraction, llms.txt probing, HTTP fetch with HTTPS upgrade fallback |
+| `utils.ts` | HTML extraction (Mozilla Readability + Turndown), heading cleanup, markdown/text cleaning, frontmatter generation, quality signal detection |
+| `cache.ts` | LRU cache keyed by URL + behavioral options, conditional revalidation, canonical URL aliasing, llms result invalidation |
+| `binary.ts` | Binary content persistence to disk, MIME-to-extension mapping, safe filename allocation |
+| `secondary-model.ts` | Dedicated webfetch/`small_model` config resolution, temporary session creation, content truncation, model fallback chain |
+| `constants.ts` | Timeouts, size limits, docs domain heuristics, binary MIME prefixes, tool description |

+ 58 - 13
oh-my-opencode-slim.schema.json

@@ -104,6 +104,10 @@
               "type": "string",
               "minLength": 1
             },
+            "description": {
+              "type": "string",
+              "minLength": 1
+            },
             "permission": {
               "anyOf": [
                 {
@@ -539,6 +543,10 @@
             "type": "string",
             "minLength": 1
           },
+          "description": {
+            "type": "string",
+            "minLength": 1
+          },
           "permission": {
             "anyOf": [
               {
@@ -973,19 +981,6 @@
         }
       }
     },
-    "websearch": {
-      "type": "object",
-      "properties": {
-        "provider": {
-          "default": "exa",
-          "type": "string",
-          "enum": [
-            "exa",
-            "tavily"
-          ]
-        }
-      }
-    },
     "interview": {
       "type": "object",
       "properties": {
@@ -1035,6 +1030,12 @@
           "minimum": 1,
           "maximum": 10
         },
+        "maxContextLines": {
+          "default": 50000,
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 500000
+        },
         "readContextMinLines": {
           "default": 10,
           "type": "integer",
@@ -1206,6 +1207,50 @@
         }
       }
     },
+    "webfetch": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "default": true,
+          "description": "When false, skip registering this enhanced webfetch so OpenCode uses its built-in version.",
+          "type": "boolean"
+        },
+        "model": {
+          "description": "Dedicated model(s) for smartfetch secondary-model summarization. Same shape as agent model config (string, array of strings/objects with id+variant). Takes priority over small_model, agents.explorer.model, and agents.librarian.model.",
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "minItems": 1,
+              "type": "array",
+              "items": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "type": "object",
+                    "properties": {
+                      "id": {
+                        "type": "string"
+                      },
+                      "variant": {
+                        "type": "string"
+                      }
+                    },
+                    "required": [
+                      "id"
+                    ]
+                  }
+                ]
+              }
+            }
+          ]
+        }
+      },
+      "additionalProperties": false
+    },
     "acpAgents": {
       "type": "object",
       "propertyNames": {

+ 1 - 1
src/agents/codemap.md

@@ -14,7 +14,7 @@ Each agent is a **prompt-driven specialist** with a factory function that create
 |-------|---------|------|-------------|---------------|
 | **orchestrator** | `createOrchestratorAgent()` | Workflow manager that delegates tasks to specialists | Primary agent with full tool access | Resolved from config or runtime preset |
 | **explorer** | `createExplorerAgent()` | Fast codebase search and pattern matching | Read-only (glob, grep, ast_grep_search) | DEFAULT_MODELS.explorer |
-| **librarian** | `createLibrarianAgent()` | External documentation and library research | Read-only (context7, gh_grep, websearch) | DEFAULT_MODELS.librarian |
+| **librarian** | `createLibrarianAgent()` | External documentation and library research | Read-only (context7, gh_grep) | DEFAULT_MODELS.librarian |
 | **oracle** | `createOracleAgent()` | Strategic technical advisor and code reviewer | Read-only (read, glob, grep, ast_grep_search) | DEFAULT_MODELS.oracle |
 | **designer** | `createDesignerAgent()` | UI/UX design, review, and implementation | Read/write (read, glob, grep, write, edit) | DEFAULT_MODELS.designer |
 | **fixer** | `createFixerAgent()` | Fast implementation specialist for bounded tasks | Read/write (read, glob, grep, write, edit) | DEFAULT_MODELS.fixer |

+ 7 - 2
src/agents/council.ts

@@ -63,8 +63,13 @@ export function createCouncilAgent(
   customAppendPrompt?: string,
 ): AgentDefinition {
   const prompt =
-    resolvePrompt(COUNCIL_AGENT_PROMPT, customPrompt, customAppendPrompt) +
-    COUNCIL_SYNTHESIS_REINFORCEMENT;
+    resolvePrompt(
+      'council',
+      customPrompt,
+      undefined,
+      COUNCIL_AGENT_PROMPT,
+      customAppendPrompt,
+    ) + COUNCIL_SYNTHESIS_REINFORCEMENT;
 
   return {
     name: 'council',

+ 3 - 1
src/agents/councillor.ts

@@ -58,8 +58,10 @@ export function createCouncillorAgent(
   variant?: string,
 ): AgentDefinition {
   const prompt = resolvePrompt(
-    COUNCILLOR_PROMPT,
+    'councillor',
     customPrompt,
+    undefined,
+    COUNCILLOR_PROMPT,
     customAppendPrompt,
   );
 

+ 1 - 1
src/agents/fixer.ts

@@ -12,7 +12,7 @@ const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 ${WRITABLE_FILE_OPERATIONS_RULES}
 
 **Constraints**:
-- NO external research (no websearch, context7, gh_grep)
+- 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

+ 130 - 3
src/agents/index.test.ts

@@ -836,13 +836,79 @@ describe('AgentOverrideConfigSchema options validation', () => {
     expect(result.success).toBe(false);
   });
 
-  test('rejects description field on overrides', () => {
+  test('accepts description field on overrides', () => {
     const result = AgentOverrideConfigSchema.safeParse({
       model: 'openai/gpt-5.6',
-      description: 'not supported for custom agents',
-    } as Record<string, unknown>);
+      description: 'A custom reviewer agent',
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.description).toBe('A custom reviewer agent');
+    }
+  });
+
+  test('rejects empty description field', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.6',
+      description: '',
+    });
     expect(result.success).toBe(false);
   });
+
+  test('description propagates through buildCustomAgentDefinition', () => {
+    const config: PluginConfig = {
+      agents: {
+        reviewer: {
+          model: 'openai/gpt-5.6',
+          description: 'Code review specialist',
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const reviewer = agents.find((a) => a.name === 'reviewer');
+    expect(reviewer).toBeDefined();
+    expect(reviewer?.description).toBe('Code review specialist');
+  });
+
+  test('description defaults to generated string when not provided', () => {
+    const config: PluginConfig = {
+      agents: {
+        reviewer: {
+          model: 'openai/gpt-5.6',
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const reviewer = agents.find((a) => a.name === 'reviewer');
+    expect(reviewer).toBeDefined();
+    expect(reviewer?.description).toBe("Custom subagent 'reviewer'");
+  });
+
+  test('description propagates through getAgentConfigs to SDK output', () => {
+    const config: PluginConfig = {
+      agents: {
+        reviewer: {
+          model: 'openai/gpt-5.6',
+          description: 'SDK reviewer agent',
+        },
+      },
+    };
+    const configs = getAgentConfigs(config);
+    expect(configs.reviewer.description).toBe('SDK reviewer agent');
+  });
+
+  test('description override applies to built-in agents', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.6',
+          description: 'Custom oracle description',
+        },
+      },
+    };
+    const configs = getAgentConfigs(config);
+    expect(configs.oracle.description).toBe('Custom oracle description');
+  });
 });
 
 describe('PluginConfigSchema custom-agent-only prompt fields', () => {
@@ -1166,3 +1232,64 @@ describe('AgentOverrideConfigSchema permission validation', () => {
     expect(result.success).toBe(false);
   });
 });
+
+describe('getDisabledAgents with malformed config', () => {
+  test('falls back to DEFAULT_DISABLED_AGENTS when disabled_agents is not an array', () => {
+    const config: PluginConfig = {
+      disabled_agents: 'not-an-array' as any,
+    };
+    const disabled = getDisabledAgents(config);
+    const expected = getDisabledAgents(undefined);
+    expect(disabled).toEqual(expected);
+  });
+
+  test('falls back to DEFAULT_DISABLED_AGENTS when disabled_agents is an object', () => {
+    const config: PluginConfig = {
+      disabled_agents: { invalid: 'object' } as any,
+    };
+    const disabled = getDisabledAgents(config);
+    const expected = getDisabledAgents(undefined);
+    expect(disabled).toEqual(expected);
+  });
+
+  test('handles valid array normally', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['explorer'],
+    };
+    const disabled = getDisabledAgents(config);
+    expect(disabled.has('explorer')).toBe(true);
+  });
+});
+
+describe('createAgents with malformed disabled_tools', () => {
+  test('does not throw when disabled_tools is not an array', () => {
+    const config: PluginConfig = {
+      disabled_tools: 'not-an-array' as any,
+    };
+    expect(() => createAgents(config)).not.toThrow();
+  });
+
+  test('does not throw when disabled_tools is an object', () => {
+    const config: PluginConfig = {
+      disabled_tools: {} as any,
+    };
+    expect(() => createAgents(config)).not.toThrow();
+  });
+
+  test('orchestrator is created with wait_for_user enabled when disabled_tools is malformed', () => {
+    const config: PluginConfig = {
+      disabled_tools: 'not-an-array' as any,
+    };
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator).toBeDefined();
+    // When disabled_tools is malformed (treated as empty array), wait_for_user
+    // should be enabled, which is reflected in the prompt text
+    expect(orchestrator?.config.prompt).toContain(
+      'call `wait_for_user` as your final tool action',
+    );
+    expect(orchestrator?.config.prompt).not.toContain(
+      '`wait_for_user` is disabled',
+    );
+  });
+});

+ 25 - 13
src/agents/index.ts

@@ -197,6 +197,9 @@ function applyOverrides(
   if (override.displayName) {
     agent.displayName = override.displayName;
   }
+  if (override.description) {
+    agent.description = override.description;
+  }
   if (override.permission) {
     agent.config.permission = override.permission;
   }
@@ -235,15 +238,22 @@ function buildCustomAgentDefinition(
   const defaultPrompt = appendTaskRejectionInstruction(
     `You are the ${name} specialist.`,
   );
-  const basePrompt = override.prompt ?? defaultPrompt;
   const primaryModel = getPrimaryModelFromOverride(override);
+  const description = override.description ?? `Custom subagent '${name}'`;
 
   return {
     name,
+    description,
     config: {
       model: primaryModel ?? DEFAULT_MODELS.oracle,
       temperature: 0.2,
-      prompt: resolvePrompt(basePrompt, filePrompt, fileAppendPrompt),
+      prompt: resolvePrompt(
+        name,
+        override.prompt,
+        filePrompt,
+        defaultPrompt,
+        fileAppendPrompt,
+      ),
     },
   } as AgentDefinition;
 }
@@ -401,11 +411,11 @@ export function createAgents(
         agent.config.prompt ?? '',
       );
 
-      const basePrompt =
-        inlinePrompt !== undefined ? inlinePrompt : defaultPrompt;
       agent.config.prompt = resolvePrompt(
-        basePrompt,
+        name,
+        inlinePrompt,
         customPrompts.prompt,
+        defaultPrompt,
         customPrompts.appendPrompt,
       );
 
@@ -541,19 +551,20 @@ export function createAgents(
     undefined,
     disabled,
     councillorAgents.length > 0 ? ['council'] : undefined,
-    !config?.disabled_tools?.includes('wait_for_user'),
+    !(
+      Array.isArray(config?.disabled_tools) &&
+      config.disabled_tools.includes('wait_for_user')
+    ),
   );
 
   const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
   const defaultOrchestratorPrompt = orchestrator.config.prompt ?? '';
 
-  const baseOrchestratorPrompt =
-    inlineOrchestratorPrompt !== undefined
-      ? inlineOrchestratorPrompt
-      : defaultOrchestratorPrompt;
   orchestrator.config.prompt = resolvePrompt(
-    baseOrchestratorPrompt,
+    'orchestrator',
+    inlineOrchestratorPrompt,
     orchestratorPrompts.prompt,
+    defaultOrchestratorPrompt,
     orchestratorPrompts.appendPrompt,
   );
 
@@ -756,8 +767,9 @@ export function getAgentConfigs(
  */
 export function getDisabledAgents(config?: PluginConfig): Set<string> {
   const userDisabled = config?.disabled_agents;
-  const disabledSource =
-    userDisabled !== undefined ? userDisabled : DEFAULT_DISABLED_AGENTS;
+  const disabledSource = Array.isArray(userDisabled)
+    ? userDisabled
+    : DEFAULT_DISABLED_AGENTS;
   const disabled = new Set<string>();
   for (const name of disabledSource) {
     if (!PROTECTED_AGENTS.has(name)) {

+ 0 - 1
src/agents/librarian.ts

@@ -14,7 +14,6 @@ const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebase
 **Tools to Use**:
 - context7: Official documentation lookup
 - gh_grep: Search GitHub repositories
-- websearch: General web search for docs
 
 ${READONLY_FILE_OPERATIONS_RULES}
 

+ 23 - 7
src/agents/orchestrator.ts

@@ -11,16 +11,26 @@ export interface AgentDefinition {
 }
 
 /**
- * Resolve agent prompt from base/custom/append inputs.
- * If customPrompt is provided, it replaces the base entirely.
- * If customAppendPrompt is provided, it appends after whichever base won.
+ * Resolve agent prompt from inline/file/append inputs.
+ *
+ * Precedence: inline prompt > file prompt > fallback. An explicit inline
+ * `override.prompt` wins over a `<agent>.md` file; the file is the
+ * shared default. `customAppendPrompt` always appends after whichever base
+ * won. Deterministic per session (construction-time only) — cache-safe.
  */
 export function resolvePrompt(
-  base: string,
-  customPrompt?: string,
+  agentName: string,
+  inlinePrompt: string | undefined,
+  filePrompt: string | undefined,
+  fallback: string,
   customAppendPrompt?: string,
 ): string {
-  const effectiveBase = customPrompt !== undefined ? customPrompt : base;
+  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;
@@ -298,7 +308,13 @@ export function createOrchestratorAgent(
     excludeDescriptions,
     waitForUserEnabled,
   );
-  const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
+  const prompt = resolvePrompt(
+    'orchestrator',
+    undefined,
+    customPrompt,
+    basePrompt,
+    customAppendPrompt,
+  );
 
   const definition: AgentDefinition = {
     name: 'orchestrator',

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

@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, spyOn, test } from 'bun:test';
+import { resolvePrompt } from './orchestrator';
+
+const FALLBACK = 'fallback prompt';
+const FILE = 'file prompt';
+const INLINE = 'inline prompt';
+const APPEND = 'append prompt';
+
+afterEach(() => {
+  spyOn(console, 'warn').mockRestore();
+});
+
+describe('resolvePrompt precedence', () => {
+  test('inline wins over file and fallback', () => {
+    expect(resolvePrompt('a', INLINE, FILE, FALLBACK)).toBe(INLINE);
+  });
+
+  test('file wins over fallback when no inline', () => {
+    expect(resolvePrompt('a', undefined, FILE, FALLBACK)).toBe(FILE);
+  });
+
+  test('fallback used when no inline and no file', () => {
+    expect(resolvePrompt('a', undefined, undefined, FALLBACK)).toBe(FALLBACK);
+  });
+
+  test('append concatenated after whichever base won', () => {
+    expect(resolvePrompt('a', INLINE, FILE, FALLBACK, APPEND)).toBe(
+      `${INLINE}\n\n${APPEND}`,
+    );
+    expect(resolvePrompt('a', undefined, FILE, FALLBACK, APPEND)).toBe(
+      `${FILE}\n\n${APPEND}`,
+    );
+    expect(resolvePrompt('a', undefined, undefined, FALLBACK, APPEND)).toBe(
+      `${FALLBACK}\n\n${APPEND}`,
+    );
+  });
+});
+
+describe('resolvePrompt conflict warning', () => {
+  test('warns when both inline and file prompt present', () => {
+    const warn = spyOn(console, 'warn').mockImplementation(() => {});
+    resolvePrompt('skeptic', INLINE, FILE, FALLBACK);
+    expect(warn).toHaveBeenCalledTimes(1);
+    const msg = warn.mock.calls[0][0] as string;
+    expect(msg).toContain("'skeptic'");
+    expect(msg).toContain('skeptic.md');
+    expect(msg).toContain('overrides');
+  });
+
+  test('does not warn when only inline prompt present', () => {
+    const warn = spyOn(console, 'warn').mockImplementation(() => {});
+    resolvePrompt('skeptic', INLINE, undefined, FALLBACK);
+    expect(warn).not.toHaveBeenCalled();
+  });
+
+  test('does not warn when only file prompt present', () => {
+    const warn = spyOn(console, 'warn').mockImplementation(() => {});
+    resolvePrompt('skeptic', undefined, FILE, FALLBACK);
+    expect(warn).not.toHaveBeenCalled();
+  });
+
+  test('does not warn when neither present', () => {
+    const warn = spyOn(console, 'warn').mockImplementation(() => {});
+    resolvePrompt('skeptic', undefined, undefined, FALLBACK);
+    expect(warn).not.toHaveBeenCalled();
+  });
+});

+ 2 - 2
src/cli/config-io.test.ts

@@ -504,11 +504,11 @@ describe('config-io', () => {
     expect(saved.presets['opencode-go'].orchestrator.model).toBe(
       'opencode-go/minimax-m3',
     );
-    expect(saved.presets['opencode-go'].orchestrator.variant).toBe('max');
+    expect(saved.presets['opencode-go'].orchestrator.variant).toBe('thinking');
     expect(saved.presets['opencode-go'].observer.model).toBe(
       'opencode-go/mimo-v2.5',
     );
-    expect(saved.presets['opencode-go'].observer.variant).toBe('max');
+    expect(saved.presets['opencode-go'].observer.variant).toBeUndefined();
   });
 
   test('disableDefaultAgents disables conflicting OpenCode built-in agents', () => {

+ 5 - 4
src/cli/providers.test.ts

@@ -74,19 +74,21 @@ describe('providers', () => {
     const agents = (config.presets as any)['opencode-go'];
     expect(agents).toBeDefined();
     expect(agents.orchestrator.model).toBe('opencode-go/minimax-m3');
-    expect(agents.orchestrator.variant).toBe('max');
+    expect(agents.orchestrator.variant).toBe('thinking');
     expect(agents.oracle.model).toBe('opencode-go/qwen3.7-max');
     expect(agents.oracle.variant).toBe('max');
     expect(agents.council).toBeUndefined();
     expect(agents.librarian.model).toBe('opencode-go/deepseek-v4-flash');
     expect(agents.librarian.variant).toBe('high');
-    expect(agents.librarian.mcps).toEqual(['websearch', 'context7', 'gh_grep']);
+    expect(agents.librarian.mcps).toEqual(['context7', 'gh_grep']);
     expect(agents.explorer.model).toBe('opencode-go/deepseek-v4-flash');
+    expect(agents.explorer.variant).toBe('high');
     expect(agents.designer.model).toBe('opencode-go/kimi-k2.7-code');
+    expect(agents.designer.variant).toBeUndefined();
     expect(agents.fixer.model).toBe('opencode-go/deepseek-v4-flash');
     expect(agents.fixer.variant).toBe('high');
     expect(agents.observer.model).toBe('opencode-go/mimo-v2.5');
-    expect(agents.observer.variant).toBe('max');
+    expect(agents.observer.variant).toBeUndefined();
   });
 
   test('generateLiteConfig rejects unsupported preset', () => {
@@ -203,7 +205,6 @@ describe('providers', () => {
 
     const agents = (config.presets as any).openai;
     expect(agents.orchestrator.mcps).toEqual(['*', '!context7']);
-    expect(agents.librarian.mcps).toContain('websearch');
     expect(agents.librarian.mcps).toContain('context7');
     expect(agents.librarian.mcps).toContain('gh_grep');
     expect(agents.designer.mcps).toEqual([]);

+ 4 - 4
src/cli/providers.ts

@@ -45,13 +45,13 @@ export const MODEL_MAPPINGS = {
     fixer: { model: 'zai-coding-plan/glm-5', variant: 'low' },
   },
   'opencode-go': {
-    orchestrator: { model: 'opencode-go/minimax-m3', variant: 'max' },
+    orchestrator: { model: 'opencode-go/minimax-m3', variant: 'thinking' },
     oracle: { model: 'opencode-go/qwen3.7-max', variant: 'max' },
-    explorer: { model: 'opencode-go/deepseek-v4-flash', variant: 'max' },
+    explorer: { model: 'opencode-go/deepseek-v4-flash', variant: 'high' },
     librarian: { model: 'opencode-go/deepseek-v4-flash', variant: 'high' },
-    designer: { model: 'opencode-go/kimi-k2.7-code', variant: 'medium' },
+    designer: { model: 'opencode-go/kimi-k2.7-code' },
     fixer: { model: 'opencode-go/deepseek-v4-flash', variant: 'high' },
-    observer: { model: 'opencode-go/mimo-v2.5', variant: 'max' },
+    observer: { model: 'opencode-go/mimo-v2.5' },
   },
 } as const;
 

+ 36 - 0
src/cli/skills.test.ts

@@ -51,3 +51,39 @@ describe('skills permissions', () => {
     expect(wildcardPerms['*']).toBe('allow');
   });
 });
+
+describe('getSkillPermissionsForAgent with malformed disabledSkillNames', () => {
+  it('does not throw when disabledSkillNames is not an array', () => {
+    expect(() =>
+      getSkillPermissionsForAgent(
+        'orchestrator',
+        undefined,
+        'not-an-array' as any,
+      ),
+    ).not.toThrow();
+  });
+
+  it('treats non-array disabledSkillNames as empty array', () => {
+    const permsWithDisabled = getSkillPermissionsForAgent(
+      'orchestrator',
+      undefined,
+      ['simplify'],
+    );
+    const permsWithMalformed = getSkillPermissionsForAgent(
+      'orchestrator',
+      undefined,
+      'not-an-array' as any,
+    );
+    // When simplify is disabled, it should be explicitly denied
+    expect(permsWithDisabled.simplify).toBe('deny');
+    // When disabledSkillNames is malformed (treated as empty), simplify should be allowed
+    expect(permsWithMalformed['*']).toBe('allow');
+  });
+
+  it('handles object as disabledSkillNames gracefully', () => {
+    const perms = getSkillPermissionsForAgent('orchestrator', undefined, {
+      invalid: 'object',
+    } as any);
+    expect(perms['*']).toBe('allow');
+  });
+});

+ 3 - 1
src/cli/skills.ts

@@ -37,7 +37,9 @@ export function getSkillPermissionsForAgent(
   skillList?: string[],
   disabledSkillNames?: string[],
 ): Record<string, 'allow' | 'ask' | 'deny'> {
-  const disabledSkills = new Set(disabledSkillNames ?? []);
+  const disabledSkills = new Set(
+    Array.isArray(disabledSkillNames) ? disabledSkillNames : [],
+  );
 
   // Orchestrator gets all skills by default, others are restricted
   const permissions: Record<string, 'allow' | 'ask' | 'deny'> = {

+ 28 - 0
src/companion/manager.test.ts

@@ -61,6 +61,18 @@ function attachFakeChild(manager: CompanionManager): { killed: () => boolean } {
   return { killed: () => killed };
 }
 
+function attachFailingChild(manager: CompanionManager): void {
+  (
+    manager as unknown as {
+      companionProcess: { kill: () => void } | null;
+    }
+  ).companionProcess = {
+    kill: () => {
+      throw new Error('mock kill failure');
+    },
+  };
+}
+
 function companionPidFile(): string {
   return path.join(path.dirname(stateFilePath()), 'companion.pid');
 }
@@ -693,4 +705,20 @@ describe('CompanionManager', () => {
     expect(state.version).toBe(1);
     expect(state.sessions).toHaveLength(1);
   });
+
+  it('logs and swallows kill() failure gracefully during exit', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, String(process.pid));
+
+    const m = make('test-kill-failure');
+    attachFailingChild(m);
+    (m as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (m as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid =
+      process.pid;
+
+    // Must not propagate the kill() exception
+    expect(() => m.onExit()).not.toThrow();
+    expect(existsSync(pidFile)).toBe(false);
+  });
 });

+ 3 - 1
src/companion/manager.ts

@@ -371,7 +371,9 @@ export class CompanionManager {
       if (this.companionProcess) {
         try {
           this.companionProcess.kill();
-        } catch {}
+        } catch (err) {
+          log('[companion] kill failed', String(err));
+        }
       }
     }
     this.companionProcess = null;

+ 1 - 2
src/config/agent-mcps.test.ts

@@ -17,12 +17,11 @@ describe('parseList', () => {
   test('orchestrator wildcard excludes context7 but includes custom mcps', () => {
     expect(
       parseList(DEFAULT_AGENT_MCPS.orchestrator, [
-        'websearch',
         'context7',
         'gh_grep',
         'custom-mcp',
       ]),
-    ).toEqual(['websearch', 'gh_grep', 'custom-mcp']);
+    ).toEqual(['gh_grep', 'custom-mcp']);
   });
 
   test('wildcard with exclusions', () => {

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

@@ -6,7 +6,7 @@ export const DEFAULT_AGENT_MCPS: Record<AgentName, string[]> = {
   orchestrator: ['*', '!context7'],
   designer: [],
   oracle: [],
-  librarian: ['websearch', 'context7', 'gh_grep'],
+  librarian: ['context7', 'gh_grep'],
   explorer: [],
   fixer: [],
   observer: [],

+ 1 - 2
src/config/codemap.md

@@ -66,7 +66,7 @@ Agent-specific configuration lookup:
 MCP permission resolution:
 1. Check agent override: config.agents[agentName]?.mcps
 2. Fall back to DEFAULT_AGENT_MCPS[agentName]
-3. Parse wildcard/exclusion syntax: ["*", "!context7"] → ["websearch", "gh_grep"]
+3. Parse wildcard/exclusion syntax: ["*", "!context7"] → ["context7", "gh_grep"]
 ```
 
 ### Preset Resolution
@@ -155,7 +155,6 @@ This allows consumers to import directly from `src/config` rather than individua
 - `disabled_skills`: List of skills to disable
 - `multiplexer`: Unified pane management config (type, layout, sizes)
 - `tmux`: Legacy tmux configuration (migrated to multiplexer)
-- `websearch`: Websearch provider configuration
 - `interview`: Interview feature configuration
 - `backgroundJobs`: Background job configuration
 - `fallback`: Failover/retry configuration

+ 8 - 0
src/config/constants.ts

@@ -89,10 +89,18 @@ export const DEFAULT_DISABLED_AGENTS: string[] = ['observer'];
 
 // Background job defaults
 export const DEFAULT_MAX_SESSIONS_PER_AGENT = 2;
+export const DEFAULT_MAX_CONTEXT_LINES = 50_000;
 export const DEFAULT_READ_CONTEXT_MIN_LINES = 10;
 export const DEFAULT_READ_CONTEXT_MAX_FILES = 8;
 export const DEFAULT_MAX_RETAINED_SNAPSHOTS = 20;
 
+/**
+ * Maximum session metadata entries retained per plugin instance.
+ * Prevents unbounded growth when session.deleted events are missed.
+ * Oldest entries are evicted first when this threshold is reached.
+ */
+export const DEFAULT_MAX_SESSION_METADATA_ENTRIES = 1000;
+
 export type ImageRouting = 'auto' | 'direct';
 
 /**

+ 142 - 1
src/config/loader.test.ts

@@ -66,6 +66,60 @@ describe('loadPluginConfig', () => {
     expect(config.autoUpdate).toBe(false);
   });
 
+  test('deep-merges webfetch settings across user and project configs', () => {
+    const userConfigPath = path.join(userConfigDir, 'opencode');
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(userConfigPath, { recursive: true });
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userConfigPath, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { model: 'user/provider-model' },
+      }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { enabled: true },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir, { silent: true });
+
+    expect(config.webfetch).toEqual({
+      enabled: true,
+      model: 'user/provider-model',
+    });
+  });
+
+  test('does not let a defaulted project webfetch enabled override user false', () => {
+    const userConfigPath = path.join(userConfigDir, 'opencode');
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(userConfigPath, { recursive: true });
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userConfigPath, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { enabled: false },
+      }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { model: 'project/provider-model' },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir, { silent: true });
+
+    expect(config.webfetch).toEqual({
+      enabled: false,
+      model: 'project/provider-model',
+    });
+  });
+
   test('validates auto image routing after project enables Observer', () => {
     const userConfigPath = path.join(userConfigDir, 'opencode');
     const projectDir = path.join(tempDir, 'project');
@@ -538,6 +592,93 @@ describe('onWarning callback', () => {
     const config = loadPluginConfig(projectDir);
     expect(config.agents?.oracle?.model).toBe('model');
   });
+
+  test('rejects config with non-array disabled_tools (schema validation)', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        disabled_tools: 'not-an-array',
+        agents: { oracle: { model: 'test/model' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    // Schema validation rejects the entire file, so config is empty
+    expect(config).toEqual({});
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.message).toBe('Config does not match schema');
+  });
+
+  test('rejects config with non-array disabled_agents (schema validation)', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        disabled_agents: { invalid: 'object' },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(config).toEqual({});
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+  });
+
+  test('rejects config with non-array disabled_mcps (schema validation)', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        disabled_mcps: 123,
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(config).toEqual({});
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+  });
+
+  test('rejects config with non-array disabled_skills (schema validation)', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        disabled_skills: true,
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(config).toEqual({});
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+  });
 });
 
 describe('deepMerge behavior', () => {
@@ -607,7 +748,7 @@ describe('deepMerge behavior', () => {
     fs.writeFileSync(
       path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        disabled_mcps: ['websearch'],
+        disabled_mcps: ['gh_grep'],
       }),
     );
 

+ 80 - 2
src/config/loader.ts

@@ -3,7 +3,11 @@ import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
-import { type PluginConfig, PluginConfigSchema } from './schema';
+import {
+  type PluginConfig,
+  PluginConfigSchema,
+  WebfetchConfigSchema,
+} from './schema';
 
 /**
  * Warning kinds produced during config loading.
@@ -141,6 +145,26 @@ function loadConfigFromPath(
       return null;
     }
 
+    // Zod applies webfetch.enabled's default while parsing each layer. Keep
+    // that default from masquerading as an explicitly configured override;
+    // the merged webfetch config is normalized after all layers are merged.
+    if (
+      result.data.webfetch &&
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      'webfetch' in rawConfig &&
+      typeof rawConfig.webfetch === 'object' &&
+      rawConfig.webfetch !== null &&
+      !Array.isArray(rawConfig.webfetch) &&
+      !Object.hasOwn(rawConfig.webfetch, 'enabled')
+    ) {
+      const { enabled: _enabled, ...webfetch } = result.data.webfetch;
+      return {
+        ...result.data,
+        webfetch: webfetch as PluginConfig['webfetch'],
+      };
+    }
+
     return result.data;
   } catch (error) {
     // File doesn't exist or isn't readable - this is expected and fine
@@ -200,6 +224,17 @@ function findConfigPathInDirs(
   return null;
 }
 
+/**
+ * Validate that `image_routing: "auto"` has a live observer agent to route
+ * images to. Emits a warning (via `onWarning`/`console.warn`) and returns
+ * `false` if "auto" routing is configured but the observer agent is
+ * disabled, since images would then have nowhere to go.
+ *
+ * @param config - Plugin configuration to validate
+ * @param configPath - Path of the config file, used in the warning payload
+ * @param options - Optional load options including the onWarning callback
+ * @returns `true` if the routing configuration is valid, `false` otherwise
+ */
 function validateFinalImageRouting(
   config: PluginConfig,
   configPath: string,
@@ -207,7 +242,9 @@ function validateFinalImageRouting(
 ): boolean {
   if (config.image_routing !== 'auto') return true;
 
-  const disabledAgents = config.disabled_agents ?? DEFAULT_DISABLED_AGENTS;
+  const disabledAgents = Array.isArray(config.disabled_agents)
+    ? config.disabled_agents
+    : DEFAULT_DISABLED_AGENTS;
   if (!disabledAgents.includes('observer')) return true;
 
   const message =
@@ -270,6 +307,10 @@ export function mergePluginConfigs(
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
+    webfetch: deepMerge(
+      base.webfetch as Record<string, unknown> | undefined,
+      override.webfetch as Record<string, unknown> | undefined,
+    ) as PluginConfig['webfetch'],
     acpAgents: deepMerge(base.acpAgents, override.acpAgents),
     companion: deepMerge(
       base.companion as Record<string, unknown> | undefined,
@@ -351,6 +392,10 @@ export function loadPluginConfig(
     config = mergePluginConfigs(config, projectConfig);
   }
 
+  if (config.webfetch) {
+    config.webfetch = WebfetchConfigSchema.parse(config.webfetch);
+  }
+
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
@@ -402,6 +447,39 @@ export function loadPluginConfig(
     options,
   );
 
+  // Normalize disabled_* config keys to ensure they are arrays or undefined.
+  // This loop is currently unreachable via the normal file-loading path:
+  // PluginConfigSchema.safeParse() rejects the WHOLE config object if any
+  // disabled_* field is non-array (no .catch() on these fields), so
+  // loadConfigFromPath returns null and the file falls back to {} BEFORE this
+  // loop ever runs. Retained only as defense-in-depth against a future schema
+  // relaxation (e.g. adding .catch() to these fields) or a construction path
+  // that bypasses safeParse entirely — not as a proven/tested fix for the
+  // originally reported crash (root cause not reproduced).
+  const ARRAY_CONFIG_KEYS = [
+    'disabled_agents',
+    'disabled_tools',
+    'disabled_mcps',
+    'disabled_skills',
+  ] as const;
+
+  const configPathForWarning = projectConfigPath ?? userConfigPath ?? '';
+  for (const key of ARRAY_CONFIG_KEYS) {
+    const value = config[key as keyof PluginConfig];
+    if (value !== undefined && !Array.isArray(value)) {
+      const message = `Config key "${key}" must be an array; ignoring invalid value.`;
+      options?.onWarning?.({
+        path: configPathForWarning,
+        kind: 'invalid-schema',
+        message,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${message}`);
+      }
+      delete config[key as keyof PluginConfig];
+    }
+  }
+
   return config;
 }
 

+ 5 - 3
src/config/project-local-customization.test.ts

@@ -129,8 +129,8 @@ describe('Project-local customization - 15 core cases', () => {
     );
   });
 
-  // Test Case 6: File prompt overrides inline built-in prompt
-  test('6. File prompt overrides inline built-in prompt', () => {
+  // Test Case 6: Inline prompt overrides file prompt
+  test('6. Inline prompt overrides file prompt', () => {
     const config = {
       agents: {
         oracle: {
@@ -150,7 +150,9 @@ describe('Project-local customization - 15 core cases', () => {
 
     const agents = createAgents(config);
     const oracle = agents.find((a) => a.name === 'oracle');
-    expect(oracle?.config.prompt).toBe('File prompt override content');
+    expect(oracle?.config.prompt).toBe(
+      'You are the inline oracle prompt override.',
+    );
   });
 
   // Test Case 7: Append file appends to inline built-in prompt

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

@@ -40,6 +40,30 @@ describe('PluginConfigSchema image_routing', () => {
   });
 });
 
+describe('PluginConfigSchema webfetch', () => {
+  it('defaults the enhanced webfetch tool to enabled', () => {
+    const result = PluginConfigSchema.safeParse({ webfetch: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.webfetch?.enabled).toBe(true);
+    }
+  });
+
+  it('accepts dedicated model fallback entries with variants', () => {
+    const result = PluginConfigSchema.safeParse({
+      webfetch: {
+        model: [
+          'openai/gpt-4o-mini',
+          { id: 'anthropic/claude-3-haiku', variant: 'low-latency' },
+        ],
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
+});
+
 describe('PluginConfigSchema backgroundJobs', () => {
   it('defaults board injection to the legacy latest strategy', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });

+ 22 - 8
src/config/schema.ts

@@ -122,6 +122,7 @@ export const AgentOverrideConfigSchema = z
     orchestratorPrompt: z.string().min(1).optional(),
     options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
     displayName: z.string().min(1).optional(),
+    description: z.string().min(1).optional(),
     permission: PermissionConfigSchema.optional(), // tool-level permission rules enforced by the SDK
   })
   .strict();
@@ -172,14 +173,8 @@ export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
 
 export type Preset = z.infer<typeof PresetSchema>;
 
-// Websearch provider configuration
-export const WebsearchConfigSchema = z.object({
-  provider: z.enum(['exa', 'tavily']).default('exa'),
-});
-export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>;
-
 // MCP names
-export const McpNameSchema = z.enum(['websearch', 'context7', 'gh_grep']);
+export const McpNameSchema = z.enum(['context7', 'gh_grep']);
 export type McpName = z.infer<typeof McpNameSchema>;
 
 export const InterviewConfigSchema = z.object({
@@ -205,6 +200,7 @@ export const BackgroundJobsConfigSchema = z.object({
       'Board injection strategy. "latest" replaces prior board messages; "checkpoint-compatible" preserves them and appends only changed board snapshots.',
     ),
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
+  maxContextLines: z.number().int().min(0).max(500_000).default(50_000),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
   maxRetainedSnapshots: z
@@ -314,6 +310,24 @@ export const CompanionConfigSchema = z.object({
 
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 
+export const WebfetchConfigSchema = z
+  .object({
+    enabled: z
+      .boolean()
+      .default(true)
+      .describe(
+        'When false, skip registering this enhanced webfetch so OpenCode uses its built-in version.',
+      ),
+    model: AgentOverrideConfigSchema.shape.model.describe(
+      'Dedicated model(s) for smartfetch secondary-model summarization. ' +
+        'Same shape as agent model config (string, array of strings/objects with id+variant). ' +
+        'Takes priority over small_model, agents.explorer.model, and agents.librarian.model.',
+    ),
+  })
+  .strict();
+
+export type WebfetchConfig = z.infer<typeof WebfetchConfigSchema>;
+
 export const AcpAgentPermissionModeSchema = z.enum(['ask', 'allow', 'reject']);
 
 export const MAX_ACP_TIMEOUT_MS = 2_147_483_647;
@@ -431,12 +445,12 @@ export const PluginConfigSchema = z
       ),
     // Multiplexer config
     multiplexer: MultiplexerConfigSchema.optional(),
-    websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
     backgroundJobs: BackgroundJobsConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
     companion: CompanionConfigSchema.optional(),
+    webfetch: WebfetchConfigSchema.optional(),
     acpAgents: AcpAgentsConfigSchema.optional(),
   })
   .superRefine((value, ctx) => {

+ 24 - 0
src/health-check.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'bun:test';
+import { minimumExpectedToolCount } from './health-check';
+
+describe('plugin health thresholds', () => {
+  test('accounts only for intentionally disabled baseline tools', () => {
+    expect(minimumExpectedToolCount()).toBe(5);
+    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(4);
+    expect(minimumExpectedToolCount(['wait_for_user', 'wait_for_user'])).toBe(
+      4,
+    );
+    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
+    expect(minimumExpectedToolCount([], false)).toBe(4);
+    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(3);
+    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(4);
+  });
+
+  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(5);
+    expect(minimumExpectedToolCount(null as any)).toBe(5);
+    expect(minimumExpectedToolCount({} as any)).toBe(5);
+  });
+});

+ 62 - 0
src/health-check.ts

@@ -0,0 +1,62 @@
+/**
+ * Plugin init health-check thresholds and helpers.
+ *
+ * Deliberately NOT re-exported from the package root (`src/index.ts`).
+ * OpenCode's legacy plugin loader iterates every named export of the
+ * root module and invokes each as a plugin factory with `PluginInput`.
+ * A helper like `minimumExpectedToolCount` would then be called with a
+ * `PluginInput` object instead of `string[]`, and its numeric return
+ * value would be pushed into the hooks array as if it were a `Hooks`
+ * object. Keeping this module internal (imported by, but not
+ * re-exported from, `src/index.ts`) avoids that class of bug entirely;
+ * see https://github.com/alvinunreal/oh-my-opencode-slim/issues/894.
+ */
+
+/** Minimum expected registrations for a healthy plugin load. */
+export const HEALTH_CHECK = {
+  minAgents: 5,
+  // Default tool set when council and ACP agents are not configured:
+  // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
+  minTools: 5,
+  minMcps: 1,
+} as const;
+
+const BASELINE_TOOL_NAMES = new Set([
+  'cancel_task',
+  'wait_for_user',
+  'webfetch',
+  'ast_grep_search',
+  'ast_grep_replace',
+]);
+
+/**
+ * Compute the minimum tool count the health check should expect, accounting
+ * for baseline tools the user has intentionally disabled.
+ *
+ * @param disabledTools - Tool names disabled via config; non-array/malformed
+ *   values (which should never occur post-validation, but are not trusted at
+ *   runtime) are treated as "nothing disabled".
+ * @param webfetchEnabled - Whether the enhanced webfetch tool is registered.
+ * @returns The adjusted minimum expected tool count
+ */
+export function minimumExpectedToolCount(
+  disabledTools: readonly string[] = [],
+  webfetchEnabled = true,
+): number {
+  // Config values come from user-edited JSON/JSONC (and can be re-derived
+  // via runtime preset switches); never trust the declared type at
+  // runtime. Fall back to "no disabled tools" instead of crashing plugin
+  // init if this isn't actually an array.
+  const safeDisabledTools = Array.isArray(disabledTools) ? disabledTools : [];
+  const disabledBaselineTools = new Set(
+    safeDisabledTools.filter(
+      (toolName) =>
+        BASELINE_TOOL_NAMES.has(toolName) &&
+        (toolName !== 'webfetch' || webfetchEnabled),
+    ),
+  );
+  const webfetchAdjustment = webfetchEnabled ? 0 : 1;
+  return (
+    HEALTH_CHECK.minTools - webfetchAdjustment - disabledBaselineTools.size
+  );
+}

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

@@ -455,16 +455,6 @@ exports[`cache-impact snapshots (update deliberately — see file header) transf
 ,
         "type": "text",
       },
-    ],
-  },
-  {
-    "info": {
-      "agent": "orchestrator",
-      "id": "m09-background-job-board",
-      "role": "user",
-      "sessionID": "ses_cache_safety_fixture",
-    },
-    "parts": [
       {
         "metadata": {
           "oh-my-opencode-slim.backgroundJobBoard": true,

+ 14 - 5
src/hooks/cache-safety-harness.test.ts

@@ -14,7 +14,7 @@ import {
 } from '../config/constants';
 import { BackgroundJobBoard, createInternalAgentTextPart } from '../utils';
 import { createDisplayNameMentionRewriter } from '../utils/agent-variant';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { isTaggedPart } from './cache-safe-injection';
 import { createFilterAvailableSkillsHook } from './filter-available-skills';
 import { processImageAttachments } from './image-hook';
 import { createPhaseReminderHook } from './phase-reminder';
@@ -25,6 +25,7 @@ import {
   createTaskSessionManagerHook,
 } from './task-session-manager';
 import type { MessageWithParts } from './types';
+import { isMessageWithParts } from './types';
 
 export const SESSION_ID = 'ses_cache_safety_fixture';
 export const FIXTURE_NOW = 1_700_000_000_000;
@@ -229,11 +230,19 @@ export function turnEndIndices(history: unknown[]): number[] {
 }
 
 export function stableFingerprints(messages: unknown[]): string[] {
+  // The volatile board is a tagged part appended to the last real message (or,
+  // for legacy paths, a whole tagged trailing message). Both must be excluded
+  // from the stable fingerprint: strip tagged parts from every message and
+  // drop any message that was wholly volatile, then fingerprint what remains.
   return messages
-    .filter(
-      (message) =>
-        !isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
-    )
+    .flatMap((message) => {
+      if (!isMessageWithParts(message)) return [message];
+      const stableParts = message.parts.filter(
+        (part) => !isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+      );
+      if (stableParts.length === 0) return [];
+      return [{ ...message, parts: stableParts }];
+    })
     .map((message) => JSON.stringify(message));
 }
 

+ 143 - 130
src/hooks/cache-safety.property.test.ts

@@ -22,7 +22,7 @@ import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
 import { readFileSync } from 'node:fs';
 import path from 'node:path';
 import { BackgroundJobsConfigSchema } from '../config';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { isTaggedPart, isVolatileTaggedMessage } from './cache-safe-injection';
 import {
   assistantTurn,
   type BoardStrategy,
@@ -87,63 +87,62 @@ describe('cache-safety: board strategy coverage drift guard', () => {
   });
 });
 
-describe.each(BOARD_STRATEGIES)(
-  'cache-safety: turn-over-turn prefix stability (%s)',
-  (strategy) => {
-    test('re-rendering a growing conversation reproduces byte-identical history', async () => {
-      const pipeline = createPipeline({ strategy });
-      const history = buildHistory();
-      const turns = turnEndIndices(history);
-      const fingerprintsFor = STRATEGY_STABLE_FINGERPRINTS[strategy];
-
-      let previous: string[] | undefined;
-      for (const [turnNumber, endIndex] of turns.entries()) {
-        // Exercise cross-turn hook state: a file-tool nudge fires and a
-        // background job launches before the second turn (a real user turn,
-        // so checkpoint mode creates a snapshot), the job is dropped before
-        // the internal-initiator turn renders with an empty board, and a
-        // second job launches before the fourth turn. Snapshot creation,
-        // replay across internal-initiator and empty-board turns, and
-        // unchanged-board dedupe all must leave stable bytes untouched —
-        // the v2.2.5 checkpoint regression rewrote them on exactly these
-        // transitions.
-        if (turnNumber === 1) {
-          pipeline.markFileToolPending();
-          pipeline.board.registerLaunch({
-            taskID: 'task-alpha',
-            parentSessionID: SESSION_ID,
-            agent: 'explorer',
-            description: 'churn fixture',
-            now: FIXTURE_NOW,
-          });
-        }
-        if (turnNumber === 2) pipeline.board.drop('task-alpha');
-        if (turnNumber === 3) {
-          pipeline.board.registerLaunch({
-            taskID: 'task-beta',
-            parentSessionID: SESSION_ID,
-            agent: 'fixer',
-            description: 'second churn fixture',
-            now: FIXTURE_NOW,
-          });
-        }
+describe.each(
+  BOARD_STRATEGIES,
+)('cache-safety: turn-over-turn prefix stability (%s)', (strategy) => {
+  test('re-rendering a growing conversation reproduces byte-identical history', async () => {
+    const pipeline = createPipeline({ strategy });
+    const history = buildHistory();
+    const turns = turnEndIndices(history);
+    const fingerprintsFor = STRATEGY_STABLE_FINGERPRINTS[strategy];
+
+    let previous: string[] | undefined;
+    for (const [turnNumber, endIndex] of turns.entries()) {
+      // Exercise cross-turn hook state: a file-tool nudge fires and a
+      // background job launches before the second turn (a real user turn,
+      // so checkpoint mode creates a snapshot), the job is dropped before
+      // the internal-initiator turn renders with an empty board, and a
+      // second job launches before the fourth turn. Snapshot creation,
+      // replay across internal-initiator and empty-board turns, and
+      // unchanged-board dedupe all must leave stable bytes untouched —
+      // the v2.2.5 checkpoint regression rewrote them on exactly these
+      // transitions.
+      if (turnNumber === 1) {
+        pipeline.markFileToolPending();
+        pipeline.board.registerLaunch({
+          taskID: 'task-alpha',
+          parentSessionID: SESSION_ID,
+          agent: 'explorer',
+          description: 'churn fixture',
+          now: FIXTURE_NOW,
+        });
+      }
+      if (turnNumber === 2) pipeline.board.drop('task-alpha');
+      if (turnNumber === 3) {
+        pipeline.board.registerLaunch({
+          taskID: 'task-beta',
+          parentSessionID: SESSION_ID,
+          agent: 'fixer',
+          description: 'second churn fixture',
+          now: FIXTURE_NOW,
+        });
+      }
 
-        const output = await renderTurn(pipeline, history, endIndex);
-        const fingerprints = fingerprintsFor(output.messages);
+      const output = await renderTurn(pipeline, history, endIndex);
+      const fingerprints = fingerprintsFor(output.messages);
 
-        if (previous) {
-          if (fingerprints.length < previous.length) {
-            throw new Error(
-              'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
-            );
-          }
-          expect(fingerprints.slice(0, previous.length)).toEqual(previous);
+      if (previous) {
+        if (fingerprints.length < previous.length) {
+          throw new Error(
+            'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
+          );
         }
-        previous = fingerprints;
+        expect(fingerprints.slice(0, previous.length)).toEqual(previous);
       }
-    });
-  },
-);
+      previous = fingerprints;
+    }
+  });
+});
 
 describe('cache-safety: turn-over-turn prefix stability', () => {
   test('a consumed file-tool nudge is reproduced by the phase reminder on the next turn', async () => {
@@ -166,44 +165,43 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
   });
 });
 
-describe.each(BOARD_STRATEGIES)(
-  'cache-safety: specialist sessions (%s)',
-  (strategy) => {
-    test('non-orchestrator payloads pass through byte-identical', async () => {
-      const pipeline = createPipeline({ strategy });
-      const specialistSession = 'ses_specialist_fixture';
-      const history = [
-        {
-          info: {
-            role: 'user',
-            agent: 'explorer',
-            sessionID: specialistSession,
-            id: 's01',
-          },
-          parts: [{ type: 'text', text: 'find the config loader' }],
+describe.each(
+  BOARD_STRATEGIES,
+)('cache-safety: specialist sessions (%s)', (strategy) => {
+  test('non-orchestrator payloads pass through byte-identical', async () => {
+    const pipeline = createPipeline({ strategy });
+    const specialistSession = 'ses_specialist_fixture';
+    const history = [
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's01',
         },
-        assistantTurn('s02', 'Searching now.'),
-        {
-          info: {
-            role: 'user',
-            agent: 'explorer',
-            sessionID: specialistSession,
-            id: 's03',
-          },
-          parts: [{ type: 'text', text: 'summarize what you found' }],
+        parts: [{ type: 'text', text: 'find the config loader' }],
+      },
+      assistantTurn('s02', 'Searching now.'),
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's03',
         },
-      ];
-      const before = history.map((message) => JSON.stringify(message));
+        parts: [{ type: 'text', text: 'summarize what you found' }],
+      },
+    ];
+    const before = history.map((message) => JSON.stringify(message));
 
-      const output: TransformOutput = { messages: structuredClone(history) };
-      await pipeline.run(output);
+    const output: TransformOutput = { messages: structuredClone(history) };
+    await pipeline.run(output);
 
-      expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
-        before,
-      );
-    });
-  },
-);
+    expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
+      before,
+    );
+  });
+});
 
 describe('cache-safety: volatile content isolation', () => {
   test('background-job state only ever changes the tagged trailing message', async () => {
@@ -227,15 +225,34 @@ describe('cache-safety: volatile content isolation', () => {
       stableFingerprints(withoutJobs.messages),
     );
 
-    // The volatile zone is exactly one tagged message, strictly trailing.
-    const volatile = withJobs.messages.filter((message) =>
-      isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    // The board is a single tagged part appended to the very end of the last
+    // message (never a separate trailing message that the provider SDK would
+    // coalesce into the last real message and rob of its cache breakpoint).
+    // Keeping the message COUNT identical to the no-board render lets the
+    // provider's last-two-messages breakpoint land on stable real content.
+    expect(withJobs.messages).toHaveLength(withoutJobs.messages.length);
+
+    const allTaggedParts = withJobs.messages.flatMap((message, index) =>
+      (message as MessageWithParts).parts
+        .map((part, partIndex) => ({ index, partIndex, part }))
+        .filter(({ part }) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
     );
-    expect(volatile).toHaveLength(1);
-    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(allTaggedParts).toHaveLength(1);
+
+    const lastMessage = withJobs.messages.at(-1) as MessageWithParts;
+    const boardHit = allTaggedParts[0];
+    // The one board part lives on the last message and is its last part.
+    expect(boardHit.index).toBe(withJobs.messages.length - 1);
+    expect(boardHit.partIndex).toBe(lastMessage.parts.length - 1);
+
+    // The no-board render carries no board part anywhere.
     expect(
       withoutJobs.messages.some((message) =>
-        isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        (message as MessageWithParts).parts.some((part) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
       ),
     ).toBe(false);
   });
@@ -275,44 +292,40 @@ describe('cache-safety: volatile content isolation', () => {
   });
 });
 
-describe.each(BOARD_STRATEGIES)(
-  'cache-safety: determinism under ambient inputs (%s)',
-  (strategy) => {
-    test('wall clock and randomness never leak into the payload', async () => {
-      const history = buildHistory();
-      const lastTurn = history.length - 1;
-      const originalRandom = Math.random;
-
-      const render = async (
-        time: number,
-        random: number,
-      ): Promise<string[]> => {
-        setSystemTime(new Date(time));
-        Math.random = () => random;
-        try {
-          const pipeline = createPipeline({ strategy });
-          pipeline.board.registerLaunch({
-            taskID: 'task-gamma',
-            parentSessionID: SESSION_ID,
-            agent: 'oracle',
-            description: 'determinism fixture',
-            now: FIXTURE_NOW,
-          });
-          const output = await renderTurn(pipeline, history, lastTurn);
-          return output.messages.map((message) => JSON.stringify(message));
-        } finally {
-          Math.random = originalRandom;
-          setSystemTime();
-        }
-      };
+describe.each(
+  BOARD_STRATEGIES,
+)('cache-safety: determinism under ambient inputs (%s)', (strategy) => {
+  test('wall clock and randomness never leak into the payload', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+    const originalRandom = Math.random;
+
+    const render = async (time: number, random: number): Promise<string[]> => {
+      setSystemTime(new Date(time));
+      Math.random = () => random;
+      try {
+        const pipeline = createPipeline({ strategy });
+        pipeline.board.registerLaunch({
+          taskID: 'task-gamma',
+          parentSessionID: SESSION_ID,
+          agent: 'oracle',
+          description: 'determinism fixture',
+          now: FIXTURE_NOW,
+        });
+        const output = await renderTurn(pipeline, history, lastTurn);
+        return output.messages.map((message) => JSON.stringify(message));
+      } finally {
+        Math.random = originalRandom;
+        setSystemTime();
+      }
+    };
 
-      const first = await render(FIXTURE_NOW, 0.1234);
-      const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
+    const first = await render(FIXTURE_NOW, 0.1234);
+    const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
 
-      expect(second).toEqual(first);
-    });
-  },
-);
+    expect(second).toEqual(first);
+  });
+});
 
 describe('cache-safety: pipeline drift guard', () => {
   const srcRoot = path.resolve(import.meta.dir, '..');

+ 4 - 0
src/hooks/foreground-fallback/index.ts

@@ -82,6 +82,10 @@ const PROVIDER_OUTAGE_PATTERNS = [
   /\bupstream outage\b/i,
   /\bprovider outage\b/i,
   /\bprovider unavailable\b/i,
+  /\bmodel\b.*\bnot available\b/i,
+  /\bmodel is not available\b/i,
+  /\bunsupported model\b/i,
+  /\bunknown model\b/i,
 ];
 
 function extractStatusCode(error: {

+ 1 - 1
src/hooks/json-error-recovery/codemap.md

@@ -8,7 +8,7 @@ Provides automatic JSON error detection and recovery for OpenCode plugin tool ex
 
 ### Core Components
 
-- **JSON_ERROR_TOOL_EXCLUDE_LIST**: Set of tools excluded from JSON error checking (bash, read, glob, webfetch, gh_grep_searchgithub, websearch_web_search_exa)
+- **JSON_ERROR_TOOL_EXCLUDE_LIST**: Set of tools excluded from JSON error checking (bash, read, glob, webfetch, gh_grep_searchgithub)
 - **JSON_ERROR_PATTERNS**: Array of regex patterns for detecting various JSON error messages
 - **JSON_ERROR_REMINDER**: Standardized error message template instructing users on JSON correction
 - **createJsonErrorRecoveryHook()**: Factory function that returns the OpenCode plugin hook

+ 0 - 1
src/hooks/json-error-recovery/hook.ts

@@ -6,7 +6,6 @@ export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
   'glob',
   'webfetch',
   'gh_grep_searchgithub',
-  'websearch_web_search_exa',
 ] as const;
 
 export const JSON_ERROR_PATTERNS = [

+ 475 - 0
src/hooks/task-session-manager/board-cache-breakpoint.test.ts

@@ -0,0 +1,475 @@
+/**
+ * Regression coverage for the Background Job Board prompt-cache breakpoint bug.
+ *
+ * Real same-session dumps (2026-07-23, ses_11145863…, dumps 000164–000169)
+ * showed the same failure on every consecutive request pair: the board sat at
+ * the very tail, but the conversation advanced by ~2 messages per turn, so the
+ * first byte divergence landed exactly at the board position and ~243 KB of
+ * tail was re-written as cache on every call. The frozen cache-read at the
+ * system boundary is the field signature.
+ *
+ * Root cause: the provider caches only the last TWO messages (Anthropic:
+ * `provider/transform.ts applyCaching → final.slice(-2)`), and the provider
+ * SDK coalesces adjacent same-role `user` messages. A board injected as its
+ * OWN trailing `user` message merges into the preceding user tool_result
+ * message and collapses both tail breakpoints onto the single merged block —
+ * so the only readable breakpoint sits on the volatile board, which moves to a
+ * new tail every request. The deepest reusable breakpoint therefore regresses
+ * to the stable system boundary.
+ *
+ * Fix: inject the board as a trailing PART on the last real message. The
+ * message COUNT stays identical to a board-free render, so the provider's
+ * second tail breakpoint lands on the previous (byte-stable, real) message,
+ * which the next request reproduces exactly and can read from cache.
+ *
+ * This suite models core's caching + SDK merge to prove the readable
+ * breakpoint now falls on stable real content.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard } from '../../utils';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './index';
+
+const SESSION = 'ses_orchestrator_1114';
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 4,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+function userMsg(id: string, text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+function anonymousUserMsg(text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+/** An assistant turn issuing a tool call, followed by its user tool_result. */
+function toolTurn(id: string, output: string) {
+  return [
+    {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: `${id}-a`,
+      },
+      parts: [
+        { type: 'text', text: ' ' },
+        {
+          type: 'tool',
+          tool: 'read',
+          callID: `${id}-call`,
+          state: { status: 'completed', input: {}, output: 'x' },
+        },
+      ],
+    },
+    {
+      info: {
+        role: 'user',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: `${id}-r`,
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'read',
+          callID: `${id}-call`,
+          state: { status: 'completed', input: {}, output },
+        },
+      ],
+    },
+  ];
+}
+
+async function inject(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // opencode rebuilds msgs from storage every request; the board is never
+  // persisted, so each request starts from real history only.
+  const request = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, request as never);
+  await hook.injectBackgroundJobBoard({}, request as never);
+  return request.messages;
+}
+
+type Msg = {
+  info: { role: string; id?: string };
+  parts: { metadata?: Record<string, unknown> }[];
+};
+
+/**
+ * Faithful model of the provider cache pipeline that produced the field bug,
+ * in the exact order opencode runs it (`provider/transform.ts`):
+ *
+ *   1. `applyCaching` selects the breakpoint messages as `msgs.slice(-2)` over
+ *      the message array BEFORE the SDK coalesces roles. This ordering is why
+ *      the bug exists: a separate trailing board `user` message makes the last
+ *      two messages [tool_result(user), board(user)], so NEITHER breakpoint
+ *      lands on the preceding assistant turn.
+ *   2. the provider SDK then coalesces adjacent same-role messages, so the two
+ *      selected user messages merge and only the final block (the board) keeps
+ *      an effective cache_control.
+ *
+ * A breakpoint is READABLE next request only if the exact byte prefix ending
+ * at that breakpoint message reproduces. Returns the readable byte-prefixes
+ * this request establishes (one per breakpoint message, measured over the full
+ * ordered block stream).
+ */
+function readableCachePrefixes(messages: unknown[]): string[] {
+  const msgs = messages as Msg[];
+
+  // Assign each message to its post-merge coalesced-turn index.
+  const turnOfMessage: number[] = [];
+  const turnEndPrefix: string[] = [];
+  let acc = '';
+  let turnIndex = -1;
+  let prevRole: string | undefined;
+  for (const message of msgs) {
+    if (message.info.role !== prevRole) {
+      turnIndex += 1;
+      prevRole = message.info.role;
+    }
+    for (const part of message.parts) acc += JSON.stringify(part);
+    turnOfMessage.push(turnIndex);
+    turnEndPrefix[turnIndex] = acc; // running end-of-turn prefix
+  }
+
+  // applyCaching selects the last two MESSAGES (pre-merge). Each realizes its
+  // cache_control on the LAST block of the coalesced turn it merges into, so
+  // the readable prefix ends at that turn's end — not the message's own end.
+  const breakpointMessages = [msgs.length - 2, msgs.length - 1].filter(
+    (i) => i >= 0,
+  );
+  const prefixes = new Set<string>();
+  for (const mi of breakpointMessages) {
+    prefixes.add(turnEndPrefix[turnOfMessage[mi]]);
+  }
+  return [...prefixes];
+}
+
+/** Simulate the OLD placement: board as its own trailing user message. */
+function withSeparateBoardMessage(
+  messages: unknown[],
+  reminderText: string,
+): unknown[] {
+  return [
+    ...(messages as unknown[]),
+    {
+      info: {
+        role: 'user',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'board-msg',
+      },
+      parts: [
+        {
+          type: 'text',
+          synthetic: true,
+          text: reminderText,
+          metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+        },
+      ],
+    },
+  ];
+}
+
+describe('background job board cache breakpoint stability', () => {
+  test('a readable cache breakpoint falls on byte-stable real content across turns', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    // Request N: history ends with a tool_result turn; board injected at tail.
+    const historyN = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+    ];
+    const outN = await inject(hook, historyN);
+
+    // Request N+1: the agent loop advanced by another tool turn.
+    const historyN1 = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+      ...toolTurn('t2', 'result-2'),
+    ];
+    const outN1 = await inject(hook, historyN1);
+
+    // NEW placement: at least one readable byte-prefix from request N is a
+    // prefix of request N+1's full byte stream — the provider can resume the
+    // cache there instead of re-writing the whole tail.
+    const prefixesN = readableCachePrefixes(outN);
+    const streamN1 = readableCachePrefixes(outN1).at(-1) ?? '';
+    const readable = prefixesN.filter((p) => streamN1.startsWith(p));
+    expect(readable.length).toBeGreaterThan(0);
+
+    // CONTRAST: the OLD separate-message placement establishes no readable
+    // prefix — its only breakpoints sit on the merged tool_result+board turn
+    // and the board turn, both of which N+1 does not reproduce at that offset.
+    const oldReminder = board.formatForPrompt(SESSION) ?? '';
+    const oldN = withSeparateBoardMessage(
+      [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'result-1')],
+      oldReminder,
+    );
+    const oldN1 = withSeparateBoardMessage(
+      [
+        userMsg('u1', 'Coordinate'),
+        ...toolTurn('t1', 'result-1'),
+        ...toolTurn('t2', 'result-2'),
+      ],
+      oldReminder,
+    );
+    const oldPrefixesN = readableCachePrefixes(oldN);
+    const oldStreamN1 = readableCachePrefixes(oldN1).at(-1) ?? '';
+    const oldReadable = oldPrefixesN.filter((p) => oldStreamN1.startsWith(p));
+    expect(oldReadable.length).toBe(0);
+  });
+
+  test('board is a trailing part on the last message, keeping message count board-free-equal', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    const history = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'result-1'),
+    ];
+
+    const emptyHook = createHook(new BackgroundJobBoard());
+    const boardFree = await inject(emptyHook, history);
+    const withBoard = await inject(hook, history);
+
+    // No new message is created for the board.
+    expect((withBoard as unknown[]).length).toBe(
+      (boardFree as unknown[]).length,
+    );
+
+    // The single board part is the last part of the last message.
+    const boardParts = (withBoard as Msg[]).flatMap((m, i) =>
+      m.parts
+        .map((p, pi) => ({ i, pi, p }))
+        .filter(
+          ({ p }) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+        ),
+    );
+    expect(boardParts).toHaveLength(1);
+    const last = withBoard.at(-1) as Msg;
+    expect(boardParts[0].i).toBe(withBoard.length - 1);
+    expect(boardParts[0].pi).toBe(last.parts.length - 1);
+  });
+
+  test('previously-sent history bytes never change across a growing conversation', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    // Fingerprint of the stable (non-board) content of every message.
+    const stableSerialize = (messages: unknown[]): string[] =>
+      (messages as Msg[]).map((m) =>
+        JSON.stringify({
+          info: m.info,
+          parts: m.parts.filter(
+            (p) => p.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] !== true,
+          ),
+        }),
+      );
+
+    const historyN = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
+    const outN = stableSerialize(await inject(hook, historyN));
+
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'done',
+    });
+    const historyN1 = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outN1 = stableSerialize(await inject(hook, historyN1));
+
+    // Every message present in request N must be byte-identical in N+1: the
+    // board (excluded here) is the only thing that ever changes, and it rides
+    // on the last message's trailing part, so real history is untouched.
+    expect(outN1.slice(0, outN.length)).toEqual(outN);
+  });
+
+  test('an already-sent tail board is not stripped when the tail advances (dumps 000086->000087)', async () => {
+    // Faithful reconstruction of the live cache bust (ses_11145863, dumps
+    // 000086 A -> 000087 B). In A the tail was a user tool_result message that
+    // carried the board as an appended trailing part; that request was SENT to
+    // the provider and cached with the board on that message. B then advanced
+    // by two new messages (assistant + user tool_result). The provider caches a
+    // byte prefix, so every message it already received in A must be byte-
+    // identical in B — INCLUDING the board bytes on the old tail. The #889
+    // append-on-tail placement dropped that board when the tail advanced,
+    // rewriting the already-sent old-tail message (A: 1376B -> B: 652B in the
+    // field dump) and busting the cache prefix from that message onward.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'grok research',
+    });
+    const hook = createHook(board);
+
+    // FULL serialization including the board part — a byte-exact fingerprint of
+    // what the provider actually received for each message.
+    const fullSerialize = (messages: unknown[]): string[] =>
+      (messages as Msg[]).map((m) => JSON.stringify(m));
+
+    // Request A: tail is a user tool_result turn; board rides on it as a
+    // trailing part (the #889 "tail is user" branch, matching dump 000086).
+    const historyA = [userMsg('u1', 'Coordinate'), ...toolTurn('t1', 'r1')];
+    const outA = await inject(hook, historyA);
+    const serA = fullSerialize(outA);
+
+    // The old tail carried the board (as sent to the provider in request A).
+    const oldTailA = outA.at(-1) as Msg;
+    expect(oldTailA.info.role).toBe('user');
+    expect(
+      oldTailA.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    // Request B: the loop advanced by exactly two new messages (assistant +
+    // user tool_result), matching dump 000087's two extra tail messages.
+    const historyB = [
+      userMsg('u1', 'Coordinate'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outB = await inject(hook, historyB);
+    const serB = fullSerialize(outB);
+
+    // Every message the provider received in request A must be byte-identical
+    // in request B, board bytes included. In particular the old tail (index
+    // serA.length - 1) must still carry its board — it must NOT be stripped.
+    expect(serB.slice(0, serA.length)).toEqual(serA);
+
+    // Explicit guard on the exact failure the field dump showed: the old-tail
+    // message keeps its board trailing part in B.
+    const oldTailB = outB[serA.length - 1] as Msg;
+    expect(
+      oldTailB.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+  });
+
+  test('duplicate anonymous user turns preserve the first board on append', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'research',
+    });
+    const hook = createHook(board);
+
+    const firstRequest = await inject(hook, [anonymousUserMsg('continue')]);
+    const firstMessage = firstRequest[0] as Msg;
+    expect(
+      firstMessage.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    const secondRequest = await inject(hook, [
+      anonymousUserMsg('continue'),
+      anonymousUserMsg('continue'),
+    ]);
+
+    // The first anonymous message is the same append-stable anchor, while the
+    // second occurrence receives the fresh tail board.
+    expect(JSON.stringify(secondRequest[0])).toBe(
+      JSON.stringify(firstRequest[0]),
+    );
+    expect(
+      (secondRequest as Msg[]).flatMap((message) =>
+        message.parts.filter(
+          (part) => part.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
+        ),
+      ),
+    ).toHaveLength(2);
+  });
+
+  test('field-dump scenario: tail is a tool_result user turn preceded by an assistant turn', async () => {
+    // Reconstructs the real bust (2026-07-23 dumps 000166→000167): the tail was
+    // a user tool_result message preceded by an assistant tool-call message,
+    // and the conversation advanced by one more tool turn between requests. The
+    // board must attach to the tool_result tail so the preceding assistant turn
+    // keeps a readable breakpoint that the next request reproduces.
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_child',
+      parentSessionID: SESSION,
+      agent: 'librarian',
+      description: 'grok research',
+    });
+    const hook = createHook(board);
+
+    const base = [
+      userMsg('u1', 'Coordinate the work'),
+      ...toolTurn('t1', 'r1'),
+    ];
+    const outN = await inject(hook, base);
+
+    // The board rode on the tail user (tool_result) message, not a new message.
+    expect((outN as unknown[]).length).toBe(base.length);
+    const tail = outN.at(-1) as Msg;
+    expect(tail.info.role).toBe('user');
+    expect(
+      tail.parts.at(-1)?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY],
+    ).toBe(true);
+
+    // Advance by one more tool turn (as the loop did between dumps).
+    const advanced = [
+      userMsg('u1', 'Coordinate the work'),
+      ...toolTurn('t1', 'r1'),
+      ...toolTurn('t2', 'r2'),
+    ];
+    const outN1 = await inject(hook, advanced);
+
+    // The assistant turn that preceded the board tail in request N is present
+    // and byte-identical in request N+1 — the readable cache boundary.
+    const prefixesN = readableCachePrefixes(outN);
+    const fullN1 = readableCachePrefixes(outN1).at(-1) ?? '';
+    expect(prefixesN.some((p) => fullN1.startsWith(p))).toBe(true);
+  });
+});

+ 672 - 75
src/hooks/task-session-manager/board-injection.ts

@@ -7,7 +7,9 @@
  * All injection logic must go through the cache-safe helpers in
  * ../cache-safe-injection.ts to ensure prompt cache safety.
  */
+import { createHash } from 'node:crypto';
 import type {
+  BackgroundJobExecution,
   BackgroundJobRecord,
   BackgroundJobStore,
   ContextFile,
@@ -20,9 +22,12 @@ import {
 import { isRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import {
+  appendTaggedSyntheticPart,
   appendTrailingVolatileMessage,
   createTaggedSyntheticPart,
+  hasTaggedPart,
   isTaggedPart,
+  isVolatileTaggedMessage,
   stripTaggedContent,
 } from '../cache-safe-injection';
 import type { MessagePart, MessageWithParts } from '../types';
@@ -48,6 +53,7 @@ type RetainedBoardSnapshot = {
   anchorKey: string;
   id: string;
   text: string;
+  terminalUnreconciledTaskIDs: BackgroundJobExecution[];
 };
 
 export type RetainedBoardSnapshotState = {
@@ -57,15 +63,51 @@ export type RetainedBoardSnapshotState = {
   firstRealMessageAnchorKey?: string;
 };
 
+/**
+ * A board the `latest` strategy has already placed (and therefore already sent
+ * to the provider) on a specific anchor message. Replayed byte-identically on
+ * every later request once that anchor is no longer the tail, so a board that
+ * was sent on a message the provider has cached never disappears.
+ *
+ * Only ONE placement is ever retained: a board that rode as a trailing PART on
+ * a USER anchor (`anchorRole: 'user'`). That is the only shape that can be
+ * reproduced later without inserting a message mid-array (A1) or grafting board
+ * text onto a non-user message (A3). `anchorRole` stays a plain string so
+ * legacy in-memory entries recorded by an earlier build (notably `'assistant'`,
+ * which was replayed by splicing a synthetic message directly after the anchor
+ * and could orphan a tool call from its result) are recognized and dropped
+ * instead of replayed.
+ */
+type RetainedTailBoard = {
+  anchorId: string;
+  anchorRole: string;
+  text: string;
+};
+
+type BoardAnchor = {
+  message: MessageWithParts;
+  id: string;
+};
+
 // ── State shape ────────────────────────────────────────────────────────
 
+export type InjectedTerminalJobs = {
+  executions: Map<string, BackgroundJobExecution>;
+  /** Prompt shape when these executions were last surfaced to the model. */
+  promptShapeKey: string;
+};
+
 export interface InjectionState {
   backgroundJobBoard: BackgroundJobStore;
   maxRetainedSnapshots: number;
   strategy: 'latest' | 'checkpoint-compatible';
   processedInjectedCompletions: Set<string>;
   processedInjectedCompletionOrder: string[];
-  terminalJobsInjectedByParent: Map<string, Set<string>>;
+  terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
+  pendingInjectedTerminalJobsByParent: Map<
+    string,
+    Map<string, BackgroundJobExecution>
+  >;
   maxProcessedInjectedCompletions: number;
   metadataKey: string;
   shouldManageSession: (sessionID: string) => boolean;
@@ -75,6 +117,15 @@ export interface InjectionState {
     prune(board: { taskIDs(): Set<string> }): void;
   };
   retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+  /**
+   * Per-session log of boards the `latest` strategy has placed on real anchor
+   * messages, keyed by anchor message id. Once an anchor is no longer the tail
+   * its board is replayed byte-identically every later request, so a board sent
+   * on a now-mid-history message is never stripped (which would rewrite already
+   * cached bytes and bust the provider prompt-cache prefix from that message
+   * onward - the field bust in dumps 000086->000087).
+   */
+  retainedTailBoards: Map<string, Map<string, RetainedTailBoard>>;
 }
 
 // ── Helpers ────────────────────────────────────────────────────────────
@@ -87,6 +138,38 @@ function djb2Hash(str: string): string {
   return (hash >>> 0).toString(16).padStart(8, '0');
 }
 
+function sha256Hash(str: string): string {
+  return createHash('sha256').update(str).digest('hex');
+}
+
+/**
+ * True when board text may ride on this message as a trailing PART.
+ *
+ * Board text may ONLY ever be appended to a `user` message. This is the single
+ * hard requirement behind the `AI_InvalidPromptError` this guard exists to
+ * prevent, and it is a property of the host's message conversion:
+ *
+ * - the USER branch of `MessageV2.toModelMessagesEffect` copies text parts as
+ *   `{ type: 'text', text }` and DISCARDS `part.metadata`;
+ * - the ASSISTANT branch copies it as
+ *   `{ type: 'text', text, providerMetadata: part.metadata }`, which
+ *   `convertToModelMessages` then forwards as `providerOptions`.
+ *
+ * `providerOptions` is validated as `Record<string, Record<string, JSONValue>>`.
+ * A board part's metadata is `{ '<metadataKey>': true }` — a boolean, not a
+ * nested record — so any board text landing on an assistant-role message fails
+ * `ModelMessage[]` validation and aborts the request before it is sent.
+ *
+ * Tool parts do not disqualify a user message: the user branch of the converter
+ * only emits text/file/compaction/subtask parts, so a user message's tool parts
+ * never become tool-call or tool-result content and cannot be separated from a
+ * pairing by appended text. Keeping such anchors eligible is what preserves the
+ * #889 tail-breakpoint placement (A4).
+ */
+function canCarryBoardPart(message: MessageWithParts): boolean {
+  return message.info.role === 'user';
+}
+
 function createOccurrenceId(
   part: MessagePart,
   message: MessageWithParts,
@@ -213,6 +296,12 @@ export function updateFromInjectedCompletion(
       result: status.result,
     });
     rememberProcessedInjectedCompletion(state, occurrenceId);
+    if (existing?.terminalUnreconciled && existing?.parentSessionID) {
+      rememberPendingInjectedTerminalJob(state, existing.parentSessionID, {
+        taskID: existing.taskID,
+        generation: existing.generation,
+      });
+    }
     return existing;
   }
 
@@ -228,6 +317,13 @@ export function updateFromInjectedCompletion(
   );
   if (!updated) return undefined;
 
+  if (updated.terminalUnreconciled && updated.parentSessionID) {
+    rememberPendingInjectedTerminalJob(state, updated.parentSessionID, {
+      taskID: updated.taskID,
+      generation: updated.generation,
+    });
+  }
+
   log('[task-session-manager] processed injected background completion', {
     taskID: updated.taskID,
     alias: updated.alias,
@@ -266,45 +362,142 @@ export function isMissingRememberedSessionError(output: string): boolean {
   );
 }
 
+function executionKey(execution: BackgroundJobExecution): string {
+  return `${execution.taskID}\u001f${execution.generation}`;
+}
+
+function sameExecutionIdentity(
+  left: readonly BackgroundJobExecution[],
+  right: readonly BackgroundJobExecution[],
+): boolean {
+  if (left.length !== right.length) return false;
+  const leftKeys = new Set(left.map(executionKey));
+  return right.every((execution) => leftKeys.has(executionKey(execution)));
+}
+
+function rememberPendingInjectedTerminalJob(
+  state: InjectionState,
+  parentSessionID: string,
+  execution: BackgroundJobExecution,
+): void {
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID) ??
+    new Map<string, BackgroundJobExecution>();
+  pending.set(executionKey(execution), { ...execution });
+  state.pendingInjectedTerminalJobsByParent.set(parentSessionID, pending);
+}
+
+function reconcileExecutionBatch(
+  state: InjectionState,
+  parentSessionID: string,
+  executions: Iterable<BackgroundJobExecution>,
+): void {
+  for (const execution of executions) {
+    const current = state.backgroundJobBoard.get(execution.taskID);
+    if (!current || current.generation !== execution.generation) {
+      log('[task-session-manager] skipped stale terminal execution', {
+        parentSessionID,
+        execution,
+        currentGeneration: current?.generation,
+      });
+      continue;
+    }
+    state.backgroundJobBoard.markReconciled(execution.taskID);
+  }
+}
+
 export function rememberInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
+  executions: readonly BackgroundJobExecution[],
+  promptShapeKey: string,
 ): void {
-  const taskIDs = state.backgroundJobBoard
-    .list(parentSessionID)
-    .filter((job) => job.terminalUnreconciled)
-    .map((job) => job.taskID);
-  if (taskIDs.length === 0) return;
+  if (!parentSessionID || executions.length === 0) return;
+
+  const uniqueExecutions = new Map(
+    executions.map((execution) => [executionKey(execution), execution]),
+  );
+  if (uniqueExecutions.size === 0) return;
+
+  const existing = state.terminalJobsInjectedByParent.get(parentSessionID);
+  if (existing && existing.promptShapeKey === promptShapeKey) {
+    // Same prompt shape: union the executions delivered by each payload.
+    for (const [key, execution] of uniqueExecutions) {
+      existing.executions.set(key, { ...execution });
+    }
+  } else {
+    // A different shape is normally reconciled before this point. Replace
+    // the entry defensively so executions from an older payload cannot leak
+    // into the new delivered batch.
+    state.terminalJobsInjectedByParent.set(parentSessionID, {
+      executions: new Map(
+        [...uniqueExecutions].map(([key, execution]) => [
+          key,
+          { ...execution },
+        ]),
+      ),
+      promptShapeKey,
+    });
+  }
+
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID);
+  if (pending) {
+    for (const key of uniqueExecutions.keys()) pending.delete(key);
+    if (pending.size === 0) {
+      state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
+    }
+  }
 
   log('[task-session-manager] terminal jobs injected for reconciliation', {
     parentSessionID,
-    taskIDs,
+    executions: [...uniqueExecutions.values()],
+    promptShapeKey,
   });
-
-  const existing =
-    state.terminalJobsInjectedByParent.get(parentSessionID) ??
-    new Set<string>();
-  for (const taskID of taskIDs) {
-    existing.add(taskID);
-  }
-  state.terminalJobsInjectedByParent.set(parentSessionID, existing);
 }
 
 export function reconcileInjectedTerminalJobs(
   state: InjectionState,
   parentSessionID: string,
 ): void {
-  const taskIDs = state.terminalJobsInjectedByParent.get(parentSessionID);
-  if (!taskIDs) return;
+  const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
+  const pending =
+    state.pendingInjectedTerminalJobsByParent.get(parentSessionID);
+  if (!entry && !pending) return;
+
+  const executions = new Map<string, BackgroundJobExecution>();
+  for (const [key, execution] of entry?.executions ?? []) {
+    executions.set(key, execution);
+  }
+  for (const [key, execution] of pending ?? []) {
+    executions.set(key, execution);
+  }
 
   log('[task-session-manager] reconciling injected terminal jobs', {
     parentSessionID,
-    taskIDs: [...taskIDs],
+    executions: [...executions.values()],
   });
 
-  for (const taskID of taskIDs) {
-    state.backgroundJobBoard.markReconciled(taskID);
-  }
+  reconcileExecutionBatch(state, parentSessionID, executions.values());
+  state.terminalJobsInjectedByParent.delete(parentSessionID);
+  state.pendingInjectedTerminalJobsByParent.delete(parentSessionID);
+}
+
+function reconcileConsumedTerminalJobs(
+  state: InjectionState,
+  parentSessionID: string,
+  promptShapeKey: string,
+): void {
+  const entry = state.terminalJobsInjectedByParent.get(parentSessionID);
+  if (!entry || entry.promptShapeKey === promptShapeKey) return;
+  // The model produced at least one new part after the request that carried
+  // these completions, so it has consumed that shaped delivery. Pending
+  // synthetic completions belong to a later delivery and remain pending.
+  log('[task-session-manager] reconciling consumed terminal jobs', {
+    parentSessionID,
+    executions: [...entry.executions.values()],
+  });
+  reconcileExecutionBatch(state, parentSessionID, entry.executions.values());
   state.terminalJobsInjectedByParent.delete(parentSessionID);
 }
 
@@ -315,72 +508,408 @@ export async function injectBackgroundJobBoard(
 ): Promise<void> {
   const messages = Array.isArray(output.messages) ? output.messages : [];
 
-  if (state.strategy === 'latest') {
-    // Strip previously injected board content: parts attached to real
-    // messages (legacy placement) and whole synthetic board messages.
-    stripTaggedContent(messages, state.metadataKey);
-  }
-
   if (state.strategy === 'checkpoint-compatible') {
     injectCheckpointBoard(state, messages);
     return;
   }
 
-  for (let i = messages.length - 1; i >= 0; i -= 1) {
-    const message = messages[i];
-    if (
-      isMessageWithParts(message) &&
-      message.parts.length > 0 &&
-      message.parts.every((part) => isTaggedPart(part, state.metadataKey))
-    ) {
-      continue;
-    }
-    if (!isUserMessageWithParts(message)) continue;
-    if (message.info.agent && message.info.agent !== 'orchestrator') return;
-    if (
-      !message.info.sessionID ||
-      !state.shouldManageSession(message.info.sessionID)
-    ) {
-      return;
-    }
+  injectLatestBoard(state, messages);
+}
 
-    const reminder = state.backgroundJobBoard.formatForPrompt(
-      message.info.sessionID,
-    );
-    if (!reminder) return;
+/**
+ * `latest` strategy: keep exactly one FRESH board on the current tail while
+ * every board already sent on an earlier (now mid-history) message stays put,
+ * byte-identical.
+ *
+ * The board is never persisted, so opencode rebuilds real history board-free
+ * each request. That means the plugin — not storage — must reproduce every
+ * board it previously placed. The prior implementation instead STRIPPED the
+ * old tail's board and re-appended a fresh board on the new tail
+ * (`stripTailBoardContent` + append). Because the old tail was already sent to
+ * the provider WITH its board, dropping it rewrote an already-cached message
+ * and invalidated the provider prompt-cache prefix from that message onward —
+ * the whole tail re-cached every turn (field bust: ses_11145863 dumps
+ * 000086→000087, old-tail user message 1376B→652B as its board vanished).
+ *
+ * Fix (append-only w.r.t. already-sent messages):
+ *   1. Strip the board ONLY from the current tail zone (the tail message's
+ *      trailing board part + whole synthetic board messages trailing it). That
+ *      zone re-caches every turn, so rewriting it is byte-safe.
+ *   2. Replay every FROZEN board (one placed on a message that is no longer the
+ *      tail) byte-identically on its original anchor, so an already-sent board
+ *      never disappears.
+ *   3. Add ONE fresh board to the current tail, preserving the #889 placement
+ *      (trailing PART on a user tail; separate trailing message on an assistant
+ *      tail) so the tail breakpoint still lands on stable content, and record
+ *      it so the NEXT request can freeze/replay it once the tail advances.
+ * A board on any earlier message is never mutated or stripped.
+ */
+function injectLatestBoard(state: InjectionState, messages: unknown[]): void {
+  // The current tail anchor: the last real (non-fully-tagged) message. It is
+  // the ONLY message whose board is volatile — the tail re-caches anyway, so
+  // freshening its board is free. Every earlier message was already sent, so
+  // its board must never change.
+  const anchor = findBoardAnchor(messages, state.metadataKey);
+  const anchorId = anchor?.id;
+
+  // Strip the board from the current tail zone only (byte-safe volatile zone).
+  stripCurrentTailBoard(messages, state.metadataKey, anchor?.message);
+
+  // Eligibility is driven by the most recent orchestrator user message (the
+  // triggering turn), which also guards against specialist/internal turns.
+  const trigger = findTriggeringUserMessage(messages, state.metadataKey);
+  const sessionID = trigger?.info.sessionID;
+
+  // Replay frozen boards even when this turn is ineligible for a fresh board
+  // (internal-initiator turn, empty board): an already-sent board must stay put
+  // regardless of the current turn. The current tail anchor is skipped — its
+  // board is (re)placed fresh below.
+  if (sessionID !== undefined) {
+    replayRetainedTailBoards(state, sessionID, messages, anchorId);
+  }
 
-    const textPart = message.parts.find(
-      (part) => part.type === 'text' && typeof part.text === 'string',
-    );
-    if (!textPart || isInternalInitiatorPart(textPart)) return;
-
-    rememberInjectedTerminalJobs(state, message.info.sessionID);
-    // Append the board as its own trailing message rather than mutating
-    // an existing user message. In long tool loops the latest user
-    // message becomes deep history; rewriting it on board state changes
-    // would invalidate the provider prompt cache for everything after
-    // it. A trailing message keeps board churn at the end of the
-    // prompt, where it only costs itself.
+  if (!trigger) return;
+  if (trigger.info.agent && trigger.info.agent !== 'orchestrator') return;
+  if (!sessionID || !state.shouldManageSession(sessionID)) return;
+  if (!anchor) return;
+
+  const shapeKey = promptShapeKey(realMessages(messages, state.metadataKey));
+  reconcileConsumedTerminalJobs(state, sessionID, shapeKey);
+
+  const boardMeta =
+    state.backgroundJobBoard.formatForPromptWithMetadata(sessionID);
+  const reminder = boardMeta?.text;
+  if (!reminder) return;
+
+  const textPart = trigger.parts.find(
+    (part) => part.type === 'text' && typeof part.text === 'string',
+  );
+  if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+  rememberInjectedTerminalJobs(
+    state,
+    sessionID,
+    boardMeta.terminalUnreconciledTaskIDs,
+    shapeKey,
+  );
+
+  // Placement rules — correctness first, then prompt-cache safety.
+  //
+  // Correctness (invariants A1-A3): the transformed array is converted to
+  // `ModelMessage[]` and schema-validated before the request is sent, and a
+  // violation raises `AI_InvalidPromptError` ("The messages do not match the
+  // ModelMessage[] schema") before the HTTP call — a hard, unrecoverable turn
+  // failure. Two rules keep the array valid:
+  //
+  //   * board text only ever rides on a `user` message (A3). The assistant
+  //     branch of the host's converter forwards `part.metadata` as
+  //     `providerMetadata`/`providerOptions`, which must be a nested record; a
+  //     board part's `{ '<key>': true }` is a boolean and fails validation. The
+  //     user branch drops metadata entirely, so it is safe.
+  //   * a synthetic board MESSAGE is only ever appended at the very END of the
+  //     array (A1). Inserting one mid-array can land between an assistant
+  //     `task` tool_call and its tool_result and break the pairing the schema
+  //     requires (A2); appending at the end cannot (A2 holds by construction).
+  //
+  // Cache safety (within the above):
+  //
+  // Provider caches read from the last two messages (Anthropic:
+  // provider/transform.ts applyCaching → final.slice(-2)), and the provider
+  // SDK coalesces adjacent same-role messages. A board injected as its own
+  // trailing `user` message merges into a preceding user text message,
+  // collapsing both tail breakpoints onto the merged block — so the only
+  // readable breakpoint sits on the volatile board. Because the board moves to
+  // a new tail every request, the deepest reusable breakpoint regresses to the
+  // stable system boundary and the entire tail re-writes as cache every call.
+  //
+  // - If the tail is a user message, append the board as its trailing PART: the
+  //   message COUNT stays identical to a board-free render, so the second tail
+  //   breakpoint lands on the previous (byte-stable, real) message. This is
+  //   also the only placement replayable later without inserting a message
+  //   mid-array, so it is the only one recorded for replay.
+  // - If the tail is an assistant message, a separate trailing USER board
+  //   message is appended at the very end of the array. It does not merge
+  //   (different role), so the assistant message keeps its own readable
+  //   breakpoint, and it uses the USER `trigger.info` — never `anchor.info` —
+  //   so the message carrying board text is genuinely user-role (A3).
+  const recordId = anchor.id;
+  if (canCarryBoardPart(anchor.message)) {
+    appendTaggedSyntheticPart(anchor.message, {
+      text: reminder,
+      metadataKey: state.metadataKey,
+    });
+    // Recording the placement under the tail's anchor id lets the NEXT request
+    // (once the tail advances) replay this exact board on this exact message,
+    // so the bytes the provider just cached for this message never change.
+    rememberTailBoard(state, sessionID, {
+      anchorId: recordId,
+      anchorRole: 'user',
+      text: reminder,
+    });
+  } else {
     appendTrailingVolatileMessage(
       messages,
       {
-        ...message.info,
-        id: `${message.info.id}-background-job-board`,
+        ...trigger.info,
+        id: `${trigger.info.id ?? 'board'}-background-job-board`,
       },
       {
         text: reminder,
         metadataKey: state.metadataKey,
       },
     );
-    return;
+    // A5: this placement is deliberately NOT retained for replay. Reproducing
+    // it once the tail advances would require splicing a message back into the
+    // middle of the array, which is exactly what orphaned an assistant
+    // tool_call from its tool_result and made the whole request invalid. A
+    // cache bust (the board's bytes move to the new tail) is strictly
+    // preferable to a hard `AI_InvalidPromptError`, so the board is simply
+    // re-rendered on the new tail instead. Any stale entry for this anchor is
+    // dropped so the retained map cannot grow or retry the unsafe placement.
+    forgetTailBoard(state, sessionID, recordId);
   }
 }
 
+/** The last real (non-fully-tagged) message — the current tail anchor. */
+function findBoardAnchor(
+  messages: unknown[],
+  metadataKey: string,
+): BoardAnchor | undefined {
+  return boardAnchors(messages, metadataKey).at(-1);
+}
+
+/**
+ * Strip the board ONLY from the current tail zone: whole synthetic board
+ * messages trailing the payload, plus a trailing board part on the current
+ * tail anchor. This is the volatile zone (the tail re-caches every turn), so
+ * rewriting it is byte-safe. A board on any earlier message is untouched —
+ * removing it would rewrite already-sent, already-cached bytes.
+ */
+function stripCurrentTailBoard(
+  messages: unknown[],
+  metadataKey: string,
+  anchor: MessageWithParts | undefined,
+): void {
+  // Drop whole synthetic board messages trailing the payload.
+  let i = messages.length - 1;
+  while (i >= 0) {
+    const message = messages[i];
+    if (!isVolatileTaggedMessage(message, metadataKey)) break;
+    messages.splice(i, 1);
+    i -= 1;
+  }
+
+  // Strip a trailing board part from the current tail anchor only.
+  if (anchor) {
+    anchor.parts = anchor.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+  }
+}
+
+/**
+ * A stable id for an anchor message that lacks an `info.id` (test fixtures,
+ * legacy shapes). Derived from role + concatenated REAL text (tagged board
+ * parts excluded) plus its append-order occurrence, so duplicate anonymous
+ * messages remain distinct while appending a later message leaves existing
+ * ids unchanged. The occurrence is internal and never enters the payload.
+ */
+function boardAnchorFallbackId(
+  message: MessageWithParts,
+  occurrence: number,
+  metadataKey: string,
+): string {
+  return `anon:${djb2Hash(boardAnchorFallbackBase(message, metadataKey))}:${occurrence}`;
+}
+
+function boardAnchorFallbackBase(
+  message: MessageWithParts,
+  metadataKey: string,
+): string {
+  const text = message.parts
+    .filter(
+      (part) =>
+        !isTaggedPart(part, metadataKey) &&
+        part.type === 'text' &&
+        typeof part.text === 'string',
+    )
+    .map((part) => part.text)
+    .join('\u0000');
+  return `${message.info.role}:${text}`;
+}
+
+/**
+ * Build internal anchor identities in append order. Anonymous messages use an
+ * occurrence suffix so duplicate content remains distinct, while appending a
+ * later message leaves all existing identities unchanged. The identity never
+ * enters the provider-visible message payload.
+ */
+function boardAnchors(messages: unknown[], metadataKey: string): BoardAnchor[] {
+  const occurrences = new Map<string, number>();
+  const anchors: BoardAnchor[] = [];
+
+  for (const candidate of messages) {
+    if (!isMessageWithParts(candidate)) continue;
+    if (
+      candidate.parts.length > 0 &&
+      candidate.parts.every((part) => isTaggedPart(part, metadataKey))
+    ) {
+      continue;
+    }
+
+    if (candidate.info.id !== undefined) {
+      anchors.push({ message: candidate, id: candidate.info.id });
+      continue;
+    }
+
+    const base = boardAnchorFallbackBase(candidate, metadataKey);
+    const occurrence = occurrences.get(base) ?? 0;
+    occurrences.set(base, occurrence + 1);
+    anchors.push({
+      message: candidate,
+      id: boardAnchorFallbackId(candidate, occurrence, metadataKey),
+    });
+  }
+
+  return anchors;
+}
+
+/** Record (or refresh) a board placed on an anchor for later replay. */
+function rememberTailBoard(
+  state: InjectionState,
+  sessionID: string,
+  board: RetainedTailBoard,
+): void {
+  const perSession =
+    state.retainedTailBoards.get(sessionID) ??
+    new Map<string, RetainedTailBoard>();
+  perSession.set(board.anchorId, board);
+  state.retainedTailBoards.set(sessionID, perSession);
+}
+
+/**
+ * Stop tracking a retained board for an anchor (A5). Used when the placement
+ * cannot be safely reproduced, so the map neither grows without bound nor
+ * retries an unsafe replay on every later request.
+ */
+function forgetTailBoard(
+  state: InjectionState,
+  sessionID: string,
+  anchorId: string,
+): void {
+  const perSession = state.retainedTailBoards.get(sessionID);
+  if (!perSession) return;
+  perSession.delete(anchorId);
+  if (perSession.size === 0) state.retainedTailBoards.delete(sessionID);
+}
+
+/**
+ * Re-append every FROZEN retained board onto its original anchor message,
+ * exactly as first sent, so a board that was sent on a message which is no
+ * longer the tail never disappears (its bytes are already in the provider's
+ * cached prefix).
+ *
+ * Replay is strictly append-a-PART-to-an-existing-message. It never inserts a
+ * message (A1) and therefore can never come between a tool_call and its
+ * tool_result (A2), and it only ever targets a `user` message (A3).
+ *
+ * A retained board whose anchor cannot satisfy those invariants is DROPPED
+ * (A5) rather than reproduced: losing a stale board costs one cache bust,
+ * whereas an invalid message array raises `AI_InvalidPromptError` during
+ * request validation and fails the turn outright.
+ *
+ * The current tail anchor (`currentAnchorId`) is skipped: its board is volatile
+ * and is (re)placed fresh by the caller. Anchors no longer present in history
+ * (compaction, revert) are pruned — their bytes are gone from the provider's
+ * view too. Replay is skipped when the anchor already carries a board, keeping
+ * the operation idempotent under repeated transforms on a shared array.
+ */
+function replayRetainedTailBoards(
+  state: InjectionState,
+  sessionID: string,
+  messages: unknown[],
+  currentAnchorId: string | undefined,
+): void {
+  const perSession = state.retainedTailBoards.get(sessionID);
+  if (!perSession || perSession.size === 0) return;
+
+  const anchorById = new Map<string, MessageWithParts>();
+  for (const anchor of boardAnchors(messages, state.metadataKey)) {
+    anchorById.set(anchor.id, anchor.message);
+  }
+
+  for (const [anchorId, board] of [...perSession.entries()]) {
+    // The current tail's board is volatile — the caller strips and re-appends
+    // it. Never freeze/replay it here.
+    if (anchorId === currentAnchorId) continue;
+
+    const anchor = anchorById.get(anchorId);
+    if (!anchor) {
+      // Anchor gone from history (compaction/revert): its bytes are no longer
+      // in the provider's view, so stop tracking it.
+      perSession.delete(anchorId);
+      continue;
+    }
+    if (hasTaggedPart(anchor, state.metadataKey)) continue;
+
+    // A5: only the trailing-PART-on-a-user-anchor placement is replayable. A
+    // board recorded against an assistant anchor (legacy state from an earlier
+    // build) was reproduced by splicing a synthetic message after the anchor —
+    // which lands between an assistant `task` tool_call and its tool_result and
+    // invalidates the whole request. A board whose anchor is no longer a user
+    // message cannot take the part path either. Both are dropped: one lost
+    // board (a bounded cache bust on that message) is preferable to a hard
+    // AI_InvalidPromptError on every request.
+    if (board.anchorRole !== 'user' || !canCarryBoardPart(anchor)) {
+      perSession.delete(anchorId);
+      continue;
+    }
+
+    appendTaggedSyntheticPart(anchor, {
+      text: board.text,
+      metadataKey: state.metadataKey,
+    });
+  }
+
+  if (perSession.size === 0) state.retainedTailBoards.delete(sessionID);
+}
+
+/**
+ * The most recent real (non-board) user message that carries a text part —
+ * used only to validate injection eligibility and derive session/text context.
+ * Tool-result-only user turns (no text part) are skipped so a long tool loop
+ * still resolves the triggering orchestrator turn. Board placement targets the
+ * tail (see injectBackgroundJobBoard).
+ */
+function findTriggeringUserMessage(
+  messages: unknown[],
+  metadataKey: string,
+): MessageWithParts | undefined {
+  for (let i = messages.length - 1; i >= 0; i -= 1) {
+    const message = messages[i];
+    if (!isMessageWithParts(message)) continue;
+    if (
+      message.parts.length > 0 &&
+      message.parts.every((part) => isTaggedPart(part, metadataKey))
+    ) {
+      continue;
+    }
+    if (!isUserMessageWithParts(message)) continue;
+    const hasText = message.parts.some(
+      (part) => part.type === 'text' && typeof part.text === 'string',
+    );
+    if (!hasText) continue;
+    return message;
+  }
+  return undefined;
+}
+
 function injectCheckpointBoard(
   state: InjectionState,
   messages: unknown[],
 ): void {
   const currentMessages = realMessages(messages, state.metadataKey);
+  const shapeKey = promptShapeKey(currentMessages);
   const tailMessage = currentMessages.at(-1);
   const sessionID = tailMessage?.info.sessionID;
   if (!tailMessage || !sessionID || !state.shouldManageSession(sessionID)) {
@@ -391,17 +920,22 @@ function injectCheckpointBoard(
     (message) =>
       isUserMessageWithParts(message) && message.info.sessionID === sessionID,
   );
-  const reminder = state.backgroundJobBoard.formatForPrompt(sessionID);
   const textPart = triggeringMessage?.parts.find(
     (part) => part.type === 'text' && typeof part.text === 'string',
   );
-  const canCreateSnapshot =
+  const canSurface =
     triggeringMessage !== undefined &&
     (!triggeringMessage.info.agent ||
       triggeringMessage.info.agent === 'orchestrator') &&
     textPart !== undefined &&
-    !isInternalInitiatorPart(textPart) &&
-    reminder !== undefined;
+    !isInternalInitiatorPart(textPart);
+
+  if (canSurface) reconcileConsumedTerminalJobs(state, sessionID, shapeKey);
+
+  const boardMeta =
+    state.backgroundJobBoard.formatForPromptWithMetadata(sessionID);
+  const reminder = boardMeta?.text;
+  const canCreateSnapshot = canSurface && reminder !== undefined;
 
   const replayBaseMessage = triggeringMessage ?? tailMessage;
   const snapshotState = updateBoardHistoryState(
@@ -412,7 +946,14 @@ function injectCheckpointBoard(
 
   if (canCreateSnapshot && reminder) {
     const anchorKey = findLastMessageAnchorKey(currentMessages);
-    if (anchorKey && snapshotState.snapshots.at(-1)?.text !== reminder) {
+    const previousSnapshot = snapshotState.snapshots.at(-1);
+    const sameSnapshot =
+      previousSnapshot?.text === reminder &&
+      sameExecutionIdentity(
+        previousSnapshot.terminalUnreconciledTaskIDs,
+        boardMeta.terminalUnreconciledTaskIDs,
+      );
+    if (anchorKey && !sameSnapshot) {
       const encodedSessionID = encodeURIComponent(sessionID);
       const sequence = snapshotState.nextSnapshotSequence;
       snapshotState.nextSnapshotSequence += 1;
@@ -424,18 +965,21 @@ function injectCheckpointBoard(
         anchorKey,
         id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
         text: reminder,
+        terminalUnreconciledTaskIDs: boardMeta.terminalUnreconciledTaskIDs,
       });
     }
-    rememberInjectedTerminalJobs(state, sessionID);
   }
 
-  replayCheckpointBoard(
+  const replayedIDs = replayCheckpointBoard(
     messages,
     replayBaseMessage,
     sessionID,
     snapshotState,
     state.metadataKey,
   );
+  if (replayedIDs.length > 0) {
+    rememberInjectedTerminalJobs(state, sessionID, replayedIDs, shapeKey);
+  }
 }
 
 function findLastMessageAnchorKey(
@@ -482,6 +1026,55 @@ function realMessages(
   });
 }
 
+/**
+ * Identity of the real prompt content/structure for one request. Stable across
+ * repeated transforms of the same request; changes as soon as relevant message
+ * or non-synthetic part content changes. Counts alone are insufficient because
+ * supported compaction can remove old content while a model turn appends new
+ * content, preserving message/part counts.
+ */
+function promptShapeKey(realMessageList: MessageWithParts[]): string {
+  const tokens: string[] = [];
+  tokens.push(`messages:${realMessageList.length}`);
+  for (const message of realMessageList) {
+    tokens.push('message');
+    tokens.push(`role:${message.info.role ?? ''}`);
+    tokens.push(`agent:${message.info.agent ?? ''}`);
+    tokens.push(`session:${message.info.sessionID ?? ''}`);
+    const realParts = message.parts.filter((part) => part.synthetic !== true);
+    tokens.push(`parts:${realParts.length}`);
+    for (const part of realParts) {
+      tokens.push('part');
+      tokens.push(stablePromptPartSignature(part));
+    }
+  }
+  return sha256Hash(tokens.join('\u001f'));
+}
+
+function stablePromptPartSignature(part: MessagePart): string {
+  return stableSerializePromptValue(part);
+}
+
+function stableSerializePromptValue(value: unknown): string {
+  if (value === null) return 'null';
+  const valueType = typeof value;
+  if (valueType === 'string') return JSON.stringify(value);
+  if (valueType === 'number' || valueType === 'boolean') return String(value);
+  if (Array.isArray(value)) {
+    return `[${value.map((item) => stableSerializePromptValue(item)).join(',')}]`;
+  }
+  if (isRecord(value)) {
+    return `{${Object.keys(value)
+      .sort()
+      .map(
+        (key) =>
+          `${JSON.stringify(key)}:${stableSerializePromptValue(value[key])}`,
+      )
+      .join(',')}}`;
+  }
+  return valueType;
+}
+
 function hasCompacted(
   previous: RetainedBoardSnapshotState,
   currentMessages: MessageWithParts[],
@@ -555,7 +1148,7 @@ function replayBoardSnapshots(
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
-): void {
+): BackgroundJobExecution[] {
   const realMessageList = realMessages(messages, metadataKey);
   const currentAnchorKeys = messageAnchorKeys(realMessageList);
   const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
@@ -572,6 +1165,7 @@ function replayBoardSnapshots(
   );
 
   const rebuiltMessages: unknown[] = [];
+  const replayedIDs: BackgroundJobExecution[] = [];
   let realMessageIndex = 0;
   for (const message of messages) {
     rebuiltMessages.push(message);
@@ -593,10 +1187,14 @@ function replayBoardSnapshots(
           usedMessageIDs,
         ),
       );
+      if (snapshot.terminalUnreconciledTaskIDs?.length) {
+        replayedIDs.push(...snapshot.terminalUnreconciledTaskIDs);
+      }
     }
   }
 
   messages.splice(0, messages.length, ...rebuiltMessages);
+  return replayedIDs;
 }
 
 function replayCheckpointBoard(
@@ -605,15 +1203,14 @@ function replayCheckpointBoard(
   sessionID: string,
   snapshotState: RetainedBoardSnapshotState,
   metadataKey: string,
-): void {
+): BackgroundJobExecution[] {
   stripTaggedContent(messages, metadataKey);
-  replayBoardSnapshots(
+  const ids = replayBoardSnapshots(
     messages,
     baseMessage,
     sessionID,
     snapshotState,
     metadataKey,
   );
-  // The caller records terminal jobs before this replay so that the normal
-  // idle reconciliation path can consume them after the prompt is processed.
+  return ids;
 }

+ 748 - 0
src/hooks/task-session-manager/board-tool-pairing.test.ts

@@ -0,0 +1,748 @@
+/**
+ * Regression coverage for the retained-board replay bug that produced
+ * `AI_InvalidPromptError: Invalid prompt: The messages do not match the
+ * ModelMessage[] schema.` in a long-running session driving background
+ * subagents (commit 208b656, also present in upstream PR #889).
+ *
+ * ── What went wrong ──────────────────────────────────────────────────────
+ *
+ * `replayRetainedTailBoards` reproduced a board that had been placed on an
+ * ASSISTANT tail by splicing a synthetic message directly after that anchor:
+ *
+ *     const index = messages.indexOf(anchor);
+ *     const boardMessage = { info: { ...anchor.info, id: ... }, parts: [...] };
+ *     messages.splice(index + 1, 0, boardMessage);
+ *
+ * `anchor.info` is an ASSISTANT message info, so the synthetic board message
+ * inherited `role: 'assistant'`. The host's conversion pipeline treats the two
+ * roles asymmetrically (verified against the shipped opencode binary,
+ * `MessageV2.toModelMessagesEffect`):
+ *
+ *   - the USER branch emits `{ type: 'text', text }` and DISCARDS `metadata`;
+ *   - the ASSISTANT branch emits
+ *     `{ type: 'text', text, providerMetadata: part.metadata }`, which
+ *     `convertToModelMessages` forwards as `providerOptions`.
+ *
+ * `providerOptions` is validated as `Record<string, Record<string, JSONValue>>`.
+ * A board part's metadata is `{ 'oh-my-opencode-slim.backgroundJobBoard': true }`
+ * — a boolean where a nested record is required — so the request failed schema
+ * validation before the HTTP call. This is why the failure only appeared in
+ * sessions with assistant tails (a finishing background `task` turn), only
+ * after 208b656 introduced `retainedTailBoards`, and never showed up in
+ * storage: the malformed message is produced in-memory by the transform.
+ *
+ * ── Invariants now enforced ──────────────────────────────────────────────
+ *
+ *  A1  a synthetic board MESSAGE is only ever appended at the END of the array,
+ *      never spliced into the middle.
+ *  A2  no injected message separates a tool_call from its matching tool_result.
+ *  A3  board text only ever rides on a `user`-role message (the rule that
+ *      actually prevents the error above).
+ *  A4  a board on a still-present user anchor is replayed byte-identically, so
+ *      already-cached bytes never change.
+ *  A5  a retained board that cannot be safely replayed is DROPPED from the
+ *      retained map rather than reproduced.
+ *
+ * The reproduction test validates against a transcription of the REAL
+ * `ModelMessage[]` zod schema together with a port of the host's conversion
+ * pipeline, both extracted from the installed opencode binary. The `ai` package
+ * is not a dependency of this repo and none was added, so the schema is
+ * reproduced rather than imported; the structural invariant assertions below
+ * stand on their own.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { z } from 'zod';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard } from '../../utils';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './index';
+
+const SESSION = 'ses_orchestrator_invalid_prompt';
+const CHILD = 'ses_child_background';
+const PROVIDER = 'anthropic';
+const MODEL = 'claude-opus-4';
+
+// ── Real ModelMessage[] schema (transcribed from the opencode binary) ──────
+
+const jsonValue: z.ZodType = z.lazy(() =>
+  z.union([
+    z.null(),
+    z.string(),
+    z.number(),
+    z.boolean(),
+    z.record(z.string(), jsonValue.optional()),
+    z.array(jsonValue),
+  ]),
+);
+
+/** `providerOptions`: Record<string, Record<string, JSONValue>>. */
+const providerOptions = z.record(
+  z.string(),
+  z.record(z.string(), jsonValue.optional()),
+);
+
+const textPart = z.object({
+  type: z.literal('text'),
+  text: z.string(),
+  providerOptions: providerOptions.optional(),
+});
+
+const filePart = z.object({
+  type: z.literal('file'),
+  mediaType: z.string(),
+  filename: z.string().optional(),
+  data: z.unknown(),
+  providerOptions: providerOptions.optional(),
+});
+
+const reasoningPart = z.object({
+  type: z.literal('reasoning'),
+  text: z.string(),
+  providerOptions: providerOptions.optional(),
+});
+
+const toolCallPart = z.object({
+  type: z.literal('tool-call'),
+  toolCallId: z.string(),
+  toolName: z.string(),
+  input: z.unknown(),
+  providerExecuted: z.boolean().optional(),
+  providerOptions: providerOptions.optional(),
+});
+
+const toolResultOutput = z.discriminatedUnion('type', [
+  z.object({ type: z.literal('text'), value: z.string() }),
+  z.object({ type: z.literal('json'), value: jsonValue }),
+  z.object({ type: z.literal('error-text'), value: z.string() }),
+  z.object({ type: z.literal('error-json'), value: jsonValue }),
+  z.object({
+    type: z.literal('content'),
+    value: z.array(z.unknown()),
+  }),
+]);
+
+const toolResultPart = z.object({
+  type: z.literal('tool-result'),
+  toolCallId: z.string(),
+  toolName: z.string(),
+  output: toolResultOutput,
+  providerOptions: providerOptions.optional(),
+});
+
+const modelMessage = z.union([
+  z.object({
+    role: z.literal('system'),
+    content: z.string(),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('user'),
+    content: z.union([z.string(), z.array(z.union([textPart, filePart]))]),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('assistant'),
+    content: z.union([
+      z.string(),
+      z.array(
+        z.union([
+          textPart,
+          filePart,
+          reasoningPart,
+          toolCallPart,
+          toolResultPart,
+        ]),
+      ),
+    ]),
+    providerOptions: providerOptions.optional(),
+  }),
+  z.object({
+    role: z.literal('tool'),
+    content: z.array(toolResultPart),
+    providerOptions: providerOptions.optional(),
+  }),
+]);
+
+const modelMessages = z.array(modelMessage);
+
+// ── Host conversion pipeline (ported from the opencode binary) ────────────
+
+type AnyPart = Record<string, any>;
+type AnyMessage = { info: Record<string, any>; parts: AnyPart[] };
+
+/**
+ * Port of `MessageV2.toModelMessagesEffect` (user + assistant branches) —
+ * the step that turns the transform hook's array into UIMessages. The
+ * metadata asymmetry between the two role branches is reproduced verbatim: it
+ * is the mechanism behind the failure under test.
+ */
+function toUIMessages(messages: unknown[]): AnyMessage[] {
+  const result: any[] = [];
+  for (const message of messages as AnyMessage[]) {
+    if (!message?.info || !Array.isArray(message.parts)) continue;
+    if (message.parts.length === 0) continue;
+
+    if (message.info.role === 'user') {
+      const parts: any[] = [];
+      for (const part of message.parts) {
+        // NOTE: no metadata is forwarded on the user path.
+        if (part.type === 'text' && !part.ignored && part.text !== '') {
+          parts.push({ type: 'text', text: part.text });
+        }
+      }
+      if (parts.length > 0) {
+        result.push({ id: message.info.id, role: 'user', parts });
+      }
+    }
+
+    if (message.info.role === 'assistant') {
+      if (message.info.error) continue;
+      // Model-match gate: when the message's model equals the request model,
+      // part metadata IS forwarded as providerMetadata.
+      const differentModel =
+        `${PROVIDER}/${MODEL}` !==
+        `${message.info.providerID}/${message.info.modelID}`;
+      const parts: any[] = [];
+      for (const part of message.parts) {
+        if (part.type === 'text') {
+          parts.push({
+            type: 'text',
+            text: part.text,
+            ...(differentModel ? {} : { providerMetadata: part.metadata }),
+          });
+        }
+        if (part.type === 'step-start') parts.push({ type: 'step-start' });
+        if (part.type === 'tool' && part.state?.status === 'completed') {
+          parts.push({
+            type: `tool-${part.tool}`,
+            state: 'output-available',
+            toolCallId: part.callID,
+            input: part.state.input,
+            output: part.state.output,
+          });
+        }
+      }
+      if (parts.length > 0)
+        result.push({ id: message.info.id, role: 'assistant', parts });
+    }
+  }
+  return result.filter((m) =>
+    m.parts.some((p: AnyPart) => p.type !== 'step-start'),
+  );
+}
+
+/**
+ * Port of the AI SDK's `convertToModelMessages` for the part kinds this suite
+ * produces. Note that a single assistant `tool-*` part expands into an
+ * assistant `tool-call` plus an immediately following `role: 'tool'` message —
+ * so the pairing is emitted adjacently by construction.
+ */
+function convertToModelMessages(uiMessages: AnyMessage[]): any[] {
+  const out: any[] = [];
+  for (const message of uiMessages as any[]) {
+    if (message.role === 'user') {
+      out.push({
+        role: 'user',
+        content: message.parts
+          .filter((p: AnyPart) => p.type === 'text')
+          .map((p: AnyPart) => ({
+            type: 'text',
+            text: p.text,
+            ...(p.providerMetadata != null
+              ? { providerOptions: p.providerMetadata }
+              : {}),
+          })),
+      });
+      continue;
+    }
+
+    if (message.role !== 'assistant') continue;
+
+    const content: any[] = [];
+    const toolResults: any[] = [];
+    for (const part of message.parts as AnyPart[]) {
+      if (part.type === 'text') {
+        content.push({
+          type: 'text',
+          text: part.text,
+          // providerMetadata → providerOptions: the exact key whose shape the
+          // ModelMessage[] schema validates.
+          ...(part.providerMetadata != null
+            ? { providerOptions: part.providerMetadata }
+            : {}),
+        });
+        continue;
+      }
+      if (typeof part.type === 'string' && part.type.startsWith('tool-')) {
+        const toolName = part.type.slice('tool-'.length);
+        content.push({
+          type: 'tool-call',
+          toolCallId: part.toolCallId,
+          toolName,
+          input: part.input,
+        });
+        toolResults.push({
+          type: 'tool-result',
+          toolCallId: part.toolCallId,
+          toolName,
+          output: { type: 'text', value: String(part.output) },
+        });
+      }
+    }
+    if (content.length > 0) out.push({ role: 'assistant', content });
+    if (toolResults.length > 0)
+      out.push({ role: 'tool', content: toolResults });
+  }
+  return out;
+}
+
+/** Mirrors the host's validation step; returns the zod error when invalid. */
+function validateModelMessages(messages: unknown[]) {
+  return modelMessages.safeParse(
+    convertToModelMessages(toUIMessages(messages)),
+  );
+}
+
+// ── Fixtures ──────────────────────────────────────────────────────────────
+
+function createHook(board: BackgroundJobBoard) {
+  return createTaskSessionManagerHook(
+    {
+      client: { session: { status: mock(async () => ({ data: {} })) } },
+      directory: '/tmp',
+      worktree: '/tmp',
+    } as never,
+    {
+      maxSessionsPerAgent: 4,
+      maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      backgroundJobBoard: board,
+      shouldManageSession: () => true,
+    },
+  );
+}
+
+function userTextTurn(id: string, text: string) {
+  return {
+    info: { id, role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [{ id: `prt_${id}`, type: 'text', text }],
+  };
+}
+
+const TASK_OUTPUT = [
+  `<task id="${CHILD}" state="completed">`,
+  '<summary>Background task completed: research the scheduler</summary>',
+  '<task_result>',
+  'Findings: the scheduler batches on idle.',
+  '</task_result>',
+  '</task>',
+].join('\n');
+
+/**
+ * The assistant turn a FINISHED background subagent produces: a `task` tool
+ * part whose terminal result is materialized on the same message. The host
+ * converter expands this single part into an assistant tool_call plus its
+ * matching tool_result.
+ */
+function finishedTaskAssistantTurn(id: string, callID: string) {
+  return {
+    info: {
+      id,
+      role: 'assistant',
+      sessionID: SESSION,
+      providerID: PROVIDER,
+      modelID: MODEL,
+    },
+    parts: [
+      { id: `prt_${id}_s`, type: 'step-start' },
+      {
+        id: `prt_${id}_t`,
+        type: 'text',
+        text: 'The background task finished.',
+      },
+      {
+        id: `prt_${id}_c`,
+        type: 'tool',
+        tool: 'task',
+        callID,
+        state: {
+          status: 'completed',
+          input: { background: true, description: 'research the scheduler' },
+          output: TASK_OUTPUT,
+          time: { start: 1, end: 2 },
+        },
+      },
+    ],
+  };
+}
+
+/** A user turn carrying only a tool result (the tool-loop shape). */
+function toolResultUserTurn(id: string, callID: string, output: string) {
+  return {
+    info: { id, role: 'user', agent: 'orchestrator', sessionID: SESSION },
+    parts: [
+      {
+        id: `prt_${id}`,
+        type: 'tool',
+        tool: 'read',
+        callID,
+        state: {
+          status: 'completed',
+          input: {},
+          output,
+          time: { start: 1, end: 2 },
+        },
+      },
+    ],
+  };
+}
+
+async function request(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // opencode rebuilds the array from storage every request; synthetic board
+  // content is never persisted, so each request starts from real history only.
+  const output = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, output as never);
+  await hook.injectBackgroundJobBoard({}, output as never);
+  return output.messages;
+}
+
+// ── Invariant helpers ─────────────────────────────────────────────────────
+
+function isBoardPart(part: AnyPart): boolean {
+  return part?.metadata?.[BACKGROUND_JOB_BOARD_METADATA_KEY] === true;
+}
+
+function isBoardMessage(message: AnyMessage): boolean {
+  return (
+    message.parts.length > 0 && message.parts.every((part) => isBoardPart(part))
+  );
+}
+
+/** A3: board text may only ride on a `user`-role message. */
+function assertBoardTextOnlyOnUserMessages(messages: unknown[]): void {
+  for (const message of messages as AnyMessage[]) {
+    if (!message.parts?.some(isBoardPart)) continue;
+    expect(
+      message.info.role,
+      `board text landed on a ${message.info.role} message; the assistant ` +
+        'branch of the host converter would forward its metadata as ' +
+        'providerOptions and fail ModelMessage[] validation',
+    ).toBe('user');
+  }
+}
+
+/**
+ * A2: every assistant `tool_call` stays immediately followed by its matching
+ * `tool_result`, measured on the CONVERTED model messages.
+ */
+function assertToolPairingIntact(messages: unknown[]): void {
+  const converted = convertToModelMessages(toUIMessages(messages));
+  for (const [index, message] of converted.entries()) {
+    if (message.role !== 'assistant') continue;
+    const callIds = (message.content as AnyPart[])
+      .filter((part) => part.type === 'tool-call')
+      .map((part) => part.toolCallId);
+    if (callIds.length === 0) continue;
+
+    const next = converted[index + 1];
+    expect(
+      next?.role,
+      `assistant tool_call(s) ${callIds.join(', ')} are not followed by a ` +
+        'tool-role message — the pairing was orphaned',
+    ).toBe('tool');
+    const resultIds = (next.content as AnyPart[]).map(
+      (part) => part.toolCallId,
+    );
+    for (const callId of callIds) {
+      expect(resultIds).toContain(callId);
+    }
+  }
+}
+
+/**
+ * A1: no synthetic board message may sit anywhere but the very end of the
+ * array — i.e. nothing was spliced into the middle of already-sent history.
+ */
+function assertNoMidArrayBoardMessage(messages: unknown[]): void {
+  const list = messages as AnyMessage[];
+  for (const [index, message] of list.entries()) {
+    if (!isBoardMessage(message)) continue;
+    expect(
+      index,
+      'a synthetic board message was inserted mid-array instead of appended',
+    ).toBe(list.length - 1);
+  }
+}
+
+/**
+ * A1/A2 combined, stated positionally: a synthetic board message may follow an
+ * assistant `task` tool_call message ONLY when it is the final element of the
+ * array. Appending after the last real message is safe — the host emits the
+ * tool_call and its tool_result adjacently from that one message, so nothing
+ * comes between them. Splicing the board after a task message that still has
+ * successors is the bug: it lands inside already-sent history.
+ */
+function assertNothingBetweenTaskCallAndResult(messages: unknown[]): void {
+  const list = messages as AnyMessage[];
+  for (const [index, message] of list.entries()) {
+    const hasTaskCall = message.parts?.some(
+      (part) => part.type === 'tool' && part.tool === 'task',
+    );
+    if (!hasTaskCall) continue;
+    const next = list[index + 1];
+    if (!next || !isBoardMessage(next)) continue;
+    expect(
+      index + 1,
+      'a synthetic board message was spliced in immediately after an ' +
+        'assistant task tool_call message that is not the tail — it sits ' +
+        'between the call and its tool_result',
+    ).toBe(list.length - 1);
+  }
+}
+
+function assertAllInvariants(messages: unknown[]): void {
+  assertNoMidArrayBoardMessage(messages);
+  assertNothingBetweenTaskCallAndResult(messages);
+  assertToolPairingIntact(messages);
+  assertBoardTextOnlyOnUserMessages(messages);
+}
+
+function boardTexts(messages: unknown[]): string[] {
+  return (messages as AnyMessage[]).flatMap((message) =>
+    (message.parts ?? []).filter(isBoardPart).map((part) => String(part.text)),
+  );
+}
+
+function runningBoard(): BackgroundJobBoard {
+  const board = new BackgroundJobBoard();
+  board.registerLaunch({
+    taskID: CHILD,
+    parentSessionID: SESSION,
+    agent: 'librarian',
+    description: 'research the scheduler',
+  });
+  return board;
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────
+
+describe('board injection keeps the ModelMessage[] array valid', () => {
+  test('reproduction: a board retained on an assistant anchor never corrupts the prompt when the background task finishes', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request 1 — the tail is the ASSISTANT turn of a just-finished background
+    // task. This is the request that makes the buggy build retain a board
+    // against an ASSISTANT anchor.
+    const historyA = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ];
+    const outA = await request(hook, historyA);
+
+    const validA = validateModelMessages(outA);
+    expect(
+      validA.success,
+      `request A failed ModelMessage[] validation: ${JSON.stringify(
+        validA.error?.issues?.slice(0, 3),
+        null,
+        2,
+      )}`,
+    ).toBe(true);
+    assertAllInvariants(outA);
+
+    // Request 2 — the loop advanced: the assistant task turn is now
+    // mid-history, followed by the user tool_result turn. The buggy build
+    // replayed the retained board by splicing an ASSISTANT-role synthetic
+    // message directly after the anchor, which both landed mid-array and
+    // carried board metadata on an assistant message.
+    board.updateStatus({
+      taskID: CHILD,
+      state: 'completed',
+      resultSummary: 'scheduler batches on idle',
+    });
+    const historyB = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'file contents'),
+    ];
+    const outB = await request(hook, historyB);
+
+    const validB = validateModelMessages(outB);
+    expect(
+      validB.success,
+      `request B failed ModelMessage[] validation (AI_InvalidPromptError): ` +
+        `${JSON.stringify(validB.error?.issues?.slice(0, 3), null, 2)}`,
+    ).toBe(true);
+    assertAllInvariants(outB);
+
+    // Request 3 — a consecutive request over the same history must stay valid
+    // and must not accumulate boards.
+    const outC = await request(hook, historyB);
+    expect(validateModelMessages(outC).success).toBe(true);
+    assertAllInvariants(outC);
+    expect(boardTexts(outC)).toHaveLength(1);
+  });
+
+  test('A1/A2: no synthetic message is ever placed between a tool_call and its tool_result across a growing tool loop', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    const history: unknown[] = [
+      userTextTurn('u1', 'Coordinate the background research'),
+    ];
+
+    // Grow the conversation the way the agent loop does: alternating assistant
+    // task turns and user tool_result turns, re-rendering every step.
+    for (let turn = 0; turn < 4; turn += 1) {
+      history.push(finishedTaskAssistantTurn(`a${turn}`, `call-task-${turn}`));
+      const mid = await request(hook, history);
+      assertAllInvariants(mid);
+      expect(validateModelMessages(mid).success).toBe(true);
+
+      history.push(
+        toolResultUserTurn(`r${turn}`, `call-read-${turn}`, `result-${turn}`),
+      );
+      const after = await request(hook, history);
+      assertAllInvariants(after);
+      expect(validateModelMessages(after).success).toBe(true);
+    }
+  });
+
+  test('A3: board text never rides on an assistant message even when the tail is an assistant turn', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    const out = await request(hook, [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ]);
+
+    // The board is present…
+    expect(boardTexts(out)).toHaveLength(1);
+    // …and it is carried by a user-role message appended at the very end.
+    assertBoardTextOnlyOnUserMessages(out);
+    const tail = (out as AnyMessage[]).at(-1);
+    expect(tail?.info.role).toBe('user');
+    expect(tail?.parts.every(isBoardPart)).toBe(true);
+    // The assistant anchor itself is untouched by the board.
+    const assistant = (out as AnyMessage[]).find(
+      (message) => message.info.role === 'assistant',
+    );
+    expect(assistant?.parts.some(isBoardPart)).toBe(false);
+  });
+
+  test('A4: a board on a still-present text-only user anchor is replayed byte-identically', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request A: the tail is a plain user turn, so the board rides on it as a
+    // trailing PART and is recorded for replay.
+    const historyA = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      toolResultUserTurn('r0', 'call-read-0', 'first read'),
+      userTextTurn('u2', 'Now summarize the findings'),
+    ];
+    const outA = await request(hook, historyA);
+    const anchorA = (outA as AnyMessage[]).find(
+      (message) => message.info.id === 'u2',
+    );
+    const retainedBoard = anchorA?.parts.at(-1);
+    expect(isBoardPart(retainedBoard as AnyPart)).toBe(true);
+    const retainedBytes = JSON.stringify(retainedBoard);
+
+    // Request B: the tail advanced past that anchor. The already-sent board
+    // bytes on `u2` must be reproduced exactly — that is the cache guarantee
+    // 208b656 introduced and this fix preserves.
+    board.updateStatus({
+      taskID: CHILD,
+      state: 'completed',
+      resultSummary: 'scheduler batches on idle',
+    });
+    const historyB = [
+      ...historyA,
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'second read'),
+    ];
+    const outB = await request(hook, historyB);
+
+    const anchorB = (outB as AnyMessage[]).find(
+      (message) => message.info.id === 'u2',
+    );
+    const replayed = anchorB?.parts.at(-1);
+    expect(isBoardPart(replayed as AnyPart)).toBe(true);
+    // Byte-identical replay: the provider's cached prefix stays valid.
+    expect(JSON.stringify(replayed)).toBe(retainedBytes);
+
+    // And the array is still valid and invariant-clean.
+    expect(validateModelMessages(outB).success).toBe(true);
+    assertAllInvariants(outB);
+  });
+
+  test('A5: an unreplayable retained board is dropped instead of retried every request', async () => {
+    const board = runningBoard();
+    const hook = createHook(board);
+
+    // Request A: assistant tail → the board is appended as a trailing message.
+    // That placement is deliberately NOT retained, because reproducing it later
+    // would require a mid-array insertion.
+    await request(hook, [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+    ]);
+
+    const historyB = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      finishedTaskAssistantTurn('a1', 'call-task-1'),
+      toolResultUserTurn('r1', 'call-read-1', 'file contents'),
+    ];
+
+    // Repeated consecutive requests must each carry exactly ONE board: no
+    // resurrection of the dropped placement, no unbounded accumulation, and no
+    // repeated attempt at the unsafe replay.
+    for (let attempt = 0; attempt < 3; attempt += 1) {
+      const out = await request(hook, historyB);
+      expect(boardTexts(out)).toHaveLength(1);
+      // The dropped board is not reproduced on the assistant anchor.
+      const assistant = (out as AnyMessage[]).find(
+        (message) => message.info.role === 'assistant',
+      );
+      expect(assistant?.parts.some(isBoardPart)).toBe(false);
+      assertAllInvariants(out);
+      expect(validateModelMessages(out).success).toBe(true);
+    }
+  });
+
+  test('a board part on an assistant message is exactly what the real schema rejects', async () => {
+    // Guards the schema port itself: if this stopped failing, the reproduction
+    // test above would pass for the wrong reason.
+    const corrupted = [
+      userTextTurn('u1', 'Coordinate the background research'),
+      {
+        info: {
+          id: 'a1',
+          role: 'assistant',
+          sessionID: SESSION,
+          providerID: PROVIDER,
+          modelID: MODEL,
+        },
+        parts: [
+          {
+            type: 'text',
+            synthetic: true,
+            text: '<system-reminder>board</system-reminder>',
+            metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+          },
+        ],
+      },
+    ];
+
+    const result = validateModelMessages(corrupted);
+    expect(result.success).toBe(false);
+    expect(JSON.stringify(result.error?.issues)).toContain('providerOptions');
+  });
+});

+ 10 - 7
src/hooks/task-session-manager/codemap.md

@@ -11,6 +11,7 @@ The directory follows a **Facade + Strategy** pattern where `index.ts` acts as t
 - **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, task context tracking, and explicit user waits. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`) and exposes `beginUserWait()` to the `wait_for_user` tool.
 - **input-wait-tracker.ts**: Provides the single `hasInputWait()` seam used by idle reconciliation and continuation evaluation. It combines local question/permission waits with the process-global explicit user-wait latch.
 - **continuation-attempt-gate.ts**: Owns process-global continuation epochs, reservations, and explicit user waits across hook recreation. The wait is encoded as an `attempts` sentinel so pre-upgrade #856 hooks sharing the store also fail closed. Distinct external user-message identity rearms both states.
+- **continuation-model-selection.ts**: Normalizes current-session and chat-hook model shapes before forwarding runtime model and variant choices to idle continuation prompts.
 - **pending-call-tracker.ts**: Tracks in-flight task calls using a capped ordered map (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely. Provides call ID generation, storage, retrieval, and cleanup for pending task invocations.
 - **task-context-tracker.ts**: Manages read context from child sessions with line-count and file caps. Stores context per task ID and provides pruning to prevent unbounded growth.
 
@@ -48,15 +49,16 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
    - Prunes stale context during lifecycle events and status transitions
 
 4. **Message Injection (`experimental.chat.messages.transform`)**
-   - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
-   - Lists active, unreconciled, and reusable sessions
-   - Remembers injected terminal jobs to reconcile them on parent idle events
+    - Injects a `<system-reminder>` part containing the `### Background Job Board` section into user messages for managed sessions
+    - Lists active, unreconciled, and reusable sessions
+    - Remembers injected terminal jobs to reconcile them on the next request after the completion was surfaced to the model (via `reconcileConsumedTerminalJobs`)
+    - The idle timer remains a backstop for when the model ends its turn without further requests; after reconciling injected terminal results, the opt-in continuation evaluator can run in the same idle cycle under its existing guards
 
 5. **Lifecycle Events (`event`)**
-   - `session.created`: Adds new task IDs to pending managed set
-   - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session
-   - `session.status` (busy): Marks sessions as running from live session state
-   - `session.deleted`: Clears job state, child jobs, and pending call records for the session
+    - `session.created`: Adds new task IDs to pending managed set
+    - `session.idle` / `session.status` (idle): Reconciles injected terminal jobs for the parent session (backstop path), then can run the opt-in continuation evaluator in the same idle cycle under its existing guards
+    - `session.status` (busy): Marks sessions as running from live session state
+    - `session.deleted`: Clears job state, child jobs, and pending call records for the session
 
 6. **Human-in-the-loop Waits**
    - `wait_for_user` calls the facade's `beginUserWait()` only after tool validation
@@ -71,6 +73,7 @@ User task call → tool.execute.before → PendingTaskCall created → task ID r
 → tool.execute.after → BackgroundJobBoard.registerLaunch() → context extracted/added
 → Message transform → BackgroundJobBoard.formatForPrompt() injected as a system-reminder message part
 → session.idle → reconcileInjectedTerminalJobs() → BackgroundJobBoard.markReconciled()
+→ opt-in continuation evaluator (same idle cycle, existing guards)
 ```
 
 ## Integration

+ 29 - 0
src/hooks/task-session-manager/continuation-evaluator.ts

@@ -11,6 +11,10 @@ import { createInternalAgentTextPart } from '../../utils';
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
+import {
+  type ContinuationModelSelection,
+  parseContinuationModelSelection,
+} from './continuation-model-selection';
 import { isActiveStatus } from './status-utils';
 
 const CONTINUATION_NUDGE =
@@ -95,10 +99,14 @@ export async function evaluateContinuation(
     options: {
       isFallbackInProgress?: (sessionID: string) => boolean;
     };
+    getObservedModelSelection: (
+      sessionID: string,
+    ) => ContinuationModelSelection | undefined;
     sessionSdk?: {
       todo?: (input: unknown) => Promise<{ data?: unknown }>;
       children?: (input: unknown) => Promise<{ data?: unknown }>;
       status?: (input: unknown) => Promise<{ data?: unknown }>;
+      get?: (input: unknown) => Promise<{ data?: unknown }>;
       promptAsync?: (input: unknown) => Promise<unknown>;
     };
   },
@@ -230,6 +238,25 @@ export async function evaluateContinuation(
       return;
     }
 
+    let currentModelSelection: ContinuationModelSelection | undefined;
+    if (deps.sessionSdk.get) {
+      try {
+        const sessionResponse = await deps.sessionSdk.get({
+          path: { id: parentSessionID },
+          throwOnError: true,
+        });
+        const session = isObjectRecord(sessionResponse?.data)
+          ? sessionResponse.data
+          : undefined;
+        currentModelSelection = parseContinuationModelSelection(session?.model);
+      } catch {
+        // Model enrichment is fail-soft. Older OpenCode session payloads do
+        // not expose Session.model, so fall back to the filtered chat hook.
+      }
+    }
+    const modelSelection =
+      currentModelSelection ?? deps.getObservedModelSelection(parentSessionID);
+
     if (
       isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
     ) {
@@ -248,6 +275,8 @@ export async function evaluateContinuation(
       path: { id: parentSessionID },
       body: {
         agent: 'orchestrator',
+        ...(modelSelection ? { model: modelSelection.model } : {}),
+        ...(modelSelection?.variant ? { variant: modelSelection.variant } : {}),
         parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
       },
       throwOnError: true,

+ 46 - 0
src/hooks/task-session-manager/continuation-model-selection.ts

@@ -0,0 +1,46 @@
+import { isRecord as isObjectRecord } from '../../utils/guards';
+
+export type ContinuationModelSelection = {
+  model: {
+    providerID: string;
+    modelID: string;
+  };
+  variant?: string;
+};
+
+/**
+ * Normalize the two runtime model shapes used across supported OpenCode
+ * versions:
+ * - chat.message / promptAsync: { providerID, modelID }
+ * - current Session.model:      { providerID, id }
+ */
+export function parseContinuationModelSelection(
+  value: unknown,
+  variantOverride?: unknown,
+): ContinuationModelSelection | undefined {
+  if (!isObjectRecord(value)) return undefined;
+
+  const providerID =
+    typeof value.providerID === 'string' && value.providerID.length > 0
+      ? value.providerID
+      : undefined;
+  const modelID =
+    typeof value.modelID === 'string' && value.modelID.length > 0
+      ? value.modelID
+      : typeof value.id === 'string' && value.id.length > 0
+        ? value.id
+        : undefined;
+  if (!providerID || !modelID) return undefined;
+
+  const variant =
+    typeof variantOverride === 'string' && variantOverride.length > 0
+      ? variantOverride
+      : typeof value.variant === 'string' && value.variant.length > 0
+        ? value.variant
+        : undefined;
+
+  return {
+    model: { providerID, modelID },
+    ...(variant ? { variant } : {}),
+  };
+}

+ 14 - 3
src/hooks/task-session-manager/event-router.ts

@@ -5,11 +5,15 @@
  * session.idle, session.error, session.status, session.deleted) to
  * the appropriate subsystems.
  */
+import type { BackgroundJobExecution } from '../../utils/background-job-board';
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import type { BackgroundJobSupervisor } from '../../utils/background-job-supervisor';
 import { log } from '../../utils/logger';
 import { isFailoverError } from '../foreground-fallback/index';
-import type { RetainedBoardSnapshotState } from './board-injection';
+import type {
+  InjectedTerminalJobs,
+  RetainedBoardSnapshotState,
+} from './board-injection';
 import type { PendingTaskCall } from './pending-call-tracker';
 
 export async function handleEvent(
@@ -75,7 +79,11 @@ export async function handleEvent(
       clearSession(sessionID: string): void;
       prune(board: { taskIDs(): Set<string> }): void;
     };
-    terminalJobsInjectedByParent: Map<string, Set<string>>;
+    terminalJobsInjectedByParent: Map<string, InjectedTerminalJobs>;
+    pendingInjectedTerminalJobsByParent: Map<
+      string,
+      Map<string, BackgroundJobExecution>
+    >;
     retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
     backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
@@ -178,7 +186,9 @@ export async function handleEvent(
         ? deps.options.shouldManageSession(sessionId)
         : false,
       terminalJobsPending: sessionId
-        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.executions.size ??
+            0) +
+          (deps.pendingInjectedTerminalJobsByParent.get(sessionId)?.size ?? 0)
         : 0,
       runningJobForSession: job?.state === 'running' || false,
     });
@@ -214,6 +224,7 @@ export async function handleEvent(
       const props = input.event.properties as { error?: unknown } | undefined;
       if (!props?.error || !isFailoverError(props.error)) {
         deps.terminalJobsInjectedByParent.delete(sessionId);
+        deps.pendingInjectedTerminalJobsByParent.delete(sessionId);
         // Record non-retryable errors on the job board so the
         // orchestrator sees the failure instead of a false completion.
         const job = deps.backgroundJobBoard.get(sessionId);

+ 1 - 5
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -43,12 +43,8 @@ export function createIdleReconciler(options: {
       if (!options.isCurrentContinuation(parentSessionID, sessionToken)) {
         return;
       }
-      const hadTerminalUnreconciled =
-        options.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID);
       options.reconcileInjectedTerminalJobs(parentSessionID);
-      if (!hadTerminalUnreconciled) {
-        void options.evaluateContinuation(parentSessionID, sessionToken);
-      }
+      void options.evaluateContinuation(parentSessionID, sessionToken);
     }, options.idleReconcileDelayMs).unref?.();
     idleReconcileTimers.set(parentSessionID, timer);
   }

File diff suppressed because it is too large
+ 981 - 138
src/hooks/task-session-manager/index.test.ts


+ 51 - 4
src/hooks/task-session-manager/index.ts

@@ -1,6 +1,7 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
+  type BackgroundJobExecution,
   type BackgroundJobStore,
   type BackgroundJobSupervisor,
   isInternalInitiatorPart,
@@ -10,6 +11,7 @@ import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
+  type InjectedTerminalJobs,
   type InjectionState,
   injectBackgroundJobBoard,
   MAX_PROCESSED_INJECTED_COMPLETIONS,
@@ -18,6 +20,10 @@ import {
   updateFromInjectedCompletion,
 } from './board-injection';
 import { evaluateContinuation as evaluateContinuationFn } from './continuation-evaluator';
+import {
+  type ContinuationModelSelection,
+  parseContinuationModelSelection,
+} from './continuation-model-selection';
 import { createContinuationTokenManager } from './continuation-token-manager';
 import { handleEvent } from './event-router';
 import { createIdleReconciler } from './idle-reconciliation';
@@ -85,7 +91,15 @@ export function createTaskSessionManagerHook(
 
   const processedInjectedCompletions = new Set<string>();
   const processedInjectedCompletionOrder: string[] = [];
-  const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const terminalJobsInjectedByParent = new Map<string, InjectedTerminalJobs>();
+  const pendingInjectedTerminalJobsByParent = new Map<
+    string,
+    Map<string, BackgroundJobExecution>
+  >();
+  const observedContinuationModels = new Map<
+    string,
+    ContinuationModelSelection
+  >();
 
   // Forward refs for circular deps — set after corresponding managers exist.
   // These are captured by closure in createIdleReconciler and only called
@@ -138,6 +152,7 @@ export function createTaskSessionManagerHook(
     todo?: (input: unknown) => Promise<SdkResponse>;
     children?: (input: unknown) => Promise<SdkResponse>;
     status?: (input: unknown) => Promise<SdkResponse>;
+    get?: (input: unknown) => Promise<SdkResponse>;
     promptAsync?: (input: unknown) => Promise<unknown>;
   };
   const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
@@ -151,6 +166,8 @@ export function createTaskSessionManagerHook(
       inputWaits,
       options,
       sessionSdk,
+      getObservedModelSelection: (sessionID) =>
+        observedContinuationModels.get(sessionID),
     });
 
   if (options.coordinator) {
@@ -162,6 +179,7 @@ export function createTaskSessionManagerHook(
         continuationTokens.clearContinuation(sessionId);
       }
       inputWaits.clearInputWaits(sessionId);
+      observedContinuationModels.delete(sessionId);
       idleReconciler.clearIdleTimers(sessionId);
       // During a foreground fallback abort/re-prompt cycle, the session
       // is being torn down and immediately recreated with a fallback model.
@@ -179,7 +197,9 @@ export function createTaskSessionManagerHook(
         if (!hardTimedOut) options.backgroundJobSupervisor?.drop(sessionId);
       }
       terminalJobsInjectedByParent.delete(sessionId);
+      pendingInjectedTerminalJobsByParent.delete(sessionId);
       injectionState.retainedBoardSnapshots.delete(sessionId);
+      injectionState.retainedTailBoards.delete(sessionId);
       taskContextTracker.clearSession(sessionId);
       taskContextTracker.prune(backgroundJobBoard);
       pendingCallTracker.clearSession(sessionId);
@@ -193,11 +213,13 @@ export function createTaskSessionManagerHook(
     processedInjectedCompletions,
     processedInjectedCompletionOrder,
     terminalJobsInjectedByParent,
+    pendingInjectedTerminalJobsByParent,
     maxProcessedInjectedCompletions: MAX_PROCESSED_INJECTED_COMPLETIONS,
     metadataKey: BACKGROUND_JOB_BOARD_METADATA_KEY,
     shouldManageSession: options.shouldManageSession,
     taskContextTracker,
     retainedBoardSnapshots: new Map(),
+    retainedTailBoards: new Map(),
   };
 
   return {
@@ -249,6 +271,21 @@ export function createTaskSessionManagerHook(
       ) {
         return;
       }
+      const outputModel = isObjectRecord(outputMessage?.model)
+        ? outputMessage.model
+        : undefined;
+      const variant =
+        typeof inputMessage?.variant === 'string'
+          ? inputMessage.variant
+          : outputModel?.variant;
+      const modelSelection =
+        parseContinuationModelSelection(inputMessage?.model, variant) ??
+        parseContinuationModelSelection(outputModel, variant);
+      if (modelSelection) {
+        observedContinuationModels.set(sessionID, modelSelection);
+      } else {
+        observedContinuationModels.delete(sessionID);
+      }
       continuationTokens.rearmForUserMessage(sessionID, messageIdentity);
     },
 
@@ -334,8 +371,16 @@ export function createTaskSessionManagerHook(
           error?: { name?: string };
         };
       };
-    }): Promise<void> =>
-      handleEvent(input, {
+    }): Promise<void> => {
+      if (input.event.type === 'server.instance.disposed') {
+        observedContinuationModels.clear();
+      } else if (input.event.type === 'session.deleted') {
+        const sessionID =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        if (sessionID) observedContinuationModels.delete(sessionID);
+      }
+
+      return handleEvent(input, {
         inputWaits,
         continuationTokens,
         options,
@@ -344,8 +389,10 @@ export function createTaskSessionManagerHook(
         pendingCallTracker,
         taskContextTracker,
         terminalJobsInjectedByParent,
+        pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
         backgroundJobSupervisor: options.backgroundJobSupervisor,
-      }),
+      });
+    },
   };
 }

+ 1 - 12
src/index.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import { mkdtemp, rm } from 'node:fs/promises';
-import plugin, { minimumExpectedToolCount } from './index';
+import plugin from './index';
 
 function createPluginClient(
   noop: () => Promise<unknown>,
@@ -54,17 +54,6 @@ function createHostTimerHarness() {
   return { now: () => now, setTimeout, clearTimeout, advanceTo };
 }
 
-describe('plugin health thresholds', () => {
-  test('accounts only for intentionally disabled baseline tools', () => {
-    expect(minimumExpectedToolCount()).toBe(5);
-    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(4);
-    expect(minimumExpectedToolCount(['wait_for_user', 'wait_for_user'])).toBe(
-      4,
-    );
-    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
-  });
-});
-
 describe('plugin env disable', () => {
   let originalEnv: typeof process.env;
 

+ 99 - 62
src/index.ts

@@ -17,7 +17,9 @@ import {
 import { parseList } from './config/agent-mcps';
 import {
   AGENT_ALIASES,
+  DEFAULT_MAX_CONTEXT_LINES,
   DEFAULT_MAX_RETAINED_SNAPSHOTS,
+  DEFAULT_MAX_SESSION_METADATA_ENTRIES,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
@@ -29,6 +31,7 @@ import {
   setActiveRuntimePreset,
 } from './config/runtime-preset';
 import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
+import { HEALTH_CHECK, minimumExpectedToolCount } from './health-check';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
@@ -74,6 +77,7 @@ import {
 } from './utils';
 import { isPluginDisabledByEnv } from './utils/env';
 import { initLogger, log } from './utils/logger';
+import { SessionMetadataStore } from './utils/session-metadata';
 import { collapseSystemInPlace } from './utils/system-collapse';
 
 /**
@@ -99,33 +103,6 @@ async function appLog(
   }
 }
 
-/** Minimum expected registrations for a healthy plugin load. */
-const HEALTH_CHECK = {
-  minAgents: 5,
-  // Default tool set when council and ACP agents are not configured:
-  // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
-  minTools: 5,
-  minMcps: 1,
-} as const;
-
-const BASELINE_TOOL_NAMES = new Set([
-  'cancel_task',
-  'wait_for_user',
-  'webfetch',
-  'ast_grep_search',
-  'ast_grep_replace',
-]);
-
-/** @internal Exposed for deterministic health-threshold tests. */
-export function minimumExpectedToolCount(
-  disabledTools: readonly string[] = [],
-): number {
-  const disabledBaselineTools = new Set(
-    disabledTools.filter((toolName) => BASELINE_TOOL_NAMES.has(toolName)),
-  );
-  return HEALTH_CHECK.minTools - disabledBaselineTools.size;
-}
-
 /**
  * Probe jsdom at init time so the first webfetch call doesn't fail
  * silently. Logs a warning if jsdom can't be imported or instantiated,
@@ -173,10 +150,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let multiplexerEnabled: boolean;
   let multiplexerSessionManager: MultiplexerSessionManager;
   let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
-  let sessionAgentMap: Map<string, string>;
-  // ponytail: cache sessionID -> project directory so TUI model writes
-  // land in the right per-project file after a project switch (ctx.directory is stale)
-  const sessionDirectories = new Map<string, string>();
+  const sessionMetadata = new SessionMetadataStore({
+    maxEntries: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
+    onEvict: (sessionID) => {
+      log('[session] evicted oldest session metadata', {
+        threshold: DEFAULT_MAX_SESSION_METADATA_ENTRIES,
+        droppedSessionId: sessionID,
+      });
+    },
+  });
   let sessionLifecycle: SessionLifecycle;
 
   let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
@@ -278,16 +260,41 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       startAvailabilityCheck(multiplexerConfig);
     }
 
-    mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
+    mcps = createBuiltinMcps(config.disabled_mcps);
     acpRunTools =
       Object.keys(config.acpAgents ?? {}).length > 0
         ? { acp_run: createAcpRunTool(config.acpAgents) }
         : {};
-    webfetch = createWebfetchTool(ctx);
+    const webfetchModel = config.webfetch?.model;
+    const webfetchModels = (() => {
+      if (!webfetchModel) return undefined;
+      const entries = Array.isArray(webfetchModel)
+        ? webfetchModel
+        : [webfetchModel];
+      type ModelRefInput = string | { id: string; variant?: string };
+      const models: Array<{ id: string; variant?: string }> = [];
+      for (const entry of entries as ModelRefInput[]) {
+        const id = typeof entry === 'string' ? entry : entry.id;
+        if (!id) continue;
+        models.push({
+          id,
+          ...(typeof entry === 'object' && entry.variant
+            ? { variant: entry.variant }
+            : {}),
+        });
+      }
+      return models.length > 0 ? models : undefined;
+    })();
+    webfetch = createWebfetchTool(ctx, {
+      binaryDir: undefined,
+      webfetchModels,
+    });
     backgroundJobBoard = new BackgroundJobBoard({
       maxReusablePerAgent:
         config.backgroundJobs?.maxSessionsPerAgent ??
         DEFAULT_MAX_SESSIONS_PER_AGENT,
+      maxContextLines:
+        config.backgroundJobs?.maxContextLines ?? DEFAULT_MAX_CONTEXT_LINES,
       readContextMinLines:
         config.backgroundJobs?.readContextMinLines ??
         DEFAULT_READ_CONTEXT_MIN_LINES,
@@ -338,9 +345,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       companion: config.companion,
     });
 
-    // Track session → agent mapping for serve-mode system prompt injection
-    sessionAgentMap = new Map<string, string>();
-
     chatHeadersHook = createChatHeadersHook(ctx);
 
     // Initialize foreground fallback manager for runtime model switching.
@@ -375,9 +379,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       backgroundJobBoard: backgroundJobCoordinator,
       backgroundJobSupervisor,
       shouldManageSession: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
       registerSessionAsOrchestrator: (sessionID) => {
-        sessionAgentMap.set(sessionID, 'orchestrator');
+        sessionMetadata.setAgent(sessionID, 'orchestrator');
       },
       isFallbackInProgress: (sessionID) =>
         foregroundFallback.isFallbackInProgress(sessionID),
@@ -416,7 +420,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Both message transforms share this gate so a rejected nudge cannot be
     // followed by a phase reminder in the same outgoing turn.
     const shouldInjectOrchestratorReminder = (sessionID: string) =>
-      sessionAgentMap.get(sessionID) === 'orchestrator';
+      sessionMetadata.getAgent(sessionID) === 'orchestrator';
 
     phaseReminder = createPhaseReminderHook({
       shouldInject: shouldInjectOrchestratorReminder,
@@ -458,28 +462,32 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       client: ctx.client,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
     });
     waitForUserTools = createWaitForUserTool({
       shouldManageSession: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
       resolveAgentName: (agent) => resolveRuntimeAgentName(config, agent),
       registerSessionAsOrchestrator: (sessionID) => {
-        sessionAgentMap.set(sessionID, 'orchestrator');
+        sessionMetadata.setAgent(sessionID, 'orchestrator');
       },
       beginUserWait: (sessionID) =>
         taskSessionManagerHook.beginUserWait(sessionID),
     });
 
+    const shouldRegisterWebfetch = config.webfetch?.enabled !== false;
     tools = {
       ...cancelTaskTools,
       ...waitForUserTools,
       ...acpRunTools,
-      webfetch,
+      ...(shouldRegisterWebfetch ? { webfetch } : {}),
       ast_grep_search,
       ast_grep_replace,
     };
-    if (config.disabled_tools && config.disabled_tools.length > 0) {
+    if (
+      Array.isArray(config.disabled_tools) &&
+      config.disabled_tools.length > 0
+    ) {
       const disabledTools = new Set(config.disabled_tools);
       tools = Object.fromEntries(
         Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
@@ -504,11 +512,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const mcpCount = Object.keys(mcps).length;
   // Skip MCP threshold when user explicitly disabled all built-in MCPs
   const mcpThreshold =
-    config.disabled_mcps && config.disabled_mcps.length > 0
+    Array.isArray(config.disabled_mcps) && config.disabled_mcps.length > 0
       ? 0
       : HEALTH_CHECK.minMcps;
-  const toolThreshold = minimumExpectedToolCount(config.disabled_tools);
-
+  const toolThreshold = minimumExpectedToolCount(
+    config.disabled_tools,
+    config.webfetch?.enabled !== false,
+  );
   if (
     agentCount < HEALTH_CHECK.minAgents ||
     toolCount < toolThreshold ||
@@ -957,6 +967,24 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         };
       };
 
+      const eventSessionID =
+        event.properties?.info?.id ?? event.properties?.sessionID;
+      const statusType = event.properties?.status?.type;
+      if (eventSessionID) {
+        if (
+          event.type === 'session.status' &&
+          (statusType === 'busy' || statusType === 'retry')
+        ) {
+          sessionMetadata.markOrchestratorActive(eventSessionID);
+        } else if (
+          event.type === 'session.idle' ||
+          (event.type === 'session.status' && statusType === 'idle') ||
+          event.type === 'session.deleted'
+        ) {
+          sessionMetadata.markOrchestratorIdle(eventSessionID);
+        }
+      }
+
       if (event.type === 'message.updated') {
         const info = event.properties?.info;
         const providerID =
@@ -981,7 +1009,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
               model,
               variant: variant ?? null,
             },
-            (info?.sessionID && sessionDirectories.get(info.sessionID)) ??
+            (info?.sessionID && sessionMetadata.getDirectory(info.sessionID)) ??
               ctx.directory,
           );
         }
@@ -991,7 +1019,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const createdSessionId = event.properties?.info?.id;
         const createdSessionDir = event.properties?.info?.directory;
         if (createdSessionId && createdSessionDir) {
-          sessionDirectories.set(createdSessionId, createdSessionDir);
+          sessionMetadata.setDirectory(createdSessionId, createdSessionDir);
         }
       }
 
@@ -1053,7 +1081,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const sessionID = props?.sessionID;
         companionManager.onSessionStatus({
           sessionId: sessionID,
-          agent: sessionID ? sessionAgentMap.get(sessionID) : undefined,
+          agent: sessionID ? sessionMetadata.getAgent(sessionID) : undefined,
           status: props?.status?.type,
         });
       }
@@ -1069,8 +1097,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
         companionManager.onSessionDeleted(sessionID);
         if (sessionID) {
-          sessionAgentMap.delete(sessionID);
-          sessionDirectories.delete(sessionID);
+          sessionMetadata.delete(sessionID);
         }
       }
     },
@@ -1136,6 +1163,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: {
         sessionID: string;
         agent?: string;
+        model?: {
+          providerID: string;
+          modelID: string;
+        };
+        variant?: string;
         parts?: unknown[];
         /** OpenCode chat.message message identity when present. */
         messageID?: string;
@@ -1146,6 +1178,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           agent?: string;
           role?: string;
           sessionID?: string;
+          model?: {
+            providerID: string;
+            modelID: string;
+            variant?: string;
+          };
         };
         parts?: unknown[];
       },
@@ -1165,7 +1202,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       if (agent) {
         foregroundFallback.registerSessionAgent(input.sessionID, agent);
-        sessionAgentMap.set(input.sessionID, agent);
+        sessionMetadata.setAgent(input.sessionID, agent);
         // A chat message means this session is actively working. This also
         // covers the race where session.status busy fires before the
         // session's agent is known.
@@ -1189,7 +1226,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       output: { system: string[] },
     ): Promise<void> => {
       const agentName = input.sessionID
-        ? sessionAgentMap.get(input.sessionID)
+        ? sessionMetadata.getAgent(input.sessionID)
         : undefined;
       if (agentName === 'orchestrator') {
         const alreadyInjected = output.system.some(
@@ -1199,12 +1236,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             s.includes('orchestrator'),
         );
         if (!alreadyInjected) {
-          // Prepend the orchestrator prompt to the system array. Use the
-          // resolved prompt from the orchestrator agent definition (which
-          // includes any custom replacement or append from orchestrator.md
-          // / orchestrator_append.md) Fall back to
-          // buildOrchestratorPrompt only if the resolved prompt is
-          // missing.
+          // Place the orchestrator prompt after AGENTS.md so the user's
+          // behavioral rules (language, code conventions, etc.) retain
+          // their intended priority. AGENTS.md is injected by OpenCode
+          // core into system[0]; prepending the orchestrator prompt before
+          // it buries user-defined rules under thousands of lines of
+          // orchestration instructions.
           const orchestratorDef = agentDefs.find(
             (a) => a.name === 'orchestrator',
           );
@@ -1213,8 +1250,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
               ? orchestratorDef.config.prompt
               : buildOrchestratorPrompt(disabledAgents);
           output.system[0] =
-            orchestratorPrompt +
-            (output.system[0] ? `\n\n${output.system[0]}` : '');
+            (output.system[0] || '') +
+            `\n\n${orchestratorPrompt}`;
         }
       }
 

+ 9 - 24
src/mcp/codemap.md

@@ -2,7 +2,7 @@
 
 ## Responsibility
 
-Defines Model Context Protocol (MCP) server configurations and integrations for the OpenCode plugin. This module provides built-in MCP servers for web search, code search, and documentation lookup, enabling agents to access external tools and resources via the MCP standard.
+Defines Model Context Protocol (MCP) server configurations and integrations for the OpenCode plugin. This module provides built-in MCP servers for documentation lookup and code search, enabling agents to access external tools and resources via the MCP standard.
 
 ## Design
 
@@ -16,23 +16,17 @@ The `src/mcp/` directory implements a modular MCP configuration system with the
 
 | Type | Purpose | Example Use Case |
 |------|---------|----------------|
-| RemoteMcpConfig | Connects to hosted MCP servers via URL | Web search, documentation lookup |
+| RemoteMcpConfig | Connects to hosted MCP servers via URL | Documentation lookup, code search |
 | LocalMcpConfig | Spawns local processes as MCP servers | Custom tool integrations |
 
 ### Built-in MCP Servers
 
-1. **websearch** (`websearch.ts`)
-   - Provider: Exa (default) or Tavily
-   - Purpose: Web search and information retrieval
-   - Configuration: Supports API key overrides via environment variables
-   - Flow: Accepts WebsearchConfig → creates RemoteMcpConfig with provider-specific endpoints
-
-2. **context7** (`context7.ts`)
+1. **context7** (`context7.ts`)
    - Purpose: Official documentation lookup for libraries
    - Endpoint: https://mcp.context7.com/mcp
    - Authentication: CONTEXT7_API_KEY environment variable
 
-3. **gh_grep** (`grep-app.ts`)
+2. **gh_grep** (`grep-app.ts`)
    - Purpose: Ultra-fast code search across GitHub repositories
    - Endpoint: https://mcp.grep.app
    - Use case: Finding code examples and patterns in public repositories
@@ -42,14 +36,12 @@ The `src/mcp/` directory implements a modular MCP configuration system with the
 ### Initialization Flow
 
 ```
-Plugin loads → createBuiltinMcps() called with disabledMcps list and optional websearchConfig
+Plugin loads → createBuiltinMcps() called with disabledMcps list
 Filters built-in MCP list to exclude disabled servers
 Creates RemoteMcpConfig instances for each enabled server
-Overrides websearch config if custom websearchConfig provided
-  ↓
 Returns record of McpConfig objects to main plugin
 ```
 
@@ -59,7 +51,6 @@ Returns record of McpConfig objects to main plugin
    - `createBuiltinMcps()` is called from `src/index.ts`
    - User configuration is merged with defaults
    - Disabled MCPs are filtered out
-   - Custom websearch provider is applied if specified
 
 2. **Integration phase** (during agent execution):
    - MCP servers are registered with the MCP runtime
@@ -75,7 +66,7 @@ Returns record of McpConfig objects to main plugin
   - Calls `createBuiltinMcps()` during plugin initialization
   - Receives McpConfig record and registers MCPs with OpenCode
 
-- **Configuration layer**: `src/config/` - Provides McpName type and WebsearchConfig schema
+- **Configuration layer**: `src/config/` - Provides McpName type and MCP configuration schemas
   - Defines MCP names and configuration schemas
   - Validates user-provided MCP configurations
 
@@ -83,8 +74,6 @@ Returns record of McpConfig objects to main plugin
 ### Dependencies
 
 - **Environment variables**:
-  - `EXA_API_KEY` - API key for Exa web search provider
-  - `TAVILY_API_KEY` - API key for Tavily web search provider
   - `CONTEXT7_API_KEY` - API key for Context7 documentation lookup
 
 
@@ -95,7 +84,7 @@ Returns record of McpConfig objects to main plugin
 
 - **Exported types**: `RemoteMcpConfig`, `LocalMcpConfig`, `McpConfig` from `types.ts`
 - **Exported functions**: `createBuiltinMcps()` from `index.ts`
-- **Pre-configured servers**: `websearch`, `context7`, `gh_grep` constants
+- **Pre-configured servers**: `context7`, `gh_grep` constants
 
 ### Configuration Overrides
 
@@ -107,10 +96,7 @@ The system supports runtime configuration overrides:
 // In user configuration (e.g., ~/.config/opencode/oh-my-opencode-slim.json)
 {
   "mcp": {
-    "disabled": ["websearch"],
-    "websearch": {
-      "provider": "tavily"
-    }
+    "disabled": ["gh_grep"]
   }
 }
 ```
@@ -118,14 +104,13 @@ The system supports runtime configuration overrides:
 
 This allows users to:
 - Disable specific MCP servers
-- Switch web search providers (Exa ↔ Tavily)
 - Customize API keys and endpoints
 
 
 ## Error Handling
 
 
-- **Missing API keys**: Throws descriptive errors for required keys (e.g., TAVILY_API_KEY)
+- **Missing API keys**: Throws descriptive errors for required keys (e.g., CONTEXT7_API_KEY)
 - **Invalid configurations**: TypeScript type system prevents invalid configurations at compile time
 - **Disabled servers**: Gracefully filtered from output without errors
 

+ 19 - 21
src/mcp/index.test.ts

@@ -6,7 +6,6 @@ describe('createBuiltinMcps', () => {
     const mcps = createBuiltinMcps();
     const names = Object.keys(mcps);
 
-    expect(names).toContain('websearch');
     expect(names).toContain('context7');
     expect(names).toContain('gh_grep');
   });
@@ -15,33 +14,30 @@ describe('createBuiltinMcps', () => {
     const mcps = createBuiltinMcps([]);
     const names = Object.keys(mcps);
 
-    expect(names.length).toBe(3);
-    expect(names).toContain('websearch');
+    expect(names.length).toBe(2);
     expect(names).toContain('context7');
     expect(names).toContain('gh_grep');
   });
 
   test('excludes single disabled MCP', () => {
-    const mcps = createBuiltinMcps(['websearch']);
+    const mcps = createBuiltinMcps(['gh_grep']);
     const names = Object.keys(mcps);
 
-    expect(names).not.toContain('websearch');
+    expect(names).not.toContain('gh_grep');
     expect(names).toContain('context7');
-    expect(names).toContain('gh_grep');
   });
 
   test('excludes multiple disabled MCPs', () => {
-    const mcps = createBuiltinMcps(['websearch', 'gh_grep']);
+    const mcps = createBuiltinMcps(['gh_grep', 'context7']);
     const names = Object.keys(mcps);
 
-    expect(names).not.toContain('websearch');
     expect(names).not.toContain('gh_grep');
-    expect(names).toContain('context7');
-    expect(names.length).toBe(1);
+    expect(names).not.toContain('context7');
+    expect(names.length).toBe(0);
   });
 
   test('excludes all MCPs when all disabled', () => {
-    const mcps = createBuiltinMcps(['websearch', 'context7', 'gh_grep']);
+    const mcps = createBuiltinMcps(['context7', 'gh_grep']);
     const names = Object.keys(mcps);
 
     expect(names.length).toBe(0);
@@ -52,8 +48,7 @@ describe('createBuiltinMcps', () => {
     const names = Object.keys(mcps);
 
     // All valid MCPs should still be present
-    expect(names.length).toBe(3);
-    expect(names).toContain('websearch');
+    expect(names.length).toBe(2);
     expect(names).toContain('context7');
     expect(names).toContain('gh_grep');
   });
@@ -70,14 +65,6 @@ describe('createBuiltinMcps', () => {
     }
   });
 
-  test('websearch MCP has correct structure', () => {
-    const mcps = createBuiltinMcps();
-    const websearch = mcps.websearch;
-
-    expect(websearch).toBeDefined();
-    expect('url' in websearch).toBe(true);
-  });
-
   test('context7 MCP has correct structure', () => {
     const mcps = createBuiltinMcps();
     const context7 = mcps.context7;
@@ -93,4 +80,15 @@ describe('createBuiltinMcps', () => {
     expect(gh_grep).toBeDefined();
     expect('url' in gh_grep).toBe(true);
   });
+
+  test('never throws when disabledMcps is not an array', () => {
+    // Regression test: a malformed/non-array config.disabled_mcps value
+    // must degrade to "nothing disabled" instead of crashing plugin init.
+    const mcps = createBuiltinMcps('' as any);
+    const names = Object.keys(mcps);
+
+    expect(names.length).toBe(2);
+    expect(names).toContain('context7');
+    expect(names).toContain('gh_grep');
+  });
 });

+ 6 - 14
src/mcp/index.ts

@@ -1,35 +1,27 @@
-import type { McpName, WebsearchConfig } from '../config';
+import type { McpName } from '../config';
 import { context7 } from './context7';
 import { gh_grep } from './grep-app';
 import type { McpConfig } from './types';
-import { createWebsearchConfig, websearch } from './websearch';
 
 export type { LocalMcpConfig, McpConfig, RemoteMcpConfig } from './types';
 
 const allBuiltinMcps: Record<McpName, McpConfig> = {
-  websearch,
   context7,
   gh_grep,
 };
 
 /**
  * Creates MCP configurations, excluding disabled ones.
- * Accepts an optional websearchConfig to override the default Exa provider.
  */
 export function createBuiltinMcps(
   disabledMcps: readonly string[] = [],
-  websearchConfig?: WebsearchConfig,
 ): Record<string, McpConfig> {
-  const mcps = Object.fromEntries(
+  // Never trust the declared type of user-config-derived values at
+  // runtime; fall back to "nothing disabled" instead of throwing.
+  const safeDisabledMcps = Array.isArray(disabledMcps) ? disabledMcps : [];
+  return Object.fromEntries(
     Object.entries(allBuiltinMcps).filter(
-      ([name]) => !disabledMcps.includes(name),
+      ([name]) => !safeDisabledMcps.includes(name),
     ),
   );
-
-  // Override websearch with user-configured provider (default: Exa)
-  if (!disabledMcps.includes('websearch')) {
-    mcps.websearch = createWebsearchConfig(websearchConfig);
-  }
-
-  return mcps;
 }

+ 0 - 47
src/mcp/websearch.ts

@@ -1,47 +0,0 @@
-import type { WebsearchConfig } from '../config';
-import type { RemoteMcpConfig } from './types';
-
-/**
- * Creates a websearch MCP config based on the provided configuration.
- * Supports Exa (default) and Tavily providers.
- * @see https://exa.ai  @see https://tavily.com
- */
-export function createWebsearchConfig(
-  config?: WebsearchConfig,
-): RemoteMcpConfig {
-  const provider = config?.provider || 'exa';
-
-  if (provider === 'tavily') {
-    const tavilyKey = process.env.TAVILY_API_KEY;
-    if (!tavilyKey) {
-      throw new Error(
-        'TAVILY_API_KEY environment variable is required for Tavily provider',
-      );
-    }
-    return {
-      type: 'remote',
-      url: 'https://mcp.tavily.com/mcp/',
-      headers: {
-        Authorization: `Bearer ${tavilyKey}`,
-      },
-      oauth: false,
-    };
-  }
-
-  // Default: Exa provider
-  // Prefer exaApiKey in URL (reliably validated by Exa MCP endpoint)
-  // Fall back to anonymous access when no key is available
-  const exaKey = process.env.EXA_API_KEY;
-  const exaUrl = exaKey
-    ? `https://mcp.exa.ai/mcp?tools=web_search_exa&exaApiKey=${encodeURIComponent(exaKey)}`
-    : 'https://mcp.exa.ai/mcp?tools=web_search_exa';
-
-  return {
-    type: 'remote',
-    url: exaUrl,
-    oauth: false,
-  };
-}
-
-// Backward compatibility: default export using default (Exa) config
-export const websearch: RemoteMcpConfig = createWebsearchConfig();

+ 1 - 1
src/skills/oh-my-opencode-slim/SKILL.md

@@ -131,7 +131,7 @@ Edit the active preset under `presets.<preset>.<agent>`:
         "model": "openai/gpt-5.6-luna",
         "variant": "low",
         "skills": [],
-        "mcps": ["websearch", "context7", "gh_grep"]
+        "mcps": ["context7", "gh_grep"]
       }
     }
   }

+ 2 - 2
src/skills/reflect/SKILL.md

@@ -37,7 +37,7 @@ repeated patterns, friction, and improvement opportunities.
 
 1. **Load recent sessions** - Query the SQLite database directly:
    ```bash
-   bun -e "import Database from 'bun:sqlite'; const db = new Database('/home/mhenke/.local/share/opencode/opencode.db'); console.log(db.query('SELECT id, directory, title, agent, model, time_created, cost, tokens_input, tokens_output FROM session ORDER BY time_created DESC LIMIT 50').all())"
+   bun -e "import Database from 'bun:sqlite'; const db = new Database(process.env.HOME + '/.local/share/opencode/opencode.db'); console.log(db.query('SELECT id, directory, title, agent, model, time_created, cost, tokens_input, tokens_output FROM session ORDER BY time_created DESC LIMIT 50').all())"
    ```
    Adjust `LIMIT 50` to `--last N` if specified.
 
@@ -45,7 +45,7 @@ repeated patterns, friction, and improvement opportunities.
 
 2. **Load session messages** - For each session ID, query the message table:
    ```bash
-   bun -e "import Database from 'bun:sqlite'; const db = new Database('/home/mhenke/.local/share/opencode/opencode.db'); console.log(db.query('SELECT data FROM message WHERE session_id = ?').all('ses_14de9c68effegtZtlATm42wnz7'))"
+   bun -e "import Database from 'bun:sqlite'; const db = new Database(process.env.HOME + '/.local/share/opencode/opencode.db'); console.log(db.query('SELECT data FROM message WHERE session_id = ?').all('<session_id>'))"
    ```
 
    **Message table columns:** `id, session_id, time_created, time_updated, data` (data is JSON with role, agent, model, summary, etc.)

+ 1 - 0
src/tools/smartfetch/codemap.md

@@ -11,6 +11,7 @@
 - **Transport/policy split from rendering:** `network.ts` focuses on URL normalization, redirect allowlists, charset/body decoding, header extraction, and llms.txt probing, while `utils.ts` focuses on turning fetched content into cleaned text/markdown/html plus frontmatter and user-facing messages.
 - **Cache keyed by fetch shape:** `cache.ts` keys fetches by URL plus behavior-affecting options (`extract_main`, `prefer_llms_txt`, `save_binary`), while render format is derived from the cached fetch result so text/markdown/html do not force redundant network requests.
 - **Graceful degradation:** missing/invalid `llms.txt`, blocked redirects, metadata-only binary responses, and secondary-model failures all return a usable result instead of throwing away the fetched content.
+- **Warning-scoped JSDOM construction:** any new JSDOM construction or css-tree trigger point must be wrapped in `withCssTreeWarningsSuppressed` (see `utils.ts`) so css-tree lexer warnings never leak into the host process stderr.
 
 ## Data & Control Flow
 

+ 53 - 1
src/tools/smartfetch/secondary-model.test.ts

@@ -1,5 +1,12 @@
 import { afterEach, describe, expect, mock, test } from 'bun:test';
-import { _testConfig, runSecondaryModelWithFallback } from './secondary-model';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  _testConfig,
+  readSecondaryModelFromConfig,
+  runSecondaryModelWithFallback,
+} from './secondary-model';
 import type { SecondaryModel } from './types';
 
 type PromptStep = {
@@ -56,6 +63,51 @@ describe('smartfetch/secondary-model', () => {
     mock.restore();
   });
 
+  test('gives dedicated webfetch models precedence over fallback sources', async () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartfetch-test-'));
+    const projectConfigDir = path.join(tempDir, '.opencode');
+    const userConfigDir = path.join(tempDir, 'user-config');
+    const originalEnv = { ...process.env };
+
+    try {
+      fs.mkdirSync(projectConfigDir, { recursive: true });
+      fs.mkdirSync(path.join(userConfigDir, 'opencode'), { recursive: true });
+      fs.writeFileSync(
+        path.join(projectConfigDir, 'opencode.json'),
+        JSON.stringify({ small_model: 'small/provider-model' }),
+      );
+      fs.writeFileSync(
+        path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          agents: {
+            explorer: { model: 'explorer/provider-model' },
+            librarian: { model: 'librarian/provider-model' },
+          },
+        }),
+      );
+      delete process.env.OPENCODE_CONFIG_DIR;
+      process.env.XDG_CONFIG_HOME = userConfigDir;
+
+      await expect(
+        readSecondaryModelFromConfig(tempDir, [
+          { id: 'dedicated/provider-model', variant: 'fast' },
+        ]),
+      ).resolves.toEqual([
+        {
+          providerID: 'dedicated',
+          modelID: 'provider-model',
+          variant: 'fast',
+        },
+        { providerID: 'small', modelID: 'provider-model' },
+        { providerID: 'explorer', modelID: 'provider-model' },
+        { providerID: 'librarian', modelID: 'provider-model' },
+      ]);
+    } finally {
+      process.env = originalEnv;
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
   test('falls back when the first model returns empty text', async () => {
     const client = createMockClient([
       { text: '   ' },

+ 29 - 11
src/tools/smartfetch/secondary-model.ts

@@ -72,26 +72,36 @@ async function readEffectiveOpenCodeConfig(directory: string) {
   };
 }
 
-export async function readSecondaryModelFromConfig(directory: string) {
+export async function readSecondaryModelFromConfig(
+  directory: string,
+  webfetchModels?: Array<{ id: string; variant?: string }>,
+) {
   try {
     const models: SecondaryModel[] = [];
     const seen = new Set<string>();
-    const pushModel = (value: unknown) => {
-      if (typeof value !== 'string') return;
-      const parsedModel = parseModelRef(value);
-      if (!parsedModel) return;
-      const key = `${parsedModel.providerID}/${parsedModel.modelID}`;
+    const addModel = (model: SecondaryModel) => {
+      const key = `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ''}`;
       if (seen.has(key)) return;
       seen.add(key);
-      models.push(parsedModel);
+      models.push(model);
     };
 
+    // Dedicated webfetch model(s) take highest priority, in order
+    if (webfetchModels) {
+      for (const ref of webfetchModels) {
+        const parsedModel = parseModelRef(ref.id);
+        if (!parsedModel) continue;
+        addModel({ ...parsedModel, variant: ref.variant });
+      }
+    }
+
     const opencodeConfig = await readEffectiveOpenCodeConfig(directory);
-    pushModel(
+    const parsedSmall = parseModelRef(
       typeof opencodeConfig.small_model === 'string'
         ? opencodeConfig.small_model
         : undefined,
     );
+    if (parsedSmall) addModel(parsedSmall);
 
     const pluginConfig = loadPluginConfig(directory);
     const explorerModel = pickAgentModelRef(
@@ -101,8 +111,15 @@ export async function readSecondaryModelFromConfig(directory: string) {
       pluginConfig.agents?.librarian?.model,
     );
 
-    pushModel(explorerModel);
-    pushModel(librarianModel);
+    const parsedExplorer = explorerModel
+      ? parseModelRef(explorerModel)
+      : undefined;
+    if (parsedExplorer) addModel(parsedExplorer);
+
+    const parsedLibrarian = librarianModel
+      ? parseModelRef(librarianModel)
+      : undefined;
+    if (parsedLibrarian) addModel(parsedLibrarian);
 
     return models;
   } catch {
@@ -255,7 +272,8 @@ async function runSecondaryModel(
         path: { id: sessionId },
         query: { directory },
         body: {
-          model,
+          model: { providerID: model.providerID, modelID: model.modelID },
+          ...(model.variant ? { variant: model.variant } : {}),
           system:
             'Answer only from the supplied content. Do not use tools or outside knowledge.',
           tools: disabledTools,

+ 2 - 1
src/tools/smartfetch/tool.ts

@@ -102,6 +102,7 @@ export function createWebfetchTool(
     async execute(args, ctx) {
       const secondaryModels = await readSecondaryModelFromConfig(
         ctx.directory || pluginCtx.directory,
+        options.webfetchModels,
       );
       const normalized = normalizeUrl(args.url);
       const url = new URL(normalized.url);
@@ -805,7 +806,7 @@ export function createWebfetchTool(
               secondary_model_input_truncated: secondaryRun.inputTruncated,
               secondary_model_input_chars: secondaryRun.inputChars,
               secondary_model_source_chars: secondaryRun.sourceChars,
-              secondary_model: `${secondaryRun.model.providerID}/${secondaryRun.model.modelID}`,
+              secondary_model: `${secondaryRun.model.providerID}/${secondaryRun.model.modelID}${secondaryRun.model.variant ? `#${secondaryRun.model.variant}` : ''}`,
             })
           : '';
         const secondaryRaw =

+ 14 - 0
src/tools/smartfetch/types.ts

@@ -1,10 +1,24 @@
+export type ModelRef = {
+  /** Provider/model string (e.g. "openai/gpt-4o-mini"). */
+  id: string;
+  /** Optional model variant annotation. */
+  variant?: string;
+};
+
 export type SmartfetchOptions = {
   binaryDir?: string;
+  /**
+   * Dedicated model(s) for secondary-model summarization.
+   * Each entry is tried in order; the first to return usable text is used.
+   */
+  webfetchModels?: ModelRef[];
 };
 
 export type SecondaryModel = {
   providerID: string;
   modelID: string;
+  /** Optional model variant passed at the body level. */
+  variant?: string;
 };
 
 export type RedirectStep = {

+ 72 - 1
src/tools/smartfetch/utils.test.ts

@@ -1,5 +1,23 @@
 import { describe, expect, test } from 'bun:test';
-import { extractHeadingsFromMarkdown, joinRenderedContent } from './utils';
+import {
+  extractFromHtml,
+  extractHeadingsFromMarkdown,
+  joinRenderedContent,
+  withCssTreeWarningsSuppressed,
+} from './utils';
+
+// 200 段逗号分隔的 box-shadow 链 —— csstree/csstree#294 的复现案例,
+// 稳定触发 css-tree lexer 15000 迭代上限警告(jsdom 29 + css-tree 3.2.1 已验证)。
+// 若上游修复后不再触发,本测试退化为弱断言(无泄漏仍成立),可移除 helper。
+const CSS_TREE_WARNING_HTML = (() => {
+  const shadows: string[] = [];
+  for (let i = 1; i <= 200; i++) {
+    shadows.push(`${i}px 0 0 -${Math.min(i + 3, 200)}px #cfcfcf`);
+  }
+  return `<!DOCTYPE html><html><head><style>
+.range-block__range::-webkit-slider-thumb { box-shadow: ${shadows.join(', ')}; }
+</style></head><body><article><h1>Hello</h1><p>World</p></article></body></html>`;
+})();
 
 describe('smartfetch/utils', () => {
   test('extracts cleaned headings from markdown', () => {
@@ -21,4 +39,57 @@ describe('smartfetch/utils', () => {
     expect(result).toContain('<!--\n---\nsource: "smartfetch"\n---\n-->');
     expect(result).toContain('<root>ok</root>');
   });
+
+  test('suppresses css-tree warnings during html extraction', async () => {
+    const originalWarn = console.warn;
+    const warnCalls: unknown[][] = [];
+    console.warn = (...args: unknown[]) => warnCalls.push(args);
+    try {
+      const result = await extractFromHtml(
+        CSS_TREE_WARNING_HTML,
+        'https://example.com/',
+        false,
+      );
+
+      const cssTreeWarnings = warnCalls.filter((args) =>
+        String(args[0]).startsWith('[csstree-match]'),
+      );
+      expect(cssTreeWarnings).toEqual([]);
+      expect(result.text).toContain('Hello');
+      expect(result.text).toContain('World');
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
+
+  test('filters only css-tree warnings inside the guard', () => {
+    const originalWarn = console.warn;
+    const warnCalls: unknown[][] = [];
+    console.warn = (...args: unknown[]) => warnCalls.push(args);
+    try {
+      withCssTreeWarningsSuppressed(() => {
+        console.warn('[csstree-match] BREAK after 15000 iterations');
+        console.warn('[smartfetch] unrelated warning');
+      });
+
+      expect(warnCalls).toEqual([['[smartfetch] unrelated warning']]);
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
+
+  test('restores the original console.warn after extraction', async () => {
+    const originalWarn = console.warn;
+    try {
+      await extractFromHtml(
+        CSS_TREE_WARNING_HTML,
+        'https://example.com/',
+        true,
+      );
+
+      expect(console.warn).toBe(originalWarn);
+    } finally {
+      console.warn = originalWarn;
+    }
+  });
 });

+ 27 - 2
src/tools/smartfetch/utils.ts

@@ -6,6 +6,27 @@ import type { CachedFetch, ExtractedContent } from './types';
 
 export { escapeHtml, parseFrontmatter };
 
+const CSS_TREE_WARN_PREFIX = '[csstree-match]';
+
+/**
+ * Suppresses css-tree lexer warnings ([csstree-match] prefix) emitted
+ * synchronously during JSDOM construction (jsdom uses css-tree to parse
+ * stylesheets; css-tree calls the global console.warn directly, bypassing
+ * jsdom's virtualConsole). Other warnings pass through untouched.
+ */
+export function withCssTreeWarningsSuppressed<T>(fn: () => T): T {
+  const originalWarn = console.warn;
+  console.warn = ((...args: unknown[]) => {
+    const first = typeof args[0] === 'string' ? args[0] : '';
+    if (!first.startsWith(CSS_TREE_WARN_PREFIX)) originalWarn(...args);
+  }) as typeof console.warn;
+  try {
+    return fn();
+  } finally {
+    console.warn = originalWarn;
+  }
+}
+
 let jsdomPromise: Promise<typeof import('jsdom')> | undefined;
 
 async function getJSDOM() {
@@ -284,7 +305,9 @@ export async function extractFromHtml(
   extractMain: boolean,
 ): Promise<ExtractedContent> {
   const JSDOM = await getJSDOM();
-  const dom = new JSDOM(html, { url: finalUrl });
+  const dom = withCssTreeWarningsSuppressed(
+    () => new JSDOM(html, { url: finalUrl }),
+  );
   const document = dom.window.document;
   const title = document.title || undefined;
   const canonical =
@@ -306,7 +329,9 @@ export async function extractFromHtml(
     .slice(0, 12);
 
   if (extractMain) {
-    const readerDom = new JSDOM(html, { url: finalUrl });
+    const readerDom = withCssTreeWarningsSuppressed(
+      () => new JSDOM(html, { url: finalUrl }),
+    );
     const article = new Readability(readerDom.window.document).parse();
     if (article?.content?.trim()) {
       const articleContainer = readerDom.window.document.createElement('div');

+ 330 - 0
src/utils/background-job-board.test.ts

@@ -139,6 +139,44 @@ describe('BackgroundJobBoard', () => {
     expect(prompt).toEndWith('</system-reminder>');
   });
 
+  test('formats prompt metadata with only the terminal jobs in the payload', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'first result',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+    board.registerLaunch({
+      taskID: 'ses_2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'second result',
+    });
+    board.updateStatus({ taskID: 'ses_2', state: 'completed' });
+    board.registerLaunch({
+      taskID: 'ses_other',
+      parentSessionID: 'parent-2',
+      agent: 'oracle',
+      description: 'other parent result',
+    });
+    board.updateStatus({ taskID: 'ses_other', state: 'completed' });
+
+    const metadata = board.formatForPromptWithMetadata('parent-1');
+
+    expect(metadata?.text).toBe(board.formatForPrompt('parent-1'));
+    expect(metadata?.terminalUnreconciledTaskIDs).toEqual([
+      { taskID: 'ses_1', generation: 1 },
+      { taskID: 'ses_2', generation: 2 },
+    ]);
+    expect(
+      metadata?.terminalUnreconciledTaskIDs.some(
+        (execution) => execution.taskID === 'ses_other',
+      ),
+    ).toBe(false);
+  });
+
   test('escapes dynamic job content inside system reminders', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -532,6 +570,26 @@ describe('BackgroundJobBoard', () => {
     expect(listener).not.toHaveBeenCalled();
   });
 
+  test('throws in one listener does not prevent subsequent listeners from receiving notification', () => {
+    const board = new BackgroundJobBoard();
+    const order: string[] = [];
+    board.addTerminalStateListener(() => {
+      throw new Error('first listener failed');
+    });
+    board.addTerminalStateListener(() => {
+      order.push('second');
+    });
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+    });
+
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+
+    expect(order).toEqual(['second']);
+  });
+
   test('cancelled jobs ignore late non-cancelled terminal statuses', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -974,4 +1032,276 @@ describe('BackgroundJobBoard', () => {
       expect(board.field('unknown-1', 'alias')).toBeUndefined();
     });
   });
+
+  describe('context budget gate', () => {
+    test('session under context threshold is reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 100, lastReadAt: 100 },
+        { path: '/src/file2.ts', lineCount: 200, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('session over context threshold is not reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'bloated session',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 30_000, lastReadAt: 100 },
+        { path: '/src/file2.ts', lineCount: 25_000, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('session at 50001 lines is not reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'just over threshold',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50_001, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('trimReusable evicts bloated sessions before count cap', () => {
+      const board = new BackgroundJobBoard({
+        maxReusablePerAgent: 2,
+      });
+
+      // Small session 1
+      board.registerLaunch({
+        taskID: 'ses_small_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session 1',
+        now: 100,
+      });
+      board.addContext('ses_small_1', [
+        { path: '/src/a.ts', lineCount: 100, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_small_1', state: 'completed' });
+      board.markReconciled('ses_small_1', 200);
+
+      // Small session 2
+      board.registerLaunch({
+        taskID: 'ses_small_2',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'small session 2',
+        now: 300,
+      });
+      board.addContext('ses_small_2', [
+        { path: '/src/b.ts', lineCount: 200, lastReadAt: 300 },
+      ]);
+      board.updateStatus({ taskID: 'ses_small_2', state: 'completed' });
+      board.markReconciled('ses_small_2', 400);
+
+      // Bloated session
+      board.registerLaunch({
+        taskID: 'ses_bloated',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'bloated session',
+        now: 500,
+      });
+      board.addContext('ses_bloated', [
+        { path: '/src/huge.ts', lineCount: 60_000, lastReadAt: 500 },
+      ]);
+      board.updateStatus({ taskID: 'ses_bloated', state: 'completed' });
+      // updateStatus({state:'completed'}) triggers trimReusable; the bloated
+      // session exceeds the context budget and is evicted there.
+      // markReconciled is a no-op because the record was already deleted.
+      board.markReconciled('ses_bloated', 600);
+
+      // Bloated session should be gone; two small sessions survive
+      expect(board.get('ses_bloated')).toBeUndefined();
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+      expect(
+        board.resolveReusable('parent-1', 'exp-2', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('session at exactly 50000 lines is reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'exactly at threshold',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50_000, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('custom maxContextLines override works', () => {
+      const board = new BackgroundJobBoard({
+        maxContextLines: 100,
+      });
+
+      // 50 lines — under custom threshold
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'under custom limit',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/file1.ts', lineCount: 50, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+
+      // 101 lines — over custom threshold
+      board.registerLaunch({
+        taskID: 'ses_2',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'over custom limit',
+      });
+      board.addContext('ses_2', [
+        { path: '/src/file2.ts', lineCount: 101, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_2', state: 'completed' });
+      board.markReconciled('ses_2');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-2', 'explorer'),
+      ).toBeUndefined();
+    });
+
+    test('running job with bloated context survives trimReusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'running',
+        parentSessionID: 'p',
+        agent: 'explorer',
+      });
+      board.addContext('running', [
+        { path: '/big.ts', lineCount: 200, lastReadAt: 1 },
+      ]);
+      board.registerLaunch({
+        taskID: 'completed',
+        parentSessionID: 'p',
+        agent: 'explorer',
+      });
+      board.updateStatus({ taskID: 'completed', state: 'completed' });
+      expect(board.get('running')).toBeDefined();
+    });
+
+    test('multi-agent isolation: bloated explorer does not evict fixer', () => {
+      const board = new BackgroundJobBoard();
+
+      // Bloated explorer session
+      board.registerLaunch({
+        taskID: 'ses_exp_bloated',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'bloated explorer',
+      });
+      board.addContext('ses_exp_bloated', [
+        { path: '/big.ts', lineCount: 60_000, lastReadAt: 100 },
+      ]);
+      board.updateStatus({ taskID: 'ses_exp_bloated', state: 'completed' });
+      board.markReconciled('ses_exp_bloated');
+
+      // Small fixer session
+      board.registerLaunch({
+        taskID: 'ses_fix_small',
+        parentSessionID: 'parent-1',
+        agent: 'fixer',
+        description: 'small fixer',
+      });
+      board.addContext('ses_fix_small', [
+        { path: '/small.ts', lineCount: 50, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_fix_small', state: 'completed' });
+      board.markReconciled('ses_fix_small');
+
+      // Explorer bloated session is evicted
+      expect(board.get('ses_exp_bloated')).toBeUndefined();
+      // Fixer session is unaffected (different agent)
+      expect(board.resolveReusable('parent-1', 'fix-1', 'fixer')).toBeDefined();
+    });
+
+    test('session with empty context files is reusable', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_empty',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'no files read',
+      });
+      // No addContext call — contextFiles stays empty
+      board.updateStatus({ taskID: 'ses_empty', state: 'completed' });
+      board.markReconciled('ses_empty');
+
+      expect(
+        board.resolveReusable('parent-1', 'exp-1', 'explorer'),
+      ).toBeDefined();
+    });
+
+    test('formatForPrompt includes context file line counts', () => {
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'ses_1',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: 'test session',
+      });
+      board.addContext('ses_1', [
+        { path: '/src/main.ts', lineCount: 500, lastReadAt: 100 },
+        { path: '/src/util.ts', lineCount: 200, lastReadAt: 200 },
+      ]);
+      board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+      board.markReconciled('ses_1');
+
+      const prompt = board.formatForPrompt('parent-1');
+      expect(prompt).toContain('500 lines');
+      expect(prompt).toContain('200 lines');
+      expect(prompt).toContain('Context read by');
+    });
+  });
 });

+ 79 - 12
src/utils/background-job-board.ts

@@ -1,10 +1,12 @@
 import {
+  DEFAULT_MAX_CONTEXT_LINES,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
   formatSystemReminder,
 } from '../config/constants';
 import type { BackgroundJobStore } from './background-job-store';
+import { log } from './logger';
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
 export interface ContextFile {
@@ -14,6 +16,16 @@ export interface ContextFile {
   lastReadAt: number;
 }
 
+export interface BackgroundJobExecution {
+  taskID: string;
+  generation: number;
+}
+
+export interface BackgroundJobPromptMetadata {
+  text: string | undefined;
+  terminalUnreconciledTaskIDs: BackgroundJobExecution[];
+}
+
 export type BackgroundJobState = TaskOutputState | 'reconciled';
 
 export interface BackgroundJobRecord {
@@ -54,6 +66,7 @@ export interface BackgroundJobRecord {
 
 export interface BackgroundJobBoardOptions {
   maxReusablePerAgent?: number;
+  maxContextLines?: number;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
 }
@@ -116,15 +129,18 @@ const AGENT_PREFIX: Record<string, string> = {
 export class BackgroundJobBoard implements BackgroundJobStore {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
+  private executionSequence = 0;
   private terminalStateListeners: TerminalStateListener[] = [];
 
   private readonly maxReusablePerAgent: number;
+  private readonly maxContextLines: number;
   private readonly readContextMinLines: number;
   private readonly readContextMaxFiles: number;
 
   constructor(options: BackgroundJobBoardOptions = {}) {
     this.maxReusablePerAgent =
       options.maxReusablePerAgent ?? DEFAULT_MAX_SESSIONS_PER_AGENT;
+    this.maxContextLines = options.maxContextLines ?? DEFAULT_MAX_CONTEXT_LINES;
     this.readContextMinLines =
       options.readContextMinLines ?? DEFAULT_READ_CONTEXT_MIN_LINES;
     this.readContextMaxFiles =
@@ -147,12 +163,20 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
   private notifyTerminalStateListeners(taskID: string): void {
     for (const listener of this.terminalStateListeners) {
-      listener(taskID);
+      try {
+        listener(taskID);
+      } catch (error) {
+        log('Board terminal state listener threw', {
+          taskID,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
     }
   }
 
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
     const now = input.now ?? Date.now();
+    const generation = ++this.executionSequence;
     const existing = this.jobs.get(input.taskID);
 
     if (existing) {
@@ -171,6 +195,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
       const updated = {
         ...existing,
+        generation,
         agent: input.agent || existing.agent,
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
@@ -186,7 +211,6 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         lastStatusError: undefined,
         terminalState: undefined,
         lastLaunchedAt: now,
-        generation: existing.generation + 1,
         runStartedAt: now,
         deadlineExceededAt: undefined,
         lastLiveBusyAt: now,
@@ -201,6 +225,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
     const record: BackgroundJobRecord = {
       taskID: input.taskID,
+      generation,
       parentSessionID: input.parentSessionID,
       agent: input.agent,
       description: input.description || `background ${input.agent} task`,
@@ -214,7 +239,6 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalUnreconciled: false,
       launchedAt: now,
       lastLaunchedAt: now,
-      generation: 1,
       runStartedAt: now,
       lastLiveBusyAt: now,
       lastUsedAt: now,
@@ -543,7 +567,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     agent?: string,
   ): BackgroundJobRecord | undefined {
     const job = this.resolve(parentSessionID, taskIDOrAlias);
-    if (!job || !isReusable(job)) return undefined;
+    if (!job || !isReusable(job, this.maxContextLines)) return undefined;
     if (agent && job.agent !== agent) return undefined;
     return job;
   }
@@ -630,15 +654,19 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return errors >= threshold || timeouts >= threshold;
   }
 
-  formatForPrompt(parentSessionID: string, _now?: number): string | undefined {
-    const active = this.list(parentSessionID).filter(
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    _now?: number,
+  ): BackgroundJobPromptMetadata | undefined {
+    const jobs = this.list(parentSessionID);
+    const active = jobs.filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
-    const reusable = this.list(parentSessionID).filter(isReusable);
+    const reusable = jobs.filter((j) => isReusable(j, this.maxContextLines));
 
     if (active.length === 0 && reusable.length === 0) return undefined;
 
-    return formatSystemReminder(
+    const text = formatSystemReminder(
       [
         '### Background Job Board',
         'SENTINEL: background-job-board-v2',
@@ -655,6 +683,16 @@ export class BackgroundJobBoard implements BackgroundJobStore {
           : ['- none']),
       ].join('\n'),
     );
+
+    const terminalUnreconciledTaskIDs = active
+      .filter((job) => job.terminalUnreconciled)
+      .map(({ taskID, generation }) => ({ taskID, generation }));
+
+    return { text, terminalUnreconciledTaskIDs };
+  }
+
+  formatForPrompt(parentSessionID: string, now?: number): string | undefined {
+    return this.formatForPromptWithMetadata(parentSessionID, now)?.text;
   }
 
   clearParent(parentSessionID: string): void {
@@ -683,10 +721,30 @@ export class BackgroundJobBoard implements BackgroundJobStore {
 
   private trimReusable(taskID: string): void {
     const job = this.jobs.get(taskID);
-    if (!job || !isReusable(job)) return;
+    if (!job) return;
+
+    // Evict sessions exceeding context budget before count cap.
+    // Runs regardless of the triggering job's reusability so that a
+    // bloated session cleans up after itself (and its peers) on
+    // completion.
+    for (const entry of this.list(job.parentSessionID)) {
+      if (
+        entry.agent === job.agent &&
+        TERMINAL_STATES.has(entry.state) &&
+        sumContextLines(entry) > this.maxContextLines
+      ) {
+        this.jobs.delete(entry.taskID);
+      }
+    }
+
+    // Only apply the count cap when the triggering job is reusable
+    if (!isReusable(job, this.maxContextLines)) return;
+
     const reusable = this.list(job.parentSessionID)
       .filter(
-        (candidate) => candidate.agent === job.agent && isReusable(candidate),
+        (candidate) =>
+          candidate.agent === job.agent &&
+          isReusable(candidate, this.maxContextLines),
       )
       .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
     for (const stale of reusable.slice(this.maxReusablePerAgent)) {
@@ -737,9 +795,18 @@ export function deriveTaskSessionLabel(input: {
     : `recent ${input.agentType} task`;
 }
 
-function isReusable(job: BackgroundJobRecord): boolean {
+function sumContextLines(record: BackgroundJobRecord): number {
+  return record.contextFiles.reduce((sum, f) => sum + (f.lineCount ?? 0), 0);
+}
+
+function isReusable(
+  job: BackgroundJobRecord,
+  maxContextLines: number,
+): boolean {
   const terminal = job.terminalState ?? terminalStateOf(job.state);
-  return terminal === 'completed' && !job.terminalUnreconciled;
+  if (terminal !== 'completed' || job.terminalUnreconciled) return false;
+
+  return sumContextLines(job) <= maxContextLines;
 }
 
 function terminalStateOf(

+ 26 - 0
src/utils/background-job-coordinator.test.ts

@@ -90,6 +90,32 @@ describe('BackgroundJobCoordinator', () => {
     expect(listener).not.toHaveBeenCalled();
   });
 
+  test('throws in one coordinator listener does not prevent subsequent listeners from receiving notification', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+    const order: string[] = [];
+
+    coordinator.addTerminalStateListener(() => {
+      throw new Error('first listener failed');
+    });
+    coordinator.addTerminalStateListener(() => {
+      order.push('second');
+    });
+
+    // Defer the session
+    coordinator.deferIfRunning('ses_123');
+
+    // Simulate terminal state notification from board
+    board.getState.mockReturnValue('completed');
+    board.isRunning.mockReturnValue(false);
+
+    // Trigger handleTerminalState via board's listener callback
+    const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
+    boardListener?.('ses_123');
+
+    expect(order).toEqual(['second']);
+  });
+
   test('full chain: board terminal → coordinator → listener for deferred job', () => {
     const board = new BackgroundJobBoard();
     const coordinator = new BackgroundJobCoordinator(board);

+ 17 - 1
src/utils/background-job-coordinator.ts

@@ -1,6 +1,7 @@
 import type {
   BackgroundJobBoard,
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
@@ -8,6 +9,7 @@ import type {
   WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { BackgroundJobStore } from './background-job-store';
+import { log } from './logger';
 import type { TaskOutputState } from './task';
 
 type TerminalStateListener = (taskID: string) => void;
@@ -62,7 +64,14 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     if (this.retryDeferredClose(taskID)) {
       // Notify listeners that session should close
       for (const listener of this.terminalStateListeners) {
-        listener(taskID);
+        try {
+          listener(taskID);
+        } catch (error) {
+          log('Coordinator terminal state listener threw', {
+            taskID,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
       }
     }
 
@@ -262,6 +271,13 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.formatForPrompt(parentSessionID, now);
   }
 
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    now = Date.now(),
+  ): BackgroundJobPromptMetadata | undefined {
+    return this.board.formatForPromptWithMetadata(parentSessionID, now);
+  }
+
   clearParent(parentSessionID: string): void {
     this.board.clearParent(parentSessionID);
   }

+ 5 - 0
src/utils/background-job-store.ts

@@ -1,5 +1,6 @@
 import type {
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
@@ -75,6 +76,10 @@ export interface BackgroundJobStore {
   hasTerminalUnreconciled(parentSessionID: string): boolean;
   hasConvergenceSignals(taskID: string, threshold?: number): boolean;
   formatForPrompt(parentSessionID: string, now?: number): string | undefined;
+  formatForPromptWithMetadata(
+    parentSessionID: string,
+    now?: number,
+  ): BackgroundJobPromptMetadata | undefined;
 
   // ── Lifecycle policy ─────────────────────────────────────────────
   /** Evaluate close policy. Returns true if session should close now.

+ 1 - 1
src/utils/codemap.md

@@ -51,7 +51,7 @@ Centralized utilities and shared abstractions used across the oh-my-opencode-sli
 1. Plugin initializes logger with session ID via initLogger(sessionId)
 2. Logs are appended to `~/.local/share/opencode/log/oh-my-opencode-slim.<sessionId>.log`
 3. Old logs (>7 days) are automatically cleaned up on initialization
-4. Log writes are queued to avoid blocking, with errors silently ignored
+4. Log writes are queued to avoid blocking. File logging falls back to stderr after initialization failure or a write failure in the active generation; stale queued writes cannot replace a newer sink
 
 ### Session Operations
 1. Council dispatch uses `promptWithTimeout()` to send prompts with configurable timeout

+ 11 - 6
src/utils/env.test.ts

@@ -10,12 +10,17 @@ describe('isTruthyEnvValue', () => {
     expect(isTruthyEnvValue(value)).toBe(true);
   });
 
-  test.each([undefined, '', '0', 'false', 'no', 'off', 'anything'])(
-    '%p is not truthy',
-    (value) => {
-      expect(isTruthyEnvValue(value)).toBe(false);
-    },
-  );
+  test.each([
+    undefined,
+    '',
+    '0',
+    'false',
+    'no',
+    'off',
+    'anything',
+  ])('%p is not truthy', (value) => {
+    expect(isTruthyEnvValue(value)).toBe(false);
+  });
 });
 
 describe('isPluginDisabledByEnv', () => {

+ 175 - 1
src/utils/logger.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
@@ -30,6 +30,180 @@ describe('logger', () => {
     expect(fs.readdirSync(tmpDir).length).toBe(0);
   });
 
+  test('falls back to stderr when logger initialization cannot create directory', async () => {
+    const blockedLogDir = path.join(tmpDir, 'not-a-directory');
+    fs.writeFileSync(blockedLogDir, 'not a directory');
+    process.env.OPENCODE_LOG_DIR = blockedLogDir;
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+
+    try {
+      initLogger('session1');
+      log('fallback message');
+      await flushLoggerForTesting();
+
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('falling back to stderr'),
+      );
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('fallback message'),
+      );
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
+  test('falls back to stderr when the log file path is a directory', async () => {
+    const logDir = path.join(tmpDir, 'log-dir');
+    const logFilePath = path.join(logDir, 'oh-my-opencode-slim.session1.log');
+    fs.mkdirSync(logFilePath, { recursive: true });
+    process.env.OPENCODE_LOG_DIR = logDir;
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+
+    try {
+      expect(() => initLogger('session1')).not.toThrow();
+      log('open failure fallback message');
+      await flushLoggerForTesting();
+
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('falling back to stderr'),
+      );
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('open failure fallback message'),
+      );
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
+  test('falls back to stderr when appending a log entry fails', async () => {
+    const logDir = path.join(tmpDir, 'log-dir');
+    process.env.OPENCODE_LOG_DIR = logDir;
+    initLogger('session1');
+    fs.rmSync(logDir, { recursive: true, force: true });
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+
+    try {
+      log('failed file write');
+      await flushLoggerForTesting();
+      log('subsequent fallback message');
+      await flushLoggerForTesting();
+
+      const fallbackWarnings = errorSpy.mock.calls.filter(
+        ([message]) =>
+          typeof message === 'string' &&
+          message.includes('falling back to stderr'),
+      );
+
+      expect(fallbackWarnings).toHaveLength(1);
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('failed file write'),
+      );
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('subsequent fallback message'),
+      );
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
+  test('warns once when multiple queued writes fail', async () => {
+    const logDir = path.join(tmpDir, 'log-dir');
+    process.env.OPENCODE_LOG_DIR = logDir;
+    initLogger('session1');
+    fs.rmSync(logDir, { recursive: true, force: true });
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+
+    try {
+      log('first queued failure');
+      log('second queued failure');
+      log('third queued failure');
+      await flushLoggerForTesting();
+
+      const fallbackWarnings = errorSpy.mock.calls.filter(
+        ([message]) =>
+          typeof message === 'string' &&
+          message.includes('falling back to stderr'),
+      );
+      expect(fallbackWarnings).toHaveLength(1);
+
+      for (const message of [
+        'first queued failure',
+        'second queued failure',
+        'third queued failure',
+      ]) {
+        expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message));
+      }
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
+  test('does not let a stale failed write replace a newer file sink', async () => {
+    const oldLogDir = fs.mkdtempSync(path.join(tmpDir, 'old-log-'));
+    const newLogDir = fs.mkdtempSync(path.join(tmpDir, 'new-log-'));
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+
+    try {
+      process.env.OPENCODE_LOG_DIR = oldLogDir;
+      initLogger('old');
+      log('stale message');
+      fs.rmSync(oldLogDir, { recursive: true, force: true });
+
+      process.env.OPENCODE_LOG_DIR = newLogDir;
+      initLogger('new');
+      log('new message');
+      await flushLoggerForTesting();
+
+      log('after stale failure');
+      await flushLoggerForTesting();
+
+      const newLogFile = path.join(newLogDir, 'oh-my-opencode-slim.new.log');
+      const content = fs.readFileSync(newLogFile, 'utf-8');
+      expect(content).toContain('new message');
+      expect(content).toContain('after stale failure');
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('stale message'),
+      );
+
+      const fallbackWarnings = errorSpy.mock.calls.filter(
+        ([message]) =>
+          typeof message === 'string' &&
+          message.includes('falling back to stderr'),
+      );
+      expect(fallbackWarnings).toHaveLength(0);
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
+  test('keeps logging best-effort when stderr fallback throws', async () => {
+    const logDir = path.join(tmpDir, 'log-dir');
+    process.env.OPENCODE_LOG_DIR = logDir;
+    initLogger('session1');
+    fs.rmSync(logDir, { recursive: true, force: true });
+    const errorSpy = spyOn(console, 'error').mockImplementation(() => {
+      throw new Error('stderr unavailable');
+    });
+
+    try {
+      expect(() => log('first failed write')).not.toThrow();
+      await flushLoggerForTesting();
+
+      expect(() => log('second failed write')).not.toThrow();
+      await flushLoggerForTesting();
+
+      errorSpy.mockImplementation(() => {});
+      log('after stderr failure');
+      await flushLoggerForTesting();
+
+      expect(errorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('after stderr failure'),
+      );
+    } finally {
+      errorSpy.mockRestore();
+    }
+  });
+
   test('initLogger creates per-session log file', () => {
     initLogger('20260416T143052');
     log('test message');

+ 82 - 17
src/utils/logger.ts

@@ -7,7 +7,16 @@ const LOG_PREFIX = 'oh-my-opencode-slim.';
 const LOG_SUFFIX = '.log';
 const RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
 
-let logFile: string | null = null;
+type LogSink =
+  | { kind: 'uninitialized' }
+  | { kind: 'file'; filePath: string }
+  | { kind: 'stderr' };
+
+const FALLBACK_WARNING =
+  '[oh-my-opencode-slim] file logging unavailable, falling back to stderr';
+
+let loggerGeneration = 0;
+let currentSink: LogSink = { kind: 'uninitialized' };
 let writeChain: Promise<void> = Promise.resolve();
 
 function getLogDir(): string {
@@ -60,25 +69,56 @@ function cleanupOldLogs(logDir: string): void {
   }
 }
 
-export function initLogger(sessionId: string): void {
-  const dir = getLogDir();
+function safeStderr(message: string): void {
   try {
-    fs.mkdirSync(dir, { recursive: true });
+    console.error(message);
   } catch {
-    // Directory creation failed - logging will silently fail
+    // Logging must remain best-effort.
   }
-  logFile = path.join(dir, `${LOG_PREFIX}${sessionId}${LOG_SUFFIX}`);
+}
+
+function enterStderrFallback(expectedGeneration: number): void {
+  if (expectedGeneration !== loggerGeneration) return;
+  if (currentSink.kind === 'stderr') return;
+
+  currentSink = { kind: 'stderr' };
+  safeStderr(FALLBACK_WARNING);
+}
+
+function handleAppendFailure(failedGeneration: number, logEntry: string): void {
+  enterStderrFallback(failedGeneration);
+  safeStderr(logEntry.trimEnd());
+}
+
+export function initLogger(sessionId: string): void {
+  const attemptGeneration = ++loggerGeneration;
+
   try {
-    fs.closeSync(fs.openSync(logFile, 'a'));
+    const dir = getLogDir();
+    fs.mkdirSync(dir, { recursive: true });
+
+    const nextLogFile = path.join(
+      dir,
+      `${LOG_PREFIX}${sessionId}${LOG_SUFFIX}`,
+    );
+    fs.closeSync(fs.openSync(nextLogFile, 'a'));
+
+    if (attemptGeneration !== loggerGeneration) return;
+
+    currentSink = {
+      kind: 'file',
+      filePath: nextLogFile,
+    };
+    cleanupOldLogs(dir);
   } catch {
-    // File creation failed - later writes will silently fail
+    enterStderrFallback(attemptGeneration);
   }
-  cleanupOldLogs(dir);
 }
 
 /** @internal Reset logger state for testing */
 export function resetLogger(): void {
-  logFile = null;
+  loggerGeneration += 1;
+  currentSink = { kind: 'uninitialized' };
   writeChain = Promise.resolve();
 }
 
@@ -86,10 +126,14 @@ export function resetLogger(): void {
 export async function flushLoggerForTesting(): Promise<void> {
   await writeChain;
 }
+
 export function log(message: string, data?: unknown): void {
-  const target = logFile;
-  if (!target) return; // Uninitialized - silently no-op
   try {
+    const sink = currentSink;
+    const entryGeneration = loggerGeneration;
+
+    if (sink.kind === 'uninitialized') return;
+
     const timestamp = new Date().toISOString();
     let dataStr = '';
     if (data !== undefined) {
@@ -99,13 +143,34 @@ export function log(message: string, data?: unknown): void {
         dataStr = '[unserializable]';
       }
     }
+
     const logEntry = `[${timestamp}] ${message} ${dataStr}\n`;
+
+    if (sink.kind === 'stderr') {
+      safeStderr(logEntry.trimEnd());
+      return;
+    }
+
+    const filePath = sink.filePath;
     writeChain = writeChain
-      .then(() => appendFile(target, logEntry))
-      .catch(() => {
-        // Silently ignore logging errors and keep future writes alive
-      });
+      .catch(() => undefined)
+      .then(async () => {
+        if (
+          entryGeneration === loggerGeneration &&
+          currentSink.kind === 'stderr'
+        ) {
+          safeStderr(logEntry.trimEnd());
+          return;
+        }
+
+        try {
+          await appendFile(filePath, logEntry);
+        } catch {
+          handleAppendFailure(entryGeneration, logEntry);
+        }
+      })
+      .catch(() => undefined);
   } catch {
-    // Silently ignore logging errors
+    // Logging must remain best-effort.
   }
 }

+ 63 - 0
src/utils/session-metadata.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from 'bun:test';
+import { SessionMetadataStore } from './session-metadata';
+
+describe('SessionMetadataStore', () => {
+  test('keeps two active orchestrators through metadata overflow', () => {
+    const store = new SessionMetadataStore({ maxEntries: 3 });
+
+    store.setAgent('orchestrator-a', 'orchestrator');
+    store.setAgent('orchestrator-b', 'orchestrator');
+    store.setAgent('old-specialist', 'explore');
+    store.setDirectory('new-session', '/tmp/project');
+
+    expect(store.size).toBe(3);
+    expect(store.getAgent('orchestrator-a')).toBe('orchestrator');
+    expect(store.getAgent('orchestrator-b')).toBe('orchestrator');
+    expect(store.hasAgent('old-specialist')).toBe(false);
+  });
+
+  test('makes an idle orchestrator evictable without dropping another active one', () => {
+    const store = new SessionMetadataStore({ maxEntries: 3 });
+
+    store.setAgent('orchestrator-a', 'orchestrator');
+    store.setAgent('orchestrator-b', 'orchestrator');
+    store.setAgent('old-specialist', 'explore');
+    store.markOrchestratorIdle('orchestrator-a');
+    store.setDirectory('new-session', '/tmp/project');
+
+    expect(store.size).toBe(3);
+    expect(store.hasAgent('orchestrator-a')).toBe(false);
+    expect(store.getAgent('orchestrator-b')).toBe('orchestrator');
+    expect(store.hasAgent('old-specialist')).toBe(true);
+  });
+
+  test('bounds agent-only metadata', () => {
+    const store = new SessionMetadataStore({ maxEntries: 2 });
+
+    store.setAgent('agent-a', 'explore');
+    store.setAgent('agent-b', 'oracle');
+    store.setAgent('agent-c', 'fixer');
+
+    expect(store.size).toBe(2);
+    expect(store.hasAgent('agent-a')).toBe(false);
+    expect(store.hasAgent('agent-b')).toBe(true);
+    expect(store.hasAgent('agent-c')).toBe(true);
+  });
+
+  test('eviction removes directory and agent metadata for one session', () => {
+    const evicted: string[] = [];
+    const store = new SessionMetadataStore({
+      maxEntries: 1,
+      onEvict: (sessionID) => evicted.push(sessionID),
+    });
+
+    store.setDirectory('old-session', '/tmp/project');
+    store.setAgent('old-session', 'explore');
+    store.setDirectory('new-session', '/tmp/project');
+
+    expect(store.size).toBe(1);
+    expect(store.hasDirectory('old-session')).toBe(false);
+    expect(store.hasAgent('old-session')).toBe(false);
+    expect(evicted).toEqual(['old-session']);
+  });
+});

+ 90 - 0
src/utils/session-metadata.ts

@@ -0,0 +1,90 @@
+type SessionMetadataEviction = (sessionID: string) => void;
+
+export class SessionMetadataStore {
+  readonly #agents = new Map<string, string>();
+  readonly #directories = new Map<string, string>();
+  readonly #insertionOrder = new Map<string, undefined>();
+  readonly #activeOrchestratorSessionIDs = new Set<string>();
+  readonly #maxEntries: number;
+  readonly #onEvict?: SessionMetadataEviction;
+
+  constructor(options: {
+    maxEntries: number;
+    onEvict?: SessionMetadataEviction;
+  }) {
+    this.#maxEntries = options.maxEntries;
+    this.#onEvict = options.onEvict;
+  }
+
+  getAgent(sessionID: string): string | undefined {
+    return this.#agents.get(sessionID);
+  }
+
+  getDirectory(sessionID: string): string | undefined {
+    return this.#directories.get(sessionID);
+  }
+
+  setAgent(sessionID: string, agent: string): void {
+    this.#agents.set(sessionID, agent);
+
+    if (agent === 'orchestrator') {
+      this.#activeOrchestratorSessionIDs.add(sessionID);
+    } else {
+      this.#activeOrchestratorSessionIDs.delete(sessionID);
+    }
+
+    this.#track(sessionID);
+  }
+
+  setDirectory(sessionID: string, directory: string): void {
+    this.#directories.set(sessionID, directory);
+    this.#track(sessionID);
+  }
+
+  markOrchestratorActive(sessionID: string): void {
+    if (this.#agents.get(sessionID) === 'orchestrator') {
+      this.#activeOrchestratorSessionIDs.add(sessionID);
+    }
+  }
+
+  markOrchestratorIdle(sessionID: string): void {
+    this.#activeOrchestratorSessionIDs.delete(sessionID);
+  }
+
+  delete(sessionID: string): void {
+    this.#agents.delete(sessionID);
+    this.#directories.delete(sessionID);
+    this.#insertionOrder.delete(sessionID);
+    this.#activeOrchestratorSessionIDs.delete(sessionID);
+  }
+
+  get size(): number {
+    return this.#insertionOrder.size;
+  }
+
+  hasAgent(sessionID: string): boolean {
+    return this.#agents.has(sessionID);
+  }
+
+  hasDirectory(sessionID: string): boolean {
+    return this.#directories.has(sessionID);
+  }
+
+  #track(sessionID: string): void {
+    if (!this.#insertionOrder.has(sessionID)) {
+      this.#insertionOrder.set(sessionID, undefined);
+    }
+
+    while (this.#insertionOrder.size > this.#maxEntries) {
+      const evictableSessionID = [...this.#insertionOrder.keys()].find(
+        (candidate) => !this.#activeOrchestratorSessionIDs.has(candidate),
+      );
+      if (evictableSessionID === undefined) return;
+
+      this.#insertionOrder.delete(evictableSessionID);
+      this.#agents.delete(evictableSessionID);
+      this.#directories.delete(evictableSessionID);
+      this.#onEvict?.(evictableSessionID);
+    }
+  }
+}

+ 45 - 0
src/utils/session.test.ts

@@ -115,4 +115,49 @@ describe('session utilities', () => {
       'Session abort timed out after 5ms',
     );
   });
+
+  test('promptWithTimeout handles late prompt rejection without unhandled rejection', async () => {
+    let deferredReject: ((error: Error) => void) | undefined;
+    const prompt = mock(
+      () =>
+        new Promise<never>((_resolve, reject) => {
+          deferredReject = reject;
+        }),
+    );
+    const abort = mock(async () => ({}));
+    const client = {
+      session: { abort, prompt },
+    } as any;
+
+    let unhandledRejection: Error | null = null;
+    const handler = (err: Error) => {
+      unhandledRejection = err;
+    };
+    process.on('unhandledRejection', handler);
+    try {
+      await expect(
+        promptWithTimeout(
+          client,
+          { path: { id: 's1' }, body: { parts: [] } },
+          5,
+        ),
+      ).rejects.toThrow('Prompt timed out after 5ms');
+
+      // Timeout behavior is unchanged — abort is called
+      expect(abort).toHaveBeenCalledWith({ path: { id: 's1' } });
+
+      // Simulate a late provider response arriving after timeout
+      if (deferredReject) {
+        deferredReject(new Error('provider error after timeout'));
+      }
+
+      // Yield to the microtask queue so the catch handler runs
+      await new Promise<void>((resolve) => setTimeout(resolve, 0));
+
+      // No unhandled rejection should surface
+      expect(unhandledRejection).toBeNull();
+    } finally {
+      process.off('unhandledRejection', handler);
+    }
+  });
 });

+ 7 - 1
src/utils/session.ts

@@ -3,6 +3,7 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
+import { log } from './logger';
 
 type OpencodeClient = PluginInput['client'];
 
@@ -109,7 +110,12 @@ export async function promptWithTimeout(
 
   try {
     const promptPromise = client.session.prompt(args);
-    promptPromise.catch(() => {});
+    promptPromise.catch((error) => {
+      log('[session] suppressed prompt rejection (race loser)', {
+        sessionId,
+        error: String(error),
+      });
+    });
 
     const racers: Array<Promise<unknown>> = [promptPromise];
 

Some files were not shown because too many files changed in this diff