Browse Source

fix(image-routing): resolve PR 877 merge conflict

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

+ 63 - 0
.all-contributorsrc

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

+ 10 - 10
README.ja-JP.md

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

+ 51 - 29
README.ko-KR.md

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

+ 22 - 11
README.md

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

+ 10 - 10
README.zh-CN.md

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

+ 1 - 4
docs/authors-preset.md

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

+ 50 - 1
docs/background-orchestration.md

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

+ 106 - 5
docs/configuration.md

@@ -124,6 +124,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `agents.<customAgent>.orchestratorPrompt` | string | - | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
 | `agents.<agent>.permission` | object \| string | - | Tool-level permission rules enforced by the SDK. See [Agent Permissions](#agent-permissions) |
 | `agents.<agent>.displayName` | string | - | Custom user-facing alias for the agent in the active config |
+| `agents.<agent>.description` | string | generated | Description shown to OpenCode and the orchestrator; defaults to `Custom subagent '<name>'` for custom agents |
 | `acpAgents.<name>.command` | string | - | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` See [ACP-connected agents](#acp-connected-agents). |
 | `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command See [ACP-connected agents](#acp-connected-agents). |
 | `acpAgents.<name>.env` | object | `{}` | Extra environment variables for the ACP subprocess See [ACP-connected agents](#acp-connected-agents). |
@@ -145,11 +146,14 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` See [Multiplexer Integration](multiplexer-integration.md). |
 | `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` See [Multiplexer Integration](multiplexer-integration.md). |
 | `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.maxContextLines` | integer | `50000` | Maximum total context lines (sum of all tracked file line counts) for a session to remain reusable. Sessions exceeding this threshold are evicted from the reusable pool on completion See [Background Job Management](#background-job-management). |
 | `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss See [Background Job Management](#background-job-management). |
 | `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 |
@@ -291,6 +295,8 @@ 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
 {
@@ -298,15 +304,22 @@ Jobs](../CONTEXT.md#background-jobs).
     "maxSessionsPerAgent": 3,
     "strategy": "checkpoint-compatible",
     "maxRetainedSnapshots": 10,
-    "continueOnIdle": true
+    "continueOnIdle": true,
+    "wallClockTimeoutMs": 900000,
+    "abortGraceMs": 10000
   }
 }
 ```
 
-Without that opt-in, idle reconciliation and background-job orchestration remain
-enabled but no hidden continuation prompts are sent. See the
+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
 
@@ -336,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
@@ -381,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": {
@@ -391,7 +492,7 @@ The field accepts either:
           "grep *": "allow"
         },
         "webfetch": "allow",
-        "websearch": "allow",
+        "websearch": "allow", // opencode's built-in websearch tool, not a plugin MCP
         "task": "deny"
       },
       "prompt": "You are Planner. Create implementation plans only. Do not implement code."

+ 20 - 7
docs/mcps.md

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

+ 1 - 1
docs/openai-preset.md

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

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

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

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

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

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

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

+ 1 - 1
docs/quick-reference.md

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

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

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

+ 8 - 2
docs/tools.md

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

+ 281 - 0
docs/webfetch.md

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

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

@@ -104,6 +104,10 @@
               "type": "string",
               "minLength": 1
             },
+            "description": {
+              "type": "string",
+              "minLength": 1
+            },
             "permission": {
               "anyOf": [
                 {
@@ -539,6 +543,10 @@
             "type": "string",
             "minLength": 1
           },
+          "description": {
+            "type": "string",
+            "minLength": 1
+          },
           "permission": {
             "anyOf": [
               {
@@ -973,19 +981,6 @@
         }
       }
     },
-    "websearch": {
-      "type": "object",
-      "properties": {
-        "provider": {
-          "default": "exa",
-          "type": "string",
-          "enum": [
-            "exa",
-            "tavily"
-          ]
-        }
-      }
-    },
     "interview": {
       "type": "object",
       "properties": {
@@ -1035,6 +1030,12 @@
           "minimum": 1,
           "maximum": 10
         },
+        "maxContextLines": {
+          "default": 50000,
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 500000
+        },
         "readContextMinLines": {
           "default": 10,
           "type": "integer",
@@ -1058,6 +1059,28 @@
           "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": {

+ 1 - 1
src/agents/codemap.md

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

+ 7 - 2
src/agents/council.ts

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

+ 3 - 1
src/agents/councillor.ts

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

+ 1 - 1
src/agents/fixer.ts

@@ -12,7 +12,7 @@ const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 ${WRITABLE_FILE_OPERATIONS_RULES}
 
 **Constraints**:
-- NO external research (no websearch, context7, gh_grep)
+- NO external research (no context7, gh_grep)
 - NO spawning subagents; telling the caller which specialist to use is fine
 - No multi-step research/planning; minimal execution sequence ok
 - If context is insufficient: use grep/glob/read directly - do not delegate

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

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

+ 25 - 13
src/agents/index.ts

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

+ 0 - 1
src/agents/librarian.ts

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

+ 23 - 7
src/agents/orchestrator.ts

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

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

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

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

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

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

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

+ 4 - 4
src/cli/providers.ts

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

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

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

+ 3 - 1
src/cli/skills.ts

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

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

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

+ 3 - 1
src/companion/manager.ts

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

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

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

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

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

+ 1 - 2
src/config/codemap.md

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

+ 8 - 0
src/config/constants.ts

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

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

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

+ 80 - 2
src/config/loader.ts

@@ -3,7 +3,11 @@ import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
-import { type PluginConfig, PluginConfigSchema } from './schema';
+import {
+  type PluginConfig,
+  PluginConfigSchema,
+  WebfetchConfigSchema,
+} from './schema';
 
 /**
  * Warning kinds produced during config loading.
@@ -141,6 +145,26 @@ function loadConfigFromPath(
       return null;
     }
 
+    // Zod applies webfetch.enabled's default while parsing each layer. Keep
+    // that default from masquerading as an explicitly configured override;
+    // the merged webfetch config is normalized after all layers are merged.
+    if (
+      result.data.webfetch &&
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      'webfetch' in rawConfig &&
+      typeof rawConfig.webfetch === 'object' &&
+      rawConfig.webfetch !== null &&
+      !Array.isArray(rawConfig.webfetch) &&
+      !Object.hasOwn(rawConfig.webfetch, 'enabled')
+    ) {
+      const { enabled: _enabled, ...webfetch } = result.data.webfetch;
+      return {
+        ...result.data,
+        webfetch: webfetch as PluginConfig['webfetch'],
+      };
+    }
+
     return result.data;
   } catch (error) {
     // File doesn't exist or isn't readable - this is expected and fine
@@ -200,6 +224,17 @@ function findConfigPathInDirs(
   return null;
 }
 
+/**
+ * Validate that `image_routing: "auto"` has a live observer agent to route
+ * images to. Emits a warning (via `onWarning`/`console.warn`) and returns
+ * `false` if "auto" routing is configured but the observer agent is
+ * disabled, since images would then have nowhere to go.
+ *
+ * @param config - Plugin configuration to validate
+ * @param configPath - Path of the config file, used in the warning payload
+ * @param options - Optional load options including the onWarning callback
+ * @returns `true` if the routing configuration is valid, `false` otherwise
+ */
 function validateFinalImageRouting(
   config: PluginConfig,
   configPath: string,
@@ -207,7 +242,9 @@ function validateFinalImageRouting(
 ): boolean {
   if (config.image_routing !== 'auto') return true;
 
-  const disabledAgents = config.disabled_agents ?? DEFAULT_DISABLED_AGENTS;
+  const disabledAgents = Array.isArray(config.disabled_agents)
+    ? config.disabled_agents
+    : DEFAULT_DISABLED_AGENTS;
   if (!disabledAgents.includes('observer')) return true;
 
   const message =
@@ -270,6 +307,10 @@ export function mergePluginConfigs(
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
+    webfetch: deepMerge(
+      base.webfetch as Record<string, unknown> | undefined,
+      override.webfetch as Record<string, unknown> | undefined,
+    ) as PluginConfig['webfetch'],
     acpAgents: deepMerge(base.acpAgents, override.acpAgents),
     companion: deepMerge(
       base.companion as Record<string, unknown> | undefined,
@@ -351,6 +392,10 @@ export function loadPluginConfig(
     config = mergePluginConfigs(config, projectConfig);
   }
 
+  if (config.webfetch) {
+    config.webfetch = WebfetchConfigSchema.parse(config.webfetch);
+  }
+
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
@@ -407,6 +452,39 @@ export function loadPluginConfig(
   // 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

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

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

+ 37 - 8
src/config/schema.ts

@@ -122,6 +122,7 @@ export const AgentOverrideConfigSchema = z
     orchestratorPrompt: z.string().min(1).optional(),
     options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
     displayName: z.string().min(1).optional(),
+    description: z.string().min(1).optional(),
     permission: PermissionConfigSchema.optional(), // tool-level permission rules enforced by the SDK
   })
   .strict();
@@ -172,14 +173,8 @@ export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
 
 export type Preset = z.infer<typeof PresetSchema>;
 
-// Websearch provider configuration
-export const WebsearchConfigSchema = z.object({
-  provider: z.enum(['exa', 'tavily']).default('exa'),
-});
-export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>;
-
 // MCP names
-export const McpNameSchema = z.enum(['websearch', 'context7', 'gh_grep']);
+export const McpNameSchema = z.enum(['context7', 'gh_grep']);
 export type McpName = z.infer<typeof McpNameSchema>;
 
 export const InterviewConfigSchema = z.object({
@@ -205,6 +200,7 @@ export const BackgroundJobsConfigSchema = z.object({
       'Board injection strategy. "latest" replaces prior board messages; "checkpoint-compatible" preserves them and appends only changed board snapshots.',
     ),
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
+  maxContextLines: z.number().int().min(0).max(500_000).default(50_000),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
   maxRetainedSnapshots: z
@@ -222,6 +218,21 @@ export const BackgroundJobsConfigSchema = z.object({
     .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(
+      'Grace period after a wall-clock deadline while OpenCode confirms the child terminal state (1,000–60,000ms).',
+    ),
 });
 
 export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
@@ -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
+  );
+}

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

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

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

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

+ 26 - 7
src/hooks/cache-safety.property.test.ts

@@ -22,7 +22,7 @@ import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
 import { readFileSync } from 'node:fs';
 import path from 'node:path';
 import { BackgroundJobsConfigSchema } from '../config';
-import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { isTaggedPart, isVolatileTaggedMessage } from './cache-safe-injection';
 import {
   assistantTurn,
   type BoardStrategy,
@@ -225,15 +225,34 @@ describe('cache-safety: volatile content isolation', () => {
       stableFingerprints(withoutJobs.messages),
     );
 
-    // The volatile zone is exactly one tagged message, strictly trailing.
-    const volatile = withJobs.messages.filter((message) =>
-      isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    // The board is a single tagged part appended to the very end of the last
+    // message (never a separate trailing message that the provider SDK would
+    // coalesce into the last real message and rob of its cache breakpoint).
+    // Keeping the message COUNT identical to the no-board render lets the
+    // provider's last-two-messages breakpoint land on stable real content.
+    expect(withJobs.messages).toHaveLength(withoutJobs.messages.length);
+
+    const allTaggedParts = withJobs.messages.flatMap((message, index) =>
+      (message as MessageWithParts).parts
+        .map((part, partIndex) => ({ index, partIndex, part }))
+        .filter(({ part }) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
     );
-    expect(volatile).toHaveLength(1);
-    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(allTaggedParts).toHaveLength(1);
+
+    const lastMessage = withJobs.messages.at(-1) as MessageWithParts;
+    const boardHit = allTaggedParts[0];
+    // The one board part lives on the last message and is its last part.
+    expect(boardHit.index).toBe(withJobs.messages.length - 1);
+    expect(boardHit.partIndex).toBe(lastMessage.parts.length - 1);
+
+    // The no-board render carries no board part anywhere.
     expect(
       withoutJobs.messages.some((message) =>
-        isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        (message as MessageWithParts).parts.some((part) =>
+          isTaggedPart(part, BACKGROUND_JOB_BOARD_METADATA_KEY),
+        ),
       ),
     ).toBe(false);
   });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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
+ 1050 - 128
src/hooks/task-session-manager/index.test.ts


+ 63 - 5
src/hooks/task-session-manager/index.ts

@@ -1,7 +1,9 @@
 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';
@@ -9,6 +11,7 @@ import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 import {
   BACKGROUND_JOB_BOARD_METADATA_KEY,
+  type InjectedTerminalJobs,
   type InjectionState,
   injectBackgroundJobBoard,
   MAX_PROCESSED_INJECTED_COMPLETIONS,
@@ -17,6 +20,10 @@ import {
   updateFromInjectedCompletion,
 } from './board-injection';
 import { evaluateContinuation as evaluateContinuationFn } from './continuation-evaluator';
+import {
+  type ContinuationModelSelection,
+  parseContinuationModelSelection,
+} from './continuation-model-selection';
 import { createContinuationTokenManager } from './continuation-token-manager';
 import { handleEvent } from './event-router';
 import { createIdleReconciler } from './idle-reconciliation';
@@ -54,6 +61,7 @@ export function createTaskSessionManagerHook(
      */
     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. */
@@ -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
@@ -136,6 +152,7 @@ export function createTaskSessionManagerHook(
     todo?: (input: unknown) => Promise<SdkResponse>;
     children?: (input: unknown) => Promise<SdkResponse>;
     status?: (input: unknown) => Promise<SdkResponse>;
+    get?: (input: unknown) => Promise<SdkResponse>;
     promptAsync?: (input: unknown) => Promise<unknown>;
   };
   const sessionSdk = (_ctx.client as unknown as { session?: SessionSdk })
@@ -149,6 +166,8 @@ export function createTaskSessionManagerHook(
       inputWaits,
       options,
       sessionSdk,
+      getObservedModelSelection: (sessionID) =>
+        observedContinuationModels.get(sessionID),
     });
 
   if (options.coordinator) {
@@ -160,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.
@@ -167,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);
@@ -185,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 {
@@ -241,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);
     },
 
@@ -252,6 +297,7 @@ export function createTaskSessionManagerHook(
         shouldManageSession: options.shouldManageSession,
         registerSessionAsOrchestrator: options.registerSessionAsOrchestrator,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -263,6 +309,7 @@ export function createTaskSessionManagerHook(
       handleToolExecuteAfter(input, output, {
         directory: _ctx.directory,
         backgroundJobBoard,
+        backgroundJobSupervisor: options.backgroundJobSupervisor,
         pendingCallTracker,
         taskContextTracker,
       }),
@@ -324,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,
@@ -334,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;
 }
 

+ 29 - 2
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,6 +93,7 @@ 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();
@@ -156,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') {
@@ -175,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,
@@ -196,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,
@@ -225,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,
@@ -241,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(
@@ -258,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 });
+    }
+  });
 });

+ 133 - 75
src/index.ts

@@ -17,7 +17,9 @@ import {
 import { parseList } from './config/agent-mcps';
 import {
   AGENT_ALIASES,
+  DEFAULT_MAX_CONTEXT_LINES,
   DEFAULT_MAX_RETAINED_SNAPSHOTS,
+  DEFAULT_MAX_SESSION_METADATA_ENTRIES,
   DEFAULT_MAX_SESSIONS_PER_AGENT,
   DEFAULT_READ_CONTEXT_MAX_FILES,
   DEFAULT_READ_CONTEXT_MIN_LINES,
@@ -30,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,
@@ -69,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';
 
 /**
@@ -103,33 +108,6 @@ async function appLog(
 const lastImageSkippedToastByDir = new Map<string, number>();
 const IMAGE_SKIPPED_DEBOUNCE_MS = 60_000;
 
-/** Minimum expected registrations for a healthy plugin load. */
-const HEALTH_CHECK = {
-  minAgents: 5,
-  // Default tool set when council and ACP agents are not configured:
-  // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
-  minTools: 5,
-  minMcps: 1,
-} as const;
-
-const BASELINE_TOOL_NAMES = new Set([
-  'cancel_task',
-  'wait_for_user',
-  'webfetch',
-  'ast_grep_search',
-  'ast_grep_replace',
-]);
-
-/** @internal Exposed for deterministic health-threshold tests. */
-export function minimumExpectedToolCount(
-  disabledTools: readonly string[] = [],
-): number {
-  const disabledBaselineTools = new Set(
-    disabledTools.filter((toolName) => BASELINE_TOOL_NAMES.has(toolName)),
-  );
-  return HEALTH_CHECK.minTools - disabledBaselineTools.size;
-}
-
 /**
  * Probe jsdom at init time so the first webfetch call doesn't fail
  * silently. Logs a warning if jsdom can't be imported or instantiated,
@@ -177,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>;
@@ -200,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>;
@@ -217,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;
@@ -282,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,
@@ -304,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
@@ -315,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);
 
@@ -324,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.
@@ -359,10 +382,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         DEFAULT_READ_CONTEXT_MAX_FILES,
       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),
@@ -401,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,
@@ -443,28 +467,32 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       client: ctx.client,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
     });
     waitForUserTools = createWaitForUserTool({
       shouldManageSession: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
+        sessionMetadata.getAgent(sessionID) === 'orchestrator',
       resolveAgentName: (agent) => resolveRuntimeAgentName(config, agent),
       registerSessionAsOrchestrator: (sessionID) => {
-        sessionAgentMap.set(sessionID, 'orchestrator');
+        sessionMetadata.setAgent(sessionID, 'orchestrator');
       },
       beginUserWait: (sessionID) =>
         taskSessionManagerHook.beginUserWait(sessionID),
     });
 
+    const shouldRegisterWebfetch = config.webfetch?.enabled !== false;
     tools = {
       ...cancelTaskTools,
       ...waitForUserTools,
       ...acpRunTools,
-      webfetch,
+      ...(shouldRegisterWebfetch ? { webfetch } : {}),
       ast_grep_search,
       ast_grep_replace,
     };
-    if (config.disabled_tools && config.disabled_tools.length > 0) {
+    if (
+      Array.isArray(config.disabled_tools) &&
+      config.disabled_tools.length > 0
+    ) {
       const disabledTools = new Set(config.disabled_tools);
       tools = Object.fromEntries(
         Object.entries(tools).filter(([name]) => !disabledTools.has(name)),
@@ -489,14 +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;
-  log(
-    `[DEBUG] config.disabled_tools type=${typeof config.disabled_tools} value=${JSON.stringify(config.disabled_tools)}`,
+  const toolThreshold = minimumExpectedToolCount(
+    config.disabled_tools,
+    config.webfetch?.enabled !== false,
   );
-  const toolThreshold = minimumExpectedToolCount(config.disabled_tools);
-
   if (
     agentCount < HEALTH_CHECK.minAgents ||
     toolCount < toolThreshold ||
@@ -691,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];
@@ -946,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 =
@@ -970,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,
           );
         }
@@ -980,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);
         }
       }
 
@@ -1042,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,
         });
       }
@@ -1058,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'](
@@ -1118,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;
@@ -1128,6 +1183,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           agent?: string;
           role?: string;
           sessionID?: string;
+          model?: {
+            providerID: string;
+            modelID: string;
+            variant?: string;
+          };
         };
         parts?: unknown[];
       },
@@ -1147,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.
@@ -1171,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(
@@ -1181,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',
           );
@@ -1194,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}`;
         }
       }
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 187 - 0
src/utils/background-job-supervisor.ts

@@ -0,0 +1,187 @@
+import type { BackgroundJobRecord } from './background-job-board';
+import type { BackgroundJobStore } from './background-job-store';
+
+type TimerHandle = ReturnType<typeof setTimeout>;
+
+export interface BackgroundJobSupervisorOptions {
+  backgroundJobStore: BackgroundJobStore;
+  wallClockTimeoutMs: number;
+  abortGraceMs: number;
+  abort: (taskID: string) => Promise<unknown>;
+  now?: () => number;
+  setTimeout?: (callback: () => void, delay: number) => TimerHandle;
+  clearTimeout?: (timer: TimerHandle) => void;
+}
+
+interface RunTimers {
+  generation: number;
+  parentSessionID: string;
+  deadlineTimer?: TimerHandle;
+  graceTimer?: TimerHandle;
+}
+
+/**
+ * One-shot wall-clock supervision for native background task sessions.
+ *
+ * This class owns only timer/generation/abort mechanics. The board/coordinator
+ * remains the atomic state and terminal-publication boundary.
+ */
+export class BackgroundJobSupervisor {
+  private readonly now: () => number;
+  private readonly setTimer: (
+    callback: () => void,
+    delay: number,
+  ) => TimerHandle;
+  private readonly clearTimer: (timer: TimerHandle) => void;
+  private readonly runs = new Map<string, RunTimers>();
+  private disposed = false;
+
+  constructor(private readonly options: BackgroundJobSupervisorOptions) {
+    this.now = options.now ?? Date.now;
+    this.setTimer =
+      options.setTimeout ?? ((callback, delay) => setTimeout(callback, delay));
+    this.clearTimer = options.clearTimeout ?? ((timer) => clearTimeout(timer));
+  }
+
+  /** Register the first observation of a launch or an explicit new run. */
+  onLaunch(record: BackgroundJobRecord): void {
+    if (this.disposed || record.background !== true) {
+      this.clear(record.taskID);
+      return;
+    }
+    if (this.options.wallClockTimeoutMs <= 0 || record.state !== 'running') {
+      this.clear(record.taskID);
+      return;
+    }
+
+    const current = this.runs.get(record.taskID);
+    if (current?.generation === record.generation) return;
+    this.clear(record.taskID);
+
+    const run: RunTimers = {
+      generation: record.generation,
+      parentSessionID: record.parentSessionID,
+    };
+    run.deadlineTimer = this.setTimer(
+      () => this.onDeadline(record.taskID, record.generation),
+      Math.max(
+        0,
+        record.runStartedAt + this.options.wallClockTimeoutMs - this.now(),
+      ),
+    );
+    this.runs.set(record.taskID, run);
+  }
+
+  /** Clear one-shot timers after any canonical terminal publication. */
+  onTerminal(record: BackgroundJobRecord): void {
+    if (
+      record.state === 'completed' ||
+      record.state === 'error' ||
+      record.state === 'cancelled' ||
+      record.state === 'reconciled'
+    ) {
+      const run = this.runs.get(record.taskID);
+      if (run?.generation === record.generation) this.clear(record.taskID);
+    }
+  }
+
+  /**
+   * Handle a child deletion before the normal board drop callback. A deletion
+   * during grace confirms the timed-out terminal; an ordinary deletion simply
+   * invalidates the run without inventing a terminal result.
+   */
+  onSessionDeleted(taskID: string): boolean {
+    if (this.disposed) {
+      this.clear(taskID);
+      return false;
+    }
+    const record = this.options.backgroundJobStore.get(taskID);
+    if (!record) {
+      this.clear(taskID);
+      return false;
+    }
+    if (record.deadlineExceededAt !== undefined && record.state === 'running') {
+      this.options.backgroundJobStore.finalizeWallClockTimeout({
+        taskID,
+        generation: record.generation,
+        now: this.now(),
+        statusUncertain: false,
+        resultSummary:
+          'Background task exceeded its wall-clock deadline; session deletion confirmed the abort.',
+      });
+      this.clear(taskID);
+      return true;
+    }
+    this.clear(taskID);
+    return false;
+  }
+
+  drop(taskID: string): void {
+    this.clear(taskID);
+  }
+
+  clearParent(parentSessionID: string): void {
+    for (const [taskID] of this.runs) {
+      if (this.runs.get(taskID)?.parentSessionID === parentSessionID) {
+        this.clear(taskID);
+      }
+    }
+  }
+
+  /** Idempotent local cleanup. It never aborts or writes terminal state. */
+  dispose(): void {
+    if (this.disposed) return;
+    this.disposed = true;
+    for (const taskID of this.runs.keys()) this.clear(taskID);
+    this.runs.clear();
+  }
+
+  private onDeadline(taskID: string, generation: number): void {
+    const run = this.runs.get(taskID);
+    if (this.disposed || !run || run.generation !== generation) return;
+    run.deadlineTimer = undefined;
+
+    const claimed = this.options.backgroundJobStore.claimWallClockDeadline({
+      taskID,
+      generation,
+      now: this.now(),
+    });
+    if (!claimed) {
+      this.clear(taskID);
+      return;
+    }
+
+    // The grace timer is armed before abort is invoked. A rejected or hanging
+    // SDK promise must never prevent the bounded terminal transition.
+    run.graceTimer = this.setTimer(
+      () => this.onGraceExpired(taskID, generation),
+      this.options.abortGraceMs,
+    );
+    Promise.resolve()
+      .then(() => this.options.abort(taskID))
+      .catch(() => undefined);
+  }
+
+  private onGraceExpired(taskID: string, generation: number): void {
+    const run = this.runs.get(taskID);
+    if (this.disposed || !run || run.generation !== generation) return;
+    run.graceTimer = undefined;
+    this.options.backgroundJobStore.finalizeWallClockTimeout({
+      taskID,
+      generation,
+      now: this.now(),
+      statusUncertain: true,
+      resultSummary:
+        'Background task exceeded its wall-clock deadline; abort was not confirmed before the grace period expired.',
+    });
+    this.clear(taskID);
+  }
+
+  private clear(taskID: string): void {
+    const run = this.runs.get(taskID);
+    if (!run) return;
+    if (run.deadlineTimer !== undefined) this.clearTimer(run.deadlineTimer);
+    if (run.graceTimer !== undefined) this.clearTimer(run.graceTimer);
+    this.runs.delete(taskID);
+  }
+}

+ 1 - 1
src/utils/codemap.md

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

+ 1 - 0
src/utils/index.ts

@@ -2,6 +2,7 @@ export * from './agent-variant';
 export * from './background-job-board';
 export * from './background-job-coordinator';
 export * from './background-job-store';
+export * from './background-job-supervisor';
 export * from './internal-initiator';
 export { initLogger, log } from './logger';
 export * from './polling';

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

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

+ 82 - 17
src/utils/logger.ts

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

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

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

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

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

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

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

+ 7 - 1
src/utils/session.ts

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

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