Browse Source

Merge origin/master into omos/upgrade-sdk-118

Resolve merge conflicts: v2 SDK flat params throughout.
- session.ts: use @opencode-ai/sdk/v2 OpencodeClient type, flat params
- continuation-evaluator.ts: v2 flat params for get/promptAsync
- secondary-model.ts: restore PR branch v2 client, add webfetchModels param
- secondary-model.test.ts: restore PR branch v2 client mocks
- index.test.ts: update assertions to v2 flat params
Michael Henke 6 days ago
parent
commit
fd8d3778e7
100 changed files with 8690 additions and 879 deletions
  1. 108 0
      .all-contributorsrc
  2. 10 10
      README.ja-JP.md
  3. 51 29
      README.ko-KR.md
  4. 27 11
      README.md
  5. 10 10
      README.zh-CN.md
  6. 2 2
      codemap.md
  7. 8 7
      docs/agents/build-agent-empty-input-diagnosis.md
  8. 1 4
      docs/authors-preset.md
  9. 64 16
      docs/background-orchestration.md
  10. 77 6
      docs/cache-verification.md
  11. 197 47
      docs/configuration.md
  12. 20 7
      docs/mcps.md
  13. 1 1
      docs/openai-preset.md
  14. 10 12
      docs/opencode-go-preset.md
  15. 1 1
      docs/opencode-zen-free-preset.md
  16. 35 17
      docs/project-local-customization.md
  17. 1 1
      docs/quick-reference.md
  18. 2 2
      docs/thirty-dollars-preset.md
  19. 8 2
      docs/tools.md
  20. 281 0
      docs/webfetch.md
  21. 82 15
      oh-my-opencode-slim.schema.json
  22. 4 3
      package.json
  23. 778 0
      scripts/cache-smoke.ts
  24. 1 1
      src/agents/codemap.md
  25. 7 2
      src/agents/council.ts
  26. 3 1
      src/agents/councillor.ts
  27. 1 1
      src/agents/fixer.ts
  28. 130 3
      src/agents/index.test.ts
  29. 25 13
      src/agents/index.ts
  30. 0 1
      src/agents/librarian.ts
  31. 30 8
      src/agents/orchestrator.ts
  32. 67 0
      src/agents/resolve-prompt-warn.test.ts
  33. 2 2
      src/cli/config-io.test.ts
  34. 5 4
      src/cli/providers.test.ts
  35. 4 4
      src/cli/providers.ts
  36. 36 0
      src/cli/skills.test.ts
  37. 3 1
      src/cli/skills.ts
  38. 1 1
      src/cli/system.ts
  39. 28 0
      src/companion/manager.test.ts
  40. 3 1
      src/companion/manager.ts
  41. 1 2
      src/config/agent-mcps.test.ts
  42. 1 1
      src/config/agent-mcps.ts
  43. 1 2
      src/config/codemap.md
  44. 15 6
      src/config/constants.ts
  45. 142 1
      src/config/loader.test.ts
  46. 85 2
      src/config/loader.ts
  47. 5 3
      src/config/project-local-customization.test.ts
  48. 81 2
      src/config/schema.test.ts
  49. 39 10
      src/config/schema.ts
  50. 24 0
      src/health-check.test.ts
  51. 62 0
      src/health-check.ts
  52. 7 11
      src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap
  53. 2 1
      src/hooks/auto-update-checker/index.ts
  54. 144 1
      src/hooks/cache-monitor/index.test.ts
  55. 102 0
      src/hooks/cache-monitor/index.ts
  56. 24 7
      src/hooks/cache-safety-harness.test.ts
  57. 143 18
      src/hooks/cache-safety.property.test.ts
  58. 4 0
      src/hooks/foreground-fallback/index.ts
  59. 79 5
      src/hooks/image-hook.test.ts
  60. 53 4
      src/hooks/image-hook.ts
  61. 1 1
      src/hooks/json-error-recovery/codemap.md
  62. 0 1
      src/hooks/json-error-recovery/hook.ts
  63. 475 0
      src/hooks/task-session-manager/board-cache-breakpoint.test.ts
  64. 725 76
      src/hooks/task-session-manager/board-injection.ts
  65. 748 0
      src/hooks/task-session-manager/board-tool-pairing.test.ts
  66. 10 7
      src/hooks/task-session-manager/codemap.md
  67. 51 23
      src/hooks/task-session-manager/continuation-evaluator.ts
  68. 46 0
      src/hooks/task-session-manager/continuation-model-selection.ts
  69. 28 4
      src/hooks/task-session-manager/event-router.ts
  70. 1 5
      src/hooks/task-session-manager/idle-reconciliation.ts
  71. 1062 131
      src/hooks/task-session-manager/index.test.ts
  72. 79 15
      src/hooks/task-session-manager/index.ts
  73. 1 0
      src/hooks/task-session-manager/pending-call-tracker.ts
  74. 305 0
      src/hooks/task-session-manager/running-task-cache-safety.test.ts
  75. 69 44
      src/hooks/task-session-manager/tool-execute-hooks.ts
  76. 188 12
      src/index.test.ts
  77. 167 75
      src/index.ts
  78. 9 24
      src/mcp/codemap.md
  79. 19 21
      src/mcp/index.test.ts
  80. 6 14
      src/mcp/index.ts
  81. 0 47
      src/mcp/websearch.ts
  82. 42 4
      src/multiplexer/cmux/session-lifecycle.ts
  83. 305 0
      src/multiplexer/session-manager.test.ts
  84. 67 14
      src/multiplexer/session-manager.ts
  85. 1 1
      src/skills/oh-my-opencode-slim/SKILL.md
  86. 2 2
      src/skills/reflect/SKILL.md
  87. 19 0
      src/tools/acp-run.test.ts
  88. 13 8
      src/tools/acp-run.ts
  89. 1 0
      src/tools/smartfetch/codemap.md
  90. 27 10
      src/tools/smartfetch/secondary-model.ts
  91. 2 1
      src/tools/smartfetch/tool.ts
  92. 14 0
      src/tools/smartfetch/types.ts
  93. 72 1
      src/tools/smartfetch/utils.test.ts
  94. 27 2
      src/tools/smartfetch/utils.ts
  95. 330 0
      src/utils/background-job-board.test.ts
  96. 233 13
      src/utils/background-job-board.ts
  97. 26 0
      src/utils/background-job-coordinator.test.ts
  98. 51 1
      src/utils/background-job-coordinator.ts
  99. 13 0
      src/utils/background-job-store.ts
  100. 317 0
      src/utils/background-job-supervisor.test.ts

+ 108 - 0
.all-contributorsrc

@@ -731,6 +731,114 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "Zhanyuanium",
+      "name": "Zhanyuanium",
+      "avatar_url": "https://avatars.githubusercontent.com/u/92024923?v=4",
+      "profile": "https://github.com/Zhanyuanium",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "kaze-gif",
+      "name": "かぜ",
+      "avatar_url": "https://avatars.githubusercontent.com/u/114116466?v=4",
+      "profile": "https://github.com/kaze-gif",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "tsankotsanev",
+      "name": "Tsanko Tsanev",
+      "avatar_url": "https://avatars.githubusercontent.com/u/76694544?v=4",
+      "profile": "https://github.com/tsankotsanev",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "shixi-li",
+      "name": "cyril",
+      "avatar_url": "https://avatars.githubusercontent.com/u/40780706?v=4",
+      "profile": "https://github.com/shixi-li",
+      "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"
+      ]
+    },
+    {
+      "login": "lih54767-coder",
+      "name": "zhaohaofan",
+      "avatar_url": "https://avatars.githubusercontent.com/u/271720354?v=4",
+      "profile": "https://github.com/lih54767-coder",
+      "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>

+ 27 - 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-80-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-92-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -796,6 +796,22 @@ Use this section as a map: start with installation, then jump to features, confi
     <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>
+      <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>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/lih54767-coder"><img src="https://avatars.githubusercontent.com/u/271720354?v=4?s=100" width="100px;" alt="zhaohaofan"/><br /><sub><b>zhaohaofan</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=lih54767-coder" 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 工具、代码搜索和格式化工具等内置工具能力 |
 
 ### 💡 预设配置

+ 2 - 2
codemap.md

@@ -41,7 +41,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/hooks/json-error-recovery/` | JSON/tool-output recovery helpers for malformed model responses. | [View Map](src/hooks/json-error-recovery/codemap.md) |
 | `src/hooks/phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases. | [View Map](src/hooks/phase-reminder/codemap.md) |
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
-| `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, and stale-session cleanup. | [View Map](src/hooks/task-session-manager/codemap.md) |
+| `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, stale-session cleanup, and terminal task reconciliation. | [View Map](src/hooks/task-session-manager/codemap.md) |
 | `src/interview/` | `/interview` feature: per-session and dashboard prompt/state orchestration, persistence, local UI, and cross-process coordination. | [View Map](src/interview/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
@@ -97,7 +97,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
   multiplexer behavior remains on the upstream path.
 - Council mode is implemented in `src/agents/`; the orchestrator dispatches councillors as subagents and the council agent synthesizes responses.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
-- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
+- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, terminal task reconciliation, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.
 

+ 8 - 7
docs/agents/build-agent-empty-input-diagnosis.md

@@ -9,7 +9,7 @@
 
 The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
 
-**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is enabled by default via `backgroundJobs.continueOnIdle` (opt out with `false`), and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
+**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is an opt-in beta via `backgroundJobs.continueOnIdle: true`, and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
 
 ## Root cause (causal chain, cross-validated)
 
@@ -40,7 +40,7 @@ The `build` agent turn with empty input is **the same class of bug** as the orig
 
 | File:line | Trigger | Body omits `agent`? | Gate |
 |---|---|---|---|
-| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when `backgroundJobs.continueOnIdle` is `true` (default **on**) | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
+| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when the opt-in beta `backgroundJobs.continueOnIdle` is `true` | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
 | `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
 | `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
 | `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
@@ -65,10 +65,11 @@ This is the pattern every `promptAsync` caller in omos should follow.
 The gate exists and works in the common case (see `continuation-evaluator.ts` and
 `task-session-manager/index.test.ts` continuation cases). Notes:
 
-1. **Continuation is on by default.** `backgroundJobs.continueOnIdle` defaults
-   to `true`; set `false` to keep idle reconciliation without continuation SDK
-   calls. When enabled, a process-local reserve/commit gate allows at most one
-   `promptAsync` per session epoch between real user messages.
+1. **Continuation is opt-in beta.** `backgroundJobs.continueOnIdle` defaults
+   to `false`; set it to `true` to enable continuation SDK calls. Idle
+   reconciliation remains active either way. When enabled, a process-local
+   reserve/commit gate allows at most one `promptAsync` per session epoch
+   between real user messages.
 
 2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`.
    The idle-reconciliation comment admits late completions can still race the
@@ -111,7 +112,7 @@ opencode's `default_agent` resolution, eliminating the path to `build`.
 ## Evidence index
 
 ### omos source
-- **Continuation nudge (fixed agent + default-on + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
+- **Continuation nudge (fixed agent + opt-in beta + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
 - **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
 - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`

+ 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).

+ 64 - 16
docs/background-orchestration.md

@@ -144,7 +144,7 @@ Rules:
 - Review tasks can run in parallel with read-only discovery, but not with edits
   they are supposed to review.
 
-### 4. Wait, cancel, and reconcile
+### 4. Wait and cancel
 
 Background tasks are not complete until OpenCode injects their terminal result or
 hook-driven completion marks them terminal.
@@ -158,14 +158,15 @@ The orchestrator should use background completion events to:
 
 The orchestrator should use `cancel_task` only when the user asks, or when a
 running lane is obsolete, wrong, or conflicts with a safer replacement plan.
-Cancellation is not rollback: if cancelling a writer, inspect and reconcile
-partial file changes before launching a replacement lane.
+Cancellation is not rollback: if cancelling a writer, inspect its partial file
+changes before launching a replacement lane.
 
-**Note on reconciliation:** Idle-based reconciliation is a heuristic. A job marked
-as reconciled means its terminal result was injected into an orchestrator turn
-that completed and the parent returned to idle; it is not proof the result was
-explicitly acknowledged or used. The orchestrator should still verify it consumed
-the relevant outputs before finalizing.
+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. 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.
@@ -321,22 +322,22 @@ multiplexer panes attached while the parent orchestrator continues scheduling.
 
 ### Incomplete-todo continuation nudge
 
-Automatic incomplete-todo continuation is **enabled by default**. Idle
-reconciliation and background-job orchestration always run; set
-`continueOnIdle` to `false` to keep those without hidden continuation prompts:
+Automatic incomplete-todo continuation is an **opt-in beta feature**. Idle
+reconciliation and background-job orchestration always run without it. Enable
+the beta only when you want hidden continuation prompts:
 
 ```jsonc
 {
   "backgroundJobs": {
-    "continueOnIdle": false
+    "continueOnIdle": true
   }
 }
 ```
 
-When `backgroundJobs.continueOnIdle` is `true` (the default), after an
-orchestrator session becomes idle the plugin may send **at most one** internal,
-delayed continuation prompt when OpenCode reports incomplete todos. That limit
-is per session between real external user messages (text/file/image).
+When `backgroundJobs.continueOnIdle` is `true`, after an orchestrator session
+becomes idle the plugin may send **at most one** internal, delayed continuation
+prompt when OpenCode reports incomplete todos. That limit is per session between
+real external user messages (text/file/image).
 Synthetic/internal inputs and subsequent idle/busy events do not rearm it. A
 real user message rearms the one-shot nudge once per message identity
 (`chat.message` `messageID` / `message.id`), shared across hook instances in the
@@ -351,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
@@ -407,6 +412,49 @@ miss at the epoch boundary, after which a fresh run of up to the configured limi
 can accumulate. The cache is lost on plugin restart, so snapshots are not
 restored beyond those present in the current OpenCode message history.
 
+### Opt-in Wall-clock Supervisor
+
+The plugin can apply a one-shot wall-clock deadline to native background task
+child sessions. It is disabled by default:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "wallClockTimeoutMs": 900000,
+    "abortGraceMs": 10000
+  }
+}
+```
+
+This supervisor recognizes only an explicit `task(..., background: true)` call.
+Foreground tasks and calls where `background` is omitted or `false` are not
+supervised. The deadline begins at the first launch observation for the current
+run. Duplicate `session.created`/tool-hook observations, busy activity, tool
+activity, and liveness timestamps do not renew it. An explicit relaunch or reuse
+starts a new run generation.
+
+When the deadline wins a race with a real terminal transition, the board records
+a persistent hard-deadline marker, marks cancellation as requested, starts the
+bounded abort grace period, and issues exactly one native session abort. The
+grace timer is independent of whether the SDK abort resolves, rejects, or hangs.
+An error, cancellation, or child deletion during grace publishes one stable
+timed-out terminal outcome. If no terminal confirmation arrives before grace
+expires, the outcome is `error`, `timedOut: true`, and `statusUncertain: true`,
+with a summary stating that abort was not confirmed.
+
+Late completion, busy, retry, or error events cannot replace a published hard
+timeout, and a hard wall-clock timeout is not recoverable through the existing
+external task-wait timeout path. The timeout outcome remains visible to the
+parent through the normal terminal-unreconciled Background Job Board flow; no
+prompt or raw task-result rewrite is used. Timeout terminals also issue a
+permanent logical pane-close intent so generic and cmux multiplexer paths do not
+respawn a pane on late busy events.
+
+`wallClockTimeoutMs` accepts `0` or integers from `60000` through `2147483647`;
+`abortGraceMs` accepts integers from `1000` through `60000`. This feature is
+wall-clock-only: no no-progress/plateau policy, foreground fallback, model swap,
+session deletion retry, or worker-death guarantee is implied.
+
 ---
 
 ## Startup Behavior

+ 77 - 6
docs/cache-verification.md

@@ -15,6 +15,10 @@ and should not be conflated.
   cache guarantee.
 - **Runtime cache monitoring** watches provider-reported cache telemetry
   during real sessions and logs a warning when a cache bust signature appears.
+- **Live cache smoke** (`bun run cache:smoke`) is a one-command operational
+  probe: it starts a real `opencode serve` with your normal config/auth/plugin,
+  runs short scripted conversations, and reports per-request provider cache
+  telemetry with a verdict.
 
 ## Continuous cache-safety tests (CI)
 
@@ -42,15 +46,82 @@ mistakes that have not been made before:
 All hook injections must go through `src/hooks/cache-safe-injection.ts`; see
 the Prompt Cache Safety section in `AGENTS.md` for the authoring rules.
 
+## Live cache smoke (`bun run cache:smoke`)
+
+Answers "is provider prompt caching working in my setup right now?" at the
+cost of a handful of real requests. It starts an isolated `opencode serve`
+(your global config, auth, and plugin apply), runs scripted scenarios in
+fresh sessions, then reads `tokens.cache.read/write` from the stored
+assistant messages — including the subagent child sessions that delegation
+scenarios spawn.
+
+Each scenario is designed to fire specific plugin payload machinery:
+
+| Scenario | Triggers | Default |
+|---|---|---|
+| `plain` | phase reminder, skills filter, system transform | ✅ |
+| `tools` | tool-result growth across steps | ✅ |
+| `nudge` | post-file-tool nudge injection, phase-reminder equilibrium | extensive |
+| `todos` | todowrite churn (create/update/complete across turns) | extensive |
+| `long` | sliding cache breakpoints over a six-turn history | extensive |
+| `board` | job-board trailing injection, injected completion, reconcile | extensive |
+| `board-churn` | board strip/re-append across many turns; plateau detection (issue #874) | extensive |
+| `running-lane` | running task tool_result mid-history across consecutive requests (PR #871) | extensive |
+| `agents` | repeated delegation, task-session reuse, subagent caching | extensive |
+
+```bash
+bun run cache:smoke                          # plain + tools (fast, cheap)
+bun run cache:smoke -- --scenario extensive  # full matrix, several minutes
+bun run cache:smoke -- --scenario board,agents
+bun run cache:smoke -- --provider anthropic --model claude-sonnet-5
+bun run cache:smoke -- --server http://127.0.0.1:4096   # reuse a running server
+
+# Issue #874 A/B: pin the board strategy via a scratch project config
+bun run cache:smoke -- --scenario board-churn --board-strategy latest
+bun run cache:smoke -- --scenario board-churn --board-strategy checkpoint-compatible
+```
+
+Two failure signatures are detected, plus an inconclusive case:
+
+- A request is flagged **SUSPECT** when it is not the first request of its
+  session, its input is ≥4096 tokens (above every provider's minimum
+  cacheable prefix), and it read zero cached tokens — the bust signature.
+- A session is flagged **plateau** when `cache-read` stays frozen at the
+  same nonzero value for 3+ consecutive requests while ≥6144 uncached input
+  tokens accumulate — the issue #874 signature, where the reusable prefix
+  stops growing even though nothing reads zero.
+
+Exit codes: `0` caching works, `1` bust or plateau detected, `2` inconclusive
+(provider reported no cache telemetry), `3` setup error. Providers with
+read-only telemetry (OpenAI-style `cached_tokens`) show `cache-write 0` —
+normal, not a failure.
+
 ## Runtime cache monitoring
 
 `src/hooks/cache-monitor/` observes `message.updated` events and the
-provider-reported `tokens.cache.read` / `tokens.cache.write` counters. When a
-session that previously hit the cache reports zero cache-read tokens on a
-sizeable request, it logs a `[cache-monitor] possible prompt-cache bust`
-warning (once per bust streak) to the plugin log. Providers that never report
-cache telemetry produce no warnings. This is the field safety net for
-provider-side behavior no offline test can model.
+provider-reported `tokens.cache.read` / `tokens.cache.write` counters. It
+logs three warning shapes (observation only, to the plugin log):
+
+- `possible prompt-cache bust` — a session that previously hit the cache
+  reports zero cache-read tokens on a sizeable request (once per bust
+  streak). The signature of a mid-session prompt-prefix change.
+- `never hit the provider cache` — a session reports zero cached tokens on
+  every sizeable request from its first turn, past both a consecutive-request
+  and a cumulative-uncached-input threshold (once per session). The signature
+  of a prefix that changes on *every* request — how the v2.2.5
+  checkpoint-board regression looked in the field. OpenCode coalesces missing
+  provider telemetry to zeros, so a cache-less provider is indistinguishable
+  from this; the thresholds and hedged wording keep that ambiguity from
+  becoming noise, and modest sessions on cache-less providers stay silent.
+- `cache-read plateau` — reads frozen at the same nonzero value for 4+
+  consecutive requests while ≥50K uncached input tokens accumulate (once per
+  plateau; re-arms when the read value changes). The issue #874 signature:
+  the reusable prefix has stopped growing even though nothing reads zero.
+  The warning suggests trying `backgroundJobs.strategy`
+  `"checkpoint-compatible"`.
+
+This is the field safety net for provider-side behavior no offline test can
+model.
 
 ## Prerequisites
 

+ 197 - 47
docs/configuration.md

@@ -114,7 +114,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 |-----------|--------|---|-----------------------------|
 | `presets.<name>.<agent>.model` | string | - | Model ID in `provider/model` format |
 | `presets.<name>.<agent>.temperature` | number | - | Temperature (0–2) |
-| `presets.<name>.<agent>.variant` | string | - | Reasoning effort: `"low"`, `"medium"`, `"high"` |
+| `presets.<name>.<agent>.variant` | string | - | Reasoning effort: `"low"`, `"medium"`, `"high"`, or `"max"` (provider-specific) |
 | `presets.<name>.<agent>.displayName` | string | - | Custom user-facing alias for the agent (e.g. `"advisor"` for `oracle`) |
 | `presets.<name>.<agent>.skills` | string[] | - | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.mcps` | string[] | - | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
@@ -124,54 +124,58 @@ 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 |
-| `acpAgents.<name>.command` | string | - | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` |
-| `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command |
-| `acpAgents.<name>.env` | object | `{}` | Extra environment variables for the ACP subprocess |
-| `acpAgents.<name>.cwd` | string | session directory | Working directory override for this ACP subprocess; protocol paths should be absolute |
-| `acpAgents.<name>.description` | string | - | Description shown to OpenCode and injected into the orchestrator routing prompt |
-| `acpAgents.<name>.prompt` | string | generated wrapper prompt | Optional full prompt for the lightweight wrapper subagent |
-| `acpAgents.<name>.orchestratorPrompt` | string | generated routing block | Optional exact routing block injected into the orchestrator prompt |
-| `acpAgents.<name>.wrapperModel` | string | fixer default | Cheap OpenCode model used by the wrapper subagent that calls `acp_run` |
-| `acpAgents.<name>.permissionMode` | string | `ask` | How ACP permission requests are handled: `ask`, `allow`, or `reject` |
-| `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) |
-| `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
-| `image_routing` | `"auto"` \| `"direct"` | omitted (legacy conditional) | Optional. When omitted, images are intercepted only when Observer is enabled, preserving existing behavior. Explicit `"auto"` requires Observer enabled and saves image attachments to disk before nudging delegation to @observer. `"direct"`: always pass images to the orchestrator. |
+| `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). |
+| `acpAgents.<name>.cwd` | string | session directory | Working directory override for this ACP subprocess; protocol paths should be absolute See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.description` | string | - | Description shown to OpenCode and injected into the orchestrator routing prompt See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.prompt` | string | generated wrapper prompt | Optional full prompt for the lightweight wrapper subagent See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.orchestratorPrompt` | string | generated routing block | Optional exact routing block injected into the orchestrator prompt See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.wrapperModel` | string | orchestrator default | Cheap OpenCode model used by the wrapper subagent that calls `acp_run` See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.permissionMode` | string | `ask` | How ACP permission requests are handled: `ask`, `allow`, or `reject` See [ACP-connected agents](#acp-connected-agents). |
+| `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) See [ACP-connected agents](#acp-connected-agents). |
+| `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset See [Custom Agents](#custom-agents). |
+| `image_routing` | `"auto"` \| `"direct"` | omitted (legacy conditional) | Optional. When omitted, resolves to `"auto"` if Observer is enabled, otherwise `"direct"`. Explicit `"auto"` requires Observer enabled and saves image attachments to disk before nudging delegation to @observer. `"direct"`: always pass images to the orchestrator. |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
-| `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, `kitty`, or `none` |
-| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij and Herdr map supported layouts to split directions; cmux maintains a right-hand agent column |
-| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij, Herdr, and cmux |
-| `multiplexer.zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `agent-tab` creates/reuses a dedicated `opencode-agents` tab; `current-tab` opens subagents as panes in the tab containing the parent OpenCode pane, falling back to the focused tab if the parent pane cannot be resolved |
-| `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
-| `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |
-| `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` |
-| `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10) |
-| `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) |
-| `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
-| `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 |
-| `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart |
-| `backgroundJobs.continueOnIdle` | boolean | `true` | When `true` (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set `false` to keep idle reconciliation and background-job orchestration without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
+| `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, `cmux`, `kitty`, or `none` See [Multiplexer Integration](multiplexer-integration.md). |
+| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij and Herdr map supported layouts to split directions; cmux maintains a right-hand agent column See [Multiplexer Integration](multiplexer-integration.md). |
+| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij, Herdr, and cmux See [Multiplexer Integration](multiplexer-integration.md). |
+| `multiplexer.zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `agent-tab` creates/reuses a dedicated `opencode-agents` tab; `current-tab` opens subagents as panes in the tab containing the parent OpenCode pane, falling back to the focused tab if the parent pane cannot be resolved See [Multiplexer Integration](multiplexer-integration.md). |
+| `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` See [Multiplexer Integration](multiplexer-integration.md). |
+| `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). |
+| `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart See [Background Job Management](#background-job-management). |
+| `backgroundJobs.continueOnIdle` | boolean | `false` | **Beta opt-in.** Set `true` to let idle orchestrator sessions with incomplete todos receive one automatic hidden continuation prompt. When omitted or `false`, idle reconciliation and background-job orchestration remain active without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
+| `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
 | `fallback.retryDelayMs` | number | `500` | Delay between retry attempts |
 | `fallback.maxRetries` | number | `3` | Maximum failover attempts before giving up |
-| `fallback.runtimeOverride` | boolean | `true` | Allow per-call model overrides to bypass the fallback chain |
+| `fallback.runtimeOverride` | boolean | `true` | **Deprecated.** No longer used. Fallback is always disabled when a user explicitly selects a model via `/model`. |
 | `fallback.retry_on_empty` | boolean | `true` | Treat silent empty provider responses (0 tokens) as failures and retry. Set `false` to accept empty responses |
-| `council.presets` | object | - | **Required if using council.** Named councillor presets |
-| `council.presets.<name>.<councillor>.model` | string | - | Councillor model |
-| `council.presets.<name>.<councillor>.variant` | string | - | Councillor variant |
-| `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor |
-| `council.default_preset` | string | `"default"` | Default preset when none is specified |
+| `council.presets` | object | - | **Required if using council.** Named councillor presets See [Council configuration note](#council-configuration-note). |
+| `council.presets.<name>.<councillor>.model` | string | - | Councillor model See [Council configuration note](#council-configuration-note). |
+| `council.presets.<name>.<councillor>.variant` | string | - | Councillor variant See [Council configuration note](#council-configuration-note). |
+| `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor See [Council configuration note](#council-configuration-note). |
+| `council.default_preset` | string | `"default"` | Default preset when none is specified See [Council configuration note](#council-configuration-note). |
 | — | — | — | *Timeouts, execution mode, and retries are now handled by the orchestrator's council-mode prompt instructions; see `src/agents/council.ts`.* |
-| `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) |
-| `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) |
-| `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI |
-| `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
-| `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |
-| `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion |
-| `companion.binaryPath` | string | - | Optional path to a custom companion binary to launch instead of the default install path |
-| `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` |
-| `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) |
+| `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) See [Interview configuration](interview.md). |
+| `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) See [Interview configuration](interview.md). |
+| `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI See [Interview configuration](interview.md). |
+| `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) See [Interview configuration](interview.md). |
+| `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence See [Interview configuration](interview.md). |
+| `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion See [Desktop Companion App](#desktop-companion-app). |
+| `companion.binaryPath` | string | - | Optional path to a custom companion binary to launch instead of the default install path See [Desktop Companion App](#desktop-companion-app). |
+| `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` See [Desktop Companion App](#desktop-companion-app). |
+| `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) See [Desktop Companion App](#desktop-companion-app). |
 
 > **niri note:** `companion-v0.1.3` includes the fixed native companion release.
 > To make it open as a bottom-right overlay, add a niri rule matching its stable
@@ -218,6 +222,9 @@ and troubleshooting.
 }
 ```
 
+> **Tip:** Use ACP to connect local agent CLIs. For example, `ollama` or `llama.cpp`
+> can be exposed as ACP agents by wrapping them in a lightweight ACP adapter.
+
 After restart, the orchestrator can delegate to `@claude-research` or
 `@gemini-acp`. Use safe names matching `^[a-z][a-z0-9_-]*$`; names cannot
 conflict with built-in or custom agents. `permissionMode` controls ACP
@@ -230,8 +237,30 @@ subprocess.
   `presets.<name>.council.model`.
 - The **councillor models** are configured separately under
   `council.presets.<name>.<councillor>.model`.
-- `council.master*` fields have been removed. A deprecation warning is
-  logged this release if a config still contains them.
+- `council.master` (exact key) has been removed; a deprecation warning is
+  logged if a config still contains it. Other `council.master_*` variants
+  (e.g., `council.master_timeout`, `council.master_fallback`) are silently
+  dropped without warning — remove them manually.
+
+```jsonc
+{
+  "council": {
+    "default_preset": "balanced",
+    "presets": {
+      "balanced": {
+        "alpha": {
+          "model": "openai/gpt-5.6-sol",
+          "variant": "high"
+        },
+        "beta": {
+          "model": "anthropic/claude-sonnet-4-5",
+          "variant": "medium"
+        }
+      }
+    }
+  }
+}
+```
 
 ### Manual Update Mode
 
@@ -261,10 +290,36 @@ major is available, the plugin shows a migration command instead.
 Background job management is enabled by default and does not need to be present
 in the starter config. Add `backgroundJobs` only if you want to tune how many
 completed/reconciled child-agent sessions are reusable, how much read context is
-shown, how board snapshots are injected, or to disable automatic incomplete-todo
-continuation prompts on idle (`continueOnIdle`, default `true`). See the
+shown, how board snapshots are injected, or to opt into beta automatic
+incomplete-todo continuation prompts on idle. For glossary definitions of
+background-job terms (board snapshot, checkpoint cache epoch, injection
+strategy, etc.), see [CONTEXT.md — Background
+Jobs](../CONTEXT.md#background-jobs).
+The wall-clock supervisor is separately opt-in and remains disabled unless
+`wallClockTimeoutMs` is set:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "maxSessionsPerAgent": 3,
+    "strategy": "checkpoint-compatible",
+    "maxRetainedSnapshots": 10,
+    "continueOnIdle": true,
+    "wallClockTimeoutMs": 900000,
+    "abortGraceMs": 10000
+  }
+}
+```
+
+Without `continueOnIdle`, idle reconciliation and background-job orchestration
+remain enabled but no hidden continuation prompts are sent. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
+`wallClockTimeoutMs` is a hard deadline that only supervises explicitly
+background native task calls; foreground calls or calls with `background`
+omitted are not supervised. It is independent from OpenCode's external
+task-wait timeout, and a wall-clock timeout cannot be recovered by reusing the
+running session.
 
 ### Agent Display Names
 
@@ -294,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
@@ -318,6 +461,9 @@ Notes:
 - Custom agents without a `model` are skipped with a warning
 - Disabled custom agents are not registered or injected into the orchestrator prompt
 
+> **Tip:** Keep `orchestratorPrompt` concise — the orchestrator reads it every turn.
+> Include: when to delegate, when NOT to delegate, and the agent's role in one paragraph.
+
 ### Agent Permissions
 
 The `permission` field provides deterministic, tool-level permission restrictions on custom agents, built-in agent overrides, and presets. Unlike prompt instructions ("do not edit files"), these rules are enforced by the OpenCode SDK at the tool-call level.
@@ -336,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": {
@@ -346,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."
@@ -407,6 +553,10 @@ When a user supplies `permission` and also uses the `skills` or `mcps` arrays on
 
 Use the `skills`/`mcps` arrays for skill and MCP gating. Use `permission` for everything else (file access, bash, web, task delegation).
 
+### Multiplexer
+
+The multiplexer hosts child agent sessions in terminal panes. See [Multiplexer Integration](multiplexer-integration.md) for backend setup, layout configuration, and troubleshooting.
+
 ### Desktop Companion App
 
 The desktop companion app provides a visual status overlay showing running and active agents. For quick installation instructions, binary paths, config defaults, and release information, see the full **[Desktop Companion Guide](companion.md)**.

+ 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 |

+ 82 - 15
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",
@@ -1055,9 +1056,31 @@
           "maximum": 100
         },
         "continueOnIdle": {
-          "default": true,
-          "description": "When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.",
+          "default": false,
+          "description": "Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.",
           "type": "boolean"
+        },
+        "wallClockTimeoutMs": {
+          "default": 0,
+          "description": "Explicit opt-in wall-clock deadline for native task(..., background: true) child sessions. 0 disables supervision; finite values are 60,000–2,147,483,647ms.",
+          "anyOf": [
+            {
+              "type": "number",
+              "const": 0
+            },
+            {
+              "type": "integer",
+              "minimum": 60000,
+              "maximum": 2147483647
+            }
+          ]
+        },
+        "abortGraceMs": {
+          "default": 10000,
+          "description": "Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).",
+          "type": "integer",
+          "minimum": 1000,
+          "maximum": 60000
         }
       }
     },
@@ -1184,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": {

+ 4 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "2.2.5",
+  "version": "2.2.9",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
@@ -51,8 +51,8 @@
   ],
   "scripts": {
     "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
-    "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/* --external @opencode-ai/sdk --external @opencode-ai/sdk/* --external @opentui/core --external @opentui/solid --external jsdom --external zod",
-    "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/* --external @opencode-ai/sdk --external @opencode-ai/sdk/* --external jsdom --external zod",
+    "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external jsdom --external zod",
+    "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external jsdom --external zod",
     "build": "bun run clean:dist && bun run build:plugin && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
     "prepare": "bun run build",
     "contributors:add": "all-contributors add",
@@ -62,6 +62,7 @@
     "verify:release": "bun run scripts/verify-release-artifact.ts",
     "verify:host-smoke": "bun run scripts/verify-opencode-host-smoke.ts",
     "verify:cache-stability": "bun run scripts/verify-opencode-cache-stability.ts",
+    "cache:smoke": "bun run scripts/cache-smoke.ts",
     "typecheck": "tsc --noEmit",
     "test": "bun test",
     "lint": "biome lint .",

+ 778 - 0
scripts/cache-smoke.ts

@@ -0,0 +1,778 @@
+/**
+ * Cache smoke — live end-to-end probe answering "is provider prompt caching
+ * working in my setup right now?"
+ *
+ * Starts a real `opencode serve` using your normal global config, auth, and
+ * plugin, runs scripted conversations against your default (or given)
+ * provider/model, and reads the provider-reported cache telemetry that
+ * OpenCode stores on every assistant message (`tokens.cache.read/write`,
+ * normalized from Anthropic's cache_control usage fields and OpenAI's
+ * `prompt_tokens_details.cached_tokens`).
+ *
+ * The scenarios are designed to trigger this plugin's payload-touching
+ * machinery on purpose — phase reminders, the post-file-tool nudge, todo
+ * churn, background job board injection/reconciliation, repeated specialist
+ * delegation with session reuse — so each injection path is validated
+ * against a real provider, including the subagent child sessions it spawns.
+ *
+ * Usage:
+ *   bun run cache:smoke [-- options]
+ *
+ * Options:
+ *   --server URL        Use an already-running OpenCode server instead of
+ *                       starting one (skips spawn/cleanup of the server).
+ *   --provider ID       Route turns to this provider (requires --model).
+ *   --model ID          Route turns to this model (requires --provider).
+ *   --agent NAME        Agent for each turn (default: server default).
+ *   --scenario LIST     Comma list of scenario names, or "extensive"/"all"
+ *                       (default: plain,tools — the cheap probe).
+ *   --turn-timeout-ms N Per-turn timeout (default 300000).
+ *   --keep-sessions     Don't delete the probe sessions afterwards.
+ *   --board-strategy S  Pin backgroundJobs.strategy ("latest" or
+ *                       "checkpoint-compatible") via a scratch project
+ *                       config — for A/B-ing issue #874. Spawned server only.
+ *
+ * Exit codes: 0 caching works · 1 bust detected · 2 inconclusive (provider
+ * reported no cache telemetry) · 3 setup/runtime error.
+ *
+ * Each run costs real requests against your provider; the extensive set also
+ * spawns background specialist sessions and takes several minutes. This is a
+ * manual/operational probe, not a CI test — the CI-side guarantees live in
+ * the cache-safety suites (see docs/cache-verification.md).
+ */
+
+import { spawn } from 'node:child_process';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+interface Args {
+  server?: string;
+  provider?: string;
+  model?: string;
+  agent?: string;
+  scenarios: string[];
+  turnTimeoutMs: number;
+  keepSessions: boolean;
+  /** Force backgroundJobs.strategy via a scratch project config (issue #874 A/B). */
+  boardStrategy?: 'latest' | 'checkpoint-compatible';
+}
+
+interface Turn {
+  text: string;
+  /** Wait after the turn completes (lets background work land). */
+  pauseAfterMs?: number;
+}
+
+interface Scenario {
+  name: string;
+  description: string;
+  /** Plugin machinery this scenario is designed to fire. */
+  triggers: string;
+  turns: (nonce: string) => Turn[];
+}
+
+interface RequestRow {
+  messageID: string;
+  input: number;
+  output: number;
+  cacheRead: number;
+  cacheWrite: number;
+}
+
+interface SessionReport {
+  label: string;
+  rows: RequestRow[];
+}
+
+type Verdict = 'ok' | 'plateau' | 'bust' | 'inconclusive';
+
+/**
+ * Requests at or above this input size with zero cache reads (after the
+ * first request of their session) count as suspect — comfortably above
+ * every provider's minimum cacheable prefix (OpenAI 1024, Anthropic ≤4096).
+ */
+const SUSPECT_INPUT_THRESHOLD = 4096;
+
+/**
+ * Cache-read plateau detection (issue #874 signature): the reusable prefix
+ * stops growing — `cache-read` stays frozen at the same nonzero value for
+ * consecutive requests while sizeable uncached input keeps accumulating.
+ * Distinct from a bust (reads never drop to zero). Small frozen streaks are
+ * normal (providers round reads to ~128-token boundaries), so both
+ * thresholds must be met.
+ */
+const PLATEAU_STREAK_THRESHOLD = 3;
+const PLATEAU_INPUT_THRESHOLD = 6144;
+
+interface PlateauFinding {
+  frozenAt: number;
+  requests: number;
+  accumulatedInput: number;
+}
+
+const NO_TOOLS = 'Do not use any tools.';
+
+const SCENARIOS: Scenario[] = [
+  {
+    name: 'plain',
+    description: 'multi-turn conversation, no tools',
+    triggers: 'phase reminder, skills filter, system transform',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
+      },
+      { text: `Reply with exactly "ack 2" and nothing else. ${NO_TOOLS}` },
+      { text: `Reply with exactly "ack 3" and nothing else. ${NO_TOOLS}` },
+    ],
+  },
+  {
+    name: 'tools',
+    description: 'tool loop within and across turns',
+    triggers: 'tool-result growth across steps',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Read the file package.json in the current directory and reply with only the value of its "name" field.`,
+      },
+      { text: `Reply with exactly "ack done" and nothing else. ${NO_TOOLS}` },
+    ],
+  },
+  {
+    name: 'nudge',
+    description: 'direct file read arms the post-file-tool nudge',
+    triggers: 'post-file-tool-nudge injection, then phase-reminder equilibrium',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Do not delegate: use your read tool yourself on package.json and reply with only its "name" value.`,
+      },
+      { text: `Reply with exactly "ack nudged" and nothing else. ${NO_TOOLS}` },
+      {
+        text: `Reply with exactly "ack settled" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
+  {
+    name: 'todos',
+    description: 'todo list created, updated, and completed across turns',
+    triggers: 'todowrite churn, todo hygiene',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Use the todowrite tool to create exactly three todos named alpha, beta, gamma. Then reply with exactly "ack todos".`,
+      },
+      {
+        text: 'Mark the todo alpha as completed and add a new todo named delta. Then reply with exactly "ack updated".',
+      },
+      {
+        text: 'Mark every remaining todo as completed. Then reply with exactly "ack cleared".',
+      },
+      { text: `Reply with exactly "ack final" and nothing else. ${NO_TOOLS}` },
+    ],
+  },
+  {
+    name: 'long',
+    description: 'six-turn conversation, growing history',
+    triggers: 'sliding cache breakpoints over a long same-session history',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
+      },
+      { text: `Name one prime number below 10. One word only. ${NO_TOOLS}` },
+      { text: `Name one planet. One word only. ${NO_TOOLS}` },
+      { text: `Name one color. One word only. ${NO_TOOLS}` },
+      { text: `Name one weekday. One word only. ${NO_TOOLS}` },
+      {
+        text: `Reply with exactly "ack long done" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
+  {
+    name: 'board',
+    description:
+      'background task launched without waiting; board appears, completion lands, reconcile',
+    triggers:
+      'background job board trailing injection, injected completion message, reconciliation',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task that lists the files in the current directory. Do not wait for it — reply immediately with exactly "ack launched".`,
+        pauseAfterMs: 30_000,
+      },
+      {
+        text: 'Reconcile any completed background tasks now, then reply with exactly "ack reconciled".',
+      },
+      {
+        text: `Reply with exactly "ack board done" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
+  {
+    name: 'board-churn',
+    description:
+      'many turns while background launches churn the job board between requests (issue #874 workload)',
+    triggers:
+      'board strip/re-append across consecutive requests; detects cache-read plateaus where the reusable prefix stops growing',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task that lists the files in the current directory. Do not wait — reply immediately with exactly "ack churn 1".`,
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: `Reply with exactly "ack churn 2" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: 'Launch exactly one background @explorer task that counts the lines in package.json. Do not wait — reply immediately with exactly "ack churn 3".',
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: `Reply with exactly "ack churn 4" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: 'Launch exactly one background @explorer task that reports the largest file in the current directory. Do not wait — reply immediately with exactly "ack churn 5".',
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: 'Reconcile all completed background tasks now, then reply with exactly "ack reconciled".',
+      },
+      {
+        text: `Reply with exactly "ack churn 7" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: `Reply with exactly "ack churn 8" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
+  {
+    name: 'running-lane',
+    description:
+      'parent keeps talking while a background lane is still running (PR #871 window)',
+    triggers:
+      'running task tool_result sits mid-history across consecutive requests; byte churn there busts the cache tail',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task with this prompt: "Produce a very thorough report of at least 600 words describing every file in the current directory, its likely purpose, and recommendations." Do not wait for it — reply immediately with exactly "ack lane started".`,
+      },
+      {
+        text: `Reply with exactly "ack while running" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: `Reply with exactly "ack still running" and nothing else. ${NO_TOOLS}`,
+        pauseAfterMs: 45_000,
+      },
+      {
+        text: 'Reconcile any completed background tasks, then reply with exactly "ack lane done".',
+      },
+    ],
+  },
+  {
+    name: 'agents',
+    description:
+      'repeated delegation: same specialist twice (session reuse), then a second specialist',
+    triggers:
+      'board churn across multiple tasks, task session reuse by alias, @mention rewriting, subagent session caching',
+    turns: (nonce) => [
+      {
+        text: `Cache smoke probe ${nonce}. Launch a background @explorer task to list the files in the current directory. Wait for it to complete, then reply with exactly "ack explorer 1".`,
+      },
+      {
+        text: 'Give @explorer one more task: report how many lines package.json has. Wait for completion, then reply with exactly "ack explorer 2".',
+      },
+      {
+        text: 'Now launch a background @fixer task to create a file named hello.txt containing the single word "hi". Wait for completion, then reply with exactly "ack fixer".',
+      },
+      {
+        text: `Reply with exactly "ack agents done" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
+];
+
+const CHEAP_SET = ['plain', 'tools'];
+const EXTENSIVE_SET = SCENARIOS.map((scenario) => scenario.name);
+
+function fail(message: string): never {
+  console.error(`\ncache-smoke: ${message}`);
+  process.exit(3);
+}
+
+function parseArgs(argv: string[]): Args {
+  const args: Args = {
+    scenarios: CHEAP_SET,
+    turnTimeoutMs: 300_000,
+    keepSessions: false,
+  };
+  for (let i = 0; i < argv.length; i += 1) {
+    const flag = argv[i];
+    const value = () => {
+      const next = argv[i + 1];
+      if (next === undefined) fail(`missing value for ${flag}`);
+      i += 1;
+      return next;
+    };
+    switch (flag) {
+      case '--server':
+        args.server = value().replace(/\/$/, '');
+        break;
+      case '--provider':
+        args.provider = value();
+        break;
+      case '--model':
+        args.model = value();
+        break;
+      case '--agent':
+        args.agent = value();
+        break;
+      case '--scenario': {
+        const requested = value();
+        if (requested === 'all' || requested === 'extensive') {
+          args.scenarios = EXTENSIVE_SET;
+          break;
+        }
+        const names = requested.split(',');
+        for (const name of names) {
+          if (!SCENARIOS.some((scenario) => scenario.name === name)) {
+            fail(
+              `unknown scenario "${name}" (${EXTENSIVE_SET.join(' | ')} | extensive | all)`,
+            );
+          }
+        }
+        args.scenarios = names;
+        break;
+      }
+      case '--turn-timeout-ms':
+        args.turnTimeoutMs = Number(value());
+        break;
+      case '--keep-sessions':
+        args.keepSessions = true;
+        break;
+      case '--board-strategy': {
+        const strategy = value();
+        if (strategy !== 'latest' && strategy !== 'checkpoint-compatible') {
+          fail('--board-strategy must be "latest" or "checkpoint-compatible"');
+        }
+        args.boardStrategy = strategy;
+        break;
+      }
+      default:
+        fail(`unknown flag ${flag}`);
+    }
+  }
+  if (!!args.provider !== !!args.model) {
+    fail('--provider and --model must be given together');
+  }
+  if (args.boardStrategy && args.server) {
+    fail(
+      '--board-strategy requires a spawned server (it writes a scratch project config); drop --server',
+    );
+  }
+  if (!Number.isFinite(args.turnTimeoutMs) || args.turnTimeoutMs <= 0) {
+    fail('--turn-timeout-ms must be a positive number');
+  }
+  return args;
+}
+
+function getFreePort(): Promise<number> {
+  return new Promise((resolve, reject) => {
+    const server = createServer();
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      if (!address || typeof address === 'string') {
+        server.close();
+        reject(new Error('failed to allocate a port'));
+        return;
+      }
+      server.close((error) => (error ? reject(error) : resolve(address.port)));
+    });
+  });
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === 'object' && !Array.isArray(value);
+}
+
+function finiteNumber(value: unknown): number {
+  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
+}
+
+async function request(
+  base: string,
+  method: string,
+  route: string,
+  body?: unknown,
+  timeoutMs = 30_000,
+): Promise<unknown> {
+  const response = await fetch(`${base}${route}`, {
+    method,
+    headers: body === undefined ? {} : { 'content-type': 'application/json' },
+    body: body === undefined ? undefined : JSON.stringify(body),
+    signal: AbortSignal.timeout(timeoutMs),
+  });
+  if (!response.ok) {
+    throw new Error(`${method} ${route} returned HTTP ${response.status}`);
+  }
+  const text = await response.text();
+  if (!text) return undefined;
+  try {
+    return JSON.parse(text);
+  } catch {
+    return undefined;
+  }
+}
+
+async function waitForHealth(base: string): Promise<void> {
+  const deadline = Date.now() + 40_000;
+  while (Date.now() < deadline) {
+    try {
+      const response = await fetch(`${base}/global/health`, {
+        signal: AbortSignal.timeout(2_000),
+      });
+      if (response.ok) return;
+    } catch {
+      // keep polling
+    }
+    await new Promise((resolve) => setTimeout(resolve, 400));
+  }
+  throw new Error('server did not become healthy within 40s');
+}
+
+function extractAssistantRows(rawMessages: unknown): RequestRow[] {
+  if (!Array.isArray(rawMessages)) return [];
+  const rows: RequestRow[] = [];
+  for (const raw of rawMessages) {
+    const info = isRecord(raw) && isRecord(raw.info) ? raw.info : undefined;
+    if (info?.role !== 'assistant') continue;
+    const tokens = isRecord(info.tokens) ? info.tokens : undefined;
+    if (!tokens) continue;
+    const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
+    rows.push({
+      messageID: typeof info.id === 'string' ? info.id : '?',
+      input: finiteNumber(tokens.input),
+      output: finiteNumber(tokens.output),
+      cacheRead: finiteNumber(cache?.read),
+      cacheWrite: finiteNumber(cache?.write),
+    });
+  }
+  return rows;
+}
+
+async function fetchSessionRows(
+  base: string,
+  sessionID: string,
+): Promise<RequestRow[]> {
+  const rawMessages = await request(
+    base,
+    'GET',
+    `/session/${encodeURIComponent(sessionID)}/message`,
+  );
+  return extractAssistantRows(rawMessages);
+}
+
+async function listChildSessions(
+  base: string,
+  parentID: string,
+): Promise<Array<{ id: string; title: string }>> {
+  const raw = await request(base, 'GET', '/session').catch(() => undefined);
+  if (!Array.isArray(raw)) return [];
+  const children: Array<{ id: string; title: string }> = [];
+  for (const item of raw) {
+    if (!isRecord(item)) continue;
+    if (item.parentID !== parentID || typeof item.id !== 'string') continue;
+    children.push({
+      id: item.id,
+      title: typeof item.title === 'string' ? item.title : item.id,
+    });
+  }
+  return children;
+}
+
+/** Suspect = non-first request with a sizeable prompt and zero cache reads. */
+function suspectRows(rows: RequestRow[]): RequestRow[] {
+  return rows
+    .slice(1)
+    .filter(
+      (row) => row.cacheRead === 0 && row.input >= SUSPECT_INPUT_THRESHOLD,
+    );
+}
+
+/**
+ * Issue #874 signature: cache-read frozen at the same nonzero value across
+ * consecutive requests while sizeable uncached input accumulates — the
+ * reusable prefix has stopped growing even though nothing reads zero.
+ */
+function findPlateau(rows: RequestRow[]): PlateauFinding | undefined {
+  let worst: PlateauFinding | undefined;
+  let streak = 1;
+  let accumulatedInput = 0;
+  for (let i = 1; i < rows.length; i += 1) {
+    const row = rows[i];
+    if (row.cacheRead > 0 && row.cacheRead === rows[i - 1].cacheRead) {
+      streak += 1;
+      accumulatedInput += row.input;
+      if (
+        streak >= PLATEAU_STREAK_THRESHOLD &&
+        accumulatedInput >= PLATEAU_INPUT_THRESHOLD &&
+        (!worst || accumulatedInput > worst.accumulatedInput)
+      ) {
+        worst = {
+          frozenAt: row.cacheRead,
+          requests: streak,
+          accumulatedInput,
+        };
+      }
+    } else {
+      streak = 1;
+      accumulatedInput = 0;
+    }
+  }
+  return worst;
+}
+
+function judge(reports: SessionReport[]): Verdict {
+  const allRows = reports.flatMap((report) => report.rows);
+  if (allRows.length < 2) return 'inconclusive';
+  const anyTelemetry = allRows.some(
+    (row) => row.cacheRead > 0 || row.cacheWrite > 0,
+  );
+  if (!anyTelemetry) return 'inconclusive';
+  const suspects = reports.flatMap((report) => suspectRows(report.rows));
+  if (suspects.length > 0) return 'bust';
+  const plateaued = reports.some((report) => findPlateau(report.rows));
+  return plateaued ? 'plateau' : 'ok';
+}
+
+function coverage(rows: RequestRow[]): string {
+  const later = rows.slice(1);
+  const read = later.reduce((sum, row) => sum + row.cacheRead, 0);
+  const input = later.reduce((sum, row) => sum + row.input, 0);
+  const denominator = read + input;
+  return denominator > 0
+    ? `${((read / denominator) * 100).toFixed(1)}%`
+    : 'n/a';
+}
+
+function printSessionTable(report: SessionReport): void {
+  console.log(`  ${report.label}`);
+  if (report.rows.length === 0) {
+    console.log('    no assistant requests with token telemetry recorded');
+    return;
+  }
+  console.log(
+    '    req  input      output   cache-read  cache-write  read-coverage',
+  );
+  const suspects = new Set(suspectRows(report.rows));
+  report.rows.forEach((row, index) => {
+    const denominator = row.input + row.cacheRead;
+    const rowCoverage =
+      denominator > 0
+        ? `${((row.cacheRead / denominator) * 100).toFixed(1)}%`
+        : 'n/a';
+    const marker = suspects.has(row) ? '  ← SUSPECT' : '';
+    console.log(
+      `    #${String(index + 1).padEnd(3)}${String(row.input).padEnd(11)}${String(row.output).padEnd(9)}${String(row.cacheRead).padEnd(12)}${String(row.cacheWrite).padEnd(13)}${rowCoverage}${marker}`,
+    );
+  });
+  console.log(
+    `    cache-read coverage after first request: ${coverage(report.rows)}`,
+  );
+  const plateau = findPlateau(report.rows);
+  if (plateau) {
+    console.log(
+      `    ⚠ plateau: cache-read frozen at ${plateau.frozenAt} for ${plateau.requests} consecutive requests while ${plateau.accumulatedInput} uncached input tokens accumulated`,
+    );
+  }
+}
+
+function printScenarioReport(
+  scenario: Scenario,
+  reports: SessionReport[],
+  verdict: Verdict,
+): void {
+  console.log(`\n━━ scenario: ${scenario.name} (${scenario.description})`);
+  console.log(`  triggers: ${scenario.triggers}`);
+  for (const report of reports) {
+    printSessionTable(report);
+  }
+  const labels: Record<Verdict, string> = {
+    ok: '✅ every sizeable follow-up request read the provider cache',
+    plateau:
+      '⚠️ cache-read plateaued — reads never dropped to zero, but the reusable prefix stopped growing while input accumulated (issue #874 signature)',
+    bust: '❌ SUSPECT requests above read 0 cached tokens — the prompt prefix changed between requests',
+    inconclusive:
+      '⚠️ provider reported no cache telemetry — cannot verify (provider may not support or report caching)',
+  };
+  console.log(`  verdict: ${labels[verdict]}`);
+}
+
+async function runScenario(
+  base: string,
+  args: Args,
+  scenario: Scenario,
+): Promise<{ reports: SessionReport[]; verdict: Verdict }> {
+  const created = await request(base, 'POST', '/session', {});
+  const sessionID = isRecord(created) ? String(created.id ?? '') : '';
+  if (!sessionID) throw new Error('POST /session returned no session id');
+  const cleanupIDs = [sessionID];
+
+  try {
+    const nonce = crypto.randomUUID();
+    for (const turn of scenario.turns(nonce)) {
+      await request(
+        base,
+        'POST',
+        `/session/${encodeURIComponent(sessionID)}/message`,
+        {
+          ...(args.agent ? { agent: args.agent } : {}),
+          ...(args.provider && args.model
+            ? { model: { providerID: args.provider, modelID: args.model } }
+            : {}),
+          parts: [{ type: 'text', text: turn.text }],
+        },
+        args.turnTimeoutMs,
+      );
+      if (turn.pauseAfterMs) {
+        console.log(
+          `  (waiting ${Math.round(turn.pauseAfterMs / 1000)}s for background work…)`,
+        );
+        await new Promise((resolve) => setTimeout(resolve, turn.pauseAfterMs));
+      }
+    }
+
+    const reports: SessionReport[] = [
+      {
+        label: `session ${sessionID} (main)`,
+        rows: await fetchSessionRows(base, sessionID),
+      },
+    ];
+    for (const child of await listChildSessions(base, sessionID)) {
+      cleanupIDs.push(child.id);
+      reports.push({
+        label: `session ${child.id} (subagent: ${child.title})`,
+        rows: await fetchSessionRows(base, child.id),
+      });
+    }
+    return { reports, verdict: judge(reports) };
+  } finally {
+    if (!args.keepSessions) {
+      for (const id of cleanupIDs.reverse()) {
+        await request(
+          base,
+          'DELETE',
+          `/session/${encodeURIComponent(id)}`,
+        ).catch(() => {});
+      }
+    } else {
+      console.log(`  sessions kept: ${cleanupIDs.join(', ')}`);
+    }
+  }
+}
+
+async function main(): Promise<void> {
+  const args = parseArgs(process.argv.slice(2));
+
+  let base = args.server;
+  let child: ReturnType<typeof spawn> | undefined;
+  let scratch: string | undefined;
+
+  if (!base) {
+    const binary = process.env.OPENCODE_BIN ?? Bun.which('opencode');
+    if (!binary) {
+      fail(
+        'opencode binary not found — install opencode or set OPENCODE_BIN, or pass --server URL',
+      );
+    }
+    scratch = mkdtempSync(path.join(tmpdir(), 'cache-smoke-'));
+    writeFileSync(
+      path.join(scratch, 'package.json'),
+      `${JSON.stringify({ name: 'cache-smoke-fixture', version: '0.0.0' }, null, 2)}\n`,
+    );
+    if (args.boardStrategy) {
+      // Project-local plugin config overrides the user config, pinning the
+      // board strategy for this run regardless of global settings.
+      mkdirSync(path.join(scratch, '.opencode'), { recursive: true });
+      writeFileSync(
+        path.join(scratch, '.opencode', 'oh-my-opencode-slim.json'),
+        `${JSON.stringify(
+          {
+            backgroundJobs: {
+              strategy: args.boardStrategy,
+              maxRetainedSnapshots: 20,
+            },
+          },
+          null,
+          2,
+        )}\n`,
+      );
+      console.log(
+        `board strategy pinned via project config: ${args.boardStrategy}`,
+      );
+    }
+    const port = await getFreePort();
+    base = `http://127.0.0.1:${port}`;
+    console.log(`starting opencode serve on ${base} (cwd: ${scratch})`);
+    child = spawn(
+      binary,
+      ['serve', '--hostname', '127.0.0.1', '--port', String(port)],
+      {
+        cwd: scratch,
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    );
+    const stderrChunks: string[] = [];
+    child.stderr?.on('data', (chunk: Buffer) => {
+      stderrChunks.push(String(chunk));
+    });
+    child.once('exit', (code) => {
+      if (code !== null && code !== 0) {
+        console.error(stderrChunks.join('').slice(-2000));
+        fail(`opencode serve exited early with code ${code}`);
+      }
+    });
+    await waitForHealth(base);
+  }
+
+  const cleanup = () => {
+    child?.kill('SIGTERM');
+    if (scratch) rmSync(scratch, { recursive: true, force: true });
+  };
+
+  try {
+    const scenarios = SCENARIOS.filter((scenario) =>
+      args.scenarios.includes(scenario.name),
+    );
+    const verdicts: Verdict[] = [];
+    for (const scenario of scenarios) {
+      console.log(`\nrunning scenario: ${scenario.name}…`);
+      const { reports, verdict } = await runScenario(base, args, scenario);
+      printScenarioReport(scenario, reports, verdict);
+      verdicts.push(verdict);
+    }
+
+    console.log('');
+    if (verdicts.includes('bust')) {
+      console.log(
+        'RESULT: ❌ cache bust detected. Cross-check the plugin build (bun run build), then use docs/cache-verification.md to localize the changing prefix byte.',
+      );
+      process.exitCode = 1;
+    } else if (verdicts.includes('plateau')) {
+      console.log(
+        'RESULT: ⚠️ cache-read plateau detected — the reusable prefix stopped growing (issue #874). Compare board strategies with --board-strategy latest vs checkpoint-compatible.',
+      );
+      process.exitCode = 1;
+    } else if (verdicts.every((verdict) => verdict === 'inconclusive')) {
+      console.log(
+        'RESULT: ⚠️ inconclusive — the provider reported no cache telemetry for any request.',
+      );
+      process.exitCode = 2;
+    } else {
+      console.log(
+        'RESULT: ✅ provider prompt caching is working across the tested scenarios.',
+      );
+    }
+  } finally {
+    cleanup();
+  }
+}
+
+main().catch((error) => {
+  fail(error instanceof Error ? error.message : String(error));
+});

+ 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}
 

+ 30 - 8
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;
@@ -210,10 +220,15 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
 - Before local edits or another writer task, compare against running task scopes.
 - Parallel background tasks are allowed only when their write scopes do not conflict.
-- Before final response, reconcile any terminal jobs shown in the Background Job Board.
 - Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
 - Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
 
+### Active Task Amendments
+- A task in the Active / Unreconciled section is still running and cannot receive another \`task\` call, even with its \`task_id\`. Do not try to resume, replace, or cancel it merely because the user adds to its existing scope.
+- For an additive request to a running lane, record the amendment in the parent conversation, tell the user it is queued, and wait for that lane's terminal result. Then resume the same specialist only after its session appears in Reusable Sessions.
+- Cancel a running task only when its current objective is genuinely obsolete or must be replaced. Never create-and-cancel speculative duplicate sessions.
+- A \`running [resumed]\` board label reflects lifecycle bookkeeping, not confirmation that a new instruction reached the specialist.
+
 ### Design Handoff Discipline
 - When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
 - Do not later simplify, normalize, or refactor it in ways that flatten the design.
@@ -226,6 +241,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
+- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
 - When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
 - Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
@@ -292,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'> = {

+ 1 - 1
src/cli/system.ts

@@ -61,7 +61,7 @@ function canExecute(
       env: environment,
       // Required on Windows to execute .cmd/.bat shims produced by npm/pnpm/yarn
       // (Node's CVE-2024-27980 patch blocks them without a shell).
-      shell: isWindows,
+      shell: isWindows && !/\.exe$/i.test(command),
     });
     return result.status === 0;
   } catch {

+ 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

+ 15 - 6
src/config/constants.ts

@@ -83,26 +83,35 @@ export const COUNCILLOR_STAGGER_MS = 250;
 // Polling stability
 export const STABLE_POLLS_THRESHOLD = 3;
 
+// Toast duration (ms) used by all OMOS toasts
+export const TOAST_DURATION_MS = 10_000;
+
 /** Agents that are disabled by default. Users must explicitly enable them
  *  by removing from disabled_agents and configuring an appropriate model. */
 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;
 
-export type ImageRouting = 'auto' | 'direct';
-
 /**
- * Used when image_routing is omitted, preserving legacy conditional Observer
- * routing. Explicit "auto" is validated separately after config layers merge.
+ * 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_IMAGE_ROUTING: ImageRouting = 'auto';
+export const DEFAULT_MAX_SESSION_METADATA_ENTRIES = 1000;
+
+export type ImageRouting = 'auto' | 'direct';
 
 export function resolveImageRouting(
   imageRouting: ImageRouting | undefined,
+  observerEnabled: boolean,
 ): ImageRouting {
-  return imageRouting ?? DEFAULT_IMAGE_ROUTING;
+  // Explicit value: use it
+  if (imageRouting !== undefined) return imageRouting;
+  // Legacy conditional: intercept only when observer is enabled
+  return observerEnabled ? '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'],
       }),
     );
 

+ 85 - 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) {
@@ -401,6 +446,44 @@ export function loadPluginConfig(
     projectConfigPath ?? userConfigPath ?? '',
     options,
   );
+  // Note: we intentionally do NOT override image_routing to 'direct' here.
+  // The observer-disabled guard in processImageAttachments handles the
+  // auto+observer-disabled case by returning true, which triggers the
+  // debounced toast in index.ts. Overriding to 'direct' here would prevent
+  // processImageAttachments from returning true and suppress the toast.
+
+  // 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

+ 81 - 2
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: {} });
@@ -51,12 +75,12 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
-  it('defaults continueOnIdle to true', () => {
+  it('defaults continueOnIdle to false', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
 
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
     }
   });
 
@@ -118,4 +142,59 @@ describe('PluginConfigSchema backgroundJobs', () => {
       }).success,
     ).toBe(false);
   });
+
+  it('defaults the wall-clock supervisor to disabled with a 10 second grace', () => {
+    const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.backgroundJobs?.wallClockTimeoutMs).toBe(0);
+      expect(result.data.backgroundJobs?.abortGraceMs).toBe(10_000);
+    }
+  });
+
+  it('accepts the documented wall-clock supervisor bounds', () => {
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 0,
+          abortGraceMs: 1_000,
+        },
+      }).success,
+    ).toBe(true);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 60_000,
+          abortGraceMs: 60_000,
+        },
+      }).success,
+    ).toBe(true);
+    expect(
+      PluginConfigSchema.safeParse({
+        backgroundJobs: {
+          wallClockTimeoutMs: 2_147_483_647,
+        },
+      }).success,
+    ).toBe(true);
+  });
+
+  it('rejects wall-clock supervisor values outside the safe integer bounds', () => {
+    const invalid = [
+      { wallClockTimeoutMs: -1 },
+      { wallClockTimeoutMs: 1 },
+      { wallClockTimeoutMs: 59_999 },
+      { wallClockTimeoutMs: 2_147_483_648 },
+      { wallClockTimeoutMs: 60_000.5 },
+      { abortGraceMs: 999 },
+      { abortGraceMs: 60_001 },
+      { abortGraceMs: 1_000.5 },
+    ];
+
+    for (const backgroundJobs of invalid) {
+      expect(PluginConfigSchema.safeParse({ backgroundJobs }).success).toBe(
+        false,
+      );
+    }
+  });
 });

+ 39 - 10
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
@@ -218,9 +214,24 @@ export const BackgroundJobsConfigSchema = z.object({
     ),
   continueOnIdle: z
     .boolean()
-    .default(true)
+    .default(false)
+    .describe(
+      'Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.',
+    ),
+  wallClockTimeoutMs: z
+    .union([z.literal(0), z.number().int().min(60_000).max(2_147_483_647)])
+    .default(0)
+    .describe(
+      'Explicit opt-in wall-clock deadline for native task(..., background: true) child sessions. 0 disables supervision; finite values are 60,000–2,147,483,647ms.',
+    ),
+  abortGraceMs: z
+    .number()
+    .int()
+    .min(1_000)
+    .max(60_000)
+    .default(10_000)
     .describe(
-      'When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.',
+      'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
     ),
 });
 
@@ -299,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;
@@ -416,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
+  );
+}

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

@@ -155,10 +155,15 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
 - Before local edits or another writer task, compare against running task scopes.
 - Parallel background tasks are allowed only when their write scopes do not conflict.
-- Before final response, reconcile any terminal jobs shown in the Background Job Board.
 - Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
 - Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
 
+### Active Task Amendments
+- A task in the Active / Unreconciled section is still running and cannot receive another \`task\` call, even with its \`task_id\`. Do not try to resume, replace, or cancel it merely because the user adds to its existing scope.
+- For an additive request to a running lane, record the amendment in the parent conversation, tell the user it is queued, and wait for that lane's terminal result. Then resume the same specialist only after its session appears in Reusable Sessions.
+- Cancel a running task only when its current objective is genuinely obsolete or must be replaced. Never create-and-cancel speculative duplicate sessions.
+- A \`running [resumed]\` board label reflects lifecycle bookkeeping, not confirmation that a new instruction reached the specialist.
+
 ### Design Handoff Discipline
 - When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
 - Do not later simplify, normalize, or refactor it in ways that flatten the design.
@@ -171,6 +176,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
+- Only sessions listed under Reusable Sessions may be resumed. Active / Unreconciled sessions are not resumable.
 - When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
 - If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
 - Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
@@ -449,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,

+ 2 - 1
src/hooks/auto-update-checker/index.ts

@@ -4,6 +4,7 @@ import {
   ensureCompanionVersion,
   loadCompanionManifestFromPackageRoot,
 } from '../../companion/updater';
+import { TOAST_DURATION_MS } from '../../config/constants';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import {
@@ -434,7 +435,7 @@ function showToast(
   title: string,
   message: string,
   variant: 'info' | 'success' | 'error' = 'info',
-  duration = 3000,
+  duration = TOAST_DURATION_MS,
 ): void {
   ctx.client.tui
     .showToast({

+ 144 - 1
src/hooks/cache-monitor/index.test.ts

@@ -100,9 +100,12 @@ describe('createCacheMonitorHook', () => {
     expect(warnings).toHaveLength(2);
   });
 
-  test('stays silent for providers that never report cache tokens', async () => {
+  test('stays silent for modest sessions that never report cache tokens', async () => {
     const { hook, warnings } = createHarness();
 
+    // Cache-less providers are indistinguishable from busted sessions
+    // (OpenCode coalesces missing telemetry to zeros); below the cumulative
+    // input threshold the monitor must give them the benefit of the doubt.
     for (const id of ['c1', 'c2', 'c3']) {
       await hook.event(
         assistantMessageEvent({ messageID: id, input: 20000, cacheRead: 0 }),
@@ -112,6 +115,146 @@ describe('createCacheMonitorHook', () => {
     expect(warnings).toHaveLength(0);
   });
 
+  test('warns once for a large session that never hits the cache', async () => {
+    const { hook, warnings } = createHarness();
+
+    // The v2.2.5 checkpoint-board signature: consecutive ~146K-input
+    // requests, zero cache reads from the very first turn.
+    for (const id of ['g1', 'g2']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 146000, cacheRead: 0 }),
+      );
+    }
+    expect(warnings).toHaveLength(0);
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'g3', input: 146000, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('never hit the provider cache');
+    expect(warnings[0].data).toMatchObject({
+      sessionID: 'ses_monitor',
+      consecutiveUncachedRequests: 3,
+      uncachedInputTokens: 438000,
+    });
+
+    // Once per session, even as the streak keeps growing.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'g4', input: 146000, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(1);
+  });
+
+  test('any reported cache activity disarms the never-cached warning', async () => {
+    const { hook, warnings } = createHarness();
+
+    // An Anthropic-style first request reports a cache write; later misses
+    // are the everReportedCache bust signature, not the never-cached one.
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'h1',
+        input: 146000,
+        cacheRead: 0,
+        cacheWrite: 140000,
+      }),
+    );
+    for (const id of ['h2', 'h3', 'h4']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 146000, cacheRead: 0 }),
+      );
+    }
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('prompt-cache bust');
+  });
+
+  test('warns when cache-read plateaus while sizeable input accumulates', async () => {
+    const { hook, warnings } = createHarness();
+
+    // Issue #874 signature: read frozen at one boundary while uncached
+    // input keeps growing turn over turn.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'p1', input: 7000, cacheRead: 42496 }),
+    );
+    for (const [index, id] of ['p2', 'p3', 'p4', 'p5'].entries()) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: id,
+          input: 15000 + index,
+          cacheRead: 42496,
+        }),
+      );
+    }
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('cache-read plateau');
+    expect(warnings[0].data).toMatchObject({
+      sessionID: 'ses_monitor',
+      frozenCacheRead: 42496,
+      consecutiveFrozenRequests: 4,
+    });
+
+    // Once per plateau, even as the streak keeps growing.
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'p6',
+        input: 20000,
+        cacheRead: 42496,
+      }),
+    );
+    expect(warnings).toHaveLength(1);
+
+    // Growth ends the plateau and re-arms the warning for a new one.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'p7', input: 900, cacheRead: 60416 }),
+    );
+    for (const id of ['p8', 'p9', 'p10', 'p11']) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: id,
+          input: 16000,
+          cacheRead: 60416,
+        }),
+      );
+    }
+    expect(warnings).toHaveLength(2);
+  });
+
+  test('stays silent on frozen reads with small accumulated input', async () => {
+    const { hook, warnings } = createHarness();
+
+    // Providers round reads to coarse boundaries; identical reads across
+    // small turns are normal and must not warn.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'q1', input: 8000, cacheRead: 17920 }),
+    );
+    for (const id of ['q2', 'q3', 'q4', 'q5', 'q6']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 400, cacheRead: 17920 }),
+      );
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('stays silent while cache-read keeps growing', async () => {
+    const { hook, warnings } = createHarness();
+
+    let read = 17920;
+    for (const [index, id] of ['r1', 'r2', 'r3', 'r4', 'r5'].entries()) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: `${id}-${index}`,
+          input: 20000,
+          cacheRead: read,
+        }),
+      );
+      read += 1024;
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
   test('stays silent on the first request and on tiny prompts', async () => {
     const { hook, warnings } = createHarness();
 

+ 102 - 0
src/hooks/cache-monitor/index.ts

@@ -25,11 +25,44 @@ const MIN_INPUT_TOKENS_FOR_WARNING = 2048;
 const MAX_TRACKED_SESSIONS = 256;
 const MAX_TRACKED_MESSAGES_PER_SESSION = 512;
 
+/**
+ * A session busted from its very first request never trips the
+ * `everReportedCache` warning below — that was the field signature of the
+ * v2.2.5 checkpoint board regression, where every request re-paid full
+ * input from turn one and the monitor stayed silent.
+ *
+ * OpenCode coalesces missing provider cache telemetry to zeros, so explicit
+ * zeros cannot distinguish "prefix changes every request" from "provider
+ * has no prompt cache". Both thresholds must be met before warning — at
+ * least this many consecutive sizeable zero-cache requests AND this much
+ * cumulative uncached input — so the warning only fires where a working
+ * cache would have saved a large amount, and the wording stays hedged.
+ */
+const NEVER_CACHED_STREAK_FOR_WARNING = 3;
+const NEVER_CACHED_INPUT_TOKENS_FOR_WARNING = 100_000;
+
+/**
+ * Cache-read plateau (issue #874 signature): `cache.read` stays frozen at
+ * the same nonzero value across consecutive requests while sizeable uncached
+ * input accumulates — the provider's reusable prefix has stopped growing
+ * even though nothing reads zero. Providers round reads to coarse
+ * boundaries, so short frozen streaks with small inputs are normal; both
+ * thresholds must be met before warning.
+ */
+const PLATEAU_STREAK_FOR_WARNING = 4;
+const PLATEAU_INPUT_TOKENS_FOR_WARNING = 50_000;
+
 interface SessionCacheState {
   completedRequests: number;
   everReportedCache: boolean;
   lastCacheRead: number;
   warnedSinceLastHit: boolean;
+  neverCachedStreak: number;
+  neverCachedInputTokens: number;
+  neverCachedWarned: boolean;
+  plateauStreak: number;
+  plateauInputTokens: number;
+  plateauWarned: boolean;
   processedMessageIDs: Set<string>;
 }
 
@@ -118,6 +151,12 @@ export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
       everReportedCache: false,
       lastCacheRead: 0,
       warnedSinceLastHit: false,
+      neverCachedStreak: 0,
+      neverCachedInputTokens: 0,
+      neverCachedWarned: false,
+      plateauStreak: 0,
+      plateauInputTokens: 0,
+      plateauWarned: false,
       processedMessageIDs: new Set(),
     };
     sessions.set(sessionID, state);
@@ -152,6 +191,69 @@ export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
       );
     }
 
+    // A session that never serves a single cached token, over enough
+    // sizeable requests that a working cache would have saved a large
+    // amount, is busted from turn one — it never arms the
+    // everReportedCache warning above. Small requests neither extend nor
+    // reset the streak: they sit under provider minimum-prefix thresholds
+    // and legitimately miss.
+    if (!state.everReportedCache) {
+      if (
+        message.cacheRead === 0 &&
+        message.cacheWrite === 0 &&
+        message.inputTokens >= MIN_INPUT_TOKENS_FOR_WARNING
+      ) {
+        state.neverCachedStreak += 1;
+        state.neverCachedInputTokens += message.inputTokens;
+      }
+      if (
+        !state.neverCachedWarned &&
+        state.neverCachedStreak >= NEVER_CACHED_STREAK_FOR_WARNING &&
+        state.neverCachedInputTokens >= NEVER_CACHED_INPUT_TOKENS_FOR_WARNING
+      ) {
+        state.neverCachedWarned = true;
+        logger(
+          '[cache-monitor] session has never hit the provider cache: every sizeable request reported 0 cache-read tokens. If this provider supports prompt caching, the prompt prefix is likely changing on every request; if not, this session is re-paying full input each turn — see docs/cache-verification.md.',
+          {
+            sessionID: message.sessionID,
+            requestNumber: state.completedRequests,
+            consecutiveUncachedRequests: state.neverCachedStreak,
+            uncachedInputTokens: state.neverCachedInputTokens,
+          },
+        );
+      }
+    }
+
+    // Cache-read plateau (issue #874): reads frozen at the same nonzero
+    // boundary while uncached input keeps accumulating — the reusable
+    // prefix has stopped growing. Reads changing (any direction) end the
+    // streak and re-arm the warning.
+    if (message.cacheRead > 0 && message.cacheRead === state.lastCacheRead) {
+      state.plateauStreak += 1;
+      state.plateauInputTokens += message.inputTokens;
+      if (
+        !state.plateauWarned &&
+        state.plateauStreak >= PLATEAU_STREAK_FOR_WARNING &&
+        state.plateauInputTokens >= PLATEAU_INPUT_TOKENS_FOR_WARNING
+      ) {
+        state.plateauWarned = true;
+        logger(
+          '[cache-monitor] cache-read plateau: the provider is reusing the same frozen prefix while sizeable uncached input accumulates — the reusable prefix has stopped growing (issue #874 signature). Consider backgroundJobs.strategy "checkpoint-compatible" — see docs/cache-verification.md.',
+          {
+            sessionID: message.sessionID,
+            requestNumber: state.completedRequests,
+            frozenCacheRead: message.cacheRead,
+            consecutiveFrozenRequests: state.plateauStreak,
+            uncachedInputTokensDuringPlateau: state.plateauInputTokens,
+          },
+        );
+      }
+    } else {
+      state.plateauStreak = 0;
+      state.plateauInputTokens = 0;
+      state.plateauWarned = false;
+    }
+
     if (message.cacheRead > 0) state.warnedSinceLastHit = false;
     state.everReportedCache =
       state.everReportedCache ||

+ 24 - 7
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,12 +25,20 @@ 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;
 
 export type TransformOutput = { messages: unknown[] };
 
+export type BoardStrategy = 'latest' | 'checkpoint-compatible';
+
+export interface PipelineOptions {
+  /** Board injection strategy under test; defaults to the production default. */
+  strategy?: BoardStrategy;
+}
+
 export interface Pipeline {
   run: (output: TransformOutput) => Promise<void>;
   markFileToolPending: () => void;
@@ -42,7 +50,7 @@ export interface Pipeline {
  * cache-safety.property.test.ts fails when the two fall out of sync — update
  * BOTH when adding, removing, or reordering a transform step.
  */
-export function createPipeline(): Pipeline {
+export function createPipeline(options: PipelineOptions = {}): Pipeline {
   const sessionAgentMap = new Map<string, string>();
   const board = new BackgroundJobBoard();
   const lifecycle = new SessionLifecycle(() => {});
@@ -67,6 +75,7 @@ export function createPipeline(): Pipeline {
     {
       maxSessionsPerAgent: 2,
       maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
+      ...(options.strategy ? { strategy: options.strategy } : {}),
       backgroundJobBoard: board,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
@@ -103,7 +112,7 @@ export function createPipeline(): Pipeline {
     processImageAttachments({
       messages: output.messages as MessageWithParts[],
       workDir: '/tmp/cache-safety-fixture',
-      imageRouting: resolveImageRouting(undefined),
+      imageRouting: resolveImageRouting(undefined, true),
       disabledAgents: new Set(),
       log: noopLog,
     });
@@ -221,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 - 18
src/hooks/cache-safety.property.test.ts

@@ -21,9 +21,11 @@
 import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
 import { readFileSync } from 'node:fs';
 import path from 'node:path';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { BackgroundJobsConfigSchema } from '../config';
+import { isTaggedPart, isVolatileTaggedMessage } from './cache-safe-injection';
 import {
   assistantTurn,
+  type BoardStrategy,
   buildHistory,
   createPipeline,
   FIXTURE_NOW,
@@ -34,24 +36,79 @@ import {
   turnEndIndices,
 } from './cache-safety-harness.test';
 import { BACKGROUND_JOB_BOARD_METADATA_KEY } from './task-session-manager';
+import type { MessageWithParts } from './types';
 
 afterEach(() => {
   setSystemTime();
 });
 
-describe('cache-safety: turn-over-turn prefix stability', () => {
+/**
+ * Per-strategy definition of "the bytes that must never be rewritten".
+ *
+ * - `latest`: board state lives in a single volatile trailing message that
+ *   is stripped and re-appended every request, so the stable prefix is
+ *   every non-volatile message.
+ * - `checkpoint-compatible`: board snapshots are append-only stable bytes
+ *   by design — that is the strategy's entire purpose — so the stable
+ *   prefix is the WHOLE provider-visible payload. Filtering tagged messages
+ *   here would hide exactly the snapshot drop/reinsert rewrite that shipped
+ *   in v2.2.5. Replayed snapshot messages are rebuilt each request from a
+ *   varying base message, so only provider-visible fields (role, agent,
+ *   parts) participate — `info` never reaches the provider.
+ *
+ * A new `BackgroundJobsConfigSchema` strategy must add an entry here (the
+ * drift guard below fails until it does), forcing an explicit decision
+ * about its cache-safety semantics before it can ship.
+ */
+const STRATEGY_STABLE_FINGERPRINTS: Record<
+  BoardStrategy,
+  (messages: unknown[]) => string[]
+> = {
+  latest: stableFingerprints,
+  'checkpoint-compatible': (messages) =>
+    (messages as MessageWithParts[]).map((message) =>
+      JSON.stringify({
+        role: message.info.role,
+        agent: message.info.agent,
+        parts: message.parts,
+      }),
+    ),
+};
+
+const BOARD_STRATEGIES = Object.keys(
+  STRATEGY_STABLE_FINGERPRINTS,
+) as BoardStrategy[];
+
+describe('cache-safety: board strategy coverage drift guard', () => {
+  test('every configurable board strategy has property coverage', () => {
+    const schemaStrategies =
+      BackgroundJobsConfigSchema.shape.strategy.unwrap().options;
+    expect([...BOARD_STRATEGIES].sort()).toEqual([...schemaStrategies].sort());
+  });
+});
+
+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();
+    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 before the
-      // second turn, and background jobs churn (launch, then drop) while
-      // later turns render — none of it may touch stable bytes.
-      if (turnNumber === 1) pipeline.markFileToolPending();
-      if (turnNumber === 2) {
+      // 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,
@@ -60,10 +117,19 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
           now: FIXTURE_NOW,
         });
       }
-      if (turnNumber === 3) pipeline.board.drop('task-alpha');
+      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 = stableFingerprints(output.messages);
+      const fingerprints = fingerprintsFor(output.messages);
 
       if (previous) {
         if (fingerprints.length < previous.length) {
@@ -76,7 +142,9 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
       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 () => {
     const pipeline = createPipeline();
     const history = buildHistory();
@@ -97,9 +165,11 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
   });
 });
 
-describe('cache-safety: specialist sessions', () => {
+describe.each(
+  BOARD_STRATEGIES,
+)('cache-safety: specialist sessions (%s)', (strategy) => {
   test('non-orchestrator payloads pass through byte-identical', async () => {
-    const pipeline = createPipeline();
+    const pipeline = createPipeline({ strategy });
     const specialistSession = 'ses_specialist_fixture';
     const history = [
       {
@@ -155,12 +225,65 @@ 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) =>
+    // 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(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) =>
+        (message as MessageWithParts).parts.some((part) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
+      ),
+    ).toBe(false);
+  });
+
+  test('checkpoint-compatible board state only ever adds tagged snapshot messages', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+
+    const emptyBoard = createPipeline({ strategy: 'checkpoint-compatible' });
+    const busyBoard = createPipeline({ strategy: 'checkpoint-compatible' });
+    busyBoard.board.registerLaunch({
+      taskID: 'task-beta',
+      parentSessionID: SESSION_ID,
+      agent: 'fixer',
+      description: 'checkpoint isolation fixture',
+      now: FIXTURE_NOW,
+    });
+
+    const withoutJobs = await renderTurn(emptyBoard, history, lastTurn);
+    const withJobs = await renderTurn(busyBoard, history, lastTurn);
+
+    // Real message bytes must be identical; board content may only appear
+    // as tagged snapshot messages (append-only by design, so they are part
+    // of the stable prefix rather than a volatile tail).
+    expect(stableFingerprints(withJobs.messages)).toEqual(
+      stableFingerprints(withoutJobs.messages),
+    );
+    const snapshots = withJobs.messages.filter((message) =>
       isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
     );
-    expect(volatile).toHaveLength(1);
-    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(snapshots.length).toBeGreaterThan(0);
     expect(
       withoutJobs.messages.some((message) =>
         isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
@@ -169,7 +292,9 @@ describe('cache-safety: volatile content isolation', () => {
   });
 });
 
-describe('cache-safety: determinism under ambient inputs', () => {
+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;
@@ -179,7 +304,7 @@ describe('cache-safety: determinism under ambient inputs', () => {
       setSystemTime(new Date(time));
       Math.random = () => random;
       try {
-        const pipeline = createPipeline();
+        const pipeline = createPipeline({ strategy });
         pipeline.board.registerLaunch({
           taskID: 'task-gamma',
           parentSessionID: SESSION_ID,

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

@@ -81,6 +81,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: {

+ 79 - 5
src/hooks/image-hook.test.ts

@@ -89,25 +89,27 @@ describe('image-hook catch logging', () => {
 describe('processImageAttachments image routing', () => {
   it('direct mode leaves image parts untouched', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'direct'),
       imageRouting: 'direct',
       disabledAgents: new Set<string>(),
       log: () => {},
     });
+    expect(result).toBe(false);
     expect(imagePartCount(message)).toBe(1);
   });
 
   it('auto mode saves image parts and adds an @observer nudge', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'auto'),
       imageRouting: 'auto',
       disabledAgents: new Set<string>(),
       log: () => {},
     });
+    expect(result).toBe(false);
     expect(imagePartCount(message)).toBe(0);
     const textParts = message.parts.filter((part) => part.type === 'text');
     expect(textParts).toHaveLength(1);
@@ -119,7 +121,7 @@ describe('processImageAttachments image routing', () => {
     processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'omitted-routing'),
-      imageRouting: resolveImageRouting(undefined),
+      imageRouting: resolveImageRouting(undefined, true),
       disabledAgents: new Set<string>(),
       log: () => {},
     });
@@ -127,18 +129,72 @@ describe('processImageAttachments image routing', () => {
     expect(message.parts.some((part) => part.type === 'text')).toBe(true);
   });
 
-  it('keeps images when auto mode has observer disabled', () => {
+  it('returns true when observer disabled and message has images', () => {
     const message = makeUserMsg([IMG]);
-    processImageAttachments({
+    const result = processImageAttachments({
       messages: [message],
       workDir: path.join(TEST_DIR, 'disabled'),
       imageRouting: 'auto',
       disabledAgents: new Set(['observer']),
       log: () => {},
     });
+    expect(result).toBe(true);
     expect(imagePartCount(message)).toBe(1);
   });
 
+  it('returns false when observer disabled but no images present', () => {
+    const message = makeUserMsg([{ type: 'text', text: 'hello' }]);
+    const result = processImageAttachments({
+      messages: [message],
+      workDir: path.join(TEST_DIR, 'disabled-noimg'),
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result).toBe(false);
+  });
+
+  it('returns true when observer disabled and an earlier (non-last) user message has images', () => {
+    const earlierMsg = makeUserMsg([IMG]);
+    const lastMsg = makeUserMsg([{ type: 'text', text: 'follow-up question' }]);
+    const result = processImageAttachments({
+      messages: [earlierMsg, lastMsg],
+      workDir: path.join(TEST_DIR, 'earlier-image'),
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result).toBe(true);
+  });
+
+  it('does not re-trigger on text-only messages after image was processed', () => {
+    // Regression test: Greptile #1 fix checked ALL messages, causing the hook
+    // to fire on every transform once an image was in the conversation history.
+    const workDir = path.join(TEST_DIR, 'no-rere-trigger');
+    const imageMsg = makeUserMsg([IMG]);
+    const textMsg = makeUserMsg([{ type: 'text', text: 'follow-up' }]);
+
+    // First call: image present → should return true
+    const result1 = processImageAttachments({
+      messages: [imageMsg, textMsg],
+      workDir,
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result1).toBe(true);
+
+    // Second call: same messages, no new image → should return false
+    const result2 = processImageAttachments({
+      messages: [imageMsg, textMsg],
+      workDir,
+      imageRouting: 'auto',
+      disabledAgents: new Set(['observer']),
+      log: () => {},
+    });
+    expect(result2).toBe(false);
+  });
+
   it('keeps images when auto mode cannot save them', () => {
     const message = makeUserMsg([
       { type: 'image', url: 'https://example.com/image.png' },
@@ -207,3 +263,21 @@ describe('processImageAttachments image routing', () => {
     expect(assistant.parts).toHaveLength(1);
   });
 });
+
+describe('resolveImageRouting', () => {
+  it('returns auto when omitted and observer enabled', () => {
+    expect(resolveImageRouting(undefined, true)).toBe('auto');
+  });
+
+  it('returns direct when omitted and observer disabled', () => {
+    expect(resolveImageRouting(undefined, false)).toBe('direct');
+  });
+
+  it('preserves explicit auto even when observer disabled', () => {
+    expect(resolveImageRouting('auto', false)).toBe('auto');
+  });
+
+  it('preserves explicit direct even when observer enabled', () => {
+    expect(resolveImageRouting('direct', true)).toBe('direct');
+  });
+});

+ 53 - 4
src/hooks/image-hook.ts

@@ -16,6 +16,14 @@ import { isUserMessageWithParts, type MessageWithParts } from './types';
 const lastCleanupByDir = new Map<string, number>();
 const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
 
+// Track how many user messages we've already checked for images per directory.
+// Without this, the observer-disabled guard re-checks ALL messages on every
+// transform. Once an image is sent, it stays in the messages array forever,
+// causing the hook to fire on every subsequent text-only message. This
+// suppresses duplicate toasts while still catching images in non-last messages
+// (Greptile #1 fix).
+const lastProcessedUserMsgCountByDir = new Map<string, number>();
+
 interface ImagePart {
   type: string;
   url?: string;
@@ -169,17 +177,57 @@ export function processImageAttachments(args: {
   imageRouting: 'auto' | 'direct';
   disabledAgents: Set<string>;
   log: (msg: string) => void;
-}): void {
+}): boolean {
   const { messages, workDir, imageRouting, disabledAgents, log } = args;
 
   // direct mode: never intercept attachments; the orchestrator handles them
   // inline. @observer remains available for manual delegation.
-  if (imageRouting === 'direct') return;
+  if (imageRouting === 'direct') {
+    return false;
+  }
 
   // auto mode: observer must be enabled (enforced at config load). Retain
   // this guard as defense-in-depth in case validation is bypassed.
   const observerEnabled = !disabledAgents.has('observer');
-  if (!observerEnabled) return;
+  if (!observerEnabled) {
+    // Check only NEW user messages for images. We track how many user messages
+    // we've already processed per session. Without this, the guard re-checks
+    // ALL messages on every transform — once an image is sent, it stays in the
+    // messages array forever, causing the hook to fire on every subsequent
+    // text-only message (regression from Greptile #1 fix).
+    //
+    // Keyed by workDir:sessionID so multiple sessions in the same project
+    // don't collide (Greptile P1: "Scope tracking by conversation").
+    const firstUserMsg = messages.find(isUserMessageWithParts);
+    const sessionId = firstUserMsg?.info.sessionID ?? 'default';
+    const counterKey = `${workDir}:${sessionId}`;
+    const userMsgCount = messages.filter(isUserMessageWithParts).length;
+    let lastProcessed = lastProcessedUserMsgCountByDir.get(counterKey) ?? 0;
+    // ponytail: reset after history compaction; re-checking old messages is harmless
+    if (userMsgCount < lastProcessed) {
+      lastProcessed = 0;
+      lastProcessedUserMsgCountByDir.set(counterKey, 0);
+    }
+    if (userMsgCount > lastProcessed) {
+      // Check only the new user messages (those we haven't seen yet)
+      let userIndex = 0;
+      for (const msg of messages) {
+        if (!isUserMessageWithParts(msg)) continue;
+        if (userIndex >= lastProcessed) {
+          // This is a new user message — check for images
+          if (msg.parts.some(isImagePart)) {
+            log('[image-hook] dropped images: observer disabled');
+            lastProcessedUserMsgCountByDir.set(counterKey, userMsgCount);
+            return true;
+          }
+        }
+        userIndex++;
+      }
+      // No images in new messages — update counter so we don't re-check them
+      lastProcessedUserMsgCountByDir.set(counterKey, userMsgCount);
+    }
+    return false;
+  }
 
   const messagesWithImages: Array<{
     msg: MessageWithParts;
@@ -200,7 +248,7 @@ export function processImageAttachments(args: {
 
   if (messagesWithImages.length === 0) {
     if (existsSync(saveDir)) cleanupAllSessions(saveDir);
-    return;
+    return false;
   }
 
   const gitignorePath = join(workDir, '.opencode', '.gitignore');
@@ -279,4 +327,5 @@ export function processImageAttachments(args: {
         },
       ]);
   }
+  return false;
 }

+ 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);
+  });
+});

+ 725 - 76
src/hooks/task-session-manager/board-injection.ts

@@ -7,17 +7,27 @@
  * 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,
 } from '../../utils';
-import { isInternalInitiatorPart, parseTaskStatusOutput } from '../../utils';
+import {
+  isInternalInitiatorPart,
+  parseTaskStatusOutput,
+  renderRunningTaskPlaceholder,
+} from '../../utils';
+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';
@@ -43,6 +53,7 @@ type RetainedBoardSnapshot = {
   anchorKey: string;
   id: string;
   text: string;
+  terminalUnreconciledTaskIDs: BackgroundJobExecution[];
 };
 
 export type RetainedBoardSnapshotState = {
@@ -52,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;
@@ -70,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 ────────────────────────────────────────────────────────────
@@ -82,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,
@@ -111,6 +199,53 @@ function createOccurrenceId(
 
 // ── Exported functions ─────────────────────────────────────────────────
 
+/**
+ * Normalize the `output` of every still-running `task` tool result to a
+ * static, deterministic placeholder keyed only on the task ID.
+ *
+ * OpenCode core stores a fixed running placeholder in `state.output` when a
+ * background task launches and materializes the terminal result separately as
+ * a synthetic completion message. However, the runtime is free to stream live
+ * child progress into a running task part's `state.output` (foreground
+ * promotion, future core versions). Any such mid-history mutation invalidates
+ * the provider prompt cache from that byte onward, re-writing the entire tail
+ * every request while a background lane runs (write-never-read loop).
+ *
+ * This makes running task parts byte-stable at the plugin layer: it only ever
+ * touches parts whose parsed state is `running`, so terminal
+ * (completed/error/cancelled) results — which must reach the orchestrator
+ * intact and mutate exactly once on completion — are never altered. It is a
+ * pure normalization: re-running it on an already-stabilized part is a no-op.
+ * Foreground (`wait:true`) tasks block and return a terminal state, so their
+ * parts are never running here and keep their real output.
+ */
+export function stabilizeRunningTaskParts(messages: unknown[]): void {
+  for (const message of messages) {
+    if (!isMessageWithParts(message)) continue;
+    for (const part of message.parts) {
+      if (part.type !== 'tool' || part.tool !== 'task') continue;
+      const state = part.state;
+      if (!isRecord(state)) continue;
+      if (typeof state.output !== 'string') continue;
+
+      // Only running task results are volatile. Terminal results (completed,
+      // error, cancelled) are materialized exactly once and must stay intact.
+      const status = parseTaskStatusOutput(state.output);
+      const runningByStatus = status?.state === 'running';
+      const runningByField =
+        state.status === 'running' && (status === undefined || runningByStatus);
+      if (!runningByStatus && !runningByField) continue;
+
+      const taskID = status?.taskID;
+      if (!taskID) continue;
+
+      const placeholder = renderRunningTaskPlaceholder(taskID);
+      if (state.output === placeholder) continue;
+      state.output = placeholder;
+    }
+  }
+}
+
 export function updateFromInjectedCompletion(
   state: InjectionState,
   part: MessagePart,
@@ -161,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;
   }
 
@@ -176,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,
@@ -214,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);
 }
 
@@ -263,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;
 
-    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.
+  // 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);
+  }
+
+  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)) {
@@ -339,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(
@@ -360,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;
@@ -372,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(
@@ -430,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[],
@@ -503,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[]>();
@@ -520,6 +1165,7 @@ function replayBoardSnapshots(
   );
 
   const rebuiltMessages: unknown[] = [];
+  const replayedIDs: BackgroundJobExecution[] = [];
   let realMessageIndex = 0;
   for (const message of messages) {
     rebuiltMessages.push(message);
@@ -541,10 +1187,14 @@ function replayBoardSnapshots(
           usedMessageIDs,
         ),
       );
+      if (snapshot.terminalUnreconciledTaskIDs?.length) {
+        replayedIDs.push(...snapshot.terminalUnreconciledTaskIDs);
+      }
     }
   }
 
   messages.splice(0, messages.length, ...rebuiltMessages);
+  return replayedIDs;
 }
 
 function replayCheckpointBoard(
@@ -553,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

+ 51 - 23
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,11 +99,15 @@ export async function evaluateContinuation(
     options: {
       isFallbackInProgress?: (sessionID: string) => boolean;
     };
+    getObservedModelSelection: (
+      sessionID: string,
+    ) => ContinuationModelSelection | undefined;
     sessionSdk?: {
-      todo?: (...args: unknown[]) => Promise<{ data?: unknown }>;
-      children?: (...args: unknown[]) => Promise<{ data?: unknown }>;
-      status?: (...args: unknown[]) => Promise<{ data?: unknown }>;
-      promptAsync?: (...args: unknown[]) => Promise<unknown>;
+      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>;
     };
   },
 ): Promise<void> {
@@ -155,15 +163,15 @@ export async function evaluateContinuation(
   let committed = false;
   try {
     const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
-      deps.sessionSdk.todo(
-        { sessionID: parentSessionID },
-        { throwOnError: true },
-      ),
-      deps.sessionSdk.children(
-        { sessionID: parentSessionID },
-        { throwOnError: true },
-      ),
-      deps.sessionSdk.status({}, { throwOnError: true }),
+      deps.sessionSdk.todo({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
     ]);
     if (
       !Array.isArray(todoResponse.data) ||
@@ -203,11 +211,11 @@ export async function evaluateContinuation(
     // Re-read liveness immediately before queuing work; board state is only
     // authoritative for terminal results observed by this plugin instance.
     const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
-      deps.sessionSdk.children(
-        { sessionID: parentSessionID },
-        { throwOnError: true },
-      ),
-      deps.sessionSdk.status({}, { throwOnError: true }),
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
     ]);
     if (
       !Array.isArray(latestChildrenResponse.data) ||
@@ -230,6 +238,25 @@ export async function evaluateContinuation(
       return;
     }
 
+    let currentModelSelection: ContinuationModelSelection | undefined;
+    if (deps.sessionSdk.get) {
+      try {
+        const sessionResponse = await deps.sessionSdk.get({
+          sessionID: 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)
     ) {
@@ -246,11 +273,12 @@ export async function evaluateContinuation(
     committed = true;
     await deps.sessionSdk.promptAsync({
       sessionID: parentSessionID,
-        agent: 'orchestrator',
-        parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
-      },
-      { throwOnError: true },
-    );
+      agent: 'orchestrator',
+      ...(modelSelection ? { model: modelSelection.model } : {}),
+      ...(modelSelection?.variant ? { variant: modelSelection.variant } : {}),
+      parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
+      throwOnError: true,
+    });
   } catch (error) {
     log(
       '[task-session-manager] continuation nudge suppressed after SDK error',

+ 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 } : {}),
+  };
+}

+ 28 - 4
src/hooks/task-session-manager/event-router.ts

@@ -5,10 +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(
@@ -74,8 +79,13 @@ 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;
   },
 ): Promise<void> {
   deps.inputWaits.trackInputWait(input.event);
@@ -122,9 +132,13 @@ export async function handleEvent(
           agent: pending.agentType,
           description: pending.label,
           objective: pending.label,
+          // session.created has no reliable call identity. Keep this
+          // registration tentative so an unrelated foreground call cannot
+          // accidentally arm wall-clock supervision.
+          background: false,
         });
         log(
-          '[task-session-manager] early board registration from session.created',
+          '[task-session-manager] tentative early board registration from session.created',
           {
             taskID: record.taskID,
             alias: record.alias,
@@ -138,6 +152,7 @@ export async function handleEvent(
   }
 
   if (input.event.type === 'server.instance.disposed') {
+    deps.backgroundJobSupervisor?.dispose();
     deps.retainedBoardSnapshots.clear();
     const idleSessionIds = deps.idleReconciler.clearAllTimers();
     // Local-only: release this instance's uncommitted reservations and drop
@@ -171,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,
     });
@@ -207,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);
@@ -307,6 +325,12 @@ export async function handleEvent(
   }
   deps.inputWaits.clearInputWaits(sessionId);
   deps.retainedBoardSnapshots.delete(sessionId);
+  const fallbackInProgress =
+    deps.options.isFallbackInProgress?.(sessionId) === true;
+  const job = deps.backgroundJobBoard.get(sessionId);
+  if (!fallbackInProgress || job?.deadlineExceededAt !== undefined) {
+    deps.backgroundJobSupervisor?.onSessionDeleted(sessionId);
+  }
 
   log('[task-session-manager] session.deleted observed', {
     sessionID: 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
+ 1062 - 131
src/hooks/task-session-manager/index.test.ts


+ 79 - 15
src/hooks/task-session-manager/index.ts

@@ -1,22 +1,29 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
+  type BackgroundJobExecution,
   type BackgroundJobStore,
+  type BackgroundJobSupervisor,
   isInternalInitiatorPart,
 } from '../../utils';
 import { isRecord as isObjectRecord } from '../../utils/guards';
-import { getClient } from '../../utils/opencode-client';
 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,
   reconcileInjectedTerminalJobs,
+  stabilizeRunningTaskParts,
   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';
@@ -48,12 +55,13 @@ export function createTaskSessionManagerHook(
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     /**
-     * When true (default), idle orchestrator sessions with incomplete todos may
-     * receive one automatic continuation promptAsync. Set false to keep idle
-     * reconciliation without continuation SDK calls.
+     * Beta opt-in. When true, idle orchestrator sessions with incomplete todos
+     * may receive one automatic continuation promptAsync. Disabled by default;
+     * idle reconciliation continues without continuation SDK calls.
      */
     continueOnIdle?: boolean;
     backgroundJobBoard?: BackgroundJobStore;
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
     shouldManageSession: (sessionID: string) => boolean;
     /** Register a session as orchestrator when the transform hook detects
      *  an orchestrator message but the session isn't in the agent map yet. */
@@ -69,7 +77,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
   },
 ) {
-  const continueOnIdle = options.continueOnIdle !== false;
+  const continueOnIdle = options.continueOnIdle === true;
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     new BackgroundJobBoard({
@@ -83,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
@@ -133,12 +149,14 @@ export function createTaskSessionManagerHook(
 
   type SdkResponse = { data?: unknown };
   type SessionSdk = {
-    todo?: (input: unknown, opts?: unknown) => Promise<SdkResponse>;
-    children?: (input: unknown, opts?: unknown) => Promise<SdkResponse>;
-    status?: (input: unknown, opts?: unknown) => Promise<SdkResponse>;
-    promptAsync?: (input: unknown, opts?: unknown) => Promise<unknown>;
+    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 = getClient(_ctx).session as SessionSdk;
+  const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
+    .session;
 
   evaluateContinuation = (parentSessionID, sessionToken) =>
     evaluateContinuationFn(parentSessionID, sessionToken, {
@@ -148,6 +166,8 @@ export function createTaskSessionManagerHook(
       inputWaits,
       options,
       sessionSdk,
+      getObservedModelSelection: (sessionID) =>
+        observedContinuationModels.get(sessionID),
     });
 
   if (options.coordinator) {
@@ -159,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.
@@ -166,11 +187,19 @@ export function createTaskSessionManagerHook(
       // lose track of the task and report it as cancelled even though the
       // oracle actually completed.
       if (!options.isFallbackInProgress?.(sessionId)) {
-        backgroundJobBoard.drop(sessionId);
+        options.backgroundJobSupervisor?.onSessionDeleted(sessionId);
+        const hardTimedOut =
+          backgroundJobBoard.field(sessionId, 'deadlineExceededAt') !==
+          undefined;
+        if (!hardTimedOut) backgroundJobBoard.drop(sessionId);
+        options.backgroundJobSupervisor?.clearParent(sessionId);
         backgroundJobBoard.clearParent(sessionId);
+        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);
@@ -184,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 {
@@ -240,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);
     },
 
@@ -251,6 +297,7 @@ export function createTaskSessionManagerHook(
         shouldManageSession: options.shouldManageSession,
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -262,6 +309,7 @@ export function createTaskSessionManagerHook(
       handleToolExecuteAfter(input, output, {
         directory: _ctx.directory,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -272,6 +320,11 @@ export function createTaskSessionManagerHook(
     ): Promise<void> => {
       const messages = Array.isArray(output.messages) ? output.messages : [];
 
+      // Keep still-running task tool results byte-stable so a live background
+      // lane never rewrites mid-history bytes and invalidates the prompt
+      // cache. Terminal results are left untouched (they materialize once).
+      stabilizeRunningTaskParts(messages);
+
       for (const [messageIndex, message] of messages.entries()) {
         if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') {
@@ -318,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,
@@ -328,7 +389,10 @@ export function createTaskSessionManagerHook(
         pendingCallTracker,
         taskContextTracker,
         terminalJobsInjectedByParent,
+        pendingInjectedTerminalJobsByParent,
         retainedBoardSnapshots: injectionState.retainedBoardSnapshots,
-      }),
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
+      });
+    },
   };
 }

+ 1 - 0
src/hooks/task-session-manager/pending-call-tracker.ts

@@ -3,6 +3,7 @@ export interface PendingTaskCall {
   parentSessionId: string;
   agentType: string;
   label: string;
+  background: boolean;
   resumedTaskId?: string;
 }
 

+ 305 - 0
src/hooks/task-session-manager/running-task-cache-safety.test.ts

@@ -0,0 +1,305 @@
+/**
+ * Regression coverage for running background-task tool-result byte stability.
+ *
+ * While a background `task` lane runs, the runtime may stream live child
+ * progress into the parent's task tool part (`state.output`). A still-running
+ * task result sits mid-history, so any per-request change to it invalidates
+ * the provider prompt cache from that byte onward, re-writing the entire tail
+ * every request (a write-never-read loop).
+ *
+ * The transform hook must:
+ *  (a) keep a running task part byte-identical across consecutive requests
+ *      while the child progresses,
+ *  (b) materialize the terminal result exactly once and keep it byte-stable
+ *      afterwards, and
+ *  (c) never produce duplicate completed results.
+ */
+import { describe, expect, mock, test } from 'bun:test';
+import { DEFAULT_MAX_RETAINED_SNAPSHOTS } from '../../config/constants';
+import { BackgroundJobBoard } from '../../utils';
+import { createTaskSessionManagerHook } from './index';
+
+const SESSION = 'ses_orchestrator_1114';
+const CHILD = 'ses_child_0771';
+
+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,
+    },
+  );
+}
+
+/** A task tool call on an assistant message, mirroring the SDK part shape. */
+function taskToolMessage(callID: string, output: string) {
+  return {
+    info: {
+      role: 'assistant',
+      agent: 'orchestrator',
+      sessionID: SESSION,
+      id: callID,
+    },
+    parts: [
+      { type: 'text', text: ' ' },
+      {
+        type: 'tool',
+        tool: 'task',
+        callID,
+        state: { status: 'running', input: { background: true }, output },
+      },
+    ],
+  };
+}
+
+function userMessage(id: string, text: string) {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: SESSION, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+/** Grab the task tool part's rendered output from a transformed history. */
+function taskOutput(messages: unknown[], callID: string): string | undefined {
+  for (const message of messages as any[]) {
+    for (const part of message?.parts ?? []) {
+      if (
+        part?.type === 'tool' &&
+        part?.tool === 'task' &&
+        part?.callID === callID
+      ) {
+        return part.state?.output as string | undefined;
+      }
+    }
+  }
+  return undefined;
+}
+
+async function transform(
+  hook: ReturnType<typeof createTaskSessionManagerHook>,
+  history: unknown[],
+): Promise<unknown[]> {
+  // prompt.ts rebuilds msgs from storage every request, so each transform
+  // starts from a fresh clone of the real history.
+  const request = { messages: structuredClone(history) };
+  await hook['experimental.chat.messages.transform']({}, request as never);
+  return request.messages;
+}
+
+// Core's running placeholder that grows with live child progress. In this
+// runtime it is static, but the transform must be robust to a runtime that
+// streams progress into it.
+function runningOutput(snapshot: string): string {
+  return [
+    `<task id="${CHILD}" state="running">`,
+    '<summary>Background task started</summary>',
+    '<task_result>',
+    snapshot,
+    '</task_result>',
+    '</task>',
+  ].join('\n');
+}
+
+describe('running task tool-result cache safety', () => {
+  test('(a) running task part is byte-identical across consecutive requests while the child progresses', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    // Two consecutive requests where the runtime streamed different live
+    // progress snapshots into the same running task part.
+    const history1 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage(
+        'call-1',
+        runningOutput('The task is working in the background... (711 bytes)'),
+      ),
+    ];
+    const history2 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage(
+        'call-1',
+        runningOutput(
+          'Progress snapshot: found 4 files, still working... (4132 bytes)',
+        ),
+      ),
+    ];
+
+    const out1 = await transform(hook, history1);
+    const out2 = await transform(hook, history2);
+
+    const o1 = taskOutput(out1, 'call-1');
+    const o2 = taskOutput(out2, 'call-1');
+
+    expect(o1).toBeDefined();
+    expect(o2).toBeDefined();
+    // Byte-identical despite different live snapshots — cache prefix preserved.
+    expect(o2).toBe(o1 as string);
+    // Deterministic placeholder keyed on the task ID, still parseable as running.
+    expect(o1).toContain(`<task id="${CHILD}" state="running">`);
+    expect(o1).not.toContain('4132 bytes');
+    expect(o1).not.toContain('711 bytes');
+  });
+
+  test('(b) terminal result materializes once and then stays byte-stable', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const completedOutput = [
+      `<task id="${CHILD}" state="completed">`,
+      '<summary>Background task completed: research grok models</summary>',
+      '<task_result>',
+      'Full research findings: repo uses xai/grok-imagine-image, latest is quality mode.',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    // The completed tool part carries a real terminal result.
+    const completedMessage = {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'call-1',
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'task',
+          callID: 'call-1',
+          state: {
+            status: 'completed',
+            input: { background: true },
+            output: completedOutput,
+          },
+        },
+      ],
+    };
+    const history = [
+      userMessage('u1', 'Coordinate the work'),
+      completedMessage,
+    ];
+
+    const out1 = await transform(hook, history);
+    const out2 = await transform(hook, history);
+
+    const o1 = taskOutput(out1, 'call-1');
+    const o2 = taskOutput(out2, 'call-1');
+
+    // The terminal result must reach the orchestrator intact and unchanged.
+    expect(o1).toBe(completedOutput);
+    expect(o2).toBe(completedOutput);
+    expect(o1).toContain('Full research findings');
+  });
+
+  test('(c) running → terminal transition mutates the part exactly once, no duplicate completed results', async () => {
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const completedOutput = [
+      `<task id="${CHILD}" state="completed">`,
+      '<summary>Background task completed: research grok models</summary>',
+      '<task_result>',
+      'Final result body.',
+      '</task_result>',
+      '</task>',
+    ].join('\n');
+
+    // Turn 1 & 2: running (byte-stable). Turn 3: completed.
+    const runningHistory = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage('call-1', runningOutput('snapshot A')),
+    ];
+    const runningHistory2 = [
+      userMessage('u1', 'Coordinate the work'),
+      taskToolMessage('call-1', runningOutput('snapshot B — bigger')),
+    ];
+    const terminalHistory = [
+      userMessage('u1', 'Coordinate the work'),
+      {
+        info: {
+          role: 'assistant',
+          agent: 'orchestrator',
+          sessionID: SESSION,
+          id: 'call-1',
+        },
+        parts: [
+          {
+            type: 'tool',
+            tool: 'task',
+            callID: 'call-1',
+            state: {
+              status: 'completed',
+              input: { background: true },
+              output: completedOutput,
+            },
+          },
+        ],
+      },
+    ];
+
+    const r1 = taskOutput(await transform(hook, runningHistory), 'call-1');
+    const r2 = taskOutput(await transform(hook, runningHistory2), 'call-1');
+    const outTerminal = await transform(hook, terminalHistory);
+    const t3 = taskOutput(outTerminal, 'call-1');
+    const t4 = taskOutput(await transform(hook, terminalHistory), 'call-1');
+
+    // Running requests are byte-identical; the single mutation is running→terminal.
+    expect(r2).toBe(r1 as string);
+    expect(r1).not.toBe(t3);
+    // Terminal stays stable afterwards (no further mutation).
+    expect(t4).toBe(t3 as string);
+    expect(t3).toBe(completedOutput);
+
+    // No duplicate completed results anywhere in the payload.
+    const completedCount = (outTerminal as any[])
+      .flatMap((m) => m?.parts ?? [])
+      .filter(
+        (p: any) =>
+          p?.type === 'tool' &&
+          p?.tool === 'task' &&
+          typeof p?.state?.output === 'string' &&
+          p.state.output.includes('state="completed"'),
+      ).length;
+    expect(completedCount).toBe(1);
+  });
+
+  test('foreground (non-background) running task parts are also stabilized deterministically', async () => {
+    // Defensive: a running task part with no background flag still normalizes
+    // to the deterministic placeholder (only terminal results are preserved).
+    const board = new BackgroundJobBoard();
+    const hook = createHook(board);
+
+    const message = {
+      info: {
+        role: 'assistant',
+        agent: 'orchestrator',
+        sessionID: SESSION,
+        id: 'call-1',
+      },
+      parts: [
+        {
+          type: 'tool',
+          tool: 'task',
+          callID: 'call-1',
+          state: {
+            status: 'running',
+            input: {},
+            output: runningOutput('live snapshot'),
+          },
+        },
+      ],
+    };
+
+    const out = await transform(hook, [userMessage('u1', 'go'), message]);
+    const o = taskOutput(out, 'call-1');
+    expect(o).toContain(`<task id="${CHILD}" state="running">`);
+    expect(o).not.toContain('live snapshot');
+  });
+});

+ 69 - 44
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -5,7 +5,11 @@
  * reusable/recoverable task_id resolution) and `tool.execute.after`
  * (read context tracking, task launch registration/update from output).
  */
-import type { BackgroundJobStore, ContextFile } from '../../utils';
+import type {
+  BackgroundJobStore,
+  BackgroundJobSupervisor,
+  ContextFile,
+} from '../../utils';
 import {
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
@@ -26,6 +30,7 @@ interface TaskArgs {
   prompt?: unknown;
   subagent_type?: unknown;
   task_id?: unknown;
+  background?: unknown;
 }
 
 export async function handleToolExecuteBefore(
@@ -40,6 +45,7 @@ export async function handleToolExecuteBefore(
       pendingCallId(sessionID?: string, callID?: string): string;
     };
     taskContextTracker: { pendingManagedTaskIds: Set<string> };
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   const toolName = input.tool.toLowerCase();
@@ -70,6 +76,7 @@ export async function handleToolExecuteBefore(
   }
 
   const agentType = args.subagent_type.trim();
+  const background = args.background === true;
 
   const label = deriveTaskSessionLabel({
     description:
@@ -86,7 +93,48 @@ export async function handleToolExecuteBefore(
     parentSessionId: input.sessionID,
     agentType,
     label,
+    background,
   };
+  if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+    const requested = args.task_id.trim();
+    const remembered =
+      deps.backgroundJobBoard.resolveReusable(
+        input.sessionID,
+        requested,
+        agentType,
+      ) ??
+      deps.backgroundJobBoard.resolveRecoverable(
+        input.sessionID,
+        requested,
+        agentType,
+      );
+
+    if (!remembered) {
+      const knownManagedTask = deps.backgroundJobBoard.resolve(
+        input.sessionID,
+        requested,
+      );
+      if (knownManagedTask?.state === 'running') {
+        throw new Error(
+          `Task ${requested} is still running and cannot be resumed or amended with task(). Do not spawn or cancel a duplicate for an additive request. Wait for its terminal result, then resume the automatically reconciled session if follow-up work is still needed.`,
+        );
+      }
+
+      if (knownManagedTask) {
+        delete args.task_id;
+      } else if (RAW_SESSION_ID_PATTERN.test(requested)) {
+        pendingCall.resumedTaskId = requested;
+      } else {
+        delete args.task_id;
+      }
+    } else {
+      args.task_id = remembered.taskID;
+      deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
+      deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+      pendingCall.resumedTaskId = remembered.taskID;
+    }
+  }
+
   deps.pendingCallTracker.add(pendingCall);
   log(
     '[task-session-manager] tool.execute.before task — pending call created',
@@ -99,48 +147,6 @@ export async function handleToolExecuteBefore(
       inputSessionID: input.sessionID,
     },
   );
-
-  if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
-    return;
-  }
-
-  const requested = args.task_id.trim();
-  const remembered =
-    deps.backgroundJobBoard.resolveReusable(
-      input.sessionID,
-      requested,
-      agentType,
-    ) ??
-    deps.backgroundJobBoard.resolveRecoverable(
-      input.sessionID,
-      requested,
-      agentType,
-    );
-
-  if (!remembered) {
-    const knownManagedTask = deps.backgroundJobBoard.resolve(
-      input.sessionID,
-      requested,
-    );
-    if (knownManagedTask) {
-      delete args.task_id;
-      return;
-    }
-
-    if (RAW_SESSION_ID_PATTERN.test(requested)) {
-      pendingCall.resumedTaskId = requested;
-      deps.pendingCallTracker.add(pendingCall);
-      return;
-    }
-    delete args.task_id;
-    return;
-  }
-
-  args.task_id = remembered.taskID;
-  deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
-  deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
-  pendingCall.resumedTaskId = remembered.taskID;
-  deps.pendingCallTracker.add(pendingCall);
 }
 
 export async function handleToolExecuteAfter(
@@ -158,6 +164,7 @@ export async function handleToolExecuteAfter(
       contextFilesForPrompt(taskId: string): ContextFile[];
       prune(board: { taskIDs(): Set<string> }): void;
     };
+    backgroundJobSupervisor?: BackgroundJobSupervisor;
   },
 ): Promise<void> {
   if (input.tool.toLowerCase() === 'read') {
@@ -177,7 +184,16 @@ export async function handleToolExecuteAfter(
 
   if (input.tool.toLowerCase() !== 'task') return;
 
-  const pending = deps.pendingCallTracker.take(input.callID, input.sessionID);
+  const exactCallID =
+    typeof input.callID === 'string' && input.callID.trim() !== ''
+      ? input.callID
+      : undefined;
+  const pending = deps.pendingCallTracker.take(
+    exactCallID,
+    exactCallID ? undefined : input.sessionID,
+  );
+  const exactCallConfirmed =
+    exactCallID !== undefined && pending?.callId === exactCallID;
   log('[task-session-manager] tool.execute.after task', {
     callID: input.callID,
     sessionID: input.sessionID,
@@ -198,7 +214,10 @@ export async function handleToolExecuteAfter(
       agent: pending.agentType,
       description: pending.label,
       objective: pending.label,
+      background: exactCallConfirmed && pending.background,
+      preserveRun: pending.resumedTaskId === undefined,
     });
+    if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
     log('[task-session-manager] background task launch registered', {
       taskID: record.taskID,
       alias: record.alias,
@@ -227,7 +246,10 @@ export async function handleToolExecuteAfter(
         agent: pending.agentType,
         description: pending.label,
         objective: pending.label,
+        background: exactCallConfirmed && pending.background,
+        preserveRun: pending.resumedTaskId === undefined,
       });
+    if (exactCallConfirmed) deps.backgroundJobSupervisor?.onLaunch(record);
     const updated = deps.backgroundJobBoard.updateStatus({
       taskID: status.taskID,
       state: status.state,
@@ -243,6 +265,7 @@ export async function handleToolExecuteAfter(
     });
     if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
       deps.backgroundJobBoard.drop(pending.resumedTaskId);
+      deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
     }
     deps.taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
     deps.backgroundJobBoard.addContext(
@@ -260,12 +283,14 @@ export async function handleToolExecuteAfter(
       isMissingRememberedSessionError(output.output)
     ) {
       deps.backgroundJobBoard.drop(pending.resumedTaskId);
+      deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
     }
     return;
   }
 
   if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
     deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    deps.backgroundJobSupervisor?.drop(pending.resumedTaskId);
   }
 
   deps.taskContextTracker.pendingManagedTaskIds.delete(taskId);

+ 188 - 12
src/index.test.ts

@@ -1,16 +1,58 @@
 import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
-import plugin, { minimumExpectedToolCount } from './index';
-
-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);
-  });
-});
+import { mkdtemp, rm } from 'node:fs/promises';
+import plugin from './index';
+
+function createPluginClient(
+  noop: () => Promise<unknown>,
+  abort?: (input: { path: { id: string } }) => Promise<unknown>,
+) {
+  const session = new Proxy(abort ? { abort } : {}, {
+    get(target, property) {
+      if (property in target) {
+        return target[property as keyof typeof target];
+      }
+      return noop;
+    },
+  }) as Record<string, unknown>;
+  return new Proxy(
+    { app: { log: noop }, session },
+    {
+      get(target, property) {
+        if (property in target) {
+          return target[property as keyof typeof target];
+        }
+        return new Proxy({}, { get: () => noop });
+      },
+    },
+  );
+}
+
+function createHostTimerHarness() {
+  let now = 0;
+  let nextID = 0;
+  const timers = new Map<number, { at: number; callback: () => void }>();
+
+  const setTimeout = (callback: () => void, delay = 0) => {
+    const id = ++nextID;
+    timers.set(id, { at: now + delay, callback });
+    return id;
+  };
+  const clearTimeout = (id: number) => timers.delete(id);
+  const advanceTo = async (target: number) => {
+    now = target;
+    while (true) {
+      const due = [...timers.entries()]
+        .filter(([, timer]) => timer.at <= now)
+        .sort(([, left], [, right]) => left.at - right.at)[0];
+      if (!due) break;
+      timers.delete(due[0]);
+      due[1].callback();
+      await Promise.resolve();
+    }
+  };
+
+  return { now: () => now, setTimeout, clearTimeout, advanceTo };
+}
 
 describe('plugin env disable', () => {
   let originalEnv: typeof process.env;
@@ -99,4 +141,138 @@ describe('plugin tool registration', () => {
       ),
     ).resolves.toContain('state: waiting_for_user');
   });
+
+  test('exposes an idempotent top-level dispose finalizer', async () => {
+    const noop = async () => ({});
+    const session = new Proxy({}, { get: () => noop }) as Record<
+      string,
+      unknown
+    >;
+    const client = new Proxy(
+      { app: { log: noop }, session },
+      {
+        get(target, property) {
+          if (property in target) {
+            return target[property as keyof typeof target];
+          }
+          return new Proxy({}, { get: () => noop });
+        },
+      },
+    );
+
+    const hooks = await plugin({
+      client,
+      directory: '/private/tmp/oh-my-opencode-slim-dispose-project',
+      worktree: '/private/tmp/oh-my-opencode-slim-dispose-project',
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    expect(hooks.dispose).toBeFunction();
+    await hooks.dispose?.();
+    await hooks.dispose?.();
+  });
+
+  test('disposes generation one timers and fresh generation two supervises launches', async () => {
+    const originalEnv = { ...process.env };
+    const originalSetTimeout = globalThis.setTimeout;
+    const originalClearTimeout = globalThis.clearTimeout;
+    const originalNow = Date.now;
+    const clock = createHostTimerHarness();
+    const abortCalls: string[] = [];
+    const noop = async () => ({});
+    const client = createPluginClient(noop, async ({ path }) => {
+      abortCalls.push(path.id);
+      return {};
+    });
+    const configDir = await mkdtemp('/tmp/oh-my-opencode-slim-phase-2r-');
+    await Bun.write(
+      `${configDir}/oh-my-opencode-slim.json`,
+      JSON.stringify({
+        backgroundJobs: {
+          wallClockTimeoutMs: 60_000,
+          abortGraceMs: 1_000,
+        },
+      }),
+    );
+    process.env = {
+      ...originalEnv,
+      OPENCODE_CONFIG_DIR: configDir,
+    };
+    delete process.env.OH_MY_OPENCODE_SLIM_DISABLE;
+    globalThis.setTimeout = clock.setTimeout as typeof globalThis.setTimeout;
+    globalThis.clearTimeout =
+      clock.clearTimeout as typeof globalThis.clearTimeout;
+    Date.now = clock.now;
+
+    const launch = async (
+      hooks: Awaited<ReturnType<typeof plugin>>,
+      callID: string,
+      taskID: string,
+    ) => {
+      await hooks['tool.execute.before']?.(
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          args: {
+            subagent_type: 'explorer',
+            background: true,
+            description: taskID,
+          },
+        },
+      );
+      await hooks['tool.execute.after']?.(
+        { tool: 'task', sessionID: 'parent-1', callID },
+        {
+          output: [
+            `task_id: ${taskID}`,
+            'state: running',
+            '',
+            '<task_result>',
+            'started',
+            '</task_result>',
+          ].join('\n'),
+        },
+      );
+    };
+
+    let generationOne: Awaited<ReturnType<typeof plugin>> | undefined;
+    let generationTwo: Awaited<ReturnType<typeof plugin>> | undefined;
+    try {
+      generationOne = await plugin({
+        client,
+        directory: configDir,
+        worktree: configDir,
+        serverUrl: new URL('http://127.0.0.1:4096'),
+      } as never);
+      expect(generationOne.dispose).toBeFunction();
+      await launch(generationOne, 'call-1', 'child-generation-1');
+
+      await clock.advanceTo(59_999);
+      expect(abortCalls).toEqual([]);
+      await generationOne.dispose?.();
+      await generationOne.dispose?.();
+      await clock.advanceTo(60_000);
+      expect(abortCalls).toEqual([]);
+
+      generationTwo = await plugin({
+        client,
+        directory: configDir,
+        worktree: configDir,
+        serverUrl: new URL('http://127.0.0.1:4096'),
+      } as never);
+      expect(generationTwo.dispose).toBeFunction();
+      await launch(generationTwo, 'call-2', 'child-generation-2');
+      await clock.advanceTo(119_999);
+      expect(abortCalls).toEqual([]);
+      await clock.advanceTo(120_000);
+      expect(abortCalls).toEqual(['child-generation-2']);
+    } finally {
+      await generationTwo?.dispose?.();
+      await generationOne?.dispose?.();
+      process.env = originalEnv;
+      globalThis.setTimeout = originalSetTimeout;
+      globalThis.clearTimeout = originalClearTimeout;
+      Date.now = originalNow;
+      await rm(configDir, { recursive: true, force: true });
+    }
+  });
 });

+ 167 - 75
src/index.ts

@@ -17,11 +17,14 @@ 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,
   resolveImageRouting,
+  TOAST_DURATION_MS,
 } from './config/constants';
 import {
   getActiveRuntimePreset,
@@ -29,6 +32,7 @@ import {
   setActiveRuntimePreset,
 } from './config/runtime-preset';
 import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
+import { HEALTH_CHECK, minimumExpectedToolCount } from './health-check';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
@@ -68,11 +72,13 @@ import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
   BackgroundJobCoordinator,
+  BackgroundJobSupervisor,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } 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';
 
 /**
@@ -98,32 +104,9 @@ 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;
-}
+// Debounce: only show image-skipped toast once per 60 seconds per project
+const lastImageSkippedToastByDir = new Map<string, number>();
+const IMAGE_SKIPPED_DEBOUNCE_MS = 60_000;
 
 /**
  * Probe jsdom at init time so the first webfetch call doesn't fail
@@ -172,10 +155,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>;
@@ -195,6 +183,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let jsonErrorRecoveryAfter: (i: unknown, o: unknown) => Promise<void>;
   let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
+  let backgroundJobSupervisor: BackgroundJobSupervisor;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
@@ -212,11 +201,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   try {
     config = loadPluginConfig(ctx.directory);
 
-    // Safety net: if a runtime preset was set via /preset command and
-    // OpenCode ever fully re-runs the plugin function (not just the
-    // config() hook), override config.preset so agents are created with
-    // the correct models. Currently only the config() hook re-runs after
-    // Instance.dispose(), so this is a defensive guard.
+    // Safety net: instance disposal reruns the plugin factory and rebuilds
+    // factory-local state, while module-level runtime preset state may persist.
+    // Reapply that persisted preset so each fresh generation creates agents
+    // with the correct models.
     const runtimePreset = getActiveRuntimePreset();
     if (runtimePreset && config.presets?.[runtimePreset]) {
       config.preset = runtimePreset;
@@ -277,16 +265,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,
@@ -299,6 +312,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     const backgroundJobCoordinator = new BackgroundJobCoordinator(
       backgroundJobBoard,
     );
+    backgroundJobSupervisor = new BackgroundJobSupervisor({
+      backgroundJobStore: backgroundJobCoordinator,
+      wallClockTimeoutMs: config.backgroundJobs?.wallClockTimeoutMs ?? 0,
+      abortGraceMs: config.backgroundJobs?.abortGraceMs ?? 10_000,
+      abort: (taskID) =>
+        ctx.client.session.abort({
+          path: { id: taskID },
+        }),
+    });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      backgroundJobSupervisor.onTerminal(record);
+    });
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
@@ -310,6 +335,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     backgroundJobCoordinator.addTerminalStateListener((taskID) => {
       void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
     });
+    backgroundJobCoordinator.addTerminalOutcomeListener((record) => {
+      if (record.deadlineExceededAt === undefined) return;
+      void multiplexerSessionManager.closeSessionPermanentlyFromCoordinator(
+        record.taskID,
+      );
+    });
 
     sessionLifecycle = new SessionLifecycle(log);
 
@@ -319,9 +350,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.
@@ -352,12 +380,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles:
         config.backgroundJobs?.readContextMaxFiles ??
         DEFAULT_READ_CONTEXT_MAX_FILES,
-      continueOnIdle: config.backgroundJobs?.continueOnIdle !== false,
+      continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
       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),
@@ -396,7 +425,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,
@@ -438,28 +467,32 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: ctx,
       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)),
@@ -484,11 +517,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 ||
@@ -683,11 +718,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
-      // Runtime preset override: if /preset switched to a runtime preset,
-      // override the model/variant/temperature from the preset's agent
-      // config. This runs after the normal model resolution because the
-      // config() hook re-runs with stale modelArrayMap after dispose(),
-      // but the runtime preset data is in the captured `config` closure.
+      // Runtime preset override: instance disposal recreates the plugin
+      // factory and its factory-local state, while module-level runtime
+      // preset data may persist. Apply that persisted selection after normal
+      // model resolution for the current generation.
       const runtimePresetName = getActiveRuntimePreset();
       if (runtimePresetName && config.presets?.[runtimePresetName]) {
         const runtimePreset = config.presets[runtimePresetName];
@@ -938,6 +972,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 =
@@ -962,7 +1014,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
               model,
               variant: variant ?? null,
             },
-            (info?.sessionID && sessionDirectories.get(info.sessionID)) ??
+            (info?.sessionID && sessionMetadata.getDirectory(info.sessionID)) ??
               ctx.directory,
           );
         }
@@ -972,7 +1024,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);
         }
       }
 
@@ -1034,7 +1086,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,
         });
       }
@@ -1050,12 +1102,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
         companionManager.onSessionDeleted(sessionID);
         if (sessionID) {
-          sessionAgentMap.delete(sessionID);
-          sessionDirectories.delete(sessionID);
+          sessionMetadata.delete(sessionID);
         }
       }
     },
 
+    dispose: async () => {
+      await taskSessionManagerHook.event({
+        event: { type: 'server.instance.disposed' },
+      });
+      await multiplexerSessionManager.cleanupOnInstanceDisposed();
+    },
+
     'tool.execute.before': async (input, output) => {
       await applyPatch['tool.execute.before'](input as never, output as never);
       await taskSessionManagerHook['tool.execute.before'](
@@ -1110,6 +1168,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;
@@ -1120,6 +1183,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           agent?: string;
           role?: string;
           sessionID?: string;
+          model?: {
+            providerID: string;
+            modelID: string;
+            variant?: string;
+          };
         };
         parts?: unknown[];
       },
@@ -1139,7 +1207,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.
@@ -1163,7 +1231,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(
@@ -1173,12 +1241,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',
           );
@@ -1186,9 +1254,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             typeof orchestratorDef?.config?.prompt === 'string'
               ? orchestratorDef.config.prompt
               : buildOrchestratorPrompt(disabledAgents);
-          output.system[0] =
-            orchestratorPrompt +
-            (output.system[0] ? `\n\n${output.system[0]}` : '');
+          output.system[0] = `${output.system[0] || ''}\n\n${orchestratorPrompt}`;
         }
       }
 
@@ -1228,13 +1294,39 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // input, the API call fails before the LLM can respond. We replace
       // image bytes with a text nudge so the orchestrator delegates to
       // @observer instead.
-      processImageAttachments({
+      const imageResult = processImageAttachments({
         messages: typedOutput.messages,
         workDir: ctx.directory,
-        imageRouting: resolveImageRouting(config.image_routing),
+        imageRouting: resolveImageRouting(
+          config.image_routing,
+          !disabledAgents.has('observer'),
+        ),
         disabledAgents,
         log,
       });
+      if (imageResult) {
+        const now = Date.now();
+        const last = lastImageSkippedToastByDir.get(ctx.directory) ?? 0;
+        if (now - last > IMAGE_SKIPPED_DEBOUNCE_MS) {
+          ctx.client.tui
+            .showToast({
+              body: {
+                title: 'Images skipped',
+                message:
+                  'Observer agent is disabled, so images can\'t be analyzed. Set image_routing to "direct" to send images to your model, or enable observer.',
+                variant: 'warning',
+                duration: TOAST_DURATION_MS,
+              },
+            })
+            .then(() => {
+              // Only advance the debounce window on a successful toast
+              // so a failed attempt doesn't suppress the next warning.
+              // Greptile: "Failed Toast Starts Debounce Window".
+              lastImageSkippedToastByDir.set(ctx.directory, now);
+            })
+            .catch(() => {});
+        }
+      }
 
       // Repair session mappings before reminder gates; nudge metadata precedes phase dedup.
       await taskSessionManagerHook['experimental.chat.messages.transform'](

+ 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();

+ 42 - 4
src/multiplexer/cmux/session-lifecycle.ts

@@ -39,6 +39,7 @@ export interface CmuxSessionLifecycleOptions {
   shutdownTimeoutMs?: number;
   isServerRunning?: (url: string) => Promise<boolean>;
   fetchStatuses?: () => Promise<Record<string, { type: string }>>;
+  permanentlyClosedSessions?: Set<string>;
 }
 
 const ACTIVITY_EVENTS = new Set([
@@ -78,6 +79,7 @@ export class CmuxSessionLifecycle {
   private cleanupPromise?: Promise<void>;
   private disposed = false;
   private spawnGeneration = 0;
+  private readonly permanentlyClosedSessions?: Set<string>;
 
   constructor(
     private readonly owner: string,
@@ -88,6 +90,7 @@ export class CmuxSessionLifecycle {
     options: CmuxSessionLifecycleOptions = {},
   ) {
     this.now = options.now ?? Date.now;
+    this.permanentlyClosedSessions = options.permanentlyClosedSessions;
     this.injectedDelay = Boolean(options.delay);
     this.delay =
       options.delay ??
@@ -122,6 +125,7 @@ export class CmuxSessionLifecycle {
     if (event.type !== 'session.created') return;
     const info = event.properties?.info;
     if (!info?.id || !info.parentID) return;
+    if (this.permanentlyClosedSessions?.has(info.id)) return;
     const now = this.now();
     const record: CmuxSessionRecord = {
       session: info.id,
@@ -165,7 +169,12 @@ export class CmuxSessionLifecycle {
       this.activity(session);
       this.backgroundJobs?.clearDeferredClose(session);
       const record = this.store.get(session);
-      if (status === 'busy' && record && !record.paneId)
+      if (
+        status === 'busy' &&
+        record &&
+        record.lifecycle === 'active' &&
+        !record.paneId
+      )
         await this.spawn(record);
     }
     if (owned.paneId) this.startPolling();
@@ -194,6 +203,21 @@ export class CmuxSessionLifecycle {
     if (record?.paneId && record.owner === this.owner) this.startPolling();
   }
 
+  async closeSessionPermanentlyFromCoordinator(session: string): Promise<void> {
+    if (this.disposed) return;
+    this.permanentlyClosedSessions?.add(session);
+    const record = this.store.get(session);
+    if (!record || record.owner !== this.owner) return;
+    record.lifecycle = 'deleted';
+    this.cancelDeferred(record);
+    this.backgroundJobs?.clearDeferredClose(session);
+    if (!record.paneId) {
+      if (!record.spawnPromise) this.store.removeWithoutPane(session);
+      return;
+    }
+    await this.requestClose(record, 'deleted');
+  }
+
   cleanup(): Promise<void> {
     this.cleanupPromise ??= this.runCleanup();
     return this.cleanupPromise;
@@ -208,7 +232,13 @@ export class CmuxSessionLifecycle {
     record: CmuxSessionRecord,
     deferred = false,
   ): Promise<void> {
-    if (this.disposed || record.owner !== this.owner) return;
+    if (
+      this.disposed ||
+      record.owner !== this.owner ||
+      record.lifecycle !== 'active' ||
+      this.permanentlyClosedSessions?.has(record.session)
+    )
+      return;
     if (record.spawnState === 'spawning' || record.paneId) return;
     const generation = this.spawnGeneration;
     const token = record.deferredSpawn?.generation;
@@ -218,7 +248,11 @@ export class CmuxSessionLifecycle {
     const result = await operation;
     if (record.spawnPromise === operation) record.spawnPromise = undefined;
     const current = this.store.get(record.session);
-    if (this.disposed || generation !== this.spawnGeneration) {
+    if (
+      this.disposed ||
+      generation !== this.spawnGeneration ||
+      this.permanentlyClosedSessions?.has(record.session)
+    ) {
       const latePane = result.paneId ?? result.orphanPaneId;
       if (latePane) await this.closeLatePane(record, latePane);
       else if (current && !current.paneId)
@@ -274,6 +308,9 @@ export class CmuxSessionLifecycle {
     if (!(await this.serverCheck(serverUrl))) {
       return { success: false, error: 'unavailable' as const };
     }
+    if (this.permanentlyClosedSessions?.has(record.session)) {
+      return { success: false, error: 'unavailable' as const };
+    }
     try {
       return await this.multiplexer.spawnPane(
         record.session,
@@ -306,7 +343,8 @@ export class CmuxSessionLifecycle {
         this.disposed ||
         this.store.get(record.session) !== record ||
         record.lifecycle !== 'active' ||
-        record.owner !== this.owner
+        record.owner !== this.owner ||
+        this.permanentlyClosedSessions?.has(record.session)
       )
         return;
       await this.spawn(record, true);

+ 305 - 0
src/multiplexer/session-manager.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from '../utils/background-job-board';
 import { BackgroundJobCoordinator } from '../utils/background-job-coordinator';
+import { CmuxSessionStore } from './cmux/session-state';
 import {
   MultiplexerSessionManager,
   resetMultiplexerSessionManagerState,
@@ -611,6 +612,215 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
+    test('wall-clock timeout closes a live pane permanently and blocks late busy respawn', async () => {
+      const ctx = createMockContext();
+      const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
+      board.registerLaunch({
+        taskID: 'wall-clock-pane',
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        background: true,
+      });
+      mockMultiplexer.spawnPane.mockResolvedValue({
+        success: true,
+        paneId: 'p-wall-clock-pane',
+      });
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+        coordinator,
+      );
+      coordinator.addTerminalOutcomeListener((record) => {
+        if (record.deadlineExceededAt !== undefined) {
+          void manager.closeSessionPermanentlyFromCoordinator(record.taskID);
+        }
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'wall-clock-pane', parentID: 'parent-1' },
+        },
+      });
+      board.claimWallClockDeadline({
+        taskID: 'wall-clock-pane',
+        generation: 1,
+        now: 100,
+      });
+      board.finalizeWallClockTimeout({
+        taskID: 'wall-clock-pane',
+        generation: 1,
+        now: 120,
+        statusUncertain: true,
+        resultSummary: 'abort was not confirmed',
+      });
+      await flushPromises();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-wall-clock-pane',
+      );
+      const spawns = mockMultiplexer.spawnPane.mock.calls.length;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'wall-clock-pane',
+          status: { type: 'busy' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'wall-clock-pane', parentID: 'parent-1' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+    });
+
+    test('generic tombstone wins a duplicate created event awaiting an existing close', async () => {
+      const close = createDeferred<boolean>();
+      mockMultiplexer.closePane.mockImplementationOnce(() => close.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'created-race', parentID: 'parent-1' },
+        },
+      };
+
+      await manager.onSessionCreated(created);
+      const deleting = manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { sessionID: 'created-race' },
+      });
+      await flushPromises();
+      const duplicate = manager.onSessionCreated(created);
+      await flushPromises();
+
+      const permanent =
+        manager.closeSessionPermanentlyFromCoordinator('created-race');
+      close.resolve(true);
+      await Promise.all([deleting, duplicate, permanent]);
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+        sessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('created-race')).toBe(false);
+      expect(state.sessions.has('created-race')).toBe(false);
+    });
+
+    test('generic tombstone is rechecked after server health await', async () => {
+      const health = createDeferred<boolean>();
+      mockIsServerRunning.mockImplementationOnce(() => health.promise);
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+
+      const creating = manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'health-race', parentID: 'parent-1' },
+        },
+      });
+      await flushPromises();
+      await manager.closeSessionPermanentlyFromCoordinator('health-race');
+      health.resolve(true);
+      await creating;
+
+      expect(mockMultiplexer.spawnPane).not.toHaveBeenCalled();
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('health-race')).toBe(false);
+    });
+
+    test('generic tombstone is rechecked across busy respawn health await', async () => {
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'respawn-race', parentID: 'parent-1' } },
+      });
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'respawn-race',
+          status: { type: 'idle' },
+        },
+      });
+      const health = createDeferred<boolean>();
+      mockIsServerRunning.mockImplementationOnce(() => health.promise);
+
+      const respawning = manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'respawn-race',
+          status: { type: 'busy' },
+        },
+      });
+      await flushPromises();
+      await manager.closeSessionPermanentlyFromCoordinator('respawn-race');
+      health.resolve(true);
+      await respawning;
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      const state = manager as unknown as {
+        knownSessions: Map<string, unknown>;
+      };
+      expect(state.knownSessions.has('respawn-race')).toBe(false);
+    });
+
+    test('disposing another generic manager does not clear a process-shared tombstone', async () => {
+      const managerA = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const managerB = new MultiplexerSessionManager(
+        createMockContext(),
+        defaultMultiplexerConfig,
+      );
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'shared-tombstone', parentID: 'parent-1' },
+        },
+      };
+
+      await managerA.onSessionCreated(created);
+      await managerA.closeSessionPermanentlyFromCoordinator('shared-tombstone');
+      await managerB.cleanupOnInstanceDisposed();
+      await managerA.onSessionCreated(created);
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+    });
+
+    test('backfills permanentlyClosedSessions for an older shared state shape', () => {
+      const key = Symbol.for(
+        'oh-my-opencode-slim.multiplexer-session-manager.state',
+      );
+      (globalThis as Record<PropertyKey, unknown>)[key] = {
+        sessions: new Map(),
+        knownSessions: new Map(),
+        spawningSessions: new Set(),
+        closingSessions: new Map(),
+      };
+
+      expect(() => resetMultiplexerSessionManagerState()).not.toThrow();
+      const state = (globalThis as Record<PropertyKey, unknown>)[key] as {
+        permanentlyClosedSessions?: unknown;
+      };
+      expect(state.permanentlyClosedSessions).toBeInstanceOf(Set);
+    });
+
     test('deleted clears deferred idle close and later terminal update is no-op', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
@@ -2144,6 +2354,101 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
     });
 
+    test('cmux wall-clock close is permanent and late busy does not respawn', async () => {
+      mockMultiplexerType = 'cmux';
+      const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
+      board.registerLaunch({
+        taskID: 'cmux-wall-clock',
+        parentSessionID: 'parent',
+        agent: 'explorer',
+        background: true,
+      });
+      const manager = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+        coordinator,
+      );
+      coordinator.addTerminalOutcomeListener((record) => {
+        if (record.deadlineExceededAt !== undefined) {
+          void manager.closeSessionPermanentlyFromCoordinator(record.taskID);
+        }
+      });
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-wall-clock', parentID: 'parent' } },
+      });
+      board.claimWallClockDeadline({
+        taskID: 'cmux-wall-clock',
+        generation: 1,
+        now: 100,
+      });
+      board.finalizeWallClockTimeout({
+        taskID: 'cmux-wall-clock',
+        generation: 1,
+        now: 120,
+        statusUncertain: true,
+        resultSummary: 'abort was not confirmed',
+      });
+      await flushPromises();
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith('%mock-pane');
+      const spawns = mockMultiplexer.spawnPane.mock.calls.length;
+      await manager.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'cmux-wall-clock',
+          status: { type: 'busy' },
+        },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'cmux-wall-clock', parentID: 'parent' } },
+      });
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(spawns);
+    });
+
+    test('cmux permanent tombstone blocks duplicate created and busy across managers', async () => {
+      mockMultiplexerType = 'cmux';
+      const created = {
+        type: 'session.created' as const,
+        properties: {
+          info: { id: 'cmux-shared-tombstone', parentID: 'parent' },
+        },
+      };
+      const managerA = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await managerA.onSessionCreated(created);
+      await managerA.closeSessionPermanentlyFromCoordinator(
+        'cmux-shared-tombstone',
+      );
+
+      expect(
+        new CmuxSessionStore().get('cmux-shared-tombstone'),
+      ).toBeUndefined();
+
+      const managerB = new MultiplexerSessionManager(
+        createMockContext(),
+        cmuxConfig,
+      );
+      await managerB.onSessionCreated(created);
+      await managerB.onSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'cmux-shared-tombstone',
+          status: { type: 'busy' },
+        },
+      });
+
+      expect(mockMultiplexer.spawnPane).toHaveBeenCalledTimes(1);
+      expect(
+        new CmuxSessionStore().get('cmux-shared-tombstone'),
+      ).toBeUndefined();
+    });
+
     test('session.deleted closes immediately', async () => {
       mockMultiplexerType = 'cmux';
       const manager = new MultiplexerSessionManager(

+ 67 - 14
src/multiplexer/session-manager.ts

@@ -40,6 +40,7 @@ interface SharedSessionState {
   knownSessions: Map<string, KnownSession>;
   spawningSessions: Set<string>;
   closingSessions: Map<string, Promise<void>>;
+  permanentlyClosedSessions: Set<string>;
 }
 
 interface SessionEvent {
@@ -69,14 +70,20 @@ function getSharedState(): SharedSessionState {
     [SHARED_STATE_KEY]?: SharedSessionState;
   };
 
-  globalWithState[SHARED_STATE_KEY] ??= {
-    sessions: new Map(),
-    knownSessions: new Map(),
-    spawningSessions: new Set(),
-    closingSessions: new Map(),
-  };
-
-  return globalWithState[SHARED_STATE_KEY];
+  let state = globalWithState[SHARED_STATE_KEY];
+  if (!state) {
+    state = {
+      sessions: new Map(),
+      knownSessions: new Map(),
+      spawningSessions: new Set(),
+      closingSessions: new Map(),
+      permanentlyClosedSessions: new Set(),
+    };
+    globalWithState[SHARED_STATE_KEY] = state;
+  }
+  // Migrate state created by older plugin instances in this process.
+  state.permanentlyClosedSessions ??= new Set();
+  return state;
 }
 
 export function resetMultiplexerSessionManagerState(): void {
@@ -85,6 +92,7 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
+  state.permanentlyClosedSessions.clear();
   new CmuxSessionStore().resetForTests();
 }
 
@@ -149,6 +157,7 @@ export class MultiplexerSessionManager {
   private knownSessions: SharedSessionState['knownSessions'];
   private spawningSessions: SharedSessionState['spawningSessions'];
   private closingSessions: SharedSessionState['closingSessions'];
+  private permanentlyClosedSessions: SharedSessionState['permanentlyClosedSessions'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
   private cmuxLifecycle?: CmuxSessionLifecycle;
@@ -164,6 +173,7 @@ export class MultiplexerSessionManager {
     this.knownSessions = sharedState.knownSessions;
     this.spawningSessions = sharedState.spawningSessions;
     this.closingSessions = sharedState.closingSessions;
+    this.permanentlyClosedSessions = sharedState.permanentlyClosedSessions;
 
     this.directory = ctx.directory;
     this.resolveServerUrl = createServerUrlResolver(ctx);
@@ -180,7 +190,10 @@ export class MultiplexerSessionManager {
         this.resolveServerUrl,
         this.directory,
         this.backgroundJobBoard,
-        options,
+        {
+          ...options,
+          permanentlyClosedSessions: this.permanentlyClosedSessions,
+        },
       );
     }
 
@@ -209,6 +222,14 @@ export class MultiplexerSessionManager {
     const title = info.title ?? 'Subagent';
     const directory = info.directory ?? this.directory;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) {
+      log('[multiplexer-session-manager] ignoring permanently closed session', {
+        instanceId: this.instanceId,
+        sessionId,
+      });
+      return;
+    }
+
     if (this.isTrackedOrSpawning(sessionId)) {
       log('[multiplexer-session-manager] session already tracked or spawning', {
         instanceId: this.instanceId,
@@ -220,6 +241,7 @@ export class MultiplexerSessionManager {
     const closing = this.closingSessions.get(sessionId);
     if (closing) await closing;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     if (this.isTrackedOrSpawning(sessionId)) return;
 
     this.knownSessions.set(sessionId, {
@@ -251,7 +273,11 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      if (this.closingSessions.has(sessionId) || this.sessions.has(sessionId)) {
+      if (
+        this.permanentlyClosedSessions.has(sessionId) ||
+        this.closingSessions.has(sessionId) ||
+        this.sessions.has(sessionId)
+      ) {
         return;
       }
 
@@ -279,7 +305,8 @@ export class MultiplexerSessionManager {
 
       if (
         !this.knownSessions.has(sessionId) ||
-        this.closingSessions.has(sessionId)
+        this.closingSessions.has(sessionId) ||
+        this.permanentlyClosedSessions.has(sessionId)
       ) {
         await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
           log(
@@ -588,9 +615,11 @@ export class MultiplexerSessionManager {
 
   private async respawnIfKnown(sessionId: string): Promise<void> {
     if (!this.enabled || !this.multiplexer) return;
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     const closing = this.closingSessions.get(sessionId);
     if (closing) await closing;
 
+    if (this.permanentlyClosedSessions.has(sessionId)) return;
     if (this.isTrackedOrSpawning(sessionId)) {
       return;
     }
@@ -625,7 +654,11 @@ export class MultiplexerSessionManager {
         return;
       }
 
-      if (this.sessions.has(sessionId) || this.closingSessions.has(sessionId)) {
+      if (
+        this.permanentlyClosedSessions.has(sessionId) ||
+        this.sessions.has(sessionId) ||
+        this.closingSessions.has(sessionId)
+      ) {
         return;
       }
 
@@ -653,7 +686,8 @@ export class MultiplexerSessionManager {
 
       if (
         !this.knownSessions.has(sessionId) ||
-        this.closingSessions.has(sessionId)
+        this.closingSessions.has(sessionId) ||
+        this.permanentlyClosedSessions.has(sessionId)
       ) {
         await this.multiplexer.closePane(paneResult.paneId).catch((err) =>
           log(
@@ -727,8 +761,26 @@ export class MultiplexerSessionManager {
     await this.closeSession(sessionId, 'idle', true);
   }
 
+  /** Permanently close a wall-clock timed-out pane and block late busy respawn. */
+  async closeSessionPermanentlyFromCoordinator(
+    sessionId: string,
+  ): Promise<void> {
+    if (this.cmuxLifecycle) {
+      return this.cmuxLifecycle.closeSessionPermanentlyFromCoordinator(
+        sessionId,
+      );
+    }
+    if (!this.enabled) return;
+    this.permanentlyClosedSessions.add(sessionId);
+    await this.closeSession(sessionId, 'deleted', true);
+  }
+
   async cleanup(): Promise<void> {
-    if (this.cmuxLifecycle) return this.cmuxLifecycle.cleanup();
+    if (this.cmuxLifecycle) {
+      await this.cmuxLifecycle.cleanup();
+      this.permanentlyClosedSessions.clear();
+      return;
+    }
     this.stopPolling();
 
     if (this.closingSessions.size > 0) {
@@ -755,6 +807,7 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
+    this.permanentlyClosedSessions.clear();
     // ponytail: deferred state lives in coordinator, not here
     // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
 

+ 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.)

+ 19 - 0
src/tools/acp-run.test.ts

@@ -0,0 +1,19 @@
+import { describe, expect, test } from 'bun:test';
+import packageJson from '../../package.json' with { type: 'json' };
+import { createAcpInitializeParams } from './acp-run';
+
+describe('ACP initialize payload', () => {
+  test('sends protocol-compliant client implementation information', () => {
+    const params = createAcpInitializeParams();
+
+    expect(params).toEqual({
+      protocolVersion: 1,
+      clientCapabilities: {},
+      clientInfo: {
+        name: 'oh-my-opencode-slim',
+        version: packageJson.version,
+      },
+    });
+    expect(params.clientInfo).not.toHaveProperty('title');
+  });
+});

+ 13 - 8
src/tools/acp-run.ts

@@ -1,6 +1,7 @@
 import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
 import { createInterface } from 'node:readline';
 import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+import packageJson from '../../package.json' with { type: 'json' };
 import {
   type AcpAgentConfig,
   type AcpAgentsConfig,
@@ -33,6 +34,17 @@ type Pending = {
   reject: (error: Error) => void;
 };
 
+export function createAcpInitializeParams() {
+  return {
+    protocolVersion: 1,
+    clientCapabilities: {},
+    clientInfo: {
+      name: 'oh-my-opencode-slim',
+      version: packageJson.version,
+    },
+  };
+}
+
 class AcpClient {
   private child: ChildProcessWithoutNullStreams;
   private next = 1;
@@ -86,14 +98,7 @@ class AcpClient {
   }
 
   async run(prompt: string): Promise<string> {
-    const init = await this.request('initialize', {
-      protocolVersion: 1,
-      clientCapabilities: {},
-      clientInfo: {
-        name: 'oh-my-opencode-slim',
-        title: 'oh-my-opencode-slim ACP bridge',
-      },
-    });
+    const init = await this.request('initialize', createAcpInitializeParams());
     this.authMethods = readAuthMethods(init);
     const created = await this.newSession();
     const sessionId = readSessionId(created);

+ 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
 

+ 27 - 10
src/tools/smartfetch/secondary-model.ts

@@ -71,26 +71,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(
@@ -100,8 +110,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 {

+ 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);
@@ -804,7 +805,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');
+    });
+  });
 });

+ 233 - 13
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 {
@@ -23,6 +35,8 @@ export interface BackgroundJobRecord {
   description: string;
   objective?: string;
   state: BackgroundJobState;
+  /** True only when the native task call explicitly supplied background:true. */
+  background: boolean;
   timedOut: boolean;
   recoverableAfterLiveBusy: boolean;
   statusUncertain: boolean;
@@ -30,6 +44,12 @@ export interface BackgroundJobRecord {
   terminalUnreconciled: boolean;
   launchedAt: number;
   lastLaunchedAt: number;
+  /** Monotonic run identity. Explicit relaunch/reuse increments it. */
+  generation: number;
+  /** First launch observation for the current generation. */
+  runStartedAt: number;
+  /** Persistent hard wall-clock marker; distinct from external task wait timeout. */
+  deadlineExceededAt?: number;
   updatedAt: number;
   lastLiveBusyAt?: number;
   completedAt?: number;
@@ -46,6 +66,7 @@ export interface BackgroundJobRecord {
 
 export interface BackgroundJobBoardOptions {
   maxReusablePerAgent?: number;
+  maxContextLines?: number;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
 }
@@ -56,6 +77,9 @@ export interface BackgroundJobLaunchInput {
   agent: string;
   description?: string;
   objective?: string;
+  background?: boolean;
+  /** Preserve the current run when this is a duplicate lifecycle observation. */
+  preserveRun?: boolean;
   now?: number;
 }
 
@@ -69,6 +93,21 @@ export interface BackgroundJobStatusInput {
   now?: number;
 }
 
+export interface WallClockTimeoutClaimInput {
+  taskID: string;
+  generation: number;
+  now?: number;
+  resultSummary?: string;
+}
+
+export interface WallClockTimeoutFinalizeInput {
+  taskID: string;
+  generation: number;
+  now?: number;
+  statusUncertain: boolean;
+  resultSummary: string;
+}
+
 type TerminalStateListener = (taskID: string) => void;
 
 const TERMINAL_STATES = new Set<BackgroundJobState>([
@@ -90,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 =
@@ -121,21 +163,44 @@ 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) {
+      if (input.preserveRun) {
+        if (existing.state !== 'running') return existing;
+        const observed = {
+          ...existing,
+          agent: input.agent || existing.agent,
+          description: input.description || existing.description,
+          objective: input.objective ?? existing.objective,
+          background: existing.background || input.background === true,
+        } satisfies BackgroundJobRecord;
+        this.jobs.set(input.taskID, observed);
+        return observed;
+      }
+
       const updated = {
         ...existing,
+        generation,
         agent: input.agent || existing.agent,
         description: input.description || existing.description,
         objective: input.objective ?? existing.objective,
         state: 'running',
+        background: input.background ?? existing.background,
         timedOut: false,
         recoverableAfterLiveBusy: false,
         statusUncertain: false,
@@ -146,6 +211,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
         lastStatusError: undefined,
         terminalState: undefined,
         lastLaunchedAt: now,
+        runStartedAt: now,
+        deadlineExceededAt: undefined,
         lastLiveBusyAt: now,
         lastUsedAt: now,
         updatedAt: now,
@@ -158,11 +225,13 @@ 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`,
       objective: input.objective,
       state: 'running',
+      background: input.background === true,
       timedOut: false,
       recoverableAfterLiveBusy: false,
       statusUncertain: false,
@@ -170,6 +239,7 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       terminalUnreconciled: false,
       launchedAt: now,
       lastLaunchedAt: now,
+      runStartedAt: now,
       lastLiveBusyAt: now,
       lastUsedAt: now,
       updatedAt: now,
@@ -189,6 +259,22 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const existing = this.jobs.get(input.taskID);
     if (!existing) return undefined;
 
+    // A wall-clock deadline is a hard, non-recoverable claim. Completion after
+    // that claim is late evidence and cannot replace the canonical timeout.
+    if (existing.deadlineExceededAt !== undefined) {
+      if (existing.state !== 'running') return existing;
+      if (input.state === 'completed' || input.state === 'running') {
+        return existing;
+      }
+      return this.finalizeWallClockTimeout({
+        taskID: input.taskID,
+        generation: existing.generation,
+        now: input.now,
+        statusUncertain: false,
+        resultSummary: existing.resultSummary ?? timeoutSummary(input.state),
+      });
+    }
+
     // Guard: stale status updates cannot reopen already terminal jobs.
     if (
       existing.state === 'reconciled' ||
@@ -258,6 +344,8 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const existing = this.jobs.get(taskID);
     if (!existing) return undefined;
 
+    if (existing.deadlineExceededAt !== undefined) return existing;
+
     const isStaleTerminal =
       TERMINAL_STATES.has(existing.state) || existing.state === 'reconciled';
     if (isStaleTerminal) {
@@ -300,7 +388,10 @@ export class BackgroundJobBoard implements BackgroundJobStore {
       ...existing,
       state: 'reconciled',
       terminalUnreconciled: false,
-      statusUncertain: false,
+      statusUncertain:
+        existing.deadlineExceededAt !== undefined
+          ? existing.statusUncertain
+          : false,
       updatedAt: now,
       lastUsedAt: now,
       terminalState: existing.terminalState ?? terminalStateOf(existing.state),
@@ -319,6 +410,16 @@ export class BackgroundJobBoard implements BackgroundJobStore {
   ): BackgroundJobRecord | undefined {
     const existing = this.jobs.get(taskID);
     if (!existing) return undefined;
+    if (existing.deadlineExceededAt !== undefined) {
+      if (existing.state !== 'running') return existing;
+      return this.finalizeWallClockTimeout({
+        taskID,
+        generation: existing.generation,
+        now,
+        statusUncertain: false,
+        resultSummary: existing.resultSummary ?? normalizeCancelReason(reason),
+      });
+    }
     if (!options.force) {
       if (existing.state === 'reconciled') return existing;
       if (TERMINAL_STATES.has(existing.state)) return existing;
@@ -376,6 +477,72 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     return this.field(taskID, 'lastLiveBusyAt');
   }
 
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(input.taskID);
+    if (
+      existing?.state !== 'running' ||
+      existing?.generation !== input.generation ||
+      existing?.deadlineExceededAt !== undefined
+    ) {
+      return undefined;
+    }
+
+    const now = input.now ?? Date.now();
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      timedOut: true,
+      deadlineExceededAt: now,
+      cancellationRequested: true,
+      statusUncertain: false,
+      updatedAt: now,
+      resultSummary:
+        input.resultSummary ??
+        'Background task exceeded its wall-clock deadline; abort requested.',
+    };
+    this.jobs.set(input.taskID, updated);
+    return updated;
+  }
+
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(input.taskID);
+    if (!existing) return undefined;
+    if (existing.state !== 'running') return existing;
+    if (
+      existing.generation !== input.generation ||
+      existing.deadlineExceededAt === undefined
+    ) {
+      return undefined;
+    }
+
+    const now = input.now ?? Date.now();
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: 'error',
+      timedOut: true,
+      recoverableAfterLiveBusy: false,
+      statusUncertain: input.statusUncertain,
+      cancellationRequested: true,
+      terminalUnreconciled: true,
+      updatedAt: now,
+      completedAt: existing.completedAt ?? now,
+      terminalState: 'error',
+      resultSummary: input.resultSummary,
+      lastStatusError: input.statusUncertain
+        ? input.resultSummary
+        : existing.lastStatusError,
+      timeoutCount: (existing.timeoutCount ?? 0) + 1,
+      lastErrorAt: now,
+      totalErrors: (existing.totalErrors ?? 0) + 1,
+    };
+    this.jobs.set(input.taskID, updated);
+    this.notifyTerminalStateListeners(input.taskID);
+    return updated;
+  }
+
   getParentSessionID(taskID: string): string | undefined {
     return this.field(taskID, 'parentSessionID');
   }
@@ -400,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;
   }
@@ -413,7 +580,11 @@ export class BackgroundJobBoard implements BackgroundJobStore {
     const job = this.resolve(parentSessionID, taskIDOrAlias);
     if (!job) return undefined;
     if (agent && job.agent !== agent) return undefined;
-    if (job.state !== 'running' || !job.recoverableAfterLiveBusy) {
+    if (
+      job.state !== 'running' ||
+      !job.recoverableAfterLiveBusy ||
+      job.deadlineExceededAt !== undefined
+    ) {
       return undefined;
     }
     return job;
@@ -483,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',
@@ -508,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 {
@@ -536,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)) {
@@ -590,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(
@@ -617,13 +831,19 @@ function normalizeWhitespace(value: string): string {
   return value.replace(/\s+/g, ' ').trim();
 }
 
+function timeoutSummary(state: TaskOutputState): string {
+  return `Background task exceeded its wall-clock deadline; abort was observed with child state ${state}.`;
+}
+
 function formatJob(job: BackgroundJobRecord): string {
   const isResume = job.lastLaunchedAt !== job.launchedAt;
   // Exclude wall-clock age labels so prompts remain stable between job-state transitions for cache reuse.
   const displayState =
     job.state === 'running' && isResume ? 'running [resumed]' : job.state;
   const status = job.terminalUnreconciled
-    ? `${job.state}, unreconciled`
+    ? `${job.state}, unreconciled${
+        job.deadlineExceededAt !== undefined ? ', timed out' : ''
+      }`
     : job.statusUncertain
       ? `${job.state}, status uncertain`
       : job.timedOut

+ 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);

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

@@ -1,14 +1,19 @@
 import type {
   BackgroundJobBoard,
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  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;
+type TerminalOutcomeListener = (record: BackgroundJobRecord) => void;
 
 /**
  * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
@@ -23,6 +28,7 @@ type TerminalStateListener = (taskID: string) => void;
  */
 export class BackgroundJobCoordinator implements BackgroundJobStore {
   private terminalStateListeners: TerminalStateListener[] = [];
+  private terminalOutcomeListeners: TerminalOutcomeListener[] = [];
   // Stores session IDs (which equal task IDs) awaiting close after background job completes
   private readonly deferredIdleCloses = new Set<string>();
 
@@ -58,9 +64,34 @@ 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),
+          });
+        }
       }
     }
+
+    const record = this.board.get?.(taskID);
+    if (record) {
+      for (const listener of this.terminalOutcomeListeners) {
+        listener(record);
+      }
+    }
+  }
+
+  /** Observe every canonical terminal publication, including non-idle jobs. */
+  addTerminalOutcomeListener(listener: TerminalOutcomeListener): void {
+    this.terminalOutcomeListeners.push(listener);
+  }
+
+  removeTerminalOutcomeListener(listener: TerminalOutcomeListener): void {
+    this.terminalOutcomeListeners = this.terminalOutcomeListeners.filter(
+      (entry) => entry !== listener,
+    );
   }
 
   // ── Lifecycle policy ─────────────────────────────────────────────
@@ -110,6 +141,18 @@ export class BackgroundJobCoordinator implements BackgroundJobStore {
     return this.board.updateFromStatusOutput(output);
   }
 
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.claimWallClockDeadline(input);
+  }
+
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.finalizeWallClockTimeout(input);
+  }
+
   markRunningFromLiveSession(
     taskID: string,
     now = Date.now(),
@@ -228,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);
   }

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

@@ -1,8 +1,11 @@
 import type {
   BackgroundJobLaunchInput,
+  BackgroundJobPromptMetadata,
   BackgroundJobRecord,
   BackgroundJobStatusInput,
   ContextFile,
+  WallClockTimeoutClaimInput,
+  WallClockTimeoutFinalizeInput,
 } from './background-job-board';
 import type { TaskOutputState } from './task';
 
@@ -19,6 +22,12 @@ export interface BackgroundJobStore {
     input: BackgroundJobStatusInput,
   ): BackgroundJobRecord | undefined;
   updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
+  claimWallClockDeadline(
+    input: WallClockTimeoutClaimInput,
+  ): BackgroundJobRecord | undefined;
+  finalizeWallClockTimeout(
+    input: WallClockTimeoutFinalizeInput,
+  ): BackgroundJobRecord | undefined;
   markRunningFromLiveSession(
     taskID: string,
     now?: number,
@@ -67,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.

+ 317 - 0
src/utils/background-job-supervisor.test.ts

@@ -0,0 +1,317 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from './background-job-board';
+import { BackgroundJobCoordinator } from './background-job-coordinator';
+import { BackgroundJobSupervisor } from './background-job-supervisor';
+
+type TimerCallback = () => void;
+
+function createTimerHarness() {
+  let now = 0;
+  let nextID = 0;
+  const timers = new Map<number, { at: number; callback: TimerCallback }>();
+
+  const setTimeout = (callback: TimerCallback, delay: number) => {
+    const id = ++nextID;
+    timers.set(id, { at: now + delay, callback });
+    return id;
+  };
+  const clearTimeout = (id: number) => {
+    timers.delete(id);
+  };
+  const advanceTo = async (target: number) => {
+    now = target;
+    while (true) {
+      const due = [...timers.entries()]
+        .filter(([, timer]) => timer.at <= now)
+        .sort(([, a], [, b]) => a.at - b.at)[0];
+      if (!due) break;
+      timers.delete(due[0]);
+      due[1].callback();
+      await Promise.resolve();
+    }
+  };
+
+  return {
+    now: () => now,
+    setTimeout,
+    clearTimeout,
+    advanceTo,
+    pending: () => timers.size,
+  };
+}
+
+function createSupervisor(
+  overrides: {
+    timeoutMs?: number;
+    graceMs?: number;
+    abort?: (taskID: string) => Promise<unknown>;
+  } = {},
+) {
+  const board = new BackgroundJobBoard();
+  const coordinator = new BackgroundJobCoordinator(board);
+  const timers = createTimerHarness();
+  const abort = mock(overrides.abort ?? (async () => undefined)) as unknown as (
+    taskID: string,
+  ) => Promise<unknown>;
+  const supervisor = new BackgroundJobSupervisor({
+    backgroundJobStore: coordinator,
+    wallClockTimeoutMs: overrides.timeoutMs ?? 100,
+    abortGraceMs: overrides.graceMs ?? 20,
+    abort,
+    now: timers.now,
+    setTimeout: timers.setTimeout,
+    clearTimeout: timers.clearTimeout,
+  });
+  coordinator.addTerminalOutcomeListener((record) =>
+    supervisor.onTerminal(record),
+  );
+
+  return { board, coordinator, supervisor, timers, abort };
+}
+
+function launch(
+  board: BackgroundJobBoard,
+  background: boolean,
+  now = 0,
+  taskID = 'ses_1',
+) {
+  return board.registerLaunch({
+    taskID,
+    parentSessionID: 'parent',
+    agent: 'explorer',
+    description: 'test job',
+    background,
+    now,
+  });
+}
+
+describe('BackgroundJobSupervisor', () => {
+  test('supervises only explicit background launches', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const foreground = launch(board, false);
+    const background = launch(board, true, 0, 'ses_2');
+
+    supervisor.onLaunch(foreground);
+    supervisor.onLaunch(background);
+    await timers.advanceTo(100);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('ses_2');
+    expect(board.get('ses_1')?.deadlineExceededAt).toBeUndefined();
+  });
+
+  test('duplicate launch observations do not renew one run deadline', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const first = launch(board, true);
+    supervisor.onLaunch(first);
+    supervisor.onLaunch({ ...first, updatedAt: 80, lastLiveBusyAt: 80 });
+
+    await timers.advanceTo(100);
+
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledWith('ses_1');
+  });
+
+  test('terminal state wins before the deadline and clears timers', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    const completed = coordinator.updateStatus({
+      taskID: job.taskID,
+      state: 'completed',
+      now: 99,
+    });
+    if (completed) supervisor.onTerminal(completed);
+    await timers.advanceTo(100);
+
+    expect(abort).not.toHaveBeenCalled();
+    expect(board.get(job.taskID)?.state).toBe('completed');
+    expect(timers.pending()).toBe(0);
+  });
+
+  test.each([
+    ['resolve', async () => undefined],
+    ['reject', async () => Promise.reject(new Error('abort failed'))],
+    ['hang', () => new Promise<never>(() => {})],
+  ])(
+    'abort %s is requested once and grace remains independent',
+    async (_, abortCall) => {
+      const { board, supervisor, timers, abort } = createSupervisor({
+        abort: abortCall,
+      });
+      const job = launch(board, true);
+      supervisor.onLaunch(job);
+      await timers.advanceTo(100);
+      await timers.advanceTo(119);
+
+      expect(abort).toHaveBeenCalledTimes(1);
+      expect(board.get(job.taskID)?.state).toBe('running');
+      await timers.advanceTo(120);
+
+      expect(board.get(job.taskID)).toMatchObject({
+        state: 'error',
+        timedOut: true,
+        statusUncertain: true,
+        cancellationRequested: true,
+      });
+      expect(board.getResultSummary(job.taskID)).toContain(
+        'abort was not confirmed',
+      );
+    },
+  );
+
+  test('completion after the deadline claim cannot replace the timeout', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+
+    const late = coordinator.updateStatus({
+      taskID: job.taskID,
+      state: 'completed',
+      resultSummary: 'late success',
+      now: 101,
+    });
+    expect(late?.state).toBe('running');
+    expect(late?.resultSummary).not.toBe('late success');
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('busy activity after the deadline neither recovers nor renews the run', async () => {
+    const { board, coordinator, supervisor, timers, abort } =
+      createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+    const beforeBusy = board.get(job.taskID);
+    coordinator.markRunningFromLiveSession(job.taskID, 101);
+
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'running',
+      lastLiveBusyAt: beforeBusy?.lastLiveBusyAt,
+      deadlineExceededAt: 100,
+    });
+    expect(
+      coordinator.resolveRecoverable('parent', job.taskID),
+    ).toBeUndefined();
+    await timers.advanceTo(120);
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(board.get(job.taskID)?.state).toBe('error');
+  });
+
+  test('error and cancelled during grace settle the same timed-out terminal', async () => {
+    for (const state of ['error', 'cancelled'] as const) {
+      const { board, coordinator, supervisor, timers } = createSupervisor();
+      const job = launch(board, true);
+      supervisor.onLaunch(job);
+      await timers.advanceTo(100);
+
+      const settled = coordinator.updateStatus({
+        taskID: job.taskID,
+        state,
+        resultSummary: 'child terminal',
+        now: 101,
+      });
+
+      expect(settled).toMatchObject({
+        state: 'error',
+        timedOut: true,
+        statusUncertain: false,
+        deadlineExceededAt: 100,
+      });
+      expect(timers.pending()).toBe(0);
+    }
+  });
+
+  test('child deletion during grace publishes a visible timed-out terminal', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    await timers.advanceTo(100);
+
+    expect(supervisor.onSessionDeleted(job.taskID)).toBe(true);
+    expect(abort).toHaveBeenCalledTimes(1);
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'error',
+      timedOut: true,
+      terminalUnreconciled: true,
+      statusUncertain: false,
+    });
+    expect(board.formatForPrompt('parent')).toContain(
+      'error, unreconciled, timed out',
+    );
+    board.markReconciled(job.taskID, 130);
+    expect(board.get(job.taskID)).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'error',
+      statusUncertain: false,
+      deadlineExceededAt: 100,
+    });
+    expect(timers.pending()).toBe(0);
+  });
+
+  test('wall-clock timeout is not recoverable while external timeout remains recoverable', async () => {
+    const { board, coordinator, supervisor, timers } = createSupervisor();
+    const external = launch(board, false, 0, 'external');
+    coordinator.updateStatus({
+      taskID: external.taskID,
+      state: 'running',
+      timedOut: true,
+    });
+    coordinator.markRunningFromLiveSession(external.taskID, 1);
+    expect(
+      coordinator.resolveRecoverable('parent', external.taskID),
+    ).toBeDefined();
+
+    const wall = launch(board, true, 0, 'wall');
+    supervisor.onLaunch(wall);
+    await timers.advanceTo(100);
+    coordinator.markRunningFromLiveSession(wall.taskID, 101);
+    expect(
+      coordinator.resolveRecoverable('parent', wall.taskID),
+    ).toBeUndefined();
+  });
+
+  test('drop, parent cleanup, dispose, and relaunch clear or replace timers', async () => {
+    const { board, supervisor, timers, abort } = createSupervisor();
+    const job = launch(board, true);
+    supervisor.onLaunch(job);
+    supervisor.drop(job.taskID);
+    board.drop(job.taskID);
+    await timers.advanceTo(100);
+    expect(abort).not.toHaveBeenCalled();
+
+    const parentJob = launch(board, true, 100, 'parent-job');
+    supervisor.onLaunch(parentJob);
+    supervisor.clearParent('parent');
+    board.clearParent('parent');
+    await timers.advanceTo(200);
+    expect(abort).not.toHaveBeenCalled();
+
+    const relaunched = launch(board, true, 200, 'relaunch');
+    supervisor.onLaunch(relaunched);
+    const secondRun = board.registerLaunch({
+      taskID: relaunched.taskID,
+      parentSessionID: 'parent',
+      agent: 'explorer',
+      background: true,
+      now: 300,
+    });
+    supervisor.onLaunch(secondRun);
+    await timers.advanceTo(399);
+    expect(abort).not.toHaveBeenCalled();
+    await timers.advanceTo(400);
+    expect(abort).toHaveBeenCalledTimes(1);
+
+    const disposedJob = launch(board, true, 400, 'disposed');
+    supervisor.onLaunch(disposedJob);
+    supervisor.dispose();
+    supervisor.dispose();
+    expect(supervisor.onSessionDeleted(disposedJob.taskID)).toBe(false);
+    expect(board.get(disposedJob.taskID)?.state).toBe('running');
+    await timers.advanceTo(500);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
+});

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