Browse Source

Merge upstream master into cache-safe job board

DanMaly 3 weeks ago
parent
commit
a0b69c72ba
100 changed files with 7336 additions and 4273 deletions
  1. 9 0
      .all-contributorsrc
  2. 95 81
      .slim/codemap.json
  3. 41 143
      AGENTS.md
  4. 2 4
      CONTEXT.md
  5. 3 3
      README.ja-JP.md
  6. 3 3
      README.ko-KR.md
  7. 22 11
      README.md
  8. 3 3
      README.zh-CN.md
  9. 1 1
      biome.json
  10. 2 2
      codemap.md
  11. 131 0
      docs/agents/build-agent-empty-input-diagnosis.md
  12. 12 2
      docs/background-orchestration.md
  13. 43 2
      docs/cache-verification.md
  14. 3 5
      docs/configuration.md
  15. 77 153
      docs/council.md
  16. 6 6
      docs/openai-preset.md
  17. 13 7
      docs/skills.md
  18. 2 51
      oh-my-opencode-slim.schema.json
  19. 1 1
      package.json
  20. 5 4
      src/agents/codemap.md
  21. 165 0
      src/agents/council-agents.test.ts
  22. 60 0
      src/agents/council-agents.ts
  23. 0 225
      src/agents/council.test.ts
  24. 33 134
      src/agents/council.ts
  25. 15 0
      src/agents/councillor.test.ts
  26. 5 3
      src/agents/councillor.ts
  27. 0 4
      src/agents/custom.test.ts
  28. 15 0
      src/agents/display-name.test.ts
  29. 3 15
      src/agents/fixer.ts
  30. 92 93
      src/agents/index.test.ts
  31. 43 29
      src/agents/index.ts
  32. 14 0
      src/agents/orchestrator.test.ts
  33. 18 7
      src/agents/orchestrator.ts
  34. 27 0
      src/agents/permissions.ts
  35. 15 0
      src/agents/task-rejection.test.ts
  36. 6 0
      src/agents/task-rejection.ts
  37. 147 0
      src/cache-safety-tripwire.test.ts
  38. 6 2
      src/cli/background-subagents.test.ts
  39. 70 7
      src/cli/config-io.test.ts
  40. 34 24
      src/cli/config-io.ts
  41. 9 3
      src/cli/index.ts
  42. 29 12
      src/cli/install.test.ts
  43. 29 10
      src/cli/install.ts
  44. 14 39
      src/cli/providers.test.ts
  45. 3 11
      src/cli/providers.ts
  46. 49 4
      src/cli/system.ts
  47. 3 3
      src/cli/types.ts
  48. 2 2
      src/codemap.md
  49. 1 4
      src/config/codemap.md
  50. 1 1
      src/config/constants.ts
  51. 0 78
      src/config/council-schema.test.ts
  52. 3 73
      src/config/council-schema.ts
  53. 88 69
      src/config/loader.test.ts
  54. 43 35
      src/config/loader.ts
  55. 1 18
      src/config/schema.ts
  56. 0 168
      src/council/codemap.md
  57. 0 959
      src/council/council-manager.test.ts
  58. 0 457
      src/council/council-manager.ts
  59. 0 1
      src/council/index.ts
  60. 487 0
      src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap
  61. 139 10
      src/hooks/auto-update-checker/cache.test.ts
  62. 103 119
      src/hooks/auto-update-checker/cache.ts
  63. 143 0
      src/hooks/auto-update-checker/checker.test.ts
  64. 254 42
      src/hooks/auto-update-checker/checker.ts
  65. 2 0
      src/hooks/auto-update-checker/constants.ts
  66. 287 11
      src/hooks/auto-update-checker/index.test.ts
  67. 81 16
      src/hooks/auto-update-checker/index.ts
  68. 106 1
      src/hooks/auto-update-checker/skill-sync.test.ts
  69. 44 2
      src/hooks/auto-update-checker/skill-sync.ts
  70. 1 0
      src/hooks/auto-update-checker/types.ts
  71. 223 0
      src/hooks/cache-monitor/index.test.ts
  72. 179 0
      src/hooks/cache-monitor/index.ts
  73. 58 0
      src/hooks/cache-payload.snapshot.test.ts
  74. 152 0
      src/hooks/cache-safe-injection.test.ts
  75. 139 0
      src/hooks/cache-safe-injection.ts
  76. 238 0
      src/hooks/cache-safety-harness.test.ts
  77. 261 0
      src/hooks/cache-safety.property.test.ts
  78. 4 5
      src/hooks/deepwork/index.ts
  79. 102 0
      src/hooks/foreground-fallback/index.test.ts
  80. 20 7
      src/hooks/foreground-fallback/index.ts
  81. 14 0
      src/hooks/index.ts
  82. 7 10
      src/hooks/phase-reminder/index.ts
  83. 3 4
      src/hooks/post-file-tool-nudge/index.ts
  84. 563 0
      src/hooks/task-session-manager/board-injection.ts
  85. 227 0
      src/hooks/task-session-manager/continuation-evaluator.ts
  86. 51 0
      src/hooks/task-session-manager/continuation-token-manager.ts
  87. 303 0
      src/hooks/task-session-manager/event-router.ts
  88. 145 0
      src/hooks/task-session-manager/idle-reconciliation.ts
  89. 770 4
      src/hooks/task-session-manager/index.test.ts
  90. 111 1031
      src/hooks/task-session-manager/index.ts
  91. 88 0
      src/hooks/task-session-manager/input-wait-tracker.ts
  92. 21 0
      src/hooks/task-session-manager/pending-call-tracker.ts
  93. 137 0
      src/hooks/task-session-manager/status-utils.ts
  94. 277 0
      src/hooks/task-session-manager/tool-execute-hooks.ts
  95. 35 26
      src/index.ts
  96. 4 0
      src/interview/service.ts
  97. 1 1
      src/multiplexer/codemap.md
  98. 1 1
      src/multiplexer/zellij/codemap.md
  99. 6 0
      src/plugin-entry.ts
  100. 32 11
      src/skills/deepwork/SKILL.md

+ 9 - 0
.all-contributorsrc

@@ -722,6 +722,15 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "GiuseppeBellamacina",
+      "name": "Giuseppe Bellamacina",
+      "avatar_url": "https://avatars.githubusercontent.com/u/102151655?v=4",
+      "profile": "https://giuseppebellamacina.com/",
+      "contributions": [
+        "code"
+      ]
     }
   ],
   "commitConvention": "angular"

+ 95 - 81
.slim/codemap.json

@@ -1,8 +1,8 @@
 {
   "metadata": {
     "version": "1.0.0",
-    "last_run": "2026-07-06T18:55:02.720Z",
-    "root": "/home/mhenke/Projects/oh-my-opencode-slim",
+    "last_run": "2026-07-16T23:33:10.792Z",
+    "root": "/home/mhenke/Projects/oh-my-opencode-slim/.slim/worktrees/update-codemaps-796",
     "include_patterns": [
       "src/**/*.ts",
       "src/**/*.d.ts",
@@ -27,50 +27,53 @@
     "exceptions": []
   },
   "file_hashes": {
-    "AGENTS.md": "e2448f31cc3ee2cd0176538784373271",
-    "README.md": "f4b9788a277ec3c405ed390b73be1f78",
+    "AGENTS.md": "a2286c06f62ff5f9540b8d49308c60d4",
+    "README.md": "df4ce9c97b53f8efeb43a610f004ccbb",
     "biome.json": "b68da34425b83fddbde5718ac6eb82f9",
-    "package.json": "d1cee3e0421a042698bd306d73197ae5",
+    "package.json": "ac69ea41de9813d2e2916a929878fc03",
+    "scripts/benchmark-opencode-cache.ts": "7b35c826831a94b76331d873a20870b9",
     "scripts/generate-schema.ts": "007f340e39adf6c3fd76feda72b71df1",
+    "scripts/verify-opencode-cache-stability.ts": "5b864c4c4098215bc7bb36a465722333",
     "scripts/verify-opencode-host-smoke.ts": "a87fdb08b123501edf81618a49bc421d",
-    "scripts/verify-release-artifact.ts": "119eb43328189d4a57c12c784f17ac9b",
-    "src/agents/council.ts": "209ef5af0a5c3c0273c9d9db890ad6b8",
+    "scripts/verify-release-artifact.ts": "b9e3ccf7be9dfd22d7edf3f1aa96fc74",
+    "src/agents/council.ts": "e41958aea7a5e8d1c56c2c4d00a9658a",
     "src/agents/councillor.ts": "f2a0b7d0f9722e1b32e4b385d6b7b716",
     "src/agents/designer.ts": "aa9cea2bea0f1732965739114559956d",
     "src/agents/explorer.ts": "d0852357f5f54d9091ce32a7576cbd4e",
     "src/agents/fixer.ts": "1717c67d9900a9d1fe8d99f2f3b968af",
-    "src/agents/index.ts": "410d82467609dfca47fc288aa73f843c",
+    "src/agents/index.ts": "b1820fc922d0caef8f9125b8f9884e74",
     "src/agents/librarian.ts": "25e64317fd9ef5f8b6759150c44c0ff8",
     "src/agents/observer.ts": "5387a97e67b194f12c6a57a35b9d8025",
     "src/agents/oracle.ts": "ef1581f9c8f06cfcec1837f85f69d06e",
-    "src/agents/orchestrator.ts": "da4f99e9634c3c5f3b6ed52a68f7a286",
+    "src/agents/orchestrator.ts": "ab71eb42a7dfe0a2fd7d9600b70c0058",
     "src/agents/permissions.ts": "c7916999c6e0edbf4666db6c02bed3cb",
     "src/cli/background-subagents.ts": "adfda967b577ad4f0494d85447977913",
     "src/cli/companion.ts": "f3031226ff810b9fc703dbd08726f71d",
     "src/cli/config-io.ts": "5f03ec3adf6e86e550c75f3d9c3252a5",
     "src/cli/config-manager.ts": "7f2960f55aaebab21d822c586c2b12eb",
-    "src/cli/custom-skills-registry.ts": "e2157e98eb57e75b24df7819d692f9c3",
+    "src/cli/custom-skills-registry.ts": "a99e63531e71688decc13155e2541611",
     "src/cli/custom-skills.ts": "105f2bd9a36490bfd35858ac36bf5bd6",
     "src/cli/doctor.ts": "deb359777d243984b6a6ced6aee32651",
-    "src/cli/index.ts": "45abb38d42c39c37066a309376381188",
+    "src/cli/index.ts": "fdd07aa92581de053e0efc87e0fa0939",
     "src/cli/install.ts": "379516229bd985e99d518aceb0fe424c",
     "src/cli/model-key-normalization.ts": "7f988cc8109c95382b9ece9730e2a7a5",
     "src/cli/paths.ts": "dd032ba57b84ab4a3a8437d51600acd7",
-    "src/cli/providers.ts": "9eee6be90e4c2d972fa73b0892ddf030",
+    "src/cli/providers.ts": "52b163daa1d840c08e7892af97aaf580",
     "src/cli/skills.ts": "b867492bc2ef473116d5e39e38957b0c",
-    "src/cli/system.ts": "b5464d7661ab1c8e196159641ee3bbed",
+    "src/cli/system.ts": "68f0a50e4fd994dd44eb430572725d82",
     "src/cli/types.ts": "7fb0770e7aa0e010f0107df45ab5572b",
-    "src/companion/manager.ts": "aa8590d929339e7c88bea307dc418ff8",
-    "src/companion/updater.ts": "45bb856e88a75e07426936b505b2f973",
+    "src/companion/manager.ts": "3a70fe4187581ca9501761e07bc12a8f",
+    "src/companion/updater.ts": "80e8e4d15e299d527b29de0985366d69",
     "src/config/agent-mcps.ts": "ce2c54b4f82a8a6ab42ed7acb1fc58bf",
-    "src/config/constants.ts": "907b20f9587b0af529b683cb097eeb7f",
-    "src/config/council-schema.ts": "51c3e6bd9aec8ac5c98043d5b7df7b2f",
+    "src/config/constants.ts": "56cdf3227b18211aee9c0f71fe20f6aa",
+    "src/config/council-schema.ts": "4c44ea0a6e653027f808e2d14649657a",
     "src/config/index.ts": "8a61e02aa676fc86cc8d9d6d30a2e617",
-    "src/config/loader.ts": "5757c143083364fb6352d7701ff1b2b2",
+    "src/config/loader.ts": "a75402f4d7973e809cf567feaad37af8",
     "src/config/runtime-preset.ts": "7f924629c21ed1f438bcea8f4a54da02",
-    "src/config/schema.ts": "84ea141440fa89245dd324fa03d30dfb",
+    "src/config/schema.ts": "8622c8a11fc3aa950bd3bba5034f8813",
+    "src/config/strip-orchestrator-model.ts": "40f71ea6af0dc37471ec02c6feca65ba",
     "src/config/utils.ts": "ea6fe8ef6dff0848f42f03c7d6983727",
-    "src/council/council-manager.ts": "771f4feb3f709d410ef0208b9996881b",
+    "src/council/council-manager.ts": "ef6fda9a031978f353a1f8ed2ae01099",
     "src/council/index.ts": "24cab5b06b4bfd91d2496692650eb18a",
     "src/hooks/apply-patch/codec.ts": "ba2086f51f88c47a67ccf930f0b1e268",
     "src/hooks/apply-patch/errors.ts": "fd2c9d9d185494f2f8b22862bd14700b",
@@ -89,55 +92,63 @@
     "src/hooks/auto-update-checker/index.ts": "2694ba0b1f252c67d08286b7d776ebe3",
     "src/hooks/auto-update-checker/skill-sync.ts": "cfabbd86c122ea31dd4c5ac1d45a5407",
     "src/hooks/auto-update-checker/types.ts": "59800bc1d2a3d189623b56cf49273892",
-    "src/hooks/chat-headers.ts": "2586390fd72f4e19da4d06a6e770aa8f",
-    "src/hooks/deepwork/index.ts": "ab5a4c49bd2974d9bfed3774da0ae0ca",
+    "src/hooks/chat-headers.ts": "282ca8c90c7313aa2085470bf0e64ea3",
+    "src/hooks/command-hook-utils.ts": "f7ce65b34074d6e489e72be909758524",
+    "src/hooks/deepwork/index.ts": "5e44843e7cb88afad6858dda28c5884d",
     "src/hooks/delegate-task-retry/hook.ts": "310c87963909ab3f40da5a61a50f5df0",
     "src/hooks/delegate-task-retry/patterns.ts": "5e4919da29af630e4e2ec37df0b58025",
     "src/hooks/filter-available-skills/index.ts": "4278d46c9018b0a86f0e63349f007f4d",
-    "src/hooks/foreground-fallback/index.ts": "2c53fd4684f6c004640d7d20517cc4aa",
-    "src/hooks/image-hook.ts": "11ff86ce46a479429938e27ca05499a3",
-    "src/hooks/index.ts": "5036bb33b2c881554f57bd6b36459b8c",
+    "src/hooks/foreground-fallback/index.ts": "bff09c691b6cb004781c2d47a69f15df",
+    "src/hooks/image-hook.ts": "088893355e67c36fb83833cb426e3091",
+    "src/hooks/index.ts": "099abd08860cefd96d756d9a7534ac50",
     "src/hooks/json-error-recovery/hook.ts": "6b86f68cdf202725ed856c07de622b62",
-    "src/hooks/loop-command/index.ts": "51dc18e6233083c854eb5b3b241b3946",
-    "src/hooks/phase-reminder/index.ts": "445cf012d3e13c242e64e37d70fae625",
-    "src/hooks/post-file-tool-nudge/index.ts": "7c7d1c25e4b6e445dfbf506b483cde82",
-    "src/hooks/reflect/index.ts": "01118001ad9d4e560881b4ef06b78d9f",
-    "src/hooks/task-session-manager/index.ts": "90b63e3321468082004b81da653fdf4a",
-    "src/hooks/task-session-manager/pending-call-tracker.ts": "4650c2f9bc9ea4b1e13b513989e5fc9e",
+    "src/hooks/loop-command/index.ts": "8ec5f5a6b060e73c3522a013e0a7a547",
+    "src/hooks/phase-reminder/index.ts": "fe27f2315b3db06d89688cd480816f03",
+    "src/hooks/post-file-tool-nudge/index.ts": "78dff408792c6860b8ed714119fa148a",
+    "src/hooks/reflect/index.ts": "1e740d6dc3087d0a3e889a4574adea9e",
+    "src/hooks/session-lifecycle.ts": "995a0d52e82f2d27f8bbcad770e7a901",
+    "src/hooks/task-session-manager/index.ts": "ba423e1fe628a920cb9c3044eddec94d",
+    "src/hooks/task-session-manager/pending-call-tracker.ts": "df93dc4ad3e83b3e9a76291bc0da7f3a",
     "src/hooks/task-session-manager/task-context-tracker.ts": "e6dc74e72aedd014643134afb2c123a6",
-    "src/hooks/types.ts": "0a80ebc8b12032bd9cc810e5fab6b6a1",
-    "src/index.ts": "b1f9feda05098802712f89990c1c209b",
+    "src/hooks/types.ts": "6915ec35709366ca261fefbdbf82d106",
+    "src/index-event.ts": "6b8a88cb4d46536145f2e97fbedfaf63",
+    "src/index.ts": "2fe60e0032400a31dcd8563a0c01bb35",
     "src/interview/dashboard-manager.ts": "40475436feb2773a825db89827ab28f5",
     "src/interview/dashboard.ts": "cfff71b7ca3a38b9e58e3ed96262ccea",
-    "src/interview/document.ts": "29e6a6d42c0d0b502dd25a022c4e1de4",
+    "src/interview/document.ts": "bfafc0e7874a61889c1555be4e8972a7",
     "src/interview/helpers.ts": "b95a7e299bb4ab38ab66a272b3ba3612",
     "src/interview/index.ts": "ab5c9a50b6c08826cfd53233cac75f38",
     "src/interview/manager.ts": "c9c61d5a5914c6cffba33ff6962e93aa",
     "src/interview/parser.ts": "aa6101cf5bebfafcbca845ba532856cf",
     "src/interview/prompts.ts": "b94ef5117d4e720cb5045080b240d890",
     "src/interview/server.ts": "fe5230962e2d44c6bec9909049c971f6",
-    "src/interview/service.ts": "ebd799bbd76a4a08fda5d29965e0477e",
+    "src/interview/service.ts": "0f202ad1189c858a47c6d75e14ce041f",
     "src/interview/session-server.ts": "b1e0169aa0720bfc92c63c09007890db",
     "src/interview/types.ts": "c09bb5def45c25f2a9add1327d88d881",
-    "src/interview/ui.ts": "1a19d9d5543e6bc706794fcc1adada8e",
+    "src/interview/ui.ts": "91e40f593b56547aaae36cbe2d6ad29c",
     "src/loop/loop-session.ts": "f57ea3e3f0441443b8608e2d1af96414",
     "src/mcp/context7.ts": "4e02e8ef204b6eb7e99a3209078428b5",
     "src/mcp/grep-app.ts": "53dba799724a92e491b57c30cdbd471d",
     "src/mcp/index.ts": "e9aec0cf22bc802c343caccd25f39fda",
     "src/mcp/types.ts": "a67078f79aa8b99c41fb5be5d9fa9319",
     "src/mcp/websearch.ts": "7c507eff1d6f9c01d3ccb928ea648ca7",
-    "src/multiplexer/factory.ts": "f46b52b6269d4a1e97a4822ab354a7c0",
-    "src/multiplexer/herdr/index.ts": "150a0b0f255ff05ab8c129cf53b4b17c",
-    "src/multiplexer/index.ts": "7c3ac610930726ff8d1d12aee025752b",
-    "src/multiplexer/session-manager.ts": "5622de7069f444d5162c09b1e347aa34",
-    "src/multiplexer/shared.ts": "ea19d777dd58300a8a73a26887c7178d",
-    "src/multiplexer/tmux/index.ts": "6a917050c75fa34fea61035b51bbc46c",
-    "src/multiplexer/types.ts": "8aa8ffcdbaf33dbacd0725d98f0a3b68",
-    "src/multiplexer/zellij/index.ts": "f97c6308f641ef9c68fb65c27247c4eb",
+    "src/multiplexer/cmux/close-policy.ts": "308d7cee31241fadd36d50beeb0a780a",
+    "src/multiplexer/cmux/index.ts": "0baaeee557310c66d09b6fe049a9796b",
+    "src/multiplexer/cmux/session-lifecycle.ts": "5440c777b70464a60b234a5f9ad63a76",
+    "src/multiplexer/cmux/session-state.ts": "d5e717c241b000f87ff0d18615b7df61",
+    "src/multiplexer/factory.ts": "89ff38238d3472603dc0aa745501d4dc",
+    "src/multiplexer/herdr/index.ts": "7d69e92a23544b26c86b4317461ff118",
+    "src/multiplexer/index.ts": "283b14804726e845668a35cce87e5156",
+    "src/multiplexer/kitty/index.ts": "e0bd994cb985b38585c96cc6d146b0af",
+    "src/multiplexer/session-manager.ts": "c39e188d74833084046e992317ab8aca",
+    "src/multiplexer/shared.ts": "683394c41053a58fe86208efb40392d3",
+    "src/multiplexer/tmux/index.ts": "0a321fb2a47389cf9049b7d2e82babf0",
+    "src/multiplexer/types.ts": "f3ddc7c01253e7bb1789739412961e89",
+    "src/multiplexer/zellij/index.ts": "4aaaf98818c0481b908c201bc3594351",
     "src/skills/clonedeps/README.md": "1e7ee3fb1032ca64141fe133a3af1cc7",
     "src/skills/codemap/README.md": "fbb3e9fd31ae685b87e630df96c3c60a",
     "src/skills/simplify/README.md": "2786c6e4e6b9f972193353b49741c8e3",
-    "src/tools/acp-run.ts": "0f2143e3e7fc75af14b101aa1ef16411",
+    "src/tools/acp-run.ts": "f36014b8d84fc415db8067019c123ae4",
     "src/tools/ast-grep/cli.ts": "94eea47198f97a4169f009e5249c3f7f",
     "src/tools/ast-grep/constants.ts": "ef016f4d4c5a6861fed9c28e968cad07",
     "src/tools/ast-grep/downloader.ts": "eda4a6bc69a3290a2e54f4d46c639bc1",
@@ -146,75 +157,78 @@
     "src/tools/ast-grep/types.ts": "34ad28b5b1e9617b584f082dba9a427c",
     "src/tools/ast-grep/utils.ts": "1dd3b2133c4b8c847a26eea0423bc0b2",
     "src/tools/cancel-task.ts": "c55e4173b619dcf199d72c6ecd6f4d09",
-    "src/tools/council.ts": "5178d86b649777c753ffb4cc9a31dcfc",
+    "src/tools/council.ts": "b00898efae8f045b36eab8719348656b",
     "src/tools/index.ts": "b562a39a524d55c1b0b33041b62437e8",
-    "src/tools/preset-manager.ts": "5dc472acedb4fb5e17a584bbb7e190e0",
+    "src/tools/preset-manager.ts": "7de532fe8a3ca3c439b48796a2182f82",
     "src/tools/smartfetch/binary.ts": "a65d816f46ebef11c39bda1764f82bb7",
     "src/tools/smartfetch/cache.ts": "9a4e272b897b6914f0925919357bfce1",
     "src/tools/smartfetch/constants.ts": "1ba20e00a4d3f4717eba62f381f9cd4c",
     "src/tools/smartfetch/index.ts": "5bbf7898199c2764351dac4bc0b28b84",
-    "src/tools/smartfetch/network.ts": "2b8b3ecaffe1bc4a66c26994bf0afcb6",
+    "src/tools/smartfetch/network.ts": "8d4084e73e0ecef33816578a1ca6b398",
     "src/tools/smartfetch/secondary-model.ts": "a086195fafce7eadc064138f772a4e25",
     "src/tools/smartfetch/tool.ts": "03e91727dc3d408bdb7f751ac647c0de",
     "src/tools/smartfetch/types.ts": "2576efe959365f34b7160c409fb54d26",
-    "src/tools/smartfetch/utils.ts": "ab169376765be6079f24f55862d9a90b",
-    "src/tui-state.ts": "dd8cbf2d515085edc548cdbecfd1ba09",
-    "src/tui.ts": "12d1e248f4eba8d6f54b164a742cab9c",
+    "src/tools/smartfetch/utils.ts": "e2cda2d4ed3f7c20c795c0805a9ebed7",
+    "src/tui-state.ts": "a2ba1a65b1a793a9e21c56ef683d853d",
+    "src/tui.ts": "d8e4a04fb5718efeab270d2e326e0682",
     "src/utils/agent-variant.ts": "6e112fb56a0eef55c8c1dbff3e9d7c8e",
-    "src/utils/background-job-board.ts": "fcdd672d9f3a8730d3db39470e0da61d",
+    "src/utils/background-job-board.ts": "83827e99a0c0d25daf661ff8ba5fc3cb",
     "src/utils/background-job-coordinator.ts": "e972bdc1bbab9f4f458a0828ddb0af69",
     "src/utils/background-job-store.ts": "2114ed7af09bb37e207b0969385707e8",
     "src/utils/compat.ts": "efb1d9db45c0926079cb780e949fb5dd",
     "src/utils/councillor-models.ts": "5155941c035ecfd11a4b6248e6c497aa",
     "src/utils/env.ts": "c4d56b5c308c1047c26d494be45cb86b",
+    "src/utils/escape-html.ts": "7ed535f85db49f0c0bc32bfc66b277dd",
+    "src/utils/frontmatter.ts": "96e4f25db0c79be5881ee7010c42f002",
     "src/utils/guards.ts": "83af4d036dd573e9008f0c1125e4918c",
-    "src/utils/index.ts": "42b1f364b48e75faa9cfeb44c6ad2660",
-    "src/utils/internal-initiator.ts": "013b87f387555db563b0241645d638b1",
-    "src/utils/logger.ts": "4e33d48e57ab1ea40cfe07a796d4ac44",
+    "src/utils/index.ts": "f301643fe3e74ee3e85d74747f90e72b",
+    "src/utils/internal-initiator.ts": "1a80afe78b5d31dc009981916c56b98d",
+    "src/utils/logger.ts": "965804c3600ab4beddbb5eede3ec53f5",
     "src/utils/polling.ts": "b1d9c52df1fae7391234d0f5476d53b5",
-    "src/utils/session.ts": "6f277d3a687e019cc47a2ed412310c1b",
-    "src/utils/subagent-depth.ts": "f925bd47ed5ffb67039508bedb14ac25",
+    "src/utils/session.ts": "011831df0cf164aa5adf3048248182dd",
     "src/utils/system-collapse.ts": "05370b9db1a8dbd4ace4958cc807b912",
     "src/utils/task.ts": "379ec59e07b805ecc4516387a301c9c2",
     "src/utils/zip-extractor.ts": "11e6d1913e049f46099bb61d4a77e62b",
     "tsconfig.json": "1d2bb6e93a43366843785a156c8e538a"
   },
   "folder_hashes": {
-    ".": "f4a663f6aac32f8ce0db004493bf6297",
-    "scripts": "362e9fb6832c103e1434719916be283d",
-    "src": "a05b32fdbbecf0039f0a25353464ca1f",
-    "src/agents": "14bf78ef0216501e70602f12d62e9839",
-    "src/cli": "68e0366396ce0091fbfc8693500ef242",
-    "src/companion": "489154aba4297da2a659a31bd91beb15",
-    "src/config": "9cd27db0477bccd91e793a4b51aee90c",
-    "src/council": "a25cae62a980ff28f7d1834236c46730",
-    "src/hooks": "89952c2777fb40f35be388f989e4b1b0",
+    ".": "a92fa8a3b48cebc741db85c685014408",
+    "scripts": "2e468cad071bd54a61ac8a40474d48bc",
+    "src": "39c025c6925d8d587e8be2995ebf7431",
+    "src/agents": "ac3df2ecd7a4ff7551a7e2d9a9de0b31",
+    "src/cli": "352ed5638f4b50e41b7f1587f1415f0f",
+    "src/companion": "8b4575fe67539633cdcf2b60aa518c30",
+    "src/config": "044488f443b498a9fa730951363c6b3d",
+    "src/council": "2f03fdb968a855548f26aee817a07db7",
+    "src/hooks": "ec9bbfa019da751700f140395a05ccd7",
     "src/hooks/apply-patch": "d20e3c103082283c3c126b7e936bc041",
     "src/hooks/auto-update-checker": "afe6d8213cc900b772b9c04ab6a31fd6",
-    "src/hooks/deepwork": "4698a85b598d3158d038313680762c1b",
+    "src/hooks/deepwork": "b702d56c5521a7e2d8c43cb93cde8846",
     "src/hooks/delegate-task-retry": "7bd4abeb2dbfc4e7aaed701de08b7509",
     "src/hooks/filter-available-skills": "2f0f2cbced0fbe91931733f645209019",
-    "src/hooks/foreground-fallback": "0f4879b5d2ae0a9fbceef13fd7357635",
+    "src/hooks/foreground-fallback": "926372a94fc581ef0be0e38a058aa5e8",
     "src/hooks/json-error-recovery": "fbe725b787123f203b78dd8dfd47db67",
-    "src/hooks/loop-command": "dd9465f50406f33c005b559dabfeec29",
-    "src/hooks/phase-reminder": "d941ded91b104a463725474697a8a9e9",
-    "src/hooks/post-file-tool-nudge": "7725bef64323410b08082fa5aa060946",
-    "src/hooks/reflect": "8e5aa8b074b44978ab071ef0ff56201a",
-    "src/hooks/task-session-manager": "009cf44a57cdb6b881ed027e477b2c3c",
-    "src/interview": "316bcd76b235d932872172a6891f1b68",
+    "src/hooks/loop-command": "364539dd39587dabfdc97d5528e28c0c",
+    "src/hooks/phase-reminder": "6fe12077008f7b98b186526dff5c0d26",
+    "src/hooks/post-file-tool-nudge": "0ea51ddc5b163ed1b1edf2e6f4c7258f",
+    "src/hooks/reflect": "2a7700cf8e7f605836d535ebf186b732",
+    "src/hooks/task-session-manager": "b1fcd51c5db5dc7ee94c36cad4338d26",
+    "src/interview": "0a42662dfb4b414a15b8a64bc1bcfcb2",
     "src/loop": "366bf2141b605deb1849f9783c993810",
     "src/mcp": "1db30ec46ae0b577ec22b74e2b4d19ea",
-    "src/multiplexer": "a8ea1efc12f7d75a9af06b25da7d5664",
-    "src/multiplexer/herdr": "e9ca0fd14736da1ea49ccca1a1736733",
-    "src/multiplexer/tmux": "f98f01b8553bf556ec302744cf61bf45",
-    "src/multiplexer/zellij": "d01383270dbd0f1fad6b67d85a69fe30",
+    "src/multiplexer": "bba220d6226456e375acd9fa63a14273",
+    "src/multiplexer/cmux": "db07e581541cafb14fd4e7dca5aba252",
+    "src/multiplexer/herdr": "fa083f9f91998079f8a5ef7fdbc864f5",
+    "src/multiplexer/kitty": "2c2948ba8d8520fd531f7664a2b6533c",
+    "src/multiplexer/tmux": "8ff587c2dd9f1d6d566250f012597876",
+    "src/multiplexer/zellij": "30f51096a0a8c6ffbfc41365326248fc",
     "src/skills": "3afb58b43174496617ece428d8deb50d",
     "src/skills/clonedeps": "d1d19753438fdb845f4efca93314a147",
     "src/skills/codemap": "1e82ef833612703b786daceb091f2422",
     "src/skills/simplify": "9c745d8113135e3103af5f1a49d67dfe",
-    "src/tools": "cd35887510b0bab8c89cba8c71a8456a",
+    "src/tools": "014be624ba388b6761d7e9034d5e8f61",
     "src/tools/ast-grep": "7091c20c0d028c22effa2b5c1e64cc58",
-    "src/tools/smartfetch": "b71f3f9464bb203a3ae3a4521eea01a6",
-    "src/utils": "cc5583330e527a04184e4a1136e721d4"
+    "src/tools/smartfetch": "ca0b801feb29912128015465354cc293",
+    "src/utils": "4e65c5d67f98529ea8b57ff49df2f640"
   }
 }

+ 41 - 143
AGENTS.md

@@ -68,12 +68,11 @@ oh-my-opencode-slim/
 │   ├── agents/       # Agent factories (orchestrator, explorer, oracle, etc.)
 │   ├── cli/          # CLI entry point
 │   ├── config/       # Constants, schemas, MCP defaults
-│   ├── council/      # Council manager (multi-LLM session orchestration)
 │   ├── hooks/        # OpenCode lifecycle hooks
 │   ├── mcp/          # MCP server definitions
 │   ├── multiplexer/  # Tmux/Zellij pane integration for child sessions
 │   ├── skills/       # Skill definitions (included in package publish)
-│   ├── tools/        # Tool definitions (council, webfetch, AST-grep, etc.)
+│   ├── tools/        # Tool definitions (webfetch, AST-grep, etc.)
 │   └── utils/        # Shared utilities (session, task, logger, env, etc.)
 ├── dist/             # Built JavaScript and declarations
 ├── docs/             # User-facing documentation
@@ -106,154 +105,51 @@ For plugin or Companion releases, follow `docs/release.md`. It documents the
 required diff inspection, companion asset workflow, GitHub release creation,
 tagging, verification, and npm publish order.
 
-## Tmux Session Lifecycle Management
-
-When working with tmux integration, understanding the session lifecycle is crucial for preventing orphaned processes and ghost panes.
-
-### Session Lifecycle Flow
-
-```
-Task Launch:
-  session.create() → tmux pane spawned → task runs
-
-Task Completes Normally:
-  session.status (idle) → extract results → session.abort()
-  → session.deleted event → tmux pane closed
-
-Task Cancelled:
-  cancel() → session.abort() → session.deleted event
-  → tmux pane closed
-
-Session Deleted Externally:
-  session.deleted event → task cleanup → tmux pane closed
-```
-
-### Key Implementation Details
-
-**1. Graceful Shutdown (src/multiplexer/tmux/index.ts)**
-```typescript
-// Always send Ctrl+C before killing pane
-spawn([tmux, "send-keys", "-t", paneId, "C-c"])
-await delay(250)
-spawn([tmux, "kill-pane", "-t", paneId])
-```
-
-**2. Session Abort Timing (src/council/council-manager.ts)**
-- Call `session.abort()` AFTER extracting task results
-- This ensures content is preserved before session termination
-- Triggers `session.deleted` event for cleanup
-
-**3. Event Handlers (src/index.ts)**
-The multiplexer session handler must stay wired up:
-- `multiplexerSessionManager.onSessionDeleted()` - closes tmux/zellij panes
-
-### Testing Tmux Integration
-
-After making changes to session management:
-
-```bash
-# 1. Build the plugin
-bun run build
-
-# 2. Run from local fork (in ~/.config/opencode/opencode.jsonc):
-# "plugin": ["file:///path/to/oh-my-opencode-slim"]
-
-# 3. Launch test tasks
-@explorer count files in src/
-@librarian search for Bun documentation
-
-# 4. Verify no orphans
-ps aux | grep "opencode attach" | grep -v grep
-# Should return 0 processes after tasks complete
-```
-
-### Common Issues
-
-**Ghost panes remaining open:**
-- Check that `session.abort()` is called after result extraction
-- Verify `session.deleted` handler is wired in src/index.ts
-
-**Orphaned opencode attach processes:**
-- Ensure graceful shutdown sends Ctrl+C before kill-pane
-- Check that tmux pane closes before process termination
+## Prompt Cache Safety
+
+Provider prompt caches are exact byte-prefix matches over the rendered
+request (tools → system → messages). Any byte that changes earlier in the
+payload invalidates the cache for everything after it, so every request in
+the session re-pays full input cost and latency. Past regressions in this
+repo all came from hooks rewriting or repositioning earlier conversation
+content.
+
+Rules when touching anything that feeds the outgoing payload (hooks,
+agent prompts, config constants):
+
+- Inject content only through `src/hooks/cache-safe-injection.ts`:
+  deterministic content via `appendTaggedSyntheticPart` (tail of an existing
+  message), per-turn volatile content via `stripTaggedContent` +
+  `appendTrailingVolatileMessage` (trailing message, end of payload).
+- Never mutate or reorder earlier messages, and never let timestamps,
+  randomness, or per-request IDs reach content before the payload tail.
+- Keep system prompts and tool sets frozen for the lifetime of a session.
+
+Enforcement (all run in `bun test` / CI):
+
+- `src/hooks/cache-safety.property.test.ts` — prefix-stability and
+  determinism properties over the real transform pipeline, with a drift
+  guard pinned to the composition in `src/index.ts`. New transform steps
+  must be added to `src/hooks/cache-safety-harness.test.ts`.
+- `src/hooks/cache-payload.snapshot.test.ts` — golden snapshots of injected
+  prompt surfaces; failing means the change busts caches once fleet-wide and
+  must be updated deliberately via `bun test --update-snapshots`.
+- `src/cache-safety-tripwire.test.ts` — bans volatile-input patterns in
+  prompt-assembly directories outside a justified allowlist.
+- `src/hooks/cache-monitor/` — runtime watchdog that logs a warning when a
+  session that was hitting the provider cache reports zero cached tokens.
+
+See `docs/cache-verification.md` for the full verification story.
 
 ## Pre-Push Code Review
 
-Before pushing changes to the repository, always run a code review to catch issues like:
+Before pushing changes to the repository, when makes sense run a code review to catch issues like:
 - Duplicate code
 - Redundant function calls
 - Race conditions
 - Logic errors
-
-### Using `/review` Command (Recommended)
-
-OpenCode has a built-in `/review` command that automatically performs comprehensive code reviews:
-
-```bash
-# Review uncommitted changes (default)
-/review
-
-# Review specific commit
-/review <commit-hash>
-
-# Review branch comparison
-/review <branch-name>
-
-# Review PR
-/review <pr-url-or-number>
-```
-
-**Why use `/review` instead of asking @oracle manually?**
-- Standardized review process with consistent focus areas (bugs, structure, performance)
-- Automatically handles git operations (diff, status, etc.)
-- Context-aware: reads full files and convention files (AGENTS.md, etc.)
-- Delegates to specialized @build subagent with proper permissions
-- Provides actionable, matter-of-fact feedback
-
-### Workflow Before Pushing
-
-1. **Make your changes**
-   ```bash
-   # ... edit files ...
-   ```
-
-2. **Stage changes**
-   ```bash
-   git add .
-   ```
-
-3. **Run code review**
-   ```
-   /review
-   ```
-
-4. **Address any issues found**
-
-5. **Run checks**
-   ```bash
-   bun run check:ci
-   bun test
-   ```
-
-6. **Commit and push**
-   ```bash
-   git commit -m "..."
-   git push origin <branch>
-   ```
-
-**Note:** The `/review` command found issues in our PR #127 (duplicate code, redundant abort calls) that neither linter nor tests caught. Always use it before pushing!
-
-## Common Patterns
-
-- This is an OpenCode plugin - most functionality lives in `src/`
-- The CLI entry point is `src/cli/index.ts`
-- The main plugin export is `src/index.ts`
-- Agent factories are in `src/agents/` - each agent has its own file + optional `.test.ts`
-- Skills are located in `src/skills/` (included in package publish)
-- Multiplexer session management is in `src/multiplexer/`
-- Council manager (multi-LLM orchestration) is in `src/council/`
-- Tmux utilities are in `src/multiplexer/tmux/`
-- 1426 tests across 83 files - run `bun test` to verify
+- Cache safety: prompt-prefix rewrites, volatile content outside the
+  trailing zone, or injections bypassing `src/hooks/cache-safe-injection.ts`
 
 ## Repository Map
 
@@ -267,12 +163,14 @@ Before working on any task, read `codemap.md` to understand:
 For deep work on a specific folder, also read that folder's `codemap.md`.
 
 ## Debugging Issues
+
 ### OpenCode
 Log files are written to:
 macOS/Linux: ~/.local/share/opencode/log/
 Windows: Press WIN+R and paste %USERPROFILE%\.local\share\opencode\log
 Log files are named with timestamps (e.g., 2025-01-09T123456.log) and the most recent 10 log files are kept.
 You can set the log level with the --log-level command-line option to get more detailed debug information. For example, opencode --log-level DEBUG.
+
 ### Plugin
 ~/.local/share/opencode/log/oh-my-opencode-slim.<timestamp>.log
 

+ 2 - 4
CONTEXT.md

@@ -14,9 +14,9 @@ A glossary of the terms used in this project's domain. Definitions describe what
 - **Fixer** — Subagent for bounded implementation and execution.
 - **Observer** — Subagent for visual/media analysis (images, PDFs, diagrams). Disabled by default.
 - **Council** — A multi-LLM agent that runs several councillors and synthesizes their views.
-- **Councillor** — A read-only LLM advisor spawned by the council; hidden from @-mention autocomplete. Cannot be disabled.
+- **Councillor** — A read-only LLM advisor dispatched as a subagent by the orchestrator. Each councillor is registered as `councillor-<name>` from the council preset. Not hidden; visible in the TUI as panes.
 - **Agent mode** — SDK classification of an agent: `primary` (orchestrator), `subagent` (specialist), or `all` (council, both user-facing and delegatable).
-- **Protected agent** — An agent that cannot be disabled (orchestrator, councillor).
+- **Protected agent** — An agent that cannot be disabled (orchestrator).
 - **Custom agent** — A user-defined agent supplied via config, distinct from the built-ins.
 - **ACP agent** — An external agent defined via the Agent Communication Protocol, run through `acp_run`.
 - **Display name** — A user-assignable name shown in @-mentions; may differ from the internal agent name.
@@ -26,8 +26,6 @@ A glossary of the terms used in this project's domain. Definitions describe what
 
 - **Consensus** — The synthesized conclusion of a council run, rated `unanimous`, `majority`, or `split`.
 - **Council preset** — A named lineup of councillor configurations used for a council run. Plugin config uses `preset` for the selected agent-override set; council config uses `default_preset` for the selected councillor lineup — the `default_` prefix disambiguates the active selection from the preset list within the council sub-object.
-- **Councillor execution mode** — Whether councillors run `parallel` (default) or `serial`.
-- **Councillor retries** — The number of retries for a councillor that returns an empty response.
 
 ## Multiplexer & Sessions
 

+ 3 - 3
README.ja-JP.md

@@ -153,12 +153,12 @@ bun run build
   "preset": "openai",
   "presets": {
     "openai": {
-      "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": ["*"], "mcps": ["*", "!context7"] },
-      "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
+      "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"] },
       "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": "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" ] },

+ 3 - 3
README.ko-KR.md

@@ -164,12 +164,12 @@ bun run build
   "preset": "openai",
   "presets": {
     "openai": {
-      "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": ["*"], "mcps": ["*", "!context7"] },
-      "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
+      "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"] },
       "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": "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" ] },

+ 22 - 11
README.md

@@ -172,12 +172,12 @@ The default generated configuration includes both `openai` and `opencode-go` pre
   "preset": "openai",
   "presets": {
     "openai": {
-      "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": ["*"], "mcps": ["*", "!context7"] },
-      "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
+      "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"] },
       "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": "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" ] },
@@ -260,7 +260,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-terra (medium)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8</code>
+      <b>Recommended Models:</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>
@@ -301,7 +301,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</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>Recommended Models:</b> <code>deepseek-v4-flash</code> <code>gpt-5.3-codex</code>
     </td>
   </tr>
   <tr>
@@ -342,7 +342,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-sol (xhigh)</code> <code>anthropic/claude-fable-5</code> <code>anthropic/claude-opus-4-8 (xhigh)</code>
+      <b>Recommended Models:</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>
@@ -432,7 +432,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</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>Recommended Models:</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>
@@ -473,7 +473,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>google/gemini-3.5-flash</code> <code>moonshotai/kimi-k2.7-code</code>
+      <b>Recommended Models:</b> <code>gemini-3.5-flash</code> <code>kimi-k2.7-code</code> <code>minimax-m3</code>
     </td>
   </tr>
   <tr>
@@ -509,12 +509,12 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Default Model:</b> <code>openai/gpt-5.6-luna (medium)</code>
+      <b>Default Model:</b> <code>openai/gpt-5.6-luna</code>
     </td>
   </tr>
   <tr>
     <td colspan="2">
-      <b>Recommended Models:</b> <code>openai/gpt-5.6-luna (medium)</code> <code>anthropic/claude-sonnet-4-6</code>
+      <b>Recommended Models:</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>
@@ -559,6 +559,11 @@ If any agent fails to respond, check your provider authentication and config fil
       <b>Default Model:</b> <code>openai/gpt-5.6-luna</code> - <i>configure a vision-capable model to enable</i>
     </td>
   </tr>
+  <tr>
+    <td colspan="2">
+      <b>Recommended Models:</b> <code>mimo-v2.5</code> <code>qwen3.5-plus</code>
+    </td>
+  </tr>
   <tr>
     <td colspan="2">
       <b>Model Guidance:</b> Choose a vision-capable model if you want the agent to read screenshots, images, PDFs, and other visual files.
@@ -578,6 +583,11 @@ servers), a skill runs no process — it is a focused playbook an agent activate
 when the task calls for it. The installer bundles eight skills and keeps them
 updated on plugin auto-update; local customizations are preserved.
 
+> [!TIP]
+> To discard local bundled-skill customizations and receive package updates, run
+> `bunx oh-my-opencode-slim install --skills=force`. This deliberately replaces
+> installed bundled skills with the package versions.
+
 | Skill | Purpose | Default agent | How to invoke |
 |:-----:|---------|---------------|---------------|
 | <img src="img/skills/codemap.webp" width="120" alt="Codemap artifact"><br>[`codemap`](src/skills/codemap/SKILL.md) | Hierarchical repository maps so agents understand codebases without re-reading everything | `orchestrator` | `run codemap` |
@@ -668,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-79-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-80-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -785,6 +795,7 @@ Use this section as a map: start with installation, then jump to features, confi
     </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>
     </tr>
   </tbody>
 </table>

+ 3 - 3
README.zh-CN.md

@@ -146,12 +146,12 @@ bun run build
   "preset": "openai",
   "presets": {
     "openai": {
-      "orchestrator": { "model": "openai/gpt-5.6-terra", "variant": "medium", "skills": ["*"], "mcps": ["*", "!context7"] },
-      "oracle": { "model": "openai/gpt-5.6-sol", "variant": "high", "skills": ["simplify"], "mcps": [] },
+      "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"] },
       "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": "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" ] },

+ 1 - 1
biome.json

@@ -11,7 +11,7 @@
   "linter": {
     "enabled": true,
     "rules": {
-      "preset": "recommended",
+      "recommended": true,
       "suspicious": {
         "noExplicitAny": "warn"
       }

+ 2 - 2
codemap.md

@@ -31,7 +31,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/agents/` | Agent factory layer for orchestrator and specialists, including prompt/model overrides, display-name normalization, MCP assignment, and permission shaping. | [View Map](src/agents/codemap.md) |
 | `src/cli/` | Installer, config editing, provider preset generation, and built-in skill installation. | [View Map](src/cli/codemap.md) |
 | `src/config/` | Configuration schema, layered loaders, preset merging, compatibility migrations, constant tables, and agent/MCP policy helpers. | [View Map](src/config/codemap.md) |
-| `src/council/` | Multi-model council orchestration with preset resolution, councillor execution modes, retries, timeout handling, and synthesis fallback flow. | [View Map](src/council/codemap.md) |
+
 | `src/hooks/` | Aggregated runtime hook surface for prompt transforms, recovery logic, task-session aliasing, nudges, and lifecycle policies. | [View Map](src/hooks/codemap.md) |
 | `src/hooks/apply-patch/` | Structured `apply_patch` parsing, matching, recovery, and rewrite pipeline. | [View Map](src/hooks/apply-patch/codemap.md) |
 | `src/hooks/auto-update-checker/` | Startup update detection, cache handling, and optional install prompt flow. | [View Map](src/hooks/auto-update-checker/codemap.md) |
@@ -95,7 +95,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - cmux-specific readiness, retry, orphan, and cleanup state lives under
   `src/multiplexer/cmux/`; the generic manager delegates cmux events so other
   multiplexer behavior remains on the upstream path.
-- `src/tools/council.ts` delegates into `src/council/`.
+- Council mode is implemented in `src/agents/`; the orchestrator dispatches councillors as subagents and the council agent synthesizes responses.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.

+ 131 - 0
docs/agents/build-agent-empty-input-diagnosis.md

@@ -0,0 +1,131 @@
+# Diagnosis: "build agent empty input" after orchestrator output
+
+**Status:** Diagnosis only — no code change yet.
+**Date:** 2026-07-19
+**Related PR:** #818 (`fix/preset-tui-slash-command`) — same root class as the original `/preset` fix.
+**Suspected sibling bug reported by user:** During `superpowers` / `brainstorm` skill conversations, when the orchestrator asks for confirmation or work is interrupted (subagent completes, background task finishes), a `build` agent turn sometimes appears with an empty user input.
+
+## TL;DR
+
+The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
+
+## Root cause (causal chain, cross-validated)
+
+1. **Orchestrator enters input-wait.** After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to `Idle` (`packages/opencode/src/effect/runner.ts:115-138`, `packages/opencode/src/session/run-state.ts:60-63`). The session is no longer "busy" from the Runner's perspective.
+
+2. **Plugin hook fires `promptAsync` with a synthetic part and no `agent` field.** Two call sites in omos do this:
+   - `src/hooks/task-session-manager/index.ts:398-402` — `CONTINUATION_NUDGE` injection, fires on `session.idle` / `session.status(idle)` when the orchestrator session has incomplete todos (matches "subagent completes" / "background task finishes").
+   - `src/interview/service.ts:622, 871, 933, 1007` — interview/skill flow injections (matches "brainstorm skill flow").
+
+3. **opencode does not guard `promptAsync` against busy/input-wait state.** The HTTP handler at `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329` does not call `assertNotBusy` and does not consult the Question service. It proceeds straight to `promptSvc.prompt`. The Runner, being `Idle`, immediately `startRun`s the new turn (`runner.ts:131-134`). No queue, no reject, no cancellation of the pending question.
+
+4. **Agent resolves to `build`.** `packages/opencode/src/session/prompt.ts:636-637`:
+   ```ts
+   const agentName = input.agent
+   const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
+   ```
+   When `input.agent` is omitted, opencode uses `agents.defaultInfo()` (`packages/opencode/src/agent/agent.ts:328-340`), which returns the first visible `mode: "primary"` agent — **`build`** (declared first in the agent registry, `agent.ts:141-155`). omos attempts to set `default_agent = "orchestrator"` via its `config` hook (`src/index.ts:546-551`), but only when `default_agent` is absent. `build` is selected whenever:
+   - `config.setDefaultAgent === false` (plugin config disables it)
+   - The user's `opencode.json` already sets a different `default_agent`
+   - The `config` hook didn't run or didn't apply (SDK/runtime version skew: plugin built against `@opencode-ai/sdk` v1.4.3, installed runtime v1.18.3)
+   - The orchestrator agent isn't registered at config-load time
+
+5. **"Empty input" is the synthetic flag's visual effect.** `synthetic: true` only controls TUI visibility. `packages/opencode/src/session/message-v2.ts:206` still includes synthetic text parts in model messages (the only filters are `!part.ignored` and `part.text !== ""`; there is no `synthetic` filter when building model messages). The user sees the `build` agent respond to a turn with no visible user message — perceived as "empty input." (`createInternalAgentTextPart` appends a `\n<!-- SLIM_INTERNAL_INITIATOR -->` marker, so the text is non-empty from the LLM's perspective.)
+
+6. **Session agent is durably corrupted.** `packages/opencode/src/session/prompt.ts:672-689` compares `current.agent !== info.agent` and, if they differ, calls `sessions.setAgentModel({ agent: info.agent, ... })`. A single agent-less `promptAsync` that resolves to `build` **permanently rewrites the session's agent to `build`** — every subsequent turn also routes to `build` until explicitly reset.
+
+## Confirmed-affected call sites
+
+| File:line | Trigger | Body omits `agent`? | Gate |
+|---|---|---|---|
+| `src/hooks/task-session-manager/index.ts:398-402` | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos | **Yes** | `continuationConsumed`, `hasInputWait` (3×: lines 282, 362, 386), `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled` |
+| `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
+| `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
+| `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
+| `src/interview/service.ts:1007` | User submits interview comment | **Yes** | same |
+| `src/interview/service.ts:504` | Interview URL notification | **Yes** (but `noReply: true`, non-synthetic text) | none |
+| `src/tools/smartfetch/secondary-model.ts:252` | Smartfetch secondary model query | **Yes** | none |
+
+## Correct pattern (for comparison)
+
+`src/hooks/foreground-fallback/index.ts:635-639` explicitly includes the agent:
+```ts
+const promptBody = {
+  parts: lastUser.parts,
+  model: ref,
+  ...(agentName ? { agent: agentName } : {}),
+};
+```
+This is the pattern every `promptAsync` caller in omos should follow.
+
+## Why the `hasInputWait` gate in task-session-manager is not sufficient
+
+The gate exists and works in the common case (`task-session-manager/index.ts:282, 362, 386`, with tests at `index.test.ts:2772-2858, 3013-3048`). But:
+
+1. **Documented race window.** `IDLE_RECONCILE_DELAY_MS = 2_000` (line 54). The comment at lines 49-53 admits: "Completions arriving after the window are still dropped (the race is reduced, not eliminated)." If `session.idle` fires and the 2s timer expires before `question.asked` is delivered, and the 3 SDK calls (`todo`/`children`/`status`) in `evaluateContinuation` all resolve before `question.asked` arrives, the nudge fires.
+
+2. **Input-wait is not the only trigger.** The interview/skill path (`src/interview/service.ts`) does **not** consult `hasInputWait` at all — it injects on user dashboard actions, which can happen while the orchestrator is mid-question.
+
+3. **The gate does not address the missing `agent` field.** Even when the nudge legitimately fires (no input-wait, real incomplete todos), the resulting turn still routes to `build` if `default_agent` is unset. The gate prevents *some* unwanted injections; it does not prevent *misrouting* when injection happens.
+
+## Why this is the same class as the #818 `/preset` fix
+
+#818's original bug: `/preset` used `createInternalAgentTextPart()` to trigger an LLM turn that was invisible in the TUI (`synthetic: true`). The fix moved `/preset` to pure TUI dialogs (`src/tui-preset.ts` uses only `api.ui.dialog` / `DialogSelect` / `DialogPrompt` / `DialogConfirm` — no `promptAsync`).
+
+This bug: other hooks still use the same `createInternalAgentTextPart` + `promptAsync` pattern, and additionally omit the `agent` field, so the invisible turn routes to `build` instead of the orchestrator. Same shape: a synthetic part starting an invisible turn. Different symptom: `build` agent instead of orchestrator.
+
+## Fix directions (not implemented — awaiting decision)
+
+### Minimal fix
+Add `agent: 'orchestrator'` to the `promptAsync` body at all four affected call sites:
+- `src/hooks/task-session-manager/index.ts:398-402`
+- `src/interview/service.ts:622, 871, 933, 1007`
+
+This ensures the continuation nudge and interview injections always route to the orchestrator regardless of opencode's `default_agent` resolution, eliminating the path to `build`.
+
+### Hardening (optional, larger scope)
+1. **Input-wait guard on the interview/skill path.** Consult `hasInputWait` (or an equivalent signal) before injecting in `src/interview/service.ts`. Do not inject while the orchestrator is waiting for user input.
+2. **Post-injection agent assertion.** After each `promptAsync`, assert `current.agent` was not changed out from under the orchestrator; if it was, restore it via `setAgentModel`.
+3. **Investigate the `default_agent` application reliability** on opencode v1.18.x. The plugin was built against `@opencode-ai/sdk` v1.4.3; the installed runtime is v1.18.3. The `config` hook's `default_agent = 'orchestrator'` mutation may not be applied reliably under this skew. (Note: #799 tracks the package upgrade.)
+4. **Shrink or eliminate the `IDLE_RECONCILE_DELAY_MS` race** for sessions that have a pending `question.asked` / `permission.asked`.
+
+## Evidence index
+
+### omos source
+- **Missing `agent` field (the bug):** `src/hooks/task-session-manager/index.ts:398-402`
+- **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
+- **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
+- **omos sets `default_agent` only when absent:** `src/index.ts:546-551`
+- **`createInternalAgentTextPart` produces `synthetic: true`:** `src/utils/internal-initiator.ts:9-21`
+- **`CONTINUATION_NUDGE` is non-empty:** `src/hooks/task-session-manager/index.ts:56-57`
+- **`hasInputWait` gate (3 checks):** `src/hooks/task-session-manager/index.ts:282, 362, 386`
+- **`IDLE_RECONCILE_DELAY_MS` race window:** `src/hooks/task-session-manager/index.ts:54` (admission at lines 49-53)
+- **`disableDefaultAgents` preserves `build` and `plan`:** `src/cli/config-io.ts:564-600`
+
+### opencode source (`anomalyco/opencode` @ `dev`)
+- **`promptAsync` HTTP handler (no busy/input-wait guard):** `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329`
+- **`prompt` HTTP handler (no guard):** same file, `:295-309`
+- **Internal `prompt` always starts a new turn:** `packages/opencode/src/session/prompt.ts:1052-1071`
+- **Agent selection: `input.agent ?? defaultInfo()`:** `packages/opencode/src/session/prompt.ts:636-637`
+- **`defaultInfo()` → `default_agent` or first visible primary:** `packages/opencode/src/agent/agent.ts:328-340`
+- **`build` agent definition (default primary, first in registry):** `packages/opencode/src/agent/agent.ts:141-155`
+- **Destructive `setAgentModel` overwrite on agent change:** `packages/opencode/src/session/prompt.ts:672-689`
+- **Synthetic parts included in model messages:** `packages/opencode/src/session/message-v2.ts:206`
+- **Runner `ensureRunning` (Idle = run now, no queue):** `packages/opencode/src/effect/runner.ts:115-138`
+- **Runner Idle transition on turn end:** `packages/opencode/src/session/run-state.ts:60-63`
+- **`assertNotBusy` (NOT used by prompt/promptAsync):** `packages/opencode/src/session/run-state.ts:71-75`
+
+### SDK types
+- **`default_agent` doc: "Falls back to 'build' if not set or invalid":** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1255-1257`
+- **`SessionPromptAsyncData.body.agent?` is optional:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:3241-3269`
+- **`build` is a built-in agent:** `node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1273-1279`
+
+## Open questions for the fix
+
+1. Should the fix be a new PR, or amended into #818? (#818 is currently `MERGEABLE` / `CLEAN` / CI green; amending widens its scope and may delay merge.)
+2. Is the interview/skill path expected to always route to the orchestrator, or could it intentionally target a different agent in some flows?
+3. Should we also harden the `default_agent` application (fix direction #3) as part of this work, or track it separately under #799?
+
+---
+
+This report is diagnostic only. No code was changed. The fix awaits the user's decision on scope and PR strategy.

+ 12 - 2
docs/background-orchestration.md

@@ -266,6 +266,13 @@ Include:
 - validation to run or report,
 - what not to do.
 
+### Task-fit rejections
+
+If a task is outside a specialist's role, it must not attempt partial work. It
+returns a brief reason to the orchestrator.
+The orchestrator treats that reason as routing input to reroute or clarify the
+task and must not retry the unchanged task with the same specialist.
+
 Good background task prompt:
 
 ```text
@@ -317,8 +324,11 @@ After an orchestrator session becomes idle, the plugin may send one internal,
 delayed continuation prompt when OpenCode reports incomplete todos. It is
 suppressed when the SDK reports the parent or any direct child as active, when a
 terminal child result has not yet been reconciled, during foreground fallback,
-or whenever SDK data is unavailable or malformed. A real subsequent user
-message rearms the one-shot nudge; internal prompts and todo updates do not.
+while OpenCode is waiting for a question or permission response, or whenever SDK
+data is unavailable or malformed. A matching reply, or a rejected question,
+clears that wait but does not itself inject a nudge; the normal session lifecycle
+decides whether a later nudge is needed. A real subsequent user message rearms
+the one-shot nudge; internal prompts and todo updates do not.
 
 This is a best-effort runtime check, not a scheduler or persisted state. After a
 plugin restart, the in-memory job board cannot establish prior result

+ 43 - 2
docs/cache-verification.md

@@ -1,8 +1,11 @@
 # Cache verification
 
-This project has two complementary checks. They answer different questions and
-should not be conflated.
+This project has four complementary layers. They answer different questions
+and should not be conflated.
 
+- **Continuous cache-safety tests** run in `bun test` (and therefore CI) and
+  enforce the payload invariants directly against the hook pipeline. This is
+  the first line of defense against cache regressions.
 - **Deterministic payload verification** proves that this plugin projects a
   stable provider payload under a controlled local capture server. It does not
   measure a provider cache.
@@ -10,6 +13,44 @@ should not be conflated.
   provider-backed OpenCode server. It is opt-in, provider-specific, and useful
   for comparing explicitly controlled arms; it is not a CI check or a general
   cache guarantee.
+- **Runtime cache monitoring** watches provider-reported cache telemetry
+  during real sessions and logs a warning when a cache bust signature appears.
+
+## Continuous cache-safety tests (CI)
+
+Provider prompt caches are exact byte-prefix matches over the rendered
+request. Instead of enumerating known-good payload shapes, these suites
+assert the properties every transform must uphold, so they also catch
+mistakes that have not been made before:
+
+- `src/hooks/cache-safety.property.test.ts` — re-renders a growing
+  conversation through the real transform pipeline (mirroring the
+  composition in `src/index.ts`) and asserts turn-over-turn byte-prefix
+  stability, isolation of volatile content to the tagged trailing message,
+  determinism under wall-clock/randomness changes, and pass-through of
+  specialist payloads. A drift guard fails when `src/index.ts` gains, loses,
+  or reorders transform steps without the suite being updated.
+- `src/hooks/cache-payload.snapshot.test.ts` — golden snapshots of every
+  prompt surface the plugin injects (phase reminder, orchestrator system
+  prompt, canonical transformed payload). A failure means the change will
+  invalidate provider caches for existing sessions once; update deliberately
+  with `bun test --update-snapshots` so the PR diff documents the impact.
+- `src/cache-safety-tripwire.test.ts` — scans prompt-assembly directories
+  for volatile-input patterns (`Date.now`, `new Date`, `Math.random`,
+  `randomUUID`, `performance.now`) outside a justified allowlist.
+
+All hook injections must go through `src/hooks/cache-safe-injection.ts`; see
+the Prompt Cache Safety section in `AGENTS.md` for the authoring rules.
+
+## Runtime cache monitoring
+
+`src/hooks/cache-monitor/` observes `message.updated` events and the
+provider-reported `tokens.cache.read` / `tokens.cache.write` counters. When a
+session that previously hit the cache reports zero cache-read tokens on a
+sizeable request, it logs a `[cache-monitor] possible prompt-cache bust`
+warning (once per bust streak) to the plugin log. Providers that never report
+cache telemetry produce no warnings. This is the field safety net for
+provider-side behavior no offline test can model.
 
 ## Prerequisites
 

+ 3 - 5
docs/configuration.md

@@ -160,9 +160,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `council.presets.<name>.<councillor>.variant` | string | - | Councillor variant |
 | `council.presets.<name>.<councillor>.prompt` | string | - | Optional role guidance for the councillor |
 | `council.default_preset` | string | `"default"` | Default preset when none is specified |
-| `council.timeout` | number | `180000` | Per-councillor timeout (ms) |
-| `council.councillor_execution_mode` | string | `"parallel"` | Run councillors in `parallel` or `serial`; use `serial` for single-model setups |
-| `council.councillor_retries` | number | `3` | Max retries per councillor on empty provider response (0–5) |
+| — | — | — | *Timeouts, execution mode, and retries are now handled by the orchestrator's council-mode prompt instructions; see `src/agents/council.ts`.* |
 | `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) |
 | `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) |
 | `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI |
@@ -230,8 +228,8 @@ subprocess.
   `presets.<name>.council.model`.
 - The **councillor models** are configured separately under
   `council.presets.<name>.<councillor>.model`.
-- Deprecated `council.master*` fields are legacy compatibility aliases only;
-  do not use them in new configs.
+- `council.master*` fields have been removed. A deprecation warning is
+  logged this release if a config still contains them.
 
 ### Manual Update Mode
 

+ 77 - 153
docs/council.md

@@ -5,14 +5,13 @@ Multi-model consensus for cases where you want more than one model's judgment.
 ## Table of Contents
 
 - [Overview](#overview)
-- [The Important Mental Model](#the-important-mental-model)
 - [Quick Setup](#quick-setup)
 - [Configuration](#configuration)
+- [Model Fallback Chain](#model-fallback-chain)
 - [Choosing the Council Model vs Councillor Models](#choosing-the-council-model-vs-councillor-models)
 - [Preset Examples](#preset-examples)
 - [Role Prompts](#role-prompts)
 - [Usage](#usage)
-- [Timeouts, Retries, and Failures](#timeouts-retries-and-failures)
 - [Compatibility Notes](#compatibility-notes)
 - [Troubleshooting](#troubleshooting)
 
@@ -20,8 +19,8 @@ Multi-model consensus for cases where you want more than one model's judgment.
 
 ## Overview
 
-The **Council agent** runs several **councillors** in parallel, then the
-**Council agent itself** synthesizes their outputs into one answer.
+The **Council agent** runs several **councillors** in parallel, then
+synthesizes their outputs into one answer.
 
 ### What you get
 
@@ -32,15 +31,21 @@ The **Council agent** runs several **councillors** in parallel, then the
 
 ### How it works
 
+Each councillor in a preset is registered as a dynamic subagent named
+`councillor-<name>` (e.g. `councillor-alpha`, `councillor-beta`), each
+with its own configured model. The orchestrator dispatches all councillors
+in parallel via OpenCode's native `task()` tool at depth 1, and each
+councillor appears as its own TUI pane.
+
 ```text
 User / Orchestrator
         |
         v
 Council agent (@council, your configured synthesizer model)
         |
-        +--> launches Councillor A (preset model)
-        +--> launches Councillor B (preset model)
-        +--> launches Councillor C (preset model)
+        +--> task(): councillor-alpha (configured model)
+        +--> task(): councillor-beta  (configured model)
+        +--> task(): councillor-gamma (configured model)
         |
         v
 Council agent synthesizes councillor results
@@ -49,26 +54,8 @@ Council agent synthesizes councillor results
 Final answer
 ```
 
----
-
-## The Important Mental Model
-
-There are **two separate model layers**:
-
-1. **The Council agent model**
-   - This is the model behind `@council` itself.
-   - It does the final synthesis.
-   - Configure it like any other agent: via your active preset's `council`
-     entry or `agents.council` override.
-
-2. **The councillor models**
-   - These are the models that actually fan out in parallel.
-   - Configure them under `council.presets.<preset>.<councillor>.model`.
-
-If you only remember one thing, remember this:
-
-> `@council` uses the normal agent config for the synthesizer model, and
-> `council.presets` for the fan-out councillor models.
+The council agent waits for all councillors to respond (or fail), then
+synthesizes their results into a single report.
 
 ---
 
@@ -114,9 +101,7 @@ Then use it directly:
 {
   "council": {
     "default_preset": "default",
-    "timeout": 180000,
-    "councillor_execution_mode": "parallel",
-    "councillor_retries": 3,
+
     "presets": {
       "default": {
         "alpha": { "model": "openai/gpt-5.6-luna" }
@@ -130,9 +115,6 @@ Then use it directly:
 |---------|------|---------|-------------|
 | `presets` | object | - | **Required.** Named councillor presets |
 | `default_preset` | string | `"default"` | Preset used when none is specified |
-| `timeout` | number | `180000` | Per-councillor timeout in ms |
-| `councillor_execution_mode` | string | `"parallel"` | `parallel` runs all councillors concurrently; `serial` runs them one at a time |
-| `councillor_retries` | number | `3` | Retries per councillor on empty provider responses |
 
 ### Councillor config
 
@@ -144,35 +126,7 @@ Each entry inside a preset is one councillor:
 | `variant` | string | No | Optional variant/reasoning setting (applies to chain entries without their own) |
 | `prompt` | string | No | Optional role guidance prepended to the user prompt |
 
-#### Councillor model fallback
-
-`model` also accepts an ordered chain. When the primary model fails or times
-out, the councillor advances to the next model instead of dropping out of the
-council. Entries are `provider/model` strings or `{ "id", "variant" }` objects:
-
-```jsonc
-{
-  "council": {
-    "presets": {
-      "review": {
-        "reviewer": {
-          "model": [
-            "openai/gpt-5.6",
-            { "id": "google/gemini-3-pro", "variant": "high" },
-            "anthropic/claude-opus-4-6"
-          ],
-          "prompt": "Focus on bugs, edge cases, and failure modes."
-        }
-      }
-    }
-  }
-}
-```
-
-Empty-response retries (`councillor_retries`) apply per model before the chain
-advances. A single string keeps the previous single-model behavior.
-
-### Council agent config
+### Council agent (synthesizer) config
 
 The **synthesizer model** is **not** configured inside `council.presets`.
 
@@ -202,8 +156,47 @@ Or with a global override:
 
 ---
 
+## Model Fallback Chain
+
+When `model` is a string, the councillor uses that single model.
+
+When `model` is an array, the councillor walks the chain in order:
+
+```jsonc
+{
+  "council": {
+    "presets": {
+      "review": {
+        "reviewer": {
+          "model": [
+            "openai/gpt-5.6",
+            { "id": "google/gemini-3-pro", "variant": "high" },
+            "anthropic/claude-opus-4-6"
+          ],
+          "prompt": "Focus on bugs, edge cases, and failure modes."
+        }
+      }
+    }
+  }
+}
+```
+
+Entries are `provider/model` strings or `{ "id", "variant" }` objects. The
+councillor tries each entry in order until one responds. Empty responses are
+retried once per entry; other failures advance to the next entry. The
+councillor only fails once every entry in the chain is exhausted.
+
+---
+
 ## Choosing the Council Model vs Councillor Models
 
+There are **two separate model layers**:
+
+1. **The Council agent model** — the model behind `@council` itself, which
+   does the final synthesis.
+2. **The councillor models** — the models that actually fan out in parallel,
+   configured under `council.presets.<preset>.<councillor>.model`.
+
 ### Configure the Council agent when you want to change
 
 - the **final synthesizer model**
@@ -269,25 +262,6 @@ Councillor models always come from:
 }
 ```
 
-### Serial mode for single-model systems
-
-```jsonc
-{
-  "council": {
-    "councillor_execution_mode": "serial",
-    "presets": {
-      "default": {
-        "alpha": { "model": "openai/gpt-5.6-luna" },
-        "beta": { "model": "openai/gpt-5.6-luna" }
-      }
-    }
-  }
-}
-```
-
-Use `serial` when parallel councillor launches would contend for the same
-underlying provider/session limits.
-
 ---
 
 ## Role Prompts
@@ -329,65 +303,39 @@ The councillor sees:
 
 ## Usage
 
-### Direct invocation
+### Invocation
 
 ```text
 @council Should we use a job queue or an outbox pattern here?
 ```
 
-### Via orchestrator delegation
+The orchestrator may also delegate to `@council` for high-stakes or
+ambiguous decisions.
+
+### What you see
 
-The orchestrator may delegate to `@council` for high-stakes or ambiguous
-decisions, but it does so sparingly because council is usually the most
-expensive path.
+Each councillor appears as its own TUI pane, dispatched in parallel. As they
+complete, their responses stream into the panes. Once all councillors have
+responded (or failed), the council agent synthesizes their results.
 
-### Output format
+### Output
 
 Council responses include:
 
-1. `Council Response` - the synthesized final answer.
-2. `Councillor Details` - each responding councillor's individual response,
+1. **Council Response** — the synthesized final answer.
+2. **Per-Councillor Details** — each responding councillor's individual response,
    using the councillor names from the configured preset.
-3. `Council Summary` - agreement, disagreement resolution, remaining
+3. **Council Summary** — agreement, disagreement resolution, remaining
    uncertainty, and a consensus confidence rating of `unanimous`, `majority`,
    or `split`.
 
-### Output footer
-
-Council responses include a footer like:
+A footer tracks participation:
 
 ```text
 ---
 *Council: 2/3 councillors responded (alpha: gpt-5.6-luna, beta: gemini-3-pro)*
 ```
 
----
-
-## Timeouts, Retries, and Failures
-
-### Timeout behavior
-
-- `timeout` is **per councillor**
-- timed-out councillors are marked `timed_out`
-- council still synthesizes from successful results
-
-### Model chain fallback
-
-When a councillor's `model` is an array, the councillors walks the chain in
-order. Empty-response retries apply per model; any other failure or timeout
-advances to the next model. The councillor only fails once every model in the
-chain is exhausted, and the reported model reflects the one that responded.
-
-### Empty response retries
-
-Some providers silently return zero tokens. Council treats that as a retryable
-failure.
-
-- `councillor_retries` defaults to `3`
-- retries only happen for **empty provider responses**
-- normal failures and timeouts are returned immediately (they advance to the
-  next model in the chain, if any)
-
 ### Failure behavior
 
 | Scenario | Behavior |
@@ -400,23 +348,11 @@ failure.
 
 ## Compatibility Notes
 
-### Deprecated `master` fields
-
-Older examples used these fields:
-
-- `council.master`
-- `council.master_timeout`
-- `council.master_fallback`
+### Removed `master` fields
 
-They are deprecated.
-
-Current behavior:
-
-- `master_timeout` is ignored
-- `master_fallback` is ignored
-- `master` is deprecated, but `master.model` is still accepted as a temporary
-  fallback for the **Council agent model only** when no explicit `council`
-  agent model is configured elsewhere
+The `council.master` field and other `master`-prefixed fields have been
+removed. A deprecation warning is logged this release if a config still
+contains them, but they no longer have any effect.
 
 Prefer this instead:
 
@@ -442,7 +378,7 @@ Prefer this instead:
 
 ### `@council` is missing
 
-Council tools are only registered when `config.council` exists.
+Council is only available when `config.council` exists.
 
 Make sure your config includes a `council` block with at least one preset.
 
@@ -454,20 +390,8 @@ Check:
 2. it exists under `council.presets`
 3. `default_preset` points to a real preset when omitted at runtime
 
-### All councillors timed out
-
-Try:
-
-```jsonc
-{
-  "council": {
-    "timeout": 300000
-  }
-}
-```
-
-Also verify the configured model IDs exist in your OpenCode environment.
-
-### Council recursion prevented
+### All councillors fail
 
-Council is meant to be a leaf agent. The councillor agent type has `council_session: 'deny'` permission, so councillors cannot invoke the council tool. OpenCode's native `subagent_depth` limit also prevents runaway delegation. Avoid recursive council chains.
+Verify the configured model IDs exist in your OpenCode environment. Each
+councillor model must be a valid `provider/model` identifier your OpenCode
+setup can reach.

+ 6 - 6
docs/openai-preset.md

@@ -33,12 +33,12 @@ The generated `openai` preset assigns each specialist an OpenAI model:
 
 | Agent | Model |
 |-------|-------|
-| Orchestrator | `openai/gpt-5.6-terra` (`medium`) |
-| Oracle | `openai/gpt-5.6-sol` (`high`) |
+| Orchestrator | `openai/gpt-5.6-terra` (`xhigh`) |
+| Oracle | `openai/gpt-5.6-sol` (`xhigh`) |
 | Librarian | `openai/gpt-5.6-luna` (`low`) |
 | Explorer | `openai/gpt-5.6-luna` (`low`) |
 | Designer | `openai/gpt-5.6-luna` (`medium`) |
-| Fixer | `openai/gpt-5.6-luna` (`medium`) |
+| Fixer | `openai/gpt-5.6-luna` (`xhigh`) |
 
 ## Generated Config Shape
 
@@ -52,13 +52,13 @@ setting the top-level `preset` field:
     "openai": {
       "orchestrator": {
         "model": "openai/gpt-5.6-terra",
-        "variant": "medium",
+        "variant": "xhigh",
         "skills": ["*"],
         "mcps": ["*", "!context7"]
       },
       "oracle": {
         "model": "openai/gpt-5.6-sol",
-        "variant": "high",
+        "variant": "xhigh",
         "skills": ["simplify"],
         "mcps": []
       },
@@ -82,7 +82,7 @@ setting the top-level `preset` field:
       },
       "fixer": {
         "model": "openai/gpt-5.6-luna",
-        "variant": "medium",
+        "variant": "xhigh",
         "skills": [],
         "mcps": []
       }

+ 13 - 7
docs/skills.md

@@ -96,7 +96,7 @@ See **[Clonedeps](clonedeps.md)** for the full workflow and file layout.
 
 **Heavy/complex coding sessions and large modifications workflow.**
 
-`deepwork` is an orchestrator-only workflow skill for managing deep architectural work, multi-phase implementations, and complex refactoring. It provides a structured approach with mandatory review gates while maintaining flexibility in planning.
+`deepwork` is an orchestrator-only workflow skill for managing deep architectural work, multi-phase implementations, and complex refactoring. It provides a structured approach with risk-based review gates while maintaining flexibility in planning.
 
 Start it directly with:
 
@@ -111,15 +111,21 @@ Start it directly with:
    `!.slim/deepwork/**` in `.ignore`. This keeps state git-local while making it
    readable to OpenCode.
 2. Orchestrator creates a session artifact at `.slim/deepwork/<task>.md`
-3. Draft plan → Oracle review → Revise until acceptable
-4. Create phased implementation plan → Oracle review
-5. Execute phase by phase with validation
-6. After each phase: validate → Oracle review → fix issues → continue
+3. Draft a phased implementation plan with a small number of coherent phases
+   based on dependencies and natural delivery boundaries. Do not split work
+   merely to make an Oracle review smaller.
+4. Before execution, show a compact overview of phase order, specialist
+   ownership/scope, the Oracle review total, the review after each phase, and a
+   short reason for each gate.
+5. Execute phase by phase: validate, update session state, then get an Oracle
+   review before advancing.
+6. Batch material findings into one bounded remediation pass with focused
+   validation. Re-review only when needed to assess a changed decision/risk or
+   an otherwise unverifiable concern.
 
 **Key features:**
 - Persistent session state in markdown files
-- Mandatory oracle reviews at plan and phase boundaries
-- Oracle phase reviews include simplify/readability feedback alongside regular correctness and risk review
+- Predictable Oracle reviews after each planned phase, declared before execution
 - V2 scheduler integration (dispatch specialists, wait for hook-driven completion, reconcile)
 - OpenCode todo lists for progress tracking
 - Flexible structure - orchestrator adapts format to task needs

+ 2 - 51
oh-my-opencode-slim.schema.json

@@ -973,32 +973,6 @@
         }
       }
     },
-    "tmux": {
-      "type": "object",
-      "properties": {
-        "enabled": {
-          "default": false,
-          "type": "boolean"
-        },
-        "layout": {
-          "default": "main-vertical",
-          "type": "string",
-          "enum": [
-            "main-horizontal",
-            "main-vertical",
-            "tiled",
-            "even-horizontal",
-            "even-vertical"
-          ]
-        },
-        "main_pane_size": {
-          "default": 60,
-          "type": "number",
-          "minimum": 20,
-          "maximum": 80
-        }
-      }
-    },
     "websearch": {
       "type": "object",
       "properties": {
@@ -1133,38 +1107,15 @@
             }
           }
         },
-        "timeout": {
-          "default": 180000,
-          "type": "number",
-          "minimum": 0
-        },
         "default_preset": {
           "default": "default",
           "type": "string"
-        },
-        "councillor_execution_mode": {
-          "default": "parallel",
-          "description": "Execution mode for councillors. \"serial\" runs them one at a time (required for single-model systems). \"parallel\" runs them concurrently (default, faster for multi-model systems).",
-          "type": "string",
-          "enum": [
-            "parallel",
-            "serial"
-          ]
-        },
-        "councillor_retries": {
-          "default": 3,
-          "description": "Number of retry attempts for councillors that return empty responses (e.g. due to provider rate limiting). Default: 3 retries.",
-          "type": "integer",
-          "minimum": 0,
-          "maximum": 5
-        },
-        "master": {
-          "description": "DEPRECATED - ignored. Council agent synthesizes directly."
         }
       },
       "required": [
         "presets"
-      ]
+      ],
+      "additionalProperties": {}
     },
     "companion": {
       "type": "object",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "2.2.3",
+  "version": "2.2.4",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",

+ 5 - 4
src/agents/codemap.md

@@ -19,8 +19,8 @@ Each agent is a **prompt-driven specialist** with a factory function that create
 | **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 |
 | **observer** | `createObserverAgent()` | Visual analysis specialist (images, PDFs, diagrams) | Read-only (read, glob, grep, ast_grep_search) | DEFAULT_MODELS.observer |
-| **council** | `createCouncilAgent()` | Multi-LLM consensus engine for high-stakes decisions | Read-only + council_session tool | DEFAULT_MODELS.council |
-| **councillor** | `createCouncillorAgent()` | Read-only council advisor (internal use only) | Read-only (read, glob, grep, ast_grep_search) | Inherited from council |
+| **council** | `createCouncilAgent()` | Multi-LLM consensus synthesis from councillor responses | Read-only | DEFAULT_MODELS.council |
+| **councillor** | `createCouncillorAgent()` | Read-only council advisor; registered dynamically per preset seat as `councillor-<name>` | Read-only (read, glob, grep, ast_grep_search) | Inherited from council preset |
 
 ### Configuration System
 
@@ -72,7 +72,8 @@ const displayNameMap = new Map<string, string>();
 // ... populate from orchestrator and all subagents ...
 injectDisplayNames(orchestrator, displayNameMap);
 
-// 5. Return agents array [orchestrator, ...allSubAgents]
+// 5. Inject council-dispatch instructions when dynamic councillors exist
+// 6. Return agents array [orchestrator, ...allSubAgents]
 return [orchestrator, ...allSubAgents];
 ```
 
@@ -163,7 +164,7 @@ The orchestrator's system prompt contains dynamic routing rules that reference a
 - **@designer**: UI/UX design and polish
 - **@fixer**: Bounded implementation tasks
 - **@observer**: Visual/media analysis
-- **@council**: Multi-model consensus for high-stakes decisions
+- **@council**: Multi-model consensus synthesis (orchestrator dispatches councillors directly in flatten mode)
 
 These rules are filtered based on disabled agents and injected into the orchestrator's prompt at startup.
 

+ 165 - 0
src/agents/council-agents.test.ts

@@ -0,0 +1,165 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { buildCouncillorAgents } from './council-agents';
+
+/**
+ * Build a minimal CouncilConfig for use in tests.
+ * We cast through `unknown` to avoid repeating the full post-transform shape
+ * which includes `_deprecated` and optional fields.
+ */
+function makeConfig(overrides: Record<string, unknown>): PluginConfig {
+  return {
+    council: {
+      presets: {},
+      default_preset: 'default',
+      _deprecated: undefined,
+      ...overrides,
+    },
+  } as unknown as PluginConfig;
+}
+
+describe('buildCouncillorAgents', () => {
+  test('returns empty array when config is undefined', () => {
+    const agents = buildCouncillorAgents(undefined, new Set());
+    expect(agents).toEqual([]);
+  });
+
+  test('returns empty array when no council config', () => {
+    const agents = buildCouncillorAgents({} as PluginConfig, new Set());
+    expect(agents).toEqual([]);
+  });
+
+  test('returns empty array when preset does not exist', () => {
+    const agents = buildCouncillorAgents(
+      makeConfig({ presets: {} }),
+      new Set(),
+    );
+    expect(agents).toEqual([]);
+  });
+
+  test('single-model councillor has config.model set and no _modelArray', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          beta: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+
+    const [agent] = agents;
+    expect(agent.name).toBe('councillor-beta');
+    expect(agent.config.model).toBe('google/gemini-3-pro');
+    expect(agent._modelArray).toBeUndefined();
+  });
+
+  test('single-model councillor with variant propagates variant to agent config', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          beta: {
+            model: 'google/gemini-3-pro',
+            variant: 'high',
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+
+    const [agent] = agents;
+    expect(agent.name).toBe('councillor-beta');
+    expect(agent.config.model).toBe('google/gemini-3-pro');
+    expect(agent.config.variant).toBe('high');
+    expect(agent._modelArray).toBeUndefined();
+  });
+
+  test('multi-model councillor has _modelArray and config.model undefined', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'openai/gpt-5.6' }, { id: 'anthropic/claude-opus' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+
+    const [agent] = agents;
+    expect(agent.name).toBe('councillor-alpha');
+    expect(agent.config.model).toBeUndefined();
+    expect(agent._modelArray).toEqual([
+      { id: 'openai/gpt-5.6' },
+      { id: 'anthropic/claude-opus' },
+    ]);
+  });
+
+  test('disabled councillor is excluded', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'openai/gpt-5.6' }],
+          },
+          beta: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+    });
+
+    const agents = buildCouncillorAgents(config, new Set(['councillor-alpha']));
+    expect(agents).toHaveLength(1);
+    expect(agents[0].name).toBe('councillor-beta');
+  });
+
+  test('uses default_preset when specified', () => {
+    const config = makeConfig({
+      presets: {
+        default: {
+          alpha: {
+            model: 'openai/gpt-5.6',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'openai/gpt-5.6' }],
+          },
+        },
+        custom: {
+          gamma: {
+            model: 'google/gemini-3-pro',
+            variant: undefined,
+            prompt: undefined,
+            models: [{ id: 'google/gemini-3-pro' }],
+          },
+        },
+      },
+      default_preset: 'custom',
+    });
+
+    const agents = buildCouncillorAgents(config, new Set());
+    expect(agents).toHaveLength(1);
+    expect(agents[0].name).toBe('councillor-gamma');
+  });
+});

+ 60 - 0
src/agents/council-agents.ts

@@ -0,0 +1,60 @@
+import type { PluginConfig } from '../config';
+import { createCouncillorAgent } from './councillor';
+import type { AgentDefinition } from './orchestrator';
+
+const COUNCILLOR_AGENT_PREFIX = 'councillor-';
+
+/**
+ * Build dynamic councillor agents from council config presets.
+ * Each councillor gets its own agent (name + model) so the orchestrator
+ * can task() them with native panes at depth 1 using per-councillor models.
+ * Agent names are prefixed with `councillor-` because raw councillor names
+ * (e.g. "alpha") can collide with OpenCode-reserved agent type names.
+ */
+export function buildCouncillorAgents(
+  config: PluginConfig | undefined,
+  disabled: Set<string>,
+): AgentDefinition[] {
+  const council = config?.council;
+  if (!council) return [];
+
+  const presetName = council.default_preset ?? 'default';
+  const preset = council.presets[presetName];
+  if (!preset) return [];
+
+  const agents: AgentDefinition[] = [];
+  for (const [name, cfg] of Object.entries(preset)) {
+    if (name === 'master') continue;
+
+    const agentName = `${COUNCILLOR_AGENT_PREFIX}${name}`;
+    if (disabled.has(agentName)) continue;
+    const base = createCouncillorAgent(
+      cfg.model,
+      undefined,
+      cfg.prompt,
+      cfg.variant,
+    );
+
+    // If a fallback chain is configured, attach _modelArray for runtime
+    // resolution and clear the primary model so the single-model field
+    // doesn't override the chain (mirrors orchestrator.ts pattern).
+    if (cfg.models.length > 1) {
+      base._modelArray = cfg.models;
+      base.config.model = undefined;
+    }
+
+    agents.push({ ...base, name: agentName });
+  }
+
+  return agents;
+}
+
+/**
+ * Return the user-facing councillor seat name for a prefixed agent name.
+ * Inverse of the prefix applied in `buildCouncillorAgents`.
+ */
+export function getCouncillorSeatName(agentName: string): string {
+  return agentName.startsWith(COUNCILLOR_AGENT_PREFIX)
+    ? agentName.slice(COUNCILLOR_AGENT_PREFIX.length)
+    : agentName;
+}

+ 0 - 225
src/agents/council.test.ts

@@ -1,225 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { formatCouncillorPrompt, formatCouncillorResults } from './council';
-
-describe('formatCouncillorResults', () => {
-  const originalPrompt =
-    'What is the best way to implement a REST API in TypeScript?';
-
-  test('formats completed councillor results correctly', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Use Express.js with TypeScript interfaces for type safety.',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'completed',
-        result:
-          'Consider Fastify for better performance and built-in type validation.',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Original Prompt**:');
-    expect(formatted).toContain(originalPrompt);
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain(
-      'Use Express.js with TypeScript interfaces for type safety.',
-    );
-    expect(formatted).toContain(
-      'Consider Fastify for better performance and built-in type validation.',
-    );
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain(
-      'You MUST follow the Synthesis Process steps before producing output',
-    );
-    expect(formatted).toContain(
-      'consensus confidence rating (unanimous, majority, or split)',
-    );
-    expect(formatted).not.toContain('**Failed/Timed-out Councillors**:');
-  });
-
-  test('includes failed councillors section when some fail', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Use Express.js with TypeScript interfaces for type safety.',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'timed_out',
-        error: 'Request timed out after 180000ms',
-      },
-      {
-        name: 'gamma',
-        model: 'google/gemini-pro',
-        status: 'failed',
-        error: 'Provider returned empty response',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain(
-      'Use Express.js with TypeScript interfaces for type safety.',
-    );
-    expect(formatted).toContain('**Failed/Timed-out Councillors**:');
-    expect(formatted).toContain(
-      '**beta**: timed_out - Request timed out after 180000ms',
-    );
-    expect(formatted).toContain(
-      '**gamma**: failed - Provider returned empty response',
-    );
-    expect(formatted).not.toContain('**beta** (gpt-5.6):');
-    expect(formatted).not.toContain('**gamma** (gemini-pro):');
-  });
-
-  test('returns fallback message when all councillors fail', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'timeout',
-        error: 'Request timed out',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'error',
-        error: 'Provider error',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**Original Prompt**:');
-    expect(formatted).toContain(originalPrompt);
-    expect(formatted).toContain('**Councillor Responses**:');
-    expect(formatted).toContain('All councillors failed to produce output:');
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain('Request timed out');
-    expect(formatted).toContain('Provider error');
-  });
-
-  test('handles councillors with result but completed status', () => {
-    const councillorResults = [
-      {
-        name: 'alpha',
-        model: 'anthropic/claude-opus-4-6',
-        status: 'completed',
-        result: 'Valid response',
-      },
-      {
-        name: 'beta',
-        model: 'openai/gpt-5.6',
-        status: 'completed',
-        result: 'Another valid response',
-      },
-    ];
-
-    const formatted = formatCouncillorResults(
-      originalPrompt,
-      councillorResults,
-    );
-
-    expect(formatted).toContain('**alpha** (claude-opus-4-6):');
-    expect(formatted).toContain('Valid response');
-    expect(formatted).toContain('**beta** (gpt-5.6):');
-    expect(formatted).toContain('Another valid response');
-    expect(formatted).toContain('review each councillor response individually');
-  });
-});
-
-describe('formatCouncillorPrompt', () => {
-  const userPrompt = 'How do I implement async/await in TypeScript?';
-
-  test('returns user prompt unchanged when no councillor prompt is provided', () => {
-    const formatted = formatCouncillorPrompt(userPrompt);
-    expect(formatted).toBe(userPrompt);
-  });
-
-  test('prepends councillor prompt with separator when provided', () => {
-    const councillorPrompt =
-      'You are a TypeScript expert. Focus on practical examples.';
-    const formatted = formatCouncillorPrompt(userPrompt, councillorPrompt);
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(userPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${userPrompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
-      ),
-    );
-  });
-
-  test('handles multiline councillor prompt', () => {
-    const councillorPrompt =
-      'You are an expert.\nFocus on clarity.\nProvide code examples.';
-    const formatted = formatCouncillorPrompt(userPrompt, councillorPrompt);
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(userPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${userPrompt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
-      ),
-    );
-  });
-
-  test('handles empty councillor prompt', () => {
-    const formatted = formatCouncillorPrompt(userPrompt, '');
-    expect(formatted).toBe(userPrompt);
-  });
-
-  test('handles multiline user prompt with councillor prompt', () => {
-    const councillorPrompt = 'You are an expert.';
-    const multilineUserPrompt = 'Line 1\nLine 2\nLine 3';
-    const formatted = formatCouncillorPrompt(
-      multilineUserPrompt,
-      councillorPrompt,
-    );
-
-    expect(formatted).toContain(councillorPrompt);
-    expect(formatted).toContain(multilineUserPrompt);
-    expect(formatted).toContain('---');
-    expect(formatted).toMatch(
-      new RegExp(
-        `^${councillorPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}\\n\\n---\\n\\n${multilineUserPrompt.replace(
-          /[.*+?^${}()|[\]\\]/g,
-          '\\$&',
-        )}$`,
-      ),
-    );
-  });
-});

+ 33 - 134
src/agents/council.ts

@@ -1,31 +1,20 @@
-import { READONLY_FILE_OPERATIONS_RULES } from '../config';
-import { shortModelLabel } from '../utils/session';
 import { type AgentDefinition, resolvePrompt } from './orchestrator';
-import { createReadOnlyAgentPermission } from './permissions';
+import { createSynthesisOnlyPermission } from './permissions';
 
 // NOTE: Councillor system prompts live in the councillor agent factory.
-// The format functions below only structure the USER message content - the
-// agent factory provides the system prompt.
+// The council agent synthesizes councillor responses passed by the orchestrator.
 
-const COUNCIL_AGENT_PROMPT = `You are the Council agent - a multi-LLM \
-orchestration system that runs consensus across multiple models.
+const COUNCIL_SYNTHESIS_REINFORCEMENT = `\n\n---\n\nYou MUST follow the Synthesis Process steps before producing output: review each councillor response individually by name, then produce the required output with a synthesized Council Response, a Per-Councillor Details section using each councillor's exact seat name (e.g. "alpha", not the model label), and a Council Summary with Consensus Level (unanimous|majority|split), Agreed Points, Disagreements + resolution, Remaining Uncertainty, and Recommended Action.`;
 
-**Tool**: You have access to the \`council_session\` tool. You also have read-only codebase inspection tools. You do not have write, edit, shell, or subagent-delegation tools.
+const COUNCIL_AGENT_PROMPT = `You are the Council agent - a \
+synthesizer for multi-model consensus.
 
-**When to use**:
-- When invoked by a user with a request
-- When you want multiple expert opinions on a complex problem
-- When higher confidence is needed through model consensus
+**Role**: You receive raw responses from multiple councillors (different models) and synthesize them into a structured council report. You do NOT dispatch councillors yourself - the orchestrator handles dispatch and provides the councillor results.
 
-**Usage**:
-1. Call the \`council_session\` tool with the user's prompt
-2. Optionally specify a preset (omit to use the configured default)
-3. Receive the councillor responses formatted for synthesis
-4. Follow the Synthesis Process below
-5. Present the result to the user
+**Tools**: You have NO tools. You synthesize purely from the councillor responses provided in your context. Do not read, glob, grep, or run shell commands.
 
 **Synthesis Process** (MANDATORY - follow in order):
-1. Read the original user prompt
+1. Read the original user prompt (provided in the context)
 2. Review each councillor's response individually - note each councillor's \
 key insight and unique contribution by name
 3. Identify agreements and contradictions between councillors
@@ -34,17 +23,13 @@ key insight and unique contribution by name
 6. Format output per the Required Output Format below
 
 **Behavior**:
-- Delegate requests directly to council_session
-- Don't pre-analyze or filter the prompt before calling council_session
 - Credit specific insights from individual councillors using their names
 - If councillors disagree, explain why you chose one approach over another
-- Do not omit per-councillor details from the final response
-- Do not collapse the output into only a final summary
 - Be transparent about trade-offs when different approaches have valid pros/cons
+- Do not omit per-councillor details from the final response
+- Do not collapse the output into only a final summary - keep the per-councillor and summary sections distinct
 - Don't just average responses - choose the best approach and improve upon it
 
-${READONLY_FILE_OPERATIONS_RULES}
-
 **Required Output Format**:
 Always include these sections in your final response:
 
@@ -53,131 +38,45 @@ Provide the best synthesized answer. Integrate the strongest points from the \
 councillors, resolve disagreements, and give the user a clear final \
 recommendation or answer. Include relevant code examples and concrete details.
 
-## Councillor Details
-Include each councillor's response separately.
-
-Use each councillor name exactly as provided in the tool result.
-
-Format each councillor like:
-
-### <councillor name>
-<that councillor's response>
-
-If a councillor failed or timed out, include that status briefly.
+## Per-Councillor Details
+For each councillor, show:
+- Their key insight, idea, or recommendation (using their exact name - the seat name, e.g. "alpha", not the model label)
+- Their confidence level (if expressed)
+- Notable points of agreement/disagreement with other councillors
+- If a councillor failed or timed out, note that status briefly instead of omitting it
 
 ## Council Summary
-Summarize where councillors agreed, where they disagreed, why you chose the \
-final answer, and any remaining uncertainty. Include a consensus confidence \
-rating: unanimous, majority, or split.`;
+- **Consensus Level**: unanimous | majority | split (pick one)
+- **Agreed Points**: what all councillors agreed on
+- **Disagreements**: where councillors differed and your resolution
+- **Remaining Uncertainty**: any caveats, untested assumptions, or open questions the council could not fully resolve
+- **Recommended Action**: what to do next`;
 
+/**
+ * Create the council agent definition.
+ * The council agent synthesizes councillor responses into a structured report.
+ * It does not dispatch councillors — the orchestrator handles that.
+ */
 export function createCouncilAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
-  const prompt = resolvePrompt(
-    COUNCIL_AGENT_PROMPT,
-    customPrompt,
-    customAppendPrompt,
-  );
+  const prompt =
+    resolvePrompt(COUNCIL_AGENT_PROMPT, customPrompt, customAppendPrompt) +
+    COUNCIL_SYNTHESIS_REINFORCEMENT;
 
-  const definition: AgentDefinition = {
+  return {
     name: 'council',
     description:
-      'Multi-LLM council agent that synthesizes responses from multiple models for higher-quality outputs',
+      'Multi-model consensus agent that synthesizes viewpoints from council members to make informed decisions with higher confidence than single models',
     config: {
+      model,
       temperature: 0.1,
       prompt,
       permission: {
-        ...createReadOnlyAgentPermission(),
-        council_session: 'allow',
+        ...createSynthesisOnlyPermission(),
       },
     },
   };
-
-  // Council's model comes from config override or is resolved at
-  // runtime; only set if a non-empty string is provided.
-  if (model) {
-    definition.config.model = model;
-  }
-
-  return definition;
-}
-
-/**
- * Build the prompt for a specific councillor session.
- *
- * Returns the raw user prompt - the agent factory (councillor.ts) provides
- * the system prompt with tool-aware instructions. No duplication.
- *
- * If a per-councillor prompt override is provided, it is prepended as
- * role/guidance context before the user's question.
- */
-export function formatCouncillorPrompt(
-  userPrompt: string,
-  councillorPrompt?: string,
-): string {
-  if (!councillorPrompt) return userPrompt;
-  return `${councillorPrompt}\n\n---\n\n${userPrompt}`;
-}
-
-/**
- * Format councillor results for the council agent to synthesize.
- *
- * Formats councillor results as structured data that the council agent
- * (which called the tool) will receive as the tool response. The council
- * agent's system prompt contains synthesis instructions.
- * Returns a special message when all councillors failed to produce output.
- */
-export function formatCouncillorResults(
-  originalPrompt: string,
-  councillorResults: Array<{
-    name: string;
-    model: string;
-    status: string;
-    result?: string;
-    error?: string;
-  }>,
-): string {
-  const completedWithResults = councillorResults.filter(
-    (cr) => cr.status === 'completed' && cr.result,
-  );
-
-  const councillorSection = completedWithResults
-    .map((cr) => {
-      const shortModel = shortModelLabel(cr.model);
-      return `**${cr.name}** (${shortModel}):\n${cr.result}`;
-    })
-    .join('\n\n');
-
-  const failedSection = councillorResults
-    .filter((cr) => cr.status !== 'completed')
-    .map((cr) => `**${cr.name}**: ${cr.status} - ${cr.error ?? 'Unknown'}`)
-    .join('\n');
-
-  // Defensive guard: caller (runCouncil) short-circuits when all fail,
-  // but this function may be reused in other contexts.
-  if (completedWithResults.length === 0) {
-    const errorDetails = councillorResults
-      .map(
-        (cr) =>
-          `**${cr.name}** (${shortModelLabel(cr.model)}): ${cr.status} - ${
-            cr.error ?? 'Unknown'
-          }`,
-      )
-      .join('\n');
-
-    return `---\n\n**Original Prompt**:\n${originalPrompt}\n\n---\n\n**Councillor Responses**:\nAll councillors failed to produce output:\n${errorDetails}\n\nPlease generate a response based on the original prompt alone.`;
-  }
-
-  let prompt = `---\n\n**Original Prompt**:\n${originalPrompt}\n\n---\n\n**Councillor Responses**:\n${councillorSection}`;
-
-  if (failedSection) {
-    prompt += `\n\n---\n\n**Failed/Timed-out Councillors**:\n${failedSection}`;
-  }
-
-  prompt +=
-    '\n\n---\n\nYou MUST follow the Synthesis Process steps before producing output: review each councillor response individually, then produce the required output with a synthesized Council Response, per-councillor details using their exact names, and a Council Summary with consensus confidence rating (unanimous, majority, or split).';
-
-  return prompt;
 }

+ 15 - 0
src/agents/councillor.test.ts

@@ -62,6 +62,21 @@ describe('createCouncillorAgent', () => {
   });
 });
 
+test('sets variant when provided', () => {
+  const agent = createCouncillorAgent(
+    'test-model',
+    undefined,
+    undefined,
+    'high',
+  );
+  expect(agent.config.variant).toBe('high');
+});
+
+test('variant is undefined when not provided', () => {
+  const agent = createCouncillorAgent('test-model');
+  expect(agent.config.variant).toBeUndefined();
+});
+
 describe('councillor permissions', () => {
   test('denies all by default with wildcard', () => {
     const agent = createCouncillorAgent('test-model');

+ 5 - 3
src/agents/councillor.ts

@@ -5,9 +5,9 @@ import { createReadOnlyAgentPermission } from './permissions';
 /**
  * Councillor agent - a read-only advisor in the multi-LLM council.
  *
- * Councillors are spawned by CouncilManager as agent sessions (visible in
- * tmux/UI). They have read-only access to the codebase via tools but CANNOT
- * modify files, run shell commands, or spawn subagents.
+ * Councillors are dispatched by the orchestrator via task() as agent sessions
+ * (visible in tmux/UI). They have read-only access to the codebase via tools
+ * but CANNOT modify files, run shell commands, or spawn subagents.
  *
  * Permission model mirrors OpenCode's built-in `explore` agent:
  * deny all, then selectively allow read-only tools.
@@ -55,6 +55,7 @@ export function createCouncillorAgent(
   model: string,
   customPrompt?: string,
   customAppendPrompt?: string,
+  variant?: string,
 ): AgentDefinition {
   const prompt = resolvePrompt(
     COUNCILLOR_PROMPT,
@@ -68,6 +69,7 @@ export function createCouncillorAgent(
       'Read-only council advisor. Examines codebase and provides independent analysis. Spawned internally by the council system.',
     config: {
       model,
+      variant,
       temperature: 0.2,
       prompt,
       // Strict read-only allowlist: deny all, then allow inspection tools only.

+ 0 - 4
src/agents/custom.test.ts

@@ -495,10 +495,6 @@ describe('permission edge cases', () => {
     expect(
       (orchestrator?.config.permission as Record<string, unknown>)?.question,
     ).toBeDefined();
-    expect(
-      (orchestrator?.config.permission as Record<string, unknown>)
-        ?.council_session,
-    ).toBeDefined();
     expect(
       (orchestrator?.config.permission as Record<string, unknown>)?.cancel_task,
     ).toBeDefined();

+ 15 - 0
src/agents/display-name.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from 'bun:test';
 import type { PluginConfig } from '../config';
+import { CouncilConfigSchema } from '../config/council-schema';
 import { createAgents, getAgentConfigs } from './index';
 
 describe('displayName', () => {
@@ -209,4 +210,18 @@ describe('displayName', () => {
     expect(sdkConfigs.reviewer).toBeUndefined();
     expect(sdkConfigs.councillor?.hidden).toBe(true);
   });
+
+  test('keeps dynamic councillor-<seat> agents hidden from @ autocomplete', () => {
+    const config: PluginConfig = {
+      disabled_agents: [],
+      council: CouncilConfigSchema.parse({
+        presets: { default: { alpha: { model: 'test/councillor' } } },
+      }),
+    };
+
+    const sdkConfigs = getAgentConfigs(config);
+
+    expect(sdkConfigs['councillor-alpha']?.hidden).toBe(true);
+    expect(sdkConfigs['councillor-alpha']?.mode).toBe('subagent');
+  });
 });

+ 3 - 15
src/agents/fixer.ts

@@ -7,22 +7,18 @@ const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 
 **Behavior**:
 - Execute the task specification provided by the Orchestrator
-- Use the research context (file paths, documentation, patterns) provided
-- Read files before using edit/write tools and gather exact content before making changes
-- Be fast and direct - no research, no delegation, No multi-step research/planning; minimal execution sequence ok
-- Write or update tests when requested, especially for bounded tasks involving test files, fixtures, mocks, or test helpers
-- Run relevant validation when requested or clearly applicable (otherwise note as skipped with reason)
 - Report completion with summary of changes
 
 ${WRITABLE_FILE_OPERATIONS_RULES}
 
 **Constraints**:
 - NO external research (no websearch, context7, gh_grep)
-- NO delegation or spawning subagents
+- NO spawning subagents; telling the caller which specialist to use is fine
 - No multi-step research/planning; minimal execution sequence ok
 - If context is insufficient: use grep/glob/read directly - do not delegate
 - Only ask for missing inputs you truly cannot retrieve yourself
 - Do not act as the primary reviewer; implement requested changes and surface obvious issues briefly
+- No design work — layout, styling, visual hierarchy, responsive behavior, animation, component feel. Refuse and tell the caller to use @designer.
 
 **Output Format**:
 <summary>
@@ -36,15 +32,7 @@ Brief summary of what was implemented
 - Tests passed: [yes/no/skip reason]
 - Validation: [passed/failed/skip reason]
 </verification>
-
-Use the following when no code changes were made:
-<summary>
-No changes required
-</summary>
-<verification>
-- Tests passed: [not run - reason]
-- Validation: [not run - reason]
-</verification>`;
+`;
 
 export function createFixerAgent(
   model: string,

+ 92 - 93
src/agents/index.test.ts

@@ -14,6 +14,7 @@ import {
   getDisabledAgents,
   isSubagent,
 } from './index';
+import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
 
 function councilConfig() {
   const parsed = CouncilConfigSchema.parse({
@@ -182,15 +183,6 @@ describe('orchestrator agent', () => {
     ).toBe('allow');
   });
 
-  test('orchestrator is denied access to council_session', () => {
-    const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === 'orchestrator');
-    expect(
-      (orchestrator as { config: { permission: Record<string, unknown> } })
-        .config.permission.council_session,
-    ).toBe('deny');
-  });
-
   test('orchestrator is allowed to invoke cancel_task', () => {
     const agents = createAgents();
     const orchestrator = agents.find((a) => a.name === 'orchestrator');
@@ -338,42 +330,10 @@ describe('skill permissions', () => {
 });
 
 describe('tool permissions', () => {
-  test('council agent is allowed to invoke council_session', () => {
-    const agents = createAgents({
-      council: councilConfig(),
-    });
-    const council = agents.find((a) => a.name === 'council');
-    expect(
-      (council as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('allow');
-  });
-
-  test('oracle is denied access to council_session', () => {
-    const agents = createAgents();
-    const oracle = agents.find((a) => a.name === 'oracle');
-    expect(
-      (oracle as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
-  });
-
-  test('explorer is denied access to council_session', () => {
-    const agents = createAgents();
-    const explorer = agents.find((a) => a.name === 'explorer');
-    expect(
-      (explorer as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
-  });
-
-  test('councillor is denied access to council_session', () => {
-    const agents = createAgents();
-    const councillor = agents.find((a) => a.name === 'councillor');
-    expect(
-      (councillor as { config: { permission: Record<string, unknown> } }).config
-        .permission.council_session,
-    ).toBe('deny');
+  test('dynamic councillor agents are prefixed to avoid reserved agent type names', () => {
+    const agents = createAgents({ council: councilConfig() });
+    expect(agents.some((a) => a.name === 'councillor-alpha')).toBe(true);
+    expect(agents.some((a) => a.name === 'alpha')).toBe(false);
   });
 
   test('oracle is denied access to cancel_task', () => {
@@ -403,24 +363,27 @@ describe('tool permissions', () => {
     ).toBe('deny');
   });
 
-  test('council agent is read-only except council_session', () => {
+  test('council agent has synthesis-only (deny-all) permissions', () => {
     const agents = createAgents({
       council: councilConfig(),
     });
     const council = agents.find((a) => a.name === 'council');
     const permission = council?.config.permission as Record<string, string>;
     expect(permission['*']).toBe('deny');
-    expect(permission.read).toBe('allow');
-    expect(permission.glob).toBe('allow');
-    expect(permission.grep).toBe('allow');
-    expect(permission.ast_grep_search).toBe('allow');
-    expect(permission.council_session).toBe('allow');
+    expect(permission.read).toBe('deny');
+    expect(permission.glob).toBe('deny');
+    expect(permission.grep).toBe('deny');
+    expect(permission.ast_grep_search).toBe('deny');
+    expect(permission.codesearch).toBe('deny');
+    expect(permission.lsp).toBe('deny');
+    expect(permission.list).toBe('deny');
     expect(permission.bash).toBe('deny');
     expect(permission.edit).toBe('deny');
     expect(permission.write).toBe('deny');
     expect(permission.apply_patch).toBe('deny');
     expect(permission.ast_grep_replace).toBe('deny');
     expect(permission.task).toBe('deny');
+    expect(permission.question).toBe('deny');
   });
 
   test('councillor remains read-only after default permissions are applied', () => {
@@ -433,7 +396,6 @@ describe('tool permissions', () => {
     expect(permission.read).toBe('allow');
     expect(permission.glob).toBe('allow');
     expect(permission.grep).toBe('allow');
-    expect(permission.council_session).toBe('deny');
     expect(permission.bash).toBe('deny');
     expect(permission.edit).toBe('deny');
     expect(permission.write).toBe('deny');
@@ -443,6 +405,22 @@ describe('tool permissions', () => {
   });
 });
 
+test('orchestrator prompt includes Council Mode block when councillors exist', () => {
+  const agents = createAgents({ council: councilConfig() });
+  const orchestrator = agents.find((a) => a.name === 'orchestrator');
+  const prompt = orchestrator?.config.prompt as string;
+  expect(prompt).toContain('## Council Mode');
+  expect(prompt).toContain("task(subagent_type='councillor-alpha'");
+  expect(prompt).toContain('proceed without it');
+});
+
+test('orchestrator prompt excludes Council Mode when no councillors', () => {
+  const agents = createAgents();
+  const orchestrator = agents.find((a) => a.name === 'orchestrator');
+  const prompt = orchestrator?.config.prompt as string;
+  expect(prompt).not.toContain('## Council Mode');
+});
+
 describe('isSubagent type guard', () => {
   test('returns true for valid subagent names', () => {
     expect(isSubagent('explorer')).toBe(true);
@@ -490,6 +468,62 @@ describe('agent classification', () => {
 });
 
 describe('createAgents', () => {
+  test('keeps task-rejection instructions in default subagent prompts without modifying replacements', () => {
+    const agents = createAgents({
+      disabled_agents: [],
+      council: councilConfig(),
+      agents: {
+        explorer: {
+          model: 'test/explorer',
+          prompt: 'Replacement explorer prompt.',
+        },
+        reviewer: {
+          model: 'test/reviewer',
+          prompt: 'Custom reviewer prompt.',
+        },
+      },
+      acpAgents: {
+        bridge: {
+          command: 'bridge-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+        },
+      },
+    });
+
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    const explorer = agents.find((agent) => agent.name === 'explorer');
+
+    expect(explorer?.config.prompt).toBe('Replacement explorer prompt.');
+    expect(orchestrator?.config.prompt).not.toContain(
+      TASK_REJECTION_INSTRUCTION,
+    );
+    expect(agents.map((agent) => agent.name)).toEqual(
+      expect.arrayContaining([
+        'observer',
+        'council',
+        'councillor',
+        'councillor-alpha',
+        'reviewer',
+        'bridge',
+      ]),
+    );
+
+    for (const agent of agents.filter((agent) =>
+      [
+        'observer',
+        'council',
+        'councillor',
+        'councillor-alpha',
+        'bridge',
+      ].includes(agent.name),
+    )) {
+      expect(agent.config.prompt).toContain(TASK_REJECTION_INSTRUCTION);
+    }
+  });
+
   test('creates all agents without config', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);
@@ -559,41 +593,7 @@ describe('council agent model resolution', () => {
     expect(councillor?.config.model).toBe(DEFAULT_MODELS.councillor);
   });
 
-  test('council falls back to legacy master.model when no preset override', () => {
-    // Simulates a pre-1.0.0 config with council.master.model but no council
-    // entry in the agent preset - the exact scenario from issue #369.
-    const config: PluginConfig = {
-      agents: {
-        oracle: { model: 'openai/gpt-5.6' },
-      },
-      council: {
-        ...councilConfig(),
-        _legacyMasterModel: 'anthropic/claude-opus-4-6',
-      },
-    };
-    const agents = createAgents(config);
-    const council = agents.find((a) => a.name === 'council');
-    expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
-  });
-
-  test('council preset override takes precedence over legacy master.model', () => {
-    // If user has explicit council in preset, that wins - legacy is ignored.
-    const config: PluginConfig = {
-      agents: {
-        council: { model: 'google/gemini-3-pro' },
-      },
-      council: {
-        ...councilConfig(),
-        _legacyMasterModel: 'anthropic/claude-opus-4-6',
-      },
-    };
-    const agents = createAgents(config);
-    const council = agents.find((a) => a.name === 'council');
-    expect(council?.config.model).toBe('google/gemini-3-pro');
-  });
-
-  test('council uses default when no legacy master and no preset override', () => {
-    // No legacy master, no preset override → standard default
+  test('council uses default when no preset override', () => {
     const config: PluginConfig = {
       council: councilConfig(),
     };
@@ -602,10 +602,8 @@ describe('council agent model resolution', () => {
     expect(council?.config.model).toBe(DEFAULT_MODELS.council);
   });
 
-  test('end-to-end: raw master.model config flows through schema to council agent', () => {
-    // Integration test: start from raw user config with deprecated master.model,
-    // parse through CouncilConfigSchema, then pass to createAgents.
-    // This validates the full seam between schema transform and agent resolution.
+  test('deprecated council.master field is ignored', () => {
+    // Verify that the deprecated master field is reported but not applied.
     const rawCouncilConfig = {
       master: { model: 'anthropic/claude-opus-4-6' },
       presets: {
@@ -619,13 +617,14 @@ describe('council agent model resolution', () => {
     expect(parsed.success).toBe(true);
 
     if (parsed.success) {
+      expect(parsed.data._deprecated).toEqual(['master']);
       const config: PluginConfig = {
         council: parsed.data,
       };
       const agents = createAgents(config);
       const council = agents.find((a) => a.name === 'council');
-      // Legacy master.model should flow through schema → agent
-      expect(council?.config.model).toBe('anthropic/claude-opus-4-6');
+      // Master is deprecated and no longer used for model fallback
+      expect(council?.config.model).toBe(DEFAULT_MODELS.council);
     }
   });
 });

+ 43 - 29
src/agents/index.ts

@@ -17,6 +17,7 @@ import {
 import { getAgentMcpList } from '../config/agent-mcps';
 
 import { createCouncilAgent } from './council';
+import { buildCouncillorAgents, getCouncillorSeatName } from './council-agents';
 import { createCouncillorAgent } from './councillor';
 import { createDesignerAgent } from './designer';
 import { createExplorerAgent } from './explorer';
@@ -29,6 +30,7 @@ import {
   createOrchestratorAgent,
   resolvePrompt,
 } from './orchestrator';
+import { appendTaskRejectionInstruction } from './task-rejection';
 
 export type { AgentDefinition } from './orchestrator';
 
@@ -38,7 +40,6 @@ type AgentFactory = (
   customAppendPrompt?: string,
 ) => AgentDefinition;
 
-const COUNCIL_TOOL_ALLOWED_AGENTS = new Set(['council']);
 const CANCEL_TASK_ALLOWED_AGENTS = new Set(['orchestrator']);
 const SAFE_AGENT_ALIAS_RE = /^[a-z][a-z0-9_-]*$/i;
 
@@ -231,7 +232,10 @@ function buildCustomAgentDefinition(
   filePrompt?: string,
   fileAppendPrompt?: string,
 ): AgentDefinition {
-  const basePrompt = override.prompt ?? `You are the ${name} specialist.`;
+  const defaultPrompt = appendTaskRejectionInstruction(
+    `You are the ${name} specialist.`,
+  );
+  const basePrompt = override.prompt ?? defaultPrompt;
   const primaryModel = getPrimaryModelFromOverride(override);
 
   return {
@@ -296,13 +300,6 @@ function applyDefaultPermissions(
 
   // Respect explicit deny on question (councillor)
   const questionPerm = existing.question === 'deny' ? 'deny' : 'allow';
-  // Councillors are denied council_session so they cannot spawn nested
-  // councils — this permission denial is now the recursion guard (the
-  // plugin's SubagentDepthTracker was removed; OpenCode's native
-  // subagent_depth covers TaskTool-based recursion for other subagents).
-  const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name)
-    ? (existing.council_session ?? 'allow')
-    : 'deny';
   const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name)
     ? (existing.cancel_task ?? 'allow')
     : 'deny';
@@ -310,7 +307,6 @@ function applyDefaultPermissions(
   agent.config.permission = {
     ...existing,
     question: questionPerm,
-    council_session: councilSessionPerm,
     cancel_task: cancelTaskPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {
@@ -396,7 +392,9 @@ export function createAgents(
 
       const override = getAgentOverride(config, name);
       const inlinePrompt = override?.prompt;
-      const defaultPrompt = agent.config.prompt ?? '';
+      const defaultPrompt = appendTaskRejectionInstruction(
+        agent.config.prompt ?? '',
+      );
 
       const basePrompt =
         inlinePrompt !== undefined ? inlinePrompt : defaultPrompt;
@@ -485,21 +483,6 @@ export function createAgents(
     return agent;
   });
 
-  // 2b. Backward compat: if council has no preset override and still uses the
-  // hardcoded default model, fall back to the deprecated council.master.model.
-  // See https://github.com/alvinunreal/oh-my-opencode-slim/issues/369
-  const legacyMasterModel = config?.council?._legacyMasterModel;
-  if (legacyMasterModel) {
-    const councilAgent = builtInSubAgents.find((a) => a.name === 'council');
-    if (
-      councilAgent &&
-      !getAgentOverride(config, 'council')?.model &&
-      councilAgent.config.model === DEFAULT_MODELS.council
-    ) {
-      councilAgent.config.model = legacyMasterModel;
-    }
-  }
-
   const customSubAgents = protoCustomAgents.map((agent) => {
     const override = getAgentOverride(config, agent.name);
     if (override) {
@@ -514,12 +497,29 @@ export function createAgents(
     return agent;
   });
 
+  // Build dynamic councillor agents from council config (flatten mode).
+  // Each councillor becomes a dispatchable subagent with its own model,
+  // so the orchestrator can task() them with native panes at depth 1.
+  const councillorAgents = buildCouncillorAgents(config, disabled).map(
+    (agent) => {
+      applyDefaultPermissions(agent, undefined, config?.disabled_skills);
+      return agent;
+    },
+  );
+
   const allSubAgents = [
     ...builtInSubAgents,
     ...customSubAgents,
     ...acpSubAgents,
+    ...councillorAgents,
   ];
 
+  for (const agent of [...acpSubAgents, ...councillorAgents]) {
+    agent.config.prompt = appendTaskRejectionInstruction(
+      agent.config.prompt ?? '',
+    );
+  }
+
   // 3. Create Orchestrator (with its own overrides and custom prompts)
   // DEFAULT_MODELS.orchestrator is undefined; model is resolved via override or
   // left unset so the runtime chat.message hook can pick it from _modelArray.
@@ -535,6 +535,7 @@ export function createAgents(
     undefined,
     undefined,
     disabled,
+    councillorAgents.length > 0 ? ['council'] : undefined,
   );
 
   const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
@@ -650,6 +651,17 @@ export function createAgents(
     updatedPrompt = `${updatedPrompt}\n\n${rewrittenAcps.join('\n\n')}`;
   }
 
+  // Inject council-dispatch block if dynamic councillors exist (flatten mode)
+  if (councillorAgents.length > 0) {
+    const dispatchList = councillorAgents
+      .map(
+        (a: AgentDefinition) =>
+          `   - task(subagent_type='${a.name}', description='Councillor ${getCouncillorSeatName(a.name)} on <brief topic>', prompt=<user's question>)`,
+      )
+      .join('\n');
+    updatedPrompt = `${updatedPrompt}\n\n## Council Mode\n\nWhen you need to run a council or the user asks for consensus/multiple opinions, use this procedure INSTEAD of delegating to @council:\n\n1. If the question references an external resource (PR, URL, issue, doc), fetch its content FIRST using your own tools (webfetch/bash/gh), then embed a concise summary in the prompt you send to each councillor — councillors have read-only codebase access only and cannot fetch external content themselves.\n2. Dispatch the user's question (with any fetched context) to each councillor in PARALLEL via task():\n${dispatchList}\n3. Collect ALL councillor responses. If any councillor returns empty or does not respond within 3 minutes, proceed without it — do not wait indefinitely. If a councillor's response is empty, retry that councillor once before continuing.\n4. Call task(subagent_type='council', description='Synthesize council report') with a prompt that includes the original user question AND all councillor responses. For each councillor, label its response with its seat name AND its model (e.g. "alpha (gpt-5.6-luna)"). Format each councillor's seat name and response clearly separated. If a councillor failed or timed out, include that status explicitly (e.g. "beta (gemini-3-pro): FAILED/TIMED OUT") instead of omitting it. Skip only councillors that returned empty after one retry.\n5. Present the council's synthesized report.\n\nThis ensures each councillor runs with its own model and the council agent synthesizes the full multi-model consensus.`;
+  }
+
   orchestrator.config.prompt = updatedPrompt;
 
   return [orchestrator, ...allSubAgents];
@@ -681,8 +693,9 @@ export function getAgentConfigs(
       // Council is callable both as a primary agent (user-facing)
       // and as a subagent (orchestrator can delegate to it)
       sdkConfig.mode = 'all';
-    } else if (name === 'councillor') {
-      // Internal agent - subagent mode, hidden from @ autocomplete
+    } else if (name === 'councillor' || name.startsWith('councillor-')) {
+      // Internal agent - subagent mode, hidden from @ autocomplete.
+      // Dynamic councillors are named councillor-<seat> (see council-agents.ts).
       sdkConfig.mode = 'subagent';
       sdkConfig.hidden = true;
     } else if (isSubagent(name)) {
@@ -694,7 +707,8 @@ export function getAgentConfigs(
     }
   };
 
-  const isInternalOnly = (name: string): boolean => name === 'councillor';
+  const isInternalOnly = (name: string): boolean =>
+    name === 'councillor' || name.startsWith('councillor-');
 
   const entries: Array<[string, SDKAgentConfig]> = [];
 

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

@@ -0,0 +1,14 @@
+import { describe, expect, test } from 'bun:test';
+import { buildOrchestratorPrompt } from './orchestrator';
+
+describe('orchestrator prompt', () => {
+  test('requires the question tool for blocking user input', () => {
+    const prompt = buildOrchestratorPrompt();
+
+    expect(prompt).toContain('use the `question` tool');
+    expect(prompt).toContain('Enable custom input');
+    expect(prompt).toContain('concise pasted response or command output');
+    expect(prompt).toContain('small bounded set of options');
+    expect(prompt).toContain('ordinary dialogue that does not block work');
+  });
+});

+ 18 - 7
src/agents/orchestrator.ts

@@ -80,14 +80,14 @@ const AGENT_DESCRIPTIONS: Record<string, string> = {
 
   council: `@council
 - Lane: High-stakes multi-model decision support
-- Role: Multi-LLM consensus engine that runs several councillors, synthesizes their views, and returns a structured council report.
+- Role: Multi-LLM consensus engine that receives raw councillor responses and synthesizes them into a structured council report.
 - Permissions: Read files
 - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
-- Capabilities: Runs multiple models in parallel, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
+- Capabilities: Synthesizes responses from independently-dispatched councillors, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
 - **Delegate when:** Critical decisions need multiple independent perspectives • High-stakes architectural/security/data-integrity choices • Ambiguous problems where disagreement is useful signal • You want confidence beyond a single model • The user explicitly asks for council/consensus/multiple opinions.
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
 - **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
-- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
+- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Per-Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
 - **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.`,
 
   observer: `@observer
@@ -115,10 +115,14 @@ const PARALLEL_DELEGATION_EXAMPLES = [
  * @param disabledAgents - Set of disabled agent names to exclude from the prompt
  * @returns The complete orchestrator prompt string
  */
-export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
+export function buildOrchestratorPrompt(
+  disabledAgents?: Set<string>,
+  excludeDescriptions?: string[],
+): string {
   // Filter agent descriptions
   const enabledAgents = Object.entries(AGENT_DESCRIPTIONS)
     .filter(([name]) => !disabledAgents?.has(name))
+    .filter(([name]) => !excludeDescriptions?.includes(name))
     .map(([, desc]) => desc)
     .join('\n\n');
 
@@ -162,7 +166,8 @@ Review available agents and lane rules. Before beginning non-trivial work, ident
 
 **Routing threshold:**
 - Handle directly only for one isolated, clear, low-risk action where delegation would cost more than execution.
-- For multi-step implementation, broad discovery, external research, visual work, or complex debugging, delegate to the suitable specialist.
+- Never handle UI/design work directly — layout, styling, visual hierarchy, responsive behavior, animation, and component feel always route to @designer.
+- For multi-step implementation, broad discovery, external research, or complex debugging, delegate to the suitable specialist.
 - If two or more parts can proceed independently, dispatch them in parallel before starting dependent work.
 - Do not delegate merely because an agent exists. Do not keep substantive work entirely in the orchestrator merely because each individual step seems easy.
 
@@ -195,7 +200,7 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 ### Background Task Discipline
 - Prefer \`task(..., background: true)\` for delegated work that can run independently.
 - For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
-- Track each task's specialist, objective, task/session ID, and file/topic ownership.
+- Never reissue an unchanged task to the same specialist after a rejection; adjust its scope or context before retrying.
 - Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
 - Before local edits or another writer task, compare against running task scopes.
 - Parallel background tasks are allowed only when their write scopes do not conflict.
@@ -236,6 +241,8 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
 - Don't guess at critical details (file paths, API choices, architectural decisions)
 - Do make reasonable assumptions for minor details and state them briefly
+- When user input is required before work can continue—including clarification, permission, or command output—use the \`question\` tool rather than leaving an ordinary assistant prompt waiting. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution
 - Answer directly, no preamble
@@ -270,8 +277,12 @@ export function createOrchestratorAgent(
   customPrompt?: string,
   customAppendPrompt?: string,
   disabledAgents?: Set<string>,
+  excludeDescriptions?: string[],
 ): AgentDefinition {
-  const basePrompt = buildOrchestratorPrompt(disabledAgents);
+  const basePrompt = buildOrchestratorPrompt(
+    disabledAgents,
+    excludeDescriptions,
+  );
   const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
 
   const definition: AgentDefinition = {

+ 27 - 0
src/agents/permissions.ts

@@ -29,3 +29,30 @@ export function createReadOnlyAgentPermission(): AgentPermission {
     ast_grep_search: 'allow',
   } as AgentPermission;
 }
+
+/**
+ * Strict deny-all permissions for the council synthesis agent.
+ *
+ * The council agent is text-in/text-out only — it must NOT use any
+ * file-inspection tools. Councillors already perform codebase exploration;
+ * the council only reconciles their text output.
+ */
+export function createSynthesisOnlyPermission(): AgentPermission {
+  return {
+    '*': 'deny',
+    bash: 'deny',
+    edit: 'deny',
+    write: 'deny',
+    apply_patch: 'deny',
+    ast_grep_replace: 'deny',
+    task: 'deny',
+    question: 'deny',
+    read: 'deny',
+    glob: 'deny',
+    grep: 'deny',
+    lsp: 'deny',
+    list: 'deny',
+    codesearch: 'deny',
+    ast_grep_search: 'deny',
+  } as AgentPermission;
+}

+ 15 - 0
src/agents/task-rejection.test.ts

@@ -0,0 +1,15 @@
+import { describe, expect, test } from 'bun:test';
+import { TASK_REJECTION_INSTRUCTION } from './task-rejection';
+
+describe('task rejection instruction', () => {
+  test('requires a plain reason-only response', () => {
+    expect(TASK_REJECTION_INSTRUCTION).toBe(
+      'If a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.',
+    );
+    expect(TASK_REJECTION_INSTRUCTION).not.toMatch(
+      /<|>|task_rejection|recommended[_ -]?agent/i,
+    );
+    expect(TASK_REJECTION_INSTRUCTION).not.toContain('permissions');
+    expect(TASK_REJECTION_INSTRUCTION).not.toContain('available context');
+  });
+});

+ 6 - 0
src/agents/task-rejection.ts

@@ -0,0 +1,6 @@
+export const TASK_REJECTION_INSTRUCTION =
+  'If a task is outside your role, do not attempt partial work. Return a brief reason to the orchestrator.';
+
+export function appendTaskRejectionInstruction(prompt: string): string {
+  return `${prompt}\n\n${TASK_REJECTION_INSTRUCTION}`;
+}

+ 147 - 0
src/cache-safety-tripwire.test.ts

@@ -0,0 +1,147 @@
+/**
+ * Cache-safety tripwire — scans prompt-assembly source directories for
+ * volatile-input patterns that silently invalidate provider prompt caches.
+ *
+ * Provider caches are exact byte-prefix matches over the rendered request.
+ * A `Date.now()`, `new Date(...)`, `Math.random()`, or `randomUUID()` whose
+ * value reaches the prompt prefix makes every request's prefix unique, so
+ * nothing is ever served from cache — silently, with no error.
+ *
+ * When this test fails for a new file:
+ *
+ *   1. If the value can reach prompt content, keep it out of the stable
+ *      prefix: route it through the trailing volatile zone via
+ *      src/hooks/cache-safe-injection.ts, or drop it.
+ *   2. If the value never feeds prompt content (timers, temp file names,
+ *      internal bookkeeping), add an allowlist entry below with a
+ *      justification that a reviewer can verify.
+ *
+ * See docs/cache-verification.md for the full invariant.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+const SRC_ROOT = import.meta.dir;
+
+/** Directories that participate in prompt/payload assembly. */
+const SCAN_DIRS = ['hooks', 'agents', 'config'];
+
+const VOLATILE_PATTERNS: Array<{ name: string; regex: RegExp }> = [
+  { name: 'Date.now()', regex: /\bDate\.now\(/ },
+  { name: 'new Date(...)', regex: /\bnew Date\(/ },
+  { name: 'Math.random()', regex: /\bMath\.random\(/ },
+  { name: 'randomUUID()', regex: /\brandomUUID\b/ },
+  { name: 'performance.now()', regex: /\bperformance\.now\(/ },
+];
+
+/**
+ * Files allowed to use volatile inputs, each with a reviewer-verifiable
+ * reason why the value can never reach the prompt prefix. Adding an entry
+ * is a code-review decision, not a formality.
+ */
+const ALLOWLIST = new Map<string, string>([
+  [
+    'hooks/auto-update-checker/skill-sync.ts',
+    'Update scheduling and install bookkeeping; produces no prompt content.',
+  ],
+  [
+    'hooks/loop-command/index.ts',
+    'Timestamps/randomness name per-run loop-history directories; the path only appears inside a newly appended user turn (payload tail), never in earlier prefix bytes.',
+  ],
+  [
+    'hooks/foreground-fallback/index.ts',
+    'Date.now() gates retry/dedup windows for model failover; no prompt content is derived from it.',
+  ],
+  [
+    'hooks/apply-patch/prepared-changes.ts',
+    'randomUUID() names temp files during atomic writes; never serialized into messages.',
+  ],
+  [
+    'hooks/task-session-manager/task-context-tracker.ts',
+    'Date.now() records lastReadAt for internal recency ordering; formatted prompt output (background job board) is confined to the volatile trailing message.',
+  ],
+  [
+    'hooks/task-session-manager/event-router.ts',
+    'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
+  ],
+  [
+    'hooks/image-hook.ts',
+    'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
+  ],
+  [
+    'hooks/auto-update-checker/cache.ts',
+    'Date.now() and process.pid name an on-disk quarantine directory during the atomic publish transaction; the path is filesystem bookkeeping, never serialized into prompt content.',
+  ],
+  [
+    'hooks/auto-update-checker/checker.ts',
+    'Date.now()/Math.random() compose a per-run temp token for install bookkeeping; it names local directories and never reaches the prompt prefix.',
+  ],
+]);
+
+async function scanForViolations(): Promise<string[]> {
+  const violations: string[] = [];
+  const glob = new Bun.Glob('**/*.ts');
+
+  for (const dir of SCAN_DIRS) {
+    const root = path.join(SRC_ROOT, dir);
+    for await (const file of glob.scan(root)) {
+      if (file.endsWith('.test.ts')) continue;
+      const relative = `${dir}/${file}`;
+      if (ALLOWLIST.has(relative)) continue;
+
+      const content = readFileSync(path.join(root, file), 'utf8');
+      for (const pattern of VOLATILE_PATTERNS) {
+        if (pattern.regex.test(content)) {
+          violations.push(`${relative} uses ${pattern.name}`);
+        }
+      }
+    }
+  }
+
+  return violations;
+}
+
+describe('cache-safety tripwire', () => {
+  test('prompt-assembly code introduces no unreviewed volatile inputs', async () => {
+    const violations = await scanForViolations();
+
+    if (violations.length > 0) {
+      throw new Error(
+        [
+          'Volatile input detected in prompt-assembly code. If its value can',
+          'reach the prompt, it will silently bust the provider cache on',
+          'every request — keep it in the volatile tail via',
+          'src/hooks/cache-safe-injection.ts, or add a justified allowlist',
+          'entry in src/cache-safety-tripwire.test.ts (see file header).',
+          '',
+          ...violations,
+        ].join('\n'),
+      );
+    }
+  });
+
+  test('allowlist contains no stale entries', async () => {
+    const stale: string[] = [];
+
+    for (const [relative] of ALLOWLIST) {
+      const absolute = path.join(SRC_ROOT, relative);
+      let content: string;
+      try {
+        content = readFileSync(absolute, 'utf8');
+      } catch {
+        stale.push(`${relative} (file no longer exists)`);
+        continue;
+      }
+      const stillMatches = VOLATILE_PATTERNS.some((pattern) =>
+        pattern.regex.test(content),
+      );
+      if (!stillMatches) {
+        stale.push(`${relative} (no volatile patterns remain)`);
+      }
+    }
+
+    expect(stale).toEqual([]);
+  });
+});

+ 6 - 2
src/cli/background-subagents.test.ts

@@ -154,6 +154,12 @@ describe('parseArgs companion', () => {
   });
 });
 
+describe('parseArgs skills', () => {
+  test('parses force skill synchronization mode', () => {
+    expect(parseArgs(['--skills=force']).skills).toBe('force');
+  });
+});
+
 describe('configureBackgroundSubagents', () => {
   let tempDir: string | undefined;
   const originalBackgroundEnv =
@@ -181,7 +187,6 @@ describe('configureBackgroundSubagents', () => {
 
     try {
       const result = await configureBackgroundSubagents({
-        hasTmux: false,
         installCustomSkills: false,
         promptForStar: false,
         reset: false,
@@ -215,7 +220,6 @@ describe('configureBackgroundSubagents', () => {
 
     try {
       const result = await configureBackgroundSubagents({
-        hasTmux: false,
         installCustomSkills: false,
         promptForStar: false,
         reset: false,

+ 70 - 7
src/cli/config-io.test.ts

@@ -46,11 +46,14 @@ describe('config-io', () => {
     mock.restore();
   });
 
-  function writePackageJson(dir: string): void {
+  function writePackageJson(dir: string, version?: string): void {
     mkdirSync(dir, { recursive: true });
     writeFileSync(
       join(dir, 'package.json'),
-      JSON.stringify({ name: 'oh-my-opencode-slim' }),
+      JSON.stringify({
+        name: 'oh-my-opencode-slim',
+        ...(version ? { version } : {}),
+      }),
     );
   }
 
@@ -181,6 +184,51 @@ describe('config-io', () => {
     expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
   });
 
+  test('addPluginToOpenCodeConfig leaves @latest bunx invocations unpinned', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@latest',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual(['oh-my-opencode-slim']);
+  });
+
+  test('addPluginToOpenCodeConfig writes the resolved version as an installer-managed tuple', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const packageRoot = join(
+      tmpDir,
+      'bunx-1000-oh-my-opencode-slim@beta',
+      'node_modules',
+      'oh-my-opencode-slim',
+    );
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({ plugin: [] }));
+    writePackageJson(packageRoot, '1.2.3');
+    process.argv[1] = join(packageRoot, 'dist', 'cli', 'index.js');
+
+    const result = await addPluginToOpenCodeConfig();
+
+    expect(result.success).toBe(true);
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toEqual([
+      [
+        'oh-my-opencode-slim@1.2.3',
+        { __ohMyOpencodeSlimManagedByInstaller: true },
+      ],
+    ]);
+  });
+
   test('addPluginToOpenCodeConfig stores local repo path for local dev paths', async () => {
     const configPath = join(tmpDir, 'opencode', 'opencode.json');
     const packageRoot = join(tmpDir, 'repo');
@@ -424,7 +472,6 @@ describe('config-io', () => {
     paths.ensureConfigDir();
 
     const result = writeLiteConfig({
-      hasTmux: true,
       installCustomSkills: false,
       reset: false,
     });
@@ -437,7 +484,6 @@ describe('config-io', () => {
     expect(saved.preset).toBe('openai');
     expect(saved.presets.openai).toBeDefined();
     expect(saved.presets['opencode-go']).toBeDefined();
-    expect(saved.tmux.enabled).toBe(true);
   });
 
   test('writeLiteConfig writes selected preset', () => {
@@ -445,7 +491,6 @@ describe('config-io', () => {
     paths.ensureConfigDir();
 
     const result = writeLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       preset: 'opencode-go',
       reset: false,
@@ -568,7 +613,6 @@ describe('config-io', () => {
             librarian: { model: 'zai-coding-plan/glm-4.7' },
           },
         },
-        tmux: { enabled: true },
       }),
     );
 
@@ -579,7 +623,26 @@ describe('config-io', () => {
     expect(detected.hasAnthropic).toBe(true);
     expect(detected.hasCopilot).toBe(true);
     expect(detected.hasZaiPlan).toBe(true);
-    expect(detected.hasTmux).toBe(true);
+  });
+
+  test('detectCurrentConfig detects installed status for installer-managed tuple', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: [
+          [
+            'oh-my-opencode-slim@1.2.3',
+            { __ohMyOpencodeSlimManagedByInstaller: true },
+          ],
+        ],
+      }),
+    );
+
+    const detected = detectCurrentConfig();
+    expect(detected.isInstalled).toBe(true);
   });
 
   test('detectCurrentConfig detects provider models in arrays', () => {

+ 34 - 24
src/cli/config-io.ts

@@ -10,6 +10,10 @@ import {
 } from 'node:fs';
 import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
+import {
+  INSTALLER_MANAGED_PLUGIN_OPTION,
+  type PluginEntry,
+} from '../plugin-entry';
 import { crossSpawn } from '../utils/compat';
 import {
   ensureConfigDir,
@@ -51,10 +55,6 @@ function getPlugins(config: OpenCodeConfig): unknown[] {
   return Array.isArray(config.plugin) ? config.plugin : [];
 }
 
-function getPluginEntries(config: OpenCodeConfig): string[] {
-  return getPlugins(config).filter(isString);
-}
-
 function getPluginSpec(entry: unknown): string | undefined {
   if (isString(entry)) return entry;
   if (!Array.isArray(entry)) return undefined;
@@ -136,7 +136,7 @@ function isMatchingPluginEntry(entry: unknown): boolean {
   return spec ? isPluginEntry(spec) : false;
 }
 
-function getPluginEntry(): string {
+function getPluginEntry(): PluginEntry {
   const cliEntryPath = process.argv[1];
 
   if (!cliEntryPath) {
@@ -146,10 +146,22 @@ function getPluginEntry(): string {
   try {
     const packageRoot = findPackageRoot(cliEntryPath);
 
-    if (!packageRoot || isPackageManagerInstall(packageRoot)) {
+    if (!packageRoot) {
       return PACKAGE_NAME;
     }
 
+    if (isPackageManagerInstall(packageRoot)) {
+      const version = getVersionFromPackageRoot(packageRoot);
+      const requestedTag = getRequestedPackageTag(packageRoot);
+      if (!version || !requestedTag || requestedTag === 'latest') {
+        return PACKAGE_NAME;
+      }
+      return [
+        `${PACKAGE_NAME}@${version}`,
+        { [INSTALLER_MANAGED_PLUGIN_OPTION]: true },
+      ];
+    }
+
     return packageRoot;
   } catch {
     return PACKAGE_NAME;
@@ -161,19 +173,22 @@ function getPluginEntry(): string {
  * Returns the version string (e.g. "1.2.3") if pinned, or undefined
  * if the plugin is unpinned (bare name or @latest).
  */
-function getPinnedVersionFromConfig(): string | undefined {
+function getConfiguredExactVersion(): string | undefined {
   try {
     const { config } = parseConfig(getExistingConfigPath());
     if (!config) return undefined;
+    let version: string | undefined;
     for (const entry of getPlugins(config)) {
       const spec = getPluginSpec(entry);
       if (!spec) continue;
-      if (spec === PACKAGE_NAME) return undefined;
-      if (spec.startsWith(`${PACKAGE_NAME}@`)) {
-        const version = spec.slice(PACKAGE_NAME.length + 1);
-        if (version && version !== 'latest') return version;
+      if (spec === PACKAGE_NAME) {
+        version = undefined;
+      } else if (spec.startsWith(`${PACKAGE_NAME}@`)) {
+        const candidate = spec.slice(PACKAGE_NAME.length + 1);
+        version = candidate && candidate !== 'latest' ? candidate : undefined;
       }
     }
+    return version;
   } catch {}
   return undefined;
 }
@@ -311,10 +326,10 @@ export async function warmOpenCodePluginCache(): Promise<ConfigMergeResult | nul
     return null;
   }
 
-  const pinnedVersion = getPinnedVersionFromConfig();
+  const configuredVersion = getConfiguredExactVersion();
   const runningVersion = getVersionFromPackageRoot(packageRoot);
   const requestedTag = getRequestedPackageTag(packageRoot);
-  const cacheVersion = pinnedVersion ?? requestedTag ?? runningVersion;
+  const cacheVersion = configuredVersion ?? requestedTag ?? runningVersion;
   const cacheDir = getOpenCodePluginCacheDir(cacheVersion);
 
   try {
@@ -654,17 +669,17 @@ export function detectCurrentConfig(): DetectedConfig {
     hasAntigravity: false,
     hasChutes: false,
     hasOpencodeZen: false,
-    hasTmux: false,
   };
 
   const { config } = parseConfig(getExistingConfigPath());
   if (!config) return result;
 
-  const plugins = getPluginEntries(config);
-  result.isInstalled = plugins.some((p) => isPluginEntry(p));
-  result.hasAntigravity = plugins.some((p) =>
-    p.startsWith('opencode-antigravity-auth'),
-  );
+  const plugins = getPlugins(config);
+  result.isInstalled = plugins.some((p) => isMatchingPluginEntry(p));
+  result.hasAntigravity = plugins.some((p) => {
+    const spec = getPluginSpec(p);
+    return spec?.startsWith('opencode-antigravity-auth') ?? false;
+  });
 
   // Check for providers
   const providers = config.provider as Record<string, unknown> | undefined;
@@ -703,11 +718,6 @@ export function detectCurrentConfig(): DetectedConfig {
         result.hasChutes = true;
       }
     }
-
-    if (configObj.tmux && typeof configObj.tmux === 'object') {
-      const tmuxConfig = configObj.tmux as { enabled?: boolean };
-      result.hasTmux = tmuxConfig.enabled === true;
-    }
   }
 
   return result;

+ 9 - 3
src/cli/index.ts

@@ -4,9 +4,9 @@ import { install } from './install';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import type {
   BackgroundSubagentsArg,
-  BooleanArg,
   CompanionArg,
   InstallArgs,
+  SkillsArg,
 } from './types';
 
 export function parseArgs(args: string[]): InstallArgs {
@@ -20,7 +20,12 @@ export function parseArgs(args: string[]): InstallArgs {
     if (arg === '--no-tui') {
       result.tui = false;
     } else if (arg.startsWith('--skills=')) {
-      result.skills = arg.split('=')[1] as BooleanArg;
+      const mode = arg.split('=')[1] as SkillsArg;
+      if (!['yes', 'no', 'force'].includes(mode)) {
+        console.error('Unsupported --skills value: use yes, no, or force');
+        process.exit(1);
+      }
+      result.skills = mode;
     } else if (arg.startsWith('--companion=')) {
       const mode = arg.split('=')[1] as CompanionArg;
       if (!['ask', 'yes', 'no'].includes(mode)) {
@@ -72,7 +77,8 @@ Usage:
   bunx oh-my-opencode-slim doctor [OPTIONS]
 
 Options:
-  --skills=yes|no        Install bundled skills (default: yes)
+  --skills=yes|no|force  Install bundled skills; force replaces existing skill
+                         directories (default: yes)
   --companion=ask|yes|no Install desktop companion binary and enable config
                          (default: ask; prompt defaults to no)
   --preset=<name>        Active generated config preset (default: openai)

+ 29 - 12
src/cli/install.test.ts

@@ -48,22 +48,26 @@ let mockFailedResult: string[] = [];
 let mockStagedResult: string[] = [];
 let mockAdoptedResult: string[] = [];
 let mockCustomizedResult: string[] = [];
+let receivedSkillSyncOptions: unknown;
 let enableInstallMocks = false;
 
 mock.module('../hooks/auto-update-checker/skill-sync', () => {
   return {
     ...actualSkillSync,
-    syncBundledSkillsFromPackage: (packageRoot: string, options?: any) =>
-      enableInstallMocks
-        ? {
-            installed: [],
-            skippedExisting: mockSkippedResult,
-            failed: mockFailedResult,
-            staged: mockStagedResult,
-            adopted: mockAdoptedResult,
-            customized: mockCustomizedResult,
-          }
-        : originalSyncBundledSkillsFromPackage(packageRoot, options),
+    syncBundledSkillsFromPackage: (packageRoot: string, options?: any) => {
+      if (enableInstallMocks) {
+        receivedSkillSyncOptions = options;
+        return {
+          installed: [],
+          skippedExisting: mockSkippedResult,
+          failed: mockFailedResult,
+          staged: mockStagedResult,
+          adopted: mockAdoptedResult,
+          customized: mockCustomizedResult,
+        };
+      }
+      return originalSyncBundledSkillsFromPackage(packageRoot, options);
+    },
   };
 });
 
@@ -143,8 +147,8 @@ mock.module('./paths', () => {
 
 function baseConfig(): InstallConfig {
   return {
-    hasTmux: false,
     installCustomSkills: false,
+    forceSkillSync: false,
     reset: false,
     backgroundSubagents: 'no',
     companion: 'ask',
@@ -197,6 +201,7 @@ describe('install skill synchronization error mapping', () => {
     mockStagedResult = [];
     mockAdoptedResult = [];
     mockCustomizedResult = [];
+    receivedSkillSyncOptions = undefined;
     originalConsoleLog = console.log;
     logSpy = mock(() => {});
     console.log = logSpy;
@@ -385,4 +390,16 @@ describe('install skill synchronization error mapping', () => {
       '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
     );
   });
+
+  test('passes force mode to bundled skill synchronization', async () => {
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'force',
+      tui: false,
+      companion: 'no',
+    });
+
+    expect(receivedSkillSyncOptions).toEqual({ force: true });
+  });
 });

+ 29 - 10
src/cli/install.ts

@@ -126,15 +126,32 @@ async function checkOpenCodeInstalled(): Promise<{
 }> {
   const installed = await isOpenCodeInstalled();
   if (!installed) {
+    const isWindows = process.platform === 'win32';
     printError('OpenCode is not installed on this system.');
     printInfo('Install it with:');
-    console.log(
-      `     ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`,
-    );
-    console.log();
-    printInfo('Or if already installed, add it to your PATH:');
-    console.log(`     ${BLUE}export PATH="$HOME/.local/bin:$PATH"${RESET}`);
-    console.log(`     ${BLUE}export PATH="$HOME/.opencode/bin:$PATH"${RESET}`);
+    if (isWindows) {
+      console.log(
+        `     ${BLUE}powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://opencode.ai/install.ps1 | iex"${RESET}`,
+      );
+      console.log();
+      printInfo('Or with winget:');
+      console.log(`     ${BLUE}winget install opencode${RESET}`);
+      console.log();
+      printInfo('Or if already installed, add it to your PATH:');
+      console.log(
+        `     ${BLUE}setx PATH "%PATH%;%LOCALAPPDATA%\\Programs\\opencode"${RESET}`,
+      );
+    } else {
+      console.log(
+        `     ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`,
+      );
+      console.log();
+      printInfo('Or if already installed, add it to your PATH:');
+      console.log(`     ${BLUE}export PATH="$HOME/.local/bin:$PATH"${RESET}`);
+      console.log(
+        `     ${BLUE}export PATH="$HOME/.opencode/bin:$PATH"${RESET}`,
+      );
+    }
     return { ok: false };
   }
   const version = await getOpenCodeVersion();
@@ -421,7 +438,9 @@ async function runInstall(config: InstallConfig): Promise<number> {
     } else {
       try {
         const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
-        const result = syncBundledSkillsFromPackage(packageRoot);
+        const result = syncBundledSkillsFromPackage(packageRoot, {
+          force: config.forceSkillSync,
+        });
         const categorizedSkipped = new Set([
           ...result.staged,
           ...result.adopted,
@@ -546,8 +565,8 @@ async function runInstall(config: InstallConfig): Promise<number> {
 
 export async function install(args: InstallArgs): Promise<number> {
   const config: InstallConfig = {
-    hasTmux: false,
-    installCustomSkills: args.skills === 'yes',
+    installCustomSkills: args.skills === 'yes' || args.skills === 'force',
+    forceSkillSync: args.skills === 'force',
     preset: args.preset,
     promptForStar: args.tui,
     dryRun: args.dryRun,

+ 14 - 39
src/cli/providers.test.ts

@@ -17,7 +17,6 @@ describe('providers', () => {
 
   test('generateLiteConfig defaults to openai and includes generated presets', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -35,36 +34,34 @@ describe('providers', () => {
     const agents = (config.presets as any).openai;
     expect(agents).toBeDefined();
     expect(agents.orchestrator.model).toBe('openai/gpt-5.6-terra');
-    expect(agents.orchestrator.variant).toBe('medium');
+    expect(agents.orchestrator.variant).toBe('xhigh');
     expect(agents.fixer.model).toBe('openai/gpt-5.6-luna');
-    expect(agents.fixer.variant).toBe('medium');
+    expect(agents.fixer.variant).toBe('xhigh');
   });
 
-  test('generateLiteConfig uses correct OpenAI models', () => {
+  test('preserves exact OpenAI model and variant mappings', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
     });
 
     const agents = (config.presets as any).openai;
-    expect(agents.orchestrator.model).toBe(
-      MODEL_MAPPINGS.openai.orchestrator.model,
-    );
-    expect(agents.oracle.model).toBe('openai/gpt-5.6-sol');
-    expect(agents.oracle.variant).toBe('high');
-    expect(agents.librarian.model).toBe('openai/gpt-5.6-luna');
-    expect(agents.librarian.variant).toBe('low');
-    expect(agents.explorer.model).toBe('openai/gpt-5.6-luna');
-    expect(agents.explorer.variant).toBe('low');
-    expect(agents.designer.model).toBe('openai/gpt-5.6-luna');
-    expect(agents.designer.variant).toBe('medium');
+    const expected = {
+      orchestrator: { model: 'openai/gpt-5.6-terra', variant: 'xhigh' },
+      oracle: { model: 'openai/gpt-5.6-sol', variant: 'xhigh' },
+      librarian: { model: 'openai/gpt-5.6-luna', variant: 'low' },
+      explorer: { model: 'openai/gpt-5.6-luna', variant: 'low' },
+      designer: { model: 'openai/gpt-5.6-luna', variant: 'medium' },
+      fixer: { model: 'openai/gpt-5.6-luna', variant: 'xhigh' },
+    } as const;
+
+    expect(MODEL_MAPPINGS.openai).toEqual(expected);
+    expect(agents).toMatchObject(expected);
   });
 
   test('generateLiteConfig can set opencode-go as active preset', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       preset: 'opencode-go',
       backgroundSubagents: 'no',
@@ -95,7 +92,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects unsupported preset', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'not-real',
         backgroundSubagents: 'no',
@@ -107,7 +103,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects non-generated model mappings as active presets', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'kimi',
         backgroundSubagents: 'no',
@@ -119,7 +114,6 @@ describe('providers', () => {
   test('generateLiteConfig rejects inherited property names as presets', () => {
     expect(() =>
       generateLiteConfig({
-        hasTmux: false,
         installCustomSkills: false,
         preset: 'toString',
         backgroundSubagents: 'no',
@@ -128,22 +122,8 @@ describe('providers', () => {
     ).toThrow('Unsupported preset "toString"');
   });
 
-  test('generateLiteConfig enables tmux when requested', () => {
-    const config = generateLiteConfig({
-      hasTmux: true,
-      installCustomSkills: false,
-      backgroundSubagents: 'no',
-      reset: false,
-    });
-
-    expect(config.tmux).toBeDefined();
-    expect((config.tmux as any).enabled).toBe(true);
-    expect((config.tmux as any).layout).toBe('main-vertical');
-  });
-
   test('generateLiteConfig companion: yes', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -158,7 +138,6 @@ describe('providers', () => {
 
   test('generateLiteConfig companion: no or omitted', () => {
     const configYes = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -167,7 +146,6 @@ describe('providers', () => {
     expect(configYes.companion).toBeUndefined();
 
     const configOmitted = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -177,7 +155,6 @@ describe('providers', () => {
 
   test('generateLiteConfig includes default skills', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -205,7 +182,6 @@ describe('providers', () => {
 
   test('generateLiteConfig includes mcps field', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,
@@ -220,7 +196,6 @@ describe('providers', () => {
 
   test('generateLiteConfig openai includes correct mcps', () => {
     const config = generateLiteConfig({
-      hasTmux: false,
       installCustomSkills: false,
       backgroundSubagents: 'no',
       reset: false,

+ 3 - 11
src/cli/providers.ts

@@ -10,12 +10,12 @@ export const GENERATED_PRESETS = ['openai', 'opencode-go'] as const;
 // Model mappings by provider/preset.
 export const MODEL_MAPPINGS = {
   openai: {
-    orchestrator: { model: 'openai/gpt-5.6-terra', variant: 'medium' },
-    oracle: { model: 'openai/gpt-5.6-sol', variant: 'high' },
+    orchestrator: { model: 'openai/gpt-5.6-terra', variant: 'xhigh' },
+    oracle: { model: 'openai/gpt-5.6-sol', variant: 'xhigh' },
     librarian: { model: 'openai/gpt-5.6-luna', variant: 'low' },
     explorer: { model: 'openai/gpt-5.6-luna', variant: 'low' },
     designer: { model: 'openai/gpt-5.6-luna', variant: 'medium' },
-    fixer: { model: 'openai/gpt-5.6-luna', variant: 'medium' },
+    fixer: { model: 'openai/gpt-5.6-luna', variant: 'xhigh' },
   },
   kimi: {
     orchestrator: { model: 'kimi-for-coding/k2p5', variant: 'max' },
@@ -136,14 +136,6 @@ export function generateLiteConfig(
     presets[presetName] = buildPreset(presetName);
   }
 
-  if (installConfig.hasTmux) {
-    config.tmux = {
-      enabled: true,
-      layout: 'main-vertical',
-      main_pane_size: 60,
-    };
-  }
-
   if (installConfig.companion === 'yes') {
     config.companion = {
       enabled: true,

+ 49 - 4
src/cli/system.ts

@@ -9,23 +9,41 @@ function resolvePathCommand(
   environment: NodeJS.ProcessEnv = process.env,
 ): string | null {
   try {
-    const resolver = process.platform === 'win32' ? 'where' : 'which';
+    const isWindows = process.platform === 'win32';
+    const resolver = isWindows ? 'where' : 'which';
     const result = spawnSync(resolver, [command], {
       encoding: 'utf-8',
       stdio: ['ignore', 'pipe', 'ignore'],
       env: environment,
+      // On Windows, `where opencode` returns multiple shims (opencode, opencode.cmd,
+      // opencode.ps1). Node's spawnSync cannot execute an extensionless npm shim,
+      // and since Node's CVE-2024-27980 patch .cmd/.bat files also require a shell.
+      shell: isWindows,
     });
 
     if (result.status !== 0) {
       return null;
     }
 
-    const resolved = result.stdout
+    const lines = result.stdout
       .split(/\r?\n/)
       .map((line) => line.trim())
-      .find(Boolean);
+      .filter(Boolean);
 
-    return resolved ?? null;
+    if (lines.length === 0) {
+      return null;
+    }
+
+    // On Windows, prefer executable shims (.cmd, .exe, .ps1) over the
+    // extensionless npm wrapper that Node cannot spawn directly.
+    if (isWindows) {
+      const executable = lines.find((line) =>
+        /\.(cmd|exe|ps1|bat)$/i.test(line),
+      );
+      return executable ?? lines[0];
+    }
+
+    return lines[0];
   } catch {
     return null;
   }
@@ -37,9 +55,13 @@ function canExecute(
   environment: NodeJS.ProcessEnv = process.env,
 ): boolean {
   try {
+    const isWindows = process.platform === 'win32';
     const result = spawnSync(command, args, {
       stdio: 'ignore',
       env: environment,
+      // Required on Windows to execute .cmd/.bat shims produced by npm/pnpm/yarn
+      // (Node's CVE-2024-27980 patch blocks them without a shell).
+      shell: isWindows,
     });
     return result.status === 0;
   } catch {
@@ -51,10 +73,33 @@ function getOpenCodePaths(
   environment: NodeJS.ProcessEnv = process.env,
 ): string[] {
   const home = environment.HOME || environment.USERPROFILE || '';
+  const isWindows = process.platform === 'win32';
+  const appData = environment.APPDATA || `${home}\\AppData\\Roaming`;
+  const localAppData = environment.LOCALAPPDATA || `${home}\\AppData\\Local`;
+
+  const windowsPaths = isWindows
+    ? [
+        // npm global shims (created as .cmd on Windows)
+        `${appData}\\npm\\opencode.cmd`,
+        `${appData}\\npm\\opencode.ps1`,
+        // pnpm global
+        `${localAppData}\\pnpm\\opencode.cmd`,
+        // Yarn global
+        `${localAppData}\\Yarn\\bin\\opencode.cmd`,
+        // opencode.ai/install .exe location
+        `${localAppData}\\Programs\\opencode\\opencode.exe`,
+        // Scoop
+        `${home}\\scoop\\shims\\opencode.exe`,
+        `${home}\\scoop\\apps\\opencode\\current\\bin\\opencode.exe`,
+        // Chocolatey
+        `C:\\ProgramData\\chocolatey\\bin\\opencode.exe`,
+      ]
+    : [];
 
   return [
     // PATH (try this first)
     'opencode',
+    ...windowsPaths,
     // User local installations (Linux & macOS)
     `${home}/.local/bin/opencode`,
     `${home}/.opencode/bin/opencode`,

+ 3 - 3
src/cli/types.ts

@@ -1,10 +1,11 @@
 export type BooleanArg = 'yes' | 'no';
+export type SkillsArg = BooleanArg | 'force';
 export type BackgroundSubagentsArg = 'ask' | 'yes' | 'no';
 export type CompanionArg = 'ask' | BooleanArg;
 
 export interface InstallArgs {
   tui: boolean;
-  skills?: BooleanArg;
+  skills?: SkillsArg;
   preset?: string;
   dryRun?: boolean;
   reset?: boolean;
@@ -21,8 +22,8 @@ export interface OpenCodeConfig {
 }
 
 export interface InstallConfig {
-  hasTmux: boolean;
   installCustomSkills: boolean;
+  forceSkillSync: boolean;
   preset?: string;
   promptForStar?: boolean;
   dryRun?: boolean;
@@ -48,5 +49,4 @@ export interface DetectedConfig {
   hasAntigravity: boolean;
   hasChutes?: boolean;
   hasOpencodeZen: boolean;
-  hasTmux: boolean;
 }

+ 2 - 2
src/codemap.md

@@ -24,7 +24,7 @@ This directory serves as the primary entry point for the plugin's runtime behavi
 ```
 OpenCode Core → Plugin Initialization (index.ts)
   → Agent Registration (createAgents/getAgentConfigs)
-  → Tool Registration (createCouncilTool, createCancelTaskTool, etc.)
+  → Tool Registration (createCancelTaskTool, etc.)
   → MCP Registration (createBuiltinMcps)
   → Hook Registration (auto-update, phase reminders, etc.)
   → Event Subscription (session lifecycle, message updates, tool execution)
@@ -121,7 +121,7 @@ Key event flows:
 - **Tools** (`src/tools/`): Tool implementations (council, webfetch, AST operations)
 - **Hooks** (`src/hooks/`): Lifecycle hooks for auto-update, phase reminders, etc.
 - **Multiplexer** (`src/multiplexer/`): Tmux/Zellij session management for child sessions
-- **Council** (`src/council/`): Multi-LLM council orchestration
+- **Council** (`src/agents/council.ts`, `src/agents/council-agents.ts`): Multi-LLM council orchestration
 - **Companion** (`src/companion/`): Companion version management
 - **Utils** (`src/utils/`): Logger, environment checks
 

+ 1 - 4
src/config/codemap.md

@@ -88,7 +88,7 @@ Preset resolution flow:
 |--------|-----------------|-------------|
 | `src/index.ts` | `loadPluginConfig()` | Main plugin entry point loads merged config |
 | `src/agents/` | Agent configuration | Agents use config for model selection and permissions |
-| `src/council/` | Council configuration | Council agent uses CouncilConfig for multi-LLM orchestration |
+| `src/agents/council.ts` | Council agent configuration | Council agent uses CouncilConfig for multi-LLM orchestration |
 | `src/multiplexer/` | Multiplexer configuration | Uses multiplexer config for pane layout and type |
 | `src/cli/` | Config file discovery | CLI tools use config paths for user/project config lookup |
 
@@ -176,10 +176,7 @@ This allows consumers to import directly from `src/config` rather than individua
 
 ### CouncilConfig
 - `presets`: Named council presets (map of presetName → CouncillorConfig[])
-- `timeout`: Council execution timeout in ms
 - `default_preset`: Default preset name to use
-- `councillor_execution_mode`: "parallel" or "serial" execution
-- `councillor_retries`: Number of retry attempts for empty responses
 
 ### MultiplexerConfig
 - `type`: "auto", "tmux", "zellij", or "none"

+ 1 - 1
src/config/constants.ts

@@ -48,7 +48,7 @@ export const POLL_INTERVAL_BACKGROUND_MS = 2000;
 export const MAX_POLL_TIME_MS = 5 * 60 * 1000; // 5 minutes
 
 // Workflow reminders
-export const PHASE_REMINDER_TEXT = `!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!`;
+export const PHASE_REMINDER_TEXT = `!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!`;
 
 export function formatSystemReminder(text: string): string {
   return `<system-reminder>\n${text}\n</system-reminder>`;

+ 0 - 78
src/config/council-schema.test.ts

@@ -59,11 +59,7 @@ describe('CouncillorConfigSchema', () => {
     if (result.success) {
       // Deprecated fields are stripped but reported via _deprecated
       expect(result.data._deprecated).toEqual(['master']);
-      // Core fields still work normally
-      expect(result.data.timeout).toBe(180000);
       expect(Object.keys(result.data.presets.default)).toEqual(['alpha']);
-      // Legacy master.model is extracted for backward-compat fallback
-      expect(result.data._legacyMasterModel).toBe('anthropic/claude-opus-4-6');
     }
   });
 
@@ -81,7 +77,6 @@ describe('CouncillorConfigSchema', () => {
 
     if (result.success) {
       expect(result.data._deprecated).toBeUndefined();
-      expect(result.data._legacyMasterModel).toBeUndefined();
     }
   });
 });
@@ -165,44 +160,6 @@ test('deprecated master with non-standard model ID still parses', () => {
 
   if (result.success) {
     expect(result.data._deprecated).toEqual(['master']);
-    // Even non-standard model IDs are extracted as-is for backward compat
-    expect(result.data._legacyMasterModel).toBe('claude-opus-4-6');
-  }
-});
-
-test('legacyMasterModel undefined when master.model is not a string', () => {
-  const config = {
-    master: { model: 42 }, // not a string
-    presets: {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-      },
-    },
-  };
-
-  const result = CouncilConfigSchema.safeParse(config);
-  expect(result.success).toBe(true);
-
-  if (result.success) {
-    expect(result.data._legacyMasterModel).toBeUndefined();
-  }
-});
-
-test('legacyMasterModel undefined when master is not an object', () => {
-  const config = {
-    master: 'oops', // not an object
-    presets: {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-      },
-    },
-  };
-
-  const result = CouncilConfigSchema.safeParse(config);
-  expect(result.success).toBe(true);
-
-  if (result.success) {
-    expect(result.data._legacyMasterModel).toBeUndefined();
   }
 });
 
@@ -305,8 +262,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(true);
 
     if (result.success) {
-      // Check defaults are filled in
-      expect(result.data.timeout).toBe(180000);
       expect(result.data.default_preset).toBe('default');
     }
   });
@@ -325,7 +280,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(true);
 
     if (result.success) {
-      expect(result.data.timeout).toBe(180000);
       expect(result.data.default_preset).toBe('custom');
     }
   });
@@ -337,38 +291,6 @@ describe('CouncilConfigSchema', () => {
     expect(result.success).toBe(false);
   });
 
-  test('rejects invalid timeout (negative)', () => {
-    const badConfig = {
-      presets: {
-        default: {
-          alpha: { model: 'openai/gpt-5.6-luna' },
-        },
-      },
-      timeout: -1000,
-    };
-
-    const result = CouncilConfigSchema.safeParse(badConfig);
-    expect(result.success).toBe(false);
-  });
-
-  test('accepts zero timeout values (no timeout)', () => {
-    const config = {
-      presets: {
-        default: {
-          alpha: { model: 'openai/gpt-5.6-luna' },
-        },
-      },
-      timeout: 0,
-    };
-
-    const result = CouncilConfigSchema.safeParse(config);
-    expect(result.success).toBe(true);
-
-    if (result.success) {
-      expect(result.data.timeout).toBe(0);
-    }
-  });
-
   test('rejects missing presets', () => {
     const badConfig = {
       master: {

+ 3 - 73
src/config/council-schema.ts

@@ -131,19 +131,6 @@ export const CouncilPresetSchema = z
 
 export type CouncilPreset = z.infer<typeof CouncilPresetSchema>;
 
-/**
- * Execution mode for councillors.
- * - parallel: Run all councillors concurrently (default, fastest for multi-model systems)
- * - serial: Run councillors one at a time (required for single-model systems to avoid conflicts)
- */
-export const CouncillorExecutionModeSchema = z
-  .enum(['parallel', 'serial'])
-  .default('parallel')
-  .describe(
-    'Execution mode for councillors. Use "serial" for single-model systems to avoid conflicts. ' +
-      'Use "parallel" for multi-model systems for faster execution.',
-  );
-
 /**
  * Top-level council configuration.
  *
@@ -157,9 +144,7 @@ export const CouncillorExecutionModeSchema = z
  *         "beta":  { "model": "openai/gpt-5.3-codex" },
  *         "gamma": { "model": "google/gemini-3-pro" }
  *       }
- *     },
- *     "timeout": 180000,
- *     "councillor_execution_mode": "serial"
+ *     }
  *   }
  * }
  * ```
@@ -167,74 +152,19 @@ export const CouncillorExecutionModeSchema = z
 export const CouncilConfigSchema = z
   .object({
     presets: z.record(z.string(), CouncilPresetSchema),
-    timeout: z.number().min(0).default(180000),
     default_preset: z.string().default('default'),
-    councillor_execution_mode: CouncillorExecutionModeSchema.describe(
-      'Execution mode for councillors. "serial" runs them one at a time (required for single-model systems). "parallel" runs them concurrently (default, faster for multi-model systems).',
-    ),
-    councillor_retries: z
-      .number()
-      .int()
-      .min(0)
-      .max(5)
-      .default(3)
-      .describe(
-        'Number of retry attempts for councillors that return empty responses ' +
-          '(e.g. due to provider rate limiting). Default: 3 retries.',
-      ),
-    // Deprecated fields - accepted for backward compatibility but ignored.
-    // The council agent now synthesizes directly; no separate master session.
-    // Uses permissive schemas since the values are discarded - strict
-    // validation would break old configs with non-standard model IDs.
-    master: z
-      .unknown()
-      .optional()
-      .describe('DEPRECATED - ignored. Council agent synthesizes directly.'),
   })
+  .passthrough()
   .transform((data) => {
     // Detect deprecated fields and attach warning for consumers
     const deprecated: string[] = [];
-    if (data.master !== undefined) deprecated.push('master');
-
-    // Backward compat: extract master.model so the council agent can use it
-    // as a fallback when no explicit council entry exists in the active preset.
-    // See https://github.com/alvinunreal/oh-my-opencode-slim/issues/369
-    const legacyMasterModel: string | undefined =
-      typeof data.master === 'object' &&
-      data.master !== null &&
-      'model' in data.master &&
-      typeof (data.master as { model: unknown }).model === 'string'
-        ? (data.master as { model: string }).model
-        : undefined;
+    if ('master' in data) deprecated.push('master');
 
     return {
       presets: data.presets,
-      timeout: data.timeout,
       default_preset: data.default_preset,
-      councillor_execution_mode: data.councillor_execution_mode,
-      councillor_retries: data.councillor_retries,
       _deprecated: deprecated.length > 0 ? deprecated : undefined,
-      _legacyMasterModel: legacyMasterModel,
     };
   });
 
 export type CouncilConfig = z.infer<typeof CouncilConfigSchema>;
-export type CouncillorExecutionMode = z.infer<
-  typeof CouncillorExecutionModeSchema
->;
-
-/**
- * Result of a council session.
- */
-export interface CouncilResult {
-  success: boolean;
-  result?: string;
-  error?: string;
-  councillorResults: Array<{
-    name: string;
-    model: string;
-    status: 'completed' | 'failed' | 'timed_out';
-    result?: string;
-    error?: string;
-  }>;
-}

+ 88 - 69
src/config/loader.test.ts

@@ -438,6 +438,94 @@ describe('onWarning callback', () => {
     expect(config.agents?.oracle?.model).toBe('valid/model');
   });
 
+  test('deprecated tmux key calls onWarning with invalid-schema and still loads', () => {
+    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({
+        tmux: { enabled: true, layout: 'main-vertical' },
+        agents: { oracle: { model: 'valid/model' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.message).toContain('Deprecated tmux config key');
+    expect(config.agents?.oracle?.model).toBe('valid/model');
+  });
+
+  test('deprecated council.master key calls onWarning with invalid-schema and still loads', () => {
+    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({
+        council: {
+          master: { model: 'openai/gpt-5.6' },
+          presets: {
+            default: {
+              alpha: { model: 'openai/gpt-5.6-luna' },
+            },
+          },
+        },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.message).toContain(
+      'Deprecated council.master config key',
+    );
+    expect(config.council?.presets?.default?.alpha?.model).toBe(
+      'openai/gpt-5.6-luna',
+    );
+  });
+
+  test('both deprecated keys fire two warnings', () => {
+    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({
+        tmux: { enabled: true },
+        council: {
+          master: { model: 'openai/gpt-5.6' },
+          presets: {
+            default: {
+              alpha: { model: 'openai/gpt-5.6-luna' },
+            },
+          },
+        },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(2);
+    const messages = warnings.map((w) => w.message);
+    expect(messages.some((m) => m.includes('Deprecated tmux'))).toBe(true);
+    expect(messages.some((m) => m.includes('Deprecated council.master'))).toBe(
+      true,
+    );
+  });
+
   test('no options object does not break loadPluginConfig', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
@@ -513,69 +601,6 @@ describe('deepMerge behavior', () => {
     expect(config.agents?.designer?.model).toBe('project/designer-model');
   });
 
-  test('merges nested tmux configs', () => {
-    const userOpencodeDir = path.join(userConfigDir, 'opencode');
-    fs.mkdirSync(userOpencodeDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        tmux: {
-          enabled: true,
-          layout: 'main-vertical',
-          main_pane_size: 60,
-        },
-      }),
-    );
-
-    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({
-        tmux: {
-          enabled: false, // Override enabled
-          layout: 'tiled', // Override layout
-        },
-      }),
-    );
-
-    const config = loadPluginConfig(projectDir);
-
-    expect(config.tmux?.enabled).toBe(false); // From project (override)
-    expect(config.tmux?.layout).toBe('tiled'); // From project
-    expect(config.tmux?.main_pane_size).toBe(60); // From user (preserved)
-  });
-
-  test("preserves user tmux.enabled when project doesn't specify", () => {
-    const userOpencodeDir = path.join(userConfigDir, 'opencode');
-    fs.mkdirSync(userOpencodeDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({
-        tmux: {
-          enabled: true,
-          layout: 'main-vertical',
-        },
-      }),
-    );
-
-    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({
-        agents: { oracle: { model: 'test' } }, // No tmux override
-      }),
-    );
-
-    const config = loadPluginConfig(projectDir);
-
-    expect(config.tmux?.enabled).toBe(true); // Preserved from user
-    expect(config.tmux?.layout).toBe('main-vertical'); // Preserved from user
-  });
-
   test('project config overrides top-level arrays', () => {
     const userOpencodeDir = path.join(userConfigDir, 'opencode');
     fs.mkdirSync(userOpencodeDir, { recursive: true });
@@ -1240,10 +1265,6 @@ describe('JSONC config support', () => {
             "explorer": { "model": "dev-explorer", },
           },
         },
-        "tmux": {
-          "enabled": true, // Enable tmux
-          "layout": "main-vertical",
-        },
       }`,
     );
 
@@ -1251,8 +1272,6 @@ describe('JSONC config support', () => {
     expect(config.preset).toBe('dev');
     expect(config.agents?.oracle?.model).toBe('dev-oracle');
     expect(config.agents?.explorer?.model).toBe('dev-explorer');
-    expect(config.tmux?.enabled).toBe(true);
-    expect(config.tmux?.layout).toBe('main-vertical');
   });
 });
 

+ 43 - 35
src/config/loader.ts

@@ -83,6 +83,48 @@ function loadConfigFromPath(
       }
       return null;
     }
+    // Warn about deprecated tmux key
+    if (
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      'tmux' in (rawConfig as Record<string, unknown>)
+    ) {
+      const tmuxMsg =
+        'Deprecated tmux config key found and ignored. Use multiplexer config instead.';
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        message: tmuxMsg,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${tmuxMsg}`);
+      }
+    }
+
+    // Warn about deprecated council.master key
+    if (
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      typeof (rawConfig as Record<string, unknown>).council === 'object' &&
+      (rawConfig as Record<string, unknown>).council !== null &&
+      'master' in
+        ((rawConfig as Record<string, unknown>).council as Record<
+          string,
+          unknown
+        >)
+    ) {
+      const masterMsg =
+        'Deprecated council.master config key found and ignored. Configure council agents via presets instead.';
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema' as ConfigLoadWarningKind,
+        message: masterMsg,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${masterMsg}`);
+      }
+    }
+
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {
@@ -223,7 +265,6 @@ export function mergePluginConfigs(
     ...override,
     agents: deepMerge(base.agents, override.agents),
     presets: deepMerge(base.presets, override.presets),
-    tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
@@ -285,7 +326,7 @@ export function deepMerge<T extends Record<string, unknown>>(
  * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.jsonc or .json
  *
  * JSONC format is preferred over JSON (allows comments and trailing commas).
- * Project config takes precedence over user config. Nested objects (agents, tmux) are
+ * Project config takes precedence over user config. Nested objects (agents, multiplexer) are
  * deep-merged, while top-level arrays are replaced entirely by project config.
  *
  * @param directory - Project directory to search for .opencode config
@@ -310,9 +351,6 @@ export function loadPluginConfig(
     config = mergePluginConfigs(config, projectConfig);
   }
 
-  // Migrate legacy tmux config to multiplexer config for backward compatibility
-  config = migrateTmuxToMultiplexer(config);
-
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
@@ -460,33 +498,3 @@ export function loadAgentPrompt(
 
   return result;
 }
-
-/**
- * Migrate legacy tmux config to multiplexer config for backward compatibility.
- * If tmux.enabled is true and no multiplexer config is set, creates a multiplexer
- * config from the tmux settings.
- *
- * @param config - Plugin config to migrate
- * @returns Config with multiplexer settings applied
- */
-function migrateTmuxToMultiplexer(config: PluginConfig): PluginConfig {
-  // If multiplexer is already configured, use it as-is
-  if (config.multiplexer?.type && config.multiplexer.type !== 'none') {
-    return config;
-  }
-
-  // If tmux is enabled, migrate to multiplexer
-  if (config.tmux?.enabled) {
-    return {
-      ...config,
-      multiplexer: {
-        type: 'tmux',
-        layout: config.tmux.layout ?? 'main-vertical',
-        main_pane_size: config.tmux.main_pane_size ?? 60,
-        zellij_pane_mode: 'agent-tab',
-      },
-    };
-  }
-
-  return config;
-}

+ 1 - 18
src/config/schema.ts

@@ -152,10 +152,6 @@ export type MultiplexerLayout = z.infer<typeof MultiplexerLayoutSchema>;
 export const ZellijPaneModeSchema = z.enum(['agent-tab', 'current-tab']);
 export type ZellijPaneMode = z.infer<typeof ZellijPaneModeSchema>;
 
-// Legacy Tmux layout options (for backward compatibility)
-export const TmuxLayoutSchema = MultiplexerLayoutSchema;
-export type TmuxLayout = MultiplexerLayout;
-
 // Multiplexer integration configuration (new unified config)
 export const MultiplexerConfigSchema = z.object({
   type: MultiplexerTypeSchema.default('none'),
@@ -166,16 +162,6 @@ export const MultiplexerConfigSchema = z.object({
 
 export type MultiplexerConfig = z.infer<typeof MultiplexerConfigSchema>;
 
-// Legacy Tmux integration configuration (for backward compatibility)
-// When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-export const TmuxConfigSchema = z.object({
-  enabled: z.boolean().default(false),
-  layout: TmuxLayoutSchema.default('main-vertical'),
-  main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
-});
-
-export type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
-
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 
 /** Normalized model entry with optional per-model variant. */
@@ -412,11 +398,8 @@ export const PluginConfigSchema = z
       .describe(
         'Skill names to disable completely. Disabled skills are not granted to agents, even when referenced by presets or agent overrides.',
       ),
-    // Multiplexer config (new unified config - preferred)
+    // Multiplexer config
     multiplexer: MultiplexerConfigSchema.optional(),
-    // Legacy tmux config (for backward compatibility)
-    // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-    tmux: TmuxConfigSchema.optional(),
     websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
     backgroundJobs: BackgroundJobsConfigSchema.optional(),

+ 0 - 168
src/council/codemap.md

@@ -1,168 +0,0 @@
-# src/council/
-
-## Responsibility
-Orchestrates multi-LLM council sessions by spawning parallel councillor agents, collecting their results, and formatting them for synthesis by the council agent. Implements the **Council Pattern** to aggregate diverse model perspectives for higher-quality decision making and complex task resolution.
-
-## Design
-
-### Core Abstraction: CouncilManager
-- **Singleton**: One instance per plugin session manages the entire council lifecycle
-- **Strategy Pattern**: Configurable execution modes (`parallel` vs `serial`) for councillor orchestration
-- **Retry Pattern**: Automatic retry on empty responses with configurable limits
-
-### Key Components
-
-| Component | Purpose | Type |
-|-----------|---------|------|
-| `CouncilManager` | Main orchestrator class | Class |
-| `runCouncil()` | Entry point for council sessions | Method |
-| `runCouncillors()` | Parallel/serial councillor execution | Method |
-| `runAgentSession()` | Single councillor lifecycle management | Method |
-| `runCouncillorWithRetry()` | Retry logic for councillors | Method |
-
-### Configuration Schema
-- **Presets**: Named configurations mapping councillor names to their models and prompts
-- **Timeout**: Global timeout for all councillor sessions (default: 180s)
-- **Execution Mode**: Parallel (default) or serial execution of councillors
-- **Retry Policy**: Number of retries for empty responses (default: 3)
-
-### Councillor Lifecycle
-1. **Spawn**: Create child session for each councillor with advisory-only tools
-2. **Prompt**: Send formatted prompt with restricted tool access (no file edits, writes, etc.)
-3. **Timeout**: Enforce session timeout with graceful abortion
-4. **Extract**: Retrieve result from session
-5. **Cleanup**: Abort session and release resources
-
-## Flow
-
-### Session Initiation
-```
-┌─────────────────────────────────────────────────────────────┐
-│                    CouncilManager                       │
-│  (parentSessionId, prompt, presetName)                 │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                    runCouncil()                       │
-│  - Resolve preset (default or named)                  │
-│  - Validate councillor configuration                   │
-│  - Notify parent session (immediate feedback)           │
-│  - Launch councillors (parallel/serial)                 │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                   runCouncillors()                     │
-│  - For each councillor config:                         │
-│    - Spawn child session (session.create)               │
- │    - Send prompt with restricted tools                 │
-│    - Extract result (extractSessionResult)              │
-│    - Cleanup session (session.abort)                  │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼
-┌─────────────────────────────────────────────────────────────┐
-│                 runAgentSession()                      │
-│  - Create session with parentID                        │
- │  - Send prompt (promptWithTimeout)                     │
-│  - Extract result with reasoning disabled               │
-│  - Abort session on completion/cleanup                 │
-└─────────────────────────────────────────────────────────────┘
-```
-
-### Parallel Execution (Default)
-- All councillors launched concurrently with staggered starts (250ms intervals)
-- Results collected via `Promise.allSettled()`
-- Timeout applies to entire council session, not individual councillors
-
-### Serial Execution (Configurable)
-- Councillors executed sequentially in defined order
-- Each councillor inherits parent session timeout
-- Useful for ordered deliberation or resource-constrained environments
-
-### Error Handling & Retries
-1. **Empty responses**: Retry up to `maxRetries` times (provider rate-limiting)
-2. **Timeouts**: Immediate failure, no retry
-3. **Session failures**: Mark as failed, continue with other councillors
-
-## Integration
-
-### Dependencies
-- **Config**: `PluginConfig` from `../config` (council presets, timeouts)
-- **Agents**: `formatCouncillorPrompt()`, `formatCouncillorResults()` from `../agents/council`
-- **Session**: `extractSessionResult()`, `promptWithTimeout()` from `../utils/session`
-- **Logger**: `log()` from `../utils/logger`
-- **Client**: `OpencodeClient` from `@opencode-ai/plugin` (session management)
-
-### Consumers
-- **Main Plugin**: `src/index.ts` - orchestrates council sessions for complex tasks
-- **Council Agent**: Receives formatted results via `formatCouncillorResults()` for synthesis
-- **Skills**: Can invoke council sessions for multi-model consensus on decisions
-
-### Configuration Example (from `../config/plugin-config.ts`)
-```typescript
-council: {
-  default_preset: 'default',
-  timeout: 180000, // 3 minutes
-  councillor_execution_mode: 'parallel',
-  councillor_retries: 3,
-  presets: {
-    default: {
-      architect: { model: 'gpt-4', prompt: 'Think like a software architect' },
-      critic: { model: 'claude-3', prompt: 'Critique the architect\'s plan' },
-      implementer: { model: 'gpt-4', prompt: 'Implement the solution' },
-    },
-  },
-}
-```
-
-### Environment Variables & Fallbacks
-- **Directory**: Inherited from plugin context (`ctx.directory`)
-- **TMUX Enabled**: Controls pane staggering and spawn delays
-- **Fallback**: `retry_on_empty` controls whether to retry empty responses
-
-## Key Behaviors
-
-### Tool Restrictions for Councillors
-Councillors operate with **advisory-only** tool access:
-- ❌ `task` - Cannot spawn new subagents
-- ❌ `question` - Cannot ask user questions
-- ❌ `edit`, `write`, `apply_patch` - Cannot modify files
-- ❌ `ast_grep_replace`, `bash` - Cannot execute commands
-- ✅ `read` - Can read files for analysis
-
-This ensures councillors provide guidance without side effects.
-
-### Notifications
-- Sends immediate feedback to parent session on council start
-- Message format: `⎔ Council starting - ${count} councillors launching - ctrl+x ↓ to watch`
-
-## Performance Considerations
-
-- **Parallel execution**: Optimal for most cases, maximizes throughput
-- **Staggered starts**: Reduces tmux pane creation contention (250ms intervals)
-- **Timeout alignment**: Single timeout for entire council avoids cascading delays
-- **Resource cleanup**: Guaranteed session abortion in `finally` block prevents leaks
-
-## Error Scenarios & Recovery
-
-| Scenario | Behavior | Recovery |
-|----------|----------|----------|
-| No council config | Return error immediately | User must configure council in plugin config |
-| Invalid preset | Return error with available presets | User selects valid preset or uses default |
-| Empty preset | Return error about no councillors | User adds councillors to preset |
-| All councillors fail | Return error with all failures | Investigate model availability or prompts |
-| Timeout | Mark timed_out status | Increase timeout or reduce council size |
-| Provider rate-limiting | Retry up to maxRetries | Automatic recovery |
-
-## Testing Points
-
-- Preset resolution (default vs named)
-- Parallel vs serial execution modes
-- Retry logic for empty responses
-- Tool restrictions enforcement
-- Session lifecycle (create → prompt → extract → abort)
-- Timeout behavior
-- Error propagation and formatting
-- Councillor result formatting for synthesis

+ 0 - 959
src/council/council-manager.test.ts

@@ -1,959 +0,0 @@
-import { describe, expect, mock, test } from 'bun:test';
-import type { PluginConfig } from '../config';
-import { CouncilConfigSchema } from '../config/council-schema';
-import { CouncilManager } from './council-manager';
-
-function createMockContext(overrides?: {
-  sessionCreateResult?:
-    | (() => { data?: { id?: string } })
-    | {
-        data?: { id?: string };
-      };
-  sessionMessagesResult?: {
-    data?: Array<{
-      info?: { role: string };
-      parts?: Array<{ type: string; text?: string }>;
-    }>;
-  };
-  promptImpl?: (args: unknown) => Promise<unknown>;
-}) {
-  let callCount = 0;
-  return {
-    client: {
-      session: {
-        create: mock(async () => {
-          callCount++;
-          const overrideResult = overrides?.sessionCreateResult;
-          if (typeof overrideResult === 'function') {
-            return overrideResult();
-          }
-          return (
-            overrideResult ?? {
-              data: { id: `test-session-${callCount}` },
-            }
-          );
-        }),
-        messages: mock(
-          async () => overrides?.sessionMessagesResult ?? { data: [] },
-        ),
-        prompt: mock(async (args: unknown) => {
-          if (overrides?.promptImpl) {
-            return await overrides.promptImpl(args);
-          }
-          return {};
-        }),
-        abort: mock(async () => ({})),
-      },
-    },
-    directory: '/tmp/test',
-  } as any;
-}
-
-function createTestCouncilConfig(overrides?: {
-  presets?: Record<string, Record<string, { model: string; variant?: string }>>;
-  default_preset?: string;
-  timeout?: number;
-}): PluginConfig {
-  const councilConfig = CouncilConfigSchema.parse({
-    presets: overrides?.presets ?? {
-      default: {
-        alpha: { model: 'openai/gpt-5.6-luna' },
-        beta: { model: 'openai/gpt-5.3-codex' },
-      },
-    },
-    default_preset: overrides?.default_preset,
-    timeout: overrides?.timeout,
-  });
-
-  return { council: councilConfig } as any;
-}
-
-describe('CouncilManager', () => {
-  describe('constructor', () => {
-    test('creates manager without config', () => {
-      const ctx = createMockContext();
-      const manager = new CouncilManager(ctx, undefined);
-      expect(manager).toBeDefined();
-    });
-
-    test('creates manager with plugin config', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Councillor response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.result).toBeDefined();
-      expect(result.councillorResults).toHaveLength(2);
-
-      // Check all councillors completed
-      expect(
-        result.councillorResults.every((r) => r.status === 'completed'),
-      ).toBe(true);
-    });
-
-    test('returns error when all councillors fail', async () => {
-      const ctx = createMockContext({
-        sessionCreateResult: () => ({ data: {} }), // Missing ID triggers failure
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(2);
-      expect(result.councillorResults.every((r) => r.status === 'failed')).toBe(
-        true,
-      );
-    });
-
-    test('uses default_preset when presetName is undefined', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Councillor response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          custom: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-        default_preset: 'custom',
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].name).toBe('beta');
-    });
-
-    test('handles mixed councillor success/failure', async () => {
-      let createCallCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          createCallCount++;
-          // First councillor succeeds, second fails
-          if (createCallCount === 1) {
-            return { data: { id: 'councillor-success' } };
-          }
-          if (createCallCount === 2) {
-            return { data: {} }; // Missing ID = failure
-          }
-          return { data: { id: `session-${createCallCount}` } };
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Successful response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              councillor1: { model: 'openai/gpt-5.6-luna' },
-              councillor2: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(2);
-
-      // Check that one completed and one failed (order not guaranteed)
-      const completedCount = result.councillorResults.filter(
-        (r) => r.status === 'completed',
-      ).length;
-      const failedCount = result.councillorResults.filter(
-        (r) => r.status === 'failed',
-      ).length;
-
-      expect(completedCount).toBe(1);
-      expect(failedCount).toBe(1);
-    });
-
-    test('uses custom timeouts from config', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-            custom: {
-              beta: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-          default_preset: 'custom',
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-    });
-
-    test('handles councillor timeout', async () => {
-      let sessionCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          sessionCount++;
-          return { data: { id: `session-${sessionCount}` } };
-        },
-        promptImpl: async (args: any) => {
-          // First councillor times out, second succeeds
-          const sessionId = args.path?.id;
-          if (sessionId === 'session-1') {
-            // Simulate timeout
-            throw new Error('Prompt timed out after 180000ms');
-          }
-          return {};
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Success' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              timeout: { model: 'openai/gpt-5.6-luna' },
-              success: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(2);
-
-      const timeoutResult = result.councillorResults.find(
-        (r) => r.name === 'timeout',
-      );
-      const successResult = result.councillorResults.find(
-        (r) => r.name === 'success',
-      );
-
-      expect(timeoutResult?.status).toBe('timed_out');
-      expect(timeoutResult?.error).toContain('timed out');
-      expect(successResult?.status).toBe('completed');
-    });
-
-    test('passes variant to councillor sessions', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna', variant: 'low' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { variant?: string; agent?: string } }]
-      >;
-      // Find the councillor call by agent field (notification may be at [0])
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-      expect(councillorCall?.[0].body?.variant).toBe('low');
-    });
-
-    test('always aborts councillor sessions after completion', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-              beta: { model: 'openai/gpt-5.3-codex' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-session-id');
-
-      // Should abort 2 councillors
-      expect(ctx.client.session.abort).toHaveBeenCalledTimes(2);
-    });
-
-    test('handles councillor with invalid model format', async () => {
-      const ctx = createMockContext();
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              badmodel: { model: 'invalid-model-no-slash' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('failed');
-      expect(result.councillorResults[0].error).toContain(
-        'Invalid model format',
-      );
-    });
-
-    test('extracts text and reasoning content from councillor responses', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [
-                { type: 'reasoning', text: 'I am thinking...' },
-                { type: 'text', text: 'Final answer.' },
-              ],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-session-id',
-      );
-
-      expect(result.success).toBe(true);
-      // Councillors filter out reasoning parts to avoid bloating the synthesis
-      expect(result.councillorResults[0].result).not.toContain(
-        'I am thinking...',
-      );
-      expect(result.councillorResults[0].result).toContain('Final answer.');
-    });
-
-    test('handles concurrent council sessions with different presets', async () => {
-      const ctx = createMockContext({
-        sessionCreateResult: () => ({ data: { id: 'session-1' } }),
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const defaultConfig = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          fast: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-      });
-      const manager1 = new CouncilManager(ctx, defaultConfig, undefined);
-      const manager2 = new CouncilManager(ctx, defaultConfig, undefined);
-
-      const [result1, result2] = await Promise.all([
-        manager1.runCouncil('test prompt 1', 'default', 'parent-1'),
-        manager2.runCouncil('test prompt 2', 'fast', 'parent-2'),
-      ]);
-
-      expect(result1.success).toBe(true);
-      expect(result2.success).toBe(true);
-      expect(result1.councillorResults[0].name).toBe('alpha');
-      expect(result2.councillorResults[0].name).toBe('beta');
-    });
-
-    test('handles empty preset gracefully', async () => {
-      const ctx = createMockContext();
-      const config = createTestCouncilConfig({
-        presets: {
-          empty: {},
-        },
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        'empty',
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toContain(
-        'Preset "empty" has no councillors configured',
-      );
-      expect(result.councillorResults).toHaveLength(0);
-    });
-
-    test('returns available presets when invalid preset name given', async () => {
-      const ctx = createMockContext();
-      const config = createTestCouncilConfig({
-        presets: {
-          default: {
-            alpha: { model: 'openai/gpt-5.6-luna' },
-          },
-          roled: {
-            beta: { model: 'openai/gpt-5.3-codex' },
-          },
-        },
-      });
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        'architect',
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toContain('Preset "architect" does not exist');
-      expect(result.error).toContain('Omit the preset parameter');
-      expect(result.error).toContain('default, roled');
-      expect(result.councillorResults).toHaveLength(0);
-    });
-
-    test('passes agent field in councillor prompt body', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { agent?: string } }]
-      >;
-      // Find councillor call by agent (notification may interleave)
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-    });
-
-    test('disables mutating and delegation tools in councillor prompt body', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config = createTestCouncilConfig();
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [{ body?: { agent?: string; tools?: Record<string, boolean> } }]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall?.[0].body?.tools).toEqual({
-        task: false,
-        question: false,
-        edit: false,
-        write: false,
-        apply_patch: false,
-        ast_grep_replace: false,
-        bash: false,
-      });
-    });
-
-    test('creates session with model label in title', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const createCalls = ctx.client.session.create.mock.calls as Array<
-        [{ body?: { title?: string } }]
-      >;
-      // Councillor title: "Council alpha (gpt-5.6-luna)"
-      expect(createCalls[0][0].body?.title).toBe(
-        'Council alpha (gpt-5.6-luna)',
-      );
-    });
-
-    test('passes councillor prompt to councillor session', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response with role guidance' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: {
-                model: 'openai/gpt-5.6-luna',
-                prompt: 'You are a meticulous reviewer focused on edge cases.',
-              },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      await manager.runCouncil('test prompt', undefined, 'parent-id');
-
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [
-          {
-            body?: {
-              parts?: Array<{ type: string; text?: string }>;
-              agent?: string;
-            };
-          },
-        ]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      expect(councillorCall).toBeDefined();
-      const promptText = councillorCall?.[0]?.body?.parts?.[0]?.text;
-      expect(promptText).toContain('test prompt');
-      expect(promptText).toContain(
-        'You are a meticulous reviewer focused on edge cases.',
-      );
-    });
-
-    test('works without any prompt overrides (backward compatible)', async () => {
-      const ctx = createMockContext({
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Response' }],
-            },
-          ],
-        },
-      });
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      // Verify no prompt contamination - councillor gets raw prompt
-      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
-        [
-          {
-            body?: {
-              parts?: Array<{ type: string; text?: string }>;
-              agent?: string;
-            };
-          },
-        ]
-      >;
-      const councillorCall = promptCalls.find(
-        (c) => c[0].body?.agent === 'councillor',
-      );
-      // Without prompt override, councillor gets just the raw user prompt
-      expect(councillorCall?.[0]?.body?.parts?.[0]?.text).toBe('test prompt');
-    });
-
-    test('retries councillor on empty response', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      // Track messages call count and return empty first, then success
-      let councillorMessagesCallCount = 0;
-      const originalMessages = ctx.client.session.messages;
-      ctx.client.session.messages = mock(async (args) => {
-        // First call (first councillor attempt): empty response
-        // Second call (councillor retry): success
-        councillorMessagesCallCount++;
-        if (councillorMessagesCallCount === 1) {
-          return {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: '' }],
-              },
-            ],
-          };
-        }
-        if (councillorMessagesCallCount === 2) {
-          return {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Success' }],
-              },
-            ],
-          };
-        }
-        // Any other calls: use original
-        return originalMessages(args);
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      // First two messages calls are for councillor (empty + success)
-      expect(councillorMessagesCallCount).toBeGreaterThanOrEqual(2);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('Success');
-    });
-
-    test('does not retry councillor on non-empty failure (timeout)', async () => {
-      let messagesCallCount = 0;
-      const ctx = createMockContext({
-        promptImpl: async () => {
-          // Simulate timeout error
-          throw new Error('Prompt timed out after 180000ms');
-        },
-      });
-
-      // Override messages to track calls (won't be reached due to timeout)
-      ctx.client.session.messages = mock(async () => {
-        messagesCallCount++;
-        return {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Success' }],
-            },
-          ],
-        };
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 2,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      // No retry on timeout - messages should not be called
-      expect(messagesCallCount).toBe(0);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('timed_out');
-      expect(result.councillorResults[0].error).toContain('timed out');
-    });
-
-    test('falls back to next model in councillor chain on failure', async () => {
-      let sessionCount = 0;
-      const ctx = createMockContext({
-        sessionCreateResult: () => {
-          sessionCount++;
-          return { data: { id: `session-${sessionCount}` } };
-        },
-        promptImpl: async (args: any) => {
-          // First model (session-1) fails; second model (session-2) succeeds.
-          if (args.path?.id === 'session-1') {
-            throw new Error('Prompt timed out after 180000ms');
-          }
-          return {};
-        },
-        sessionMessagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Fallback success' }],
-            },
-          ],
-        },
-      });
-
-      const config: PluginConfig = {
-        council: {
-          presets: {
-            default: {
-              alpha: {
-                model: ['openai/gpt-5.6-luna', 'openai/gpt-5.3-codex'],
-              },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(true);
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('Fallback success');
-      // Reported model reflects the fallback that actually responded.
-      expect(result.councillorResults[0].model).toBe('openai/gpt-5.3-codex');
-    });
-
-    test('exhausts councillor retries and returns failure', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('All councillors failed or timed out');
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('failed');
-      expect(result.councillorResults[0].error).toContain(
-        'Empty response from provider',
-      );
-    });
-
-    test('returns empty councillor result when retry_on_empty is false', async () => {
-      const ctx = createMockContext({
-        promptImpl: async () => ({}),
-      });
-
-      // Always return empty response
-      ctx.client.session.messages = mock(async () => ({
-        data: [
-          {
-            info: { role: 'assistant' },
-            parts: [{ type: 'text', text: '' }],
-          },
-        ],
-      }));
-
-      const config: PluginConfig = {
-        council: {
-          councillor_retries: 1,
-          presets: {
-            default: {
-              alpha: { model: 'openai/gpt-5.6-luna' },
-            },
-          },
-        },
-        fallback: {
-          retry_on_empty: false,
-        },
-      } as any;
-      const manager = new CouncilManager(ctx, config, undefined);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'parent-id',
-      );
-
-      // With retry_on_empty: false, empty response is accepted as completed
-      expect(result.councillorResults).toHaveLength(1);
-      expect(result.councillorResults[0].status).toBe('completed');
-      expect(result.councillorResults[0].result).toBe('');
-      // Council succeeds because empty is accepted as valid response
-      // The formatted result contains the message about all councillors failing
-      expect(result.success).toBe(true);
-      expect(result.result).toContain(
-        'All councillors failed to produce output',
-      );
-      expect(result.result).toContain('test prompt');
-    });
-  });
-});

+ 0 - 457
src/council/council-manager.ts

@@ -1,457 +0,0 @@
-/**
- * Council Manager
- *
- * Orchestrates multi-LLM council sessions: launches councillors in
- * parallel and collects their results for the council agent to synthesize.
- */
-
-import type { PluginInput } from '@opencode-ai/plugin';
-import {
-  formatCouncillorPrompt,
-  formatCouncillorResults,
-} from '../agents/council';
-import type { PluginConfig } from '../config';
-import {
-  COUNCILLOR_STAGGER_MS,
-  TMUX_SPAWN_DELAY_MS,
-} from '../config/constants';
-import type { CouncillorConfig, CouncilResult } from '../config/council-schema';
-import { normalizeCouncillorModels } from '../utils/councillor-models';
-import { log } from '../utils/logger';
-import {
-  extractSessionResult,
-  type PromptBody,
-  parseModelReference,
-  promptWithTimeout,
-  shortModelLabel,
-} from '../utils/session';
-
-type OpencodeClient = PluginInput['client'];
-
-// ---------------------------------------------------------------------------
-// CouncilManager
-// ---------------------------------------------------------------------------
-
-export class CouncilManager {
-  private client: OpencodeClient;
-  private directory: string;
-  private config?: PluginConfig;
-  private tmuxEnabled: boolean;
-  private deprecatedFields?: string[];
-  private legacyMasterModel?: string;
-
-  constructor(ctx: PluginInput, config?: PluginConfig, tmuxEnabled = false) {
-    this.client = ctx.client;
-    this.directory = ctx.directory;
-    this.config = config;
-    this.deprecatedFields = config?.council?._deprecated;
-    this.legacyMasterModel = config?.council?._legacyMasterModel;
-    this.tmuxEnabled = tmuxEnabled;
-  }
-
-  /** Return deprecated config fields detected during parsing (for tool warnings). */
-  getDeprecatedFields(): string[] | undefined {
-    return this.deprecatedFields;
-  }
-
-  /** Return the legacy master.model if it was used as fallback. */
-  getLegacyMasterModel(): string | undefined {
-    return this.legacyMasterModel;
-  }
-
-  /**
-   * Run a full council session.
-   *
-   * 1. Look up the preset
-   * 2. Launch all councillors in parallel
-   * 3. Collect results (respecting timeout)
-   * 4. Return formatted councillor results for synthesis
-   */
-  async runCouncil(
-    prompt: string,
-    presetName: string | undefined,
-    parentSessionId: string,
-  ): Promise<CouncilResult> {
-    const councilConfig = this.config?.council;
-    if (!councilConfig) {
-      log('[council-manager] Council configuration not found');
-      return {
-        success: false,
-        error: 'Council not configured',
-        councillorResults: [],
-      };
-    }
-
-    const resolvedPreset =
-      presetName ?? councilConfig.default_preset ?? 'default';
-    const preset = councilConfig.presets[resolvedPreset];
-
-    if (!preset) {
-      const available = Object.keys(councilConfig.presets).join(', ');
-      log(`[council-manager] Preset "${resolvedPreset}" not found`);
-      return {
-        success: false,
-        error: `Preset "${resolvedPreset}" does not exist. Omit the preset parameter to use the default, or call again with one of: ${available}`,
-        councillorResults: [],
-      };
-    }
-
-    if (Object.keys(preset).length === 0) {
-      log(`[council-manager] Preset "${resolvedPreset}" has no councillors`);
-      return {
-        success: false,
-        error: `Preset "${resolvedPreset}" has no councillors configured. Note: the reserved key "master" is ignored - use councillor names as keys`,
-        councillorResults: [],
-      };
-    }
-
-    const timeout = councilConfig.timeout ?? 180000;
-    const executionMode = councilConfig.councillor_execution_mode ?? 'parallel';
-    const maxRetries = councilConfig.councillor_retries ?? 3;
-
-    const councillorCount = Object.keys(preset).length;
-
-    log(`[council-manager] Starting council with preset "${resolvedPreset}"`, {
-      councillors: Object.keys(preset),
-    });
-
-    // Notify parent session that council is starting
-    this.sendStartNotification(parentSessionId, councillorCount).catch(
-      (err) => {
-        log('[council-manager] Failed to send start notification', {
-          error: err instanceof Error ? err.message : String(err),
-        });
-      },
-    );
-
-    // Run councillors (parallel or serial based on config)
-    const councillorResults = await this.runCouncillors(
-      prompt,
-      preset,
-      parentSessionId,
-      timeout,
-      executionMode,
-      maxRetries,
-    );
-
-    const completedCount = councillorResults.filter(
-      (r) => r.status === 'completed',
-    ).length;
-
-    log(
-      `[council-manager] Councillors completed: ${completedCount}/${councillorResults.length}`,
-    );
-
-    if (completedCount === 0) {
-      return {
-        success: false,
-        error: 'All councillors failed or timed out',
-        councillorResults,
-      };
-    }
-
-    // Format councillor results for the council agent to synthesize
-    const formattedCouncillorResults = formatCouncillorResults(
-      prompt,
-      councillorResults,
-    );
-
-    log('[council-manager] Council completed successfully');
-
-    return {
-      success: true,
-      result: formattedCouncillorResults,
-      councillorResults,
-    };
-  }
-
-  // -------------------------------------------------------------------------
-  // Parent session notification
-  // -------------------------------------------------------------------------
-
-  /**
-   * Inject a start notification into the parent session so the user
-   * sees immediate feedback while councillors are spinning up.
-   */
-  private async sendStartNotification(
-    parentSessionId: string,
-    councillorCount: number,
-  ): Promise<void> {
-    const message = [
-      `⎔ Council starting - ${councillorCount} councillors launching - ctrl+x ↓ to watch`,
-      '',
-      '[system status: continue without acknowledging this notification]',
-    ].join('\n');
-    await this.client.session.prompt({
-      path: { id: parentSessionId },
-      body: {
-        noReply: true,
-        parts: [{ type: 'text', text: message }],
-      },
-    });
-  }
-
-  // -------------------------------------------------------------------------
-  // Shared session lifecycle
-  // -------------------------------------------------------------------------
-
-  /**
-   * Run a single agent session: create → register → prompt → extract → cleanup.
-   */
-  private async runAgentSession(options: {
-    parentSessionId: string;
-    title: string;
-    agent: string;
-    model: string;
-    promptText: string;
-    variant?: string;
-    timeout: number;
-    includeReasoning?: boolean;
-  }): Promise<string> {
-    const modelRef = parseModelReference(options.model);
-    if (!modelRef) {
-      throw new Error(`Invalid model format: ${options.model}`);
-    }
-
-    let sessionId: string | undefined;
-
-    try {
-      const session = await this.client.session.create({
-        body: {
-          parentID: options.parentSessionId,
-          title: options.title,
-        },
-        query: { directory: this.directory },
-      });
-
-      if (!session.data?.id) {
-        throw new Error('Failed to create session');
-      }
-
-      sessionId = session.data.id;
-
-      if (this.tmuxEnabled) {
-        await new Promise((r) => setTimeout(r, TMUX_SPAWN_DELAY_MS));
-      }
-
-      // Councillors are advisory only: disable delegation, questions, and known mutating
-      // tools even if host defaults would otherwise expose them.
-      const body: PromptBody = {
-        agent: options.agent,
-        model: modelRef,
-        tools: {
-          task: false,
-          question: false,
-          edit: false,
-          write: false,
-          apply_patch: false,
-          ast_grep_replace: false,
-          bash: false,
-        },
-        parts: [{ type: 'text', text: options.promptText }],
-      };
-
-      if (options.variant) {
-        body.variant = options.variant;
-      }
-
-      await promptWithTimeout(
-        this.client,
-        {
-          path: { id: sessionId },
-          body,
-          query: { directory: this.directory },
-        },
-        options.timeout,
-      );
-
-      const extraction = await extractSessionResult(this.client, sessionId, {
-        includeReasoning: options.includeReasoning,
-      });
-
-      if (extraction.empty) {
-        const retryOnEmpty = this.config?.fallback?.retry_on_empty ?? true;
-        if (retryOnEmpty) {
-          throw new Error('Empty response from provider');
-        }
-      }
-
-      return extraction.text;
-    } finally {
-      if (sessionId) {
-        this.client.session.abort({ path: { id: sessionId } }).catch(() => {});
-      }
-    }
-  }
-
-  // -------------------------------------------------------------------------
-  // Phase 1: Councillors
-  // -------------------------------------------------------------------------
-
-  private async runCouncillors(
-    prompt: string,
-    councillors: Record<string, CouncillorConfig>,
-    parentSessionId: string,
-    timeout: number,
-    executionMode: 'parallel' | 'serial' = 'parallel',
-    maxRetries: number,
-  ): Promise<CouncilResult['councillorResults']> {
-    const entries = Object.entries(councillors);
-    const results: Array<{
-      name: string;
-      model: string;
-      status: 'completed' | 'failed' | 'timed_out';
-      result?: string;
-      error?: string;
-    }> = [];
-
-    if (executionMode === 'serial') {
-      // Serial execution: run each councillor one at a time
-      for (const [name, config] of entries) {
-        results.push(
-          await this.runCouncillorWithRetry(
-            name,
-            config,
-            prompt,
-            parentSessionId,
-            timeout,
-            maxRetries,
-          ),
-        );
-      }
-    } else {
-      // Parallel execution (default): run all councillors concurrently
-      const promises = entries.map(([name, config], index) =>
-        (async () => {
-          // Stagger launches only when multiplexer panes can be created.
-          // Outside tmux/zellij this delay only adds latency with no benefit.
-          if (this.tmuxEnabled && index > 0) {
-            await new Promise((r) =>
-              setTimeout(r, index * COUNCILLOR_STAGGER_MS),
-            );
-          }
-
-          return this.runCouncillorWithRetry(
-            name,
-            config,
-            prompt,
-            parentSessionId,
-            timeout,
-            maxRetries,
-          );
-        })(),
-      );
-
-      const settled = await Promise.allSettled(promises);
-
-      for (let index = 0; index < settled.length; index++) {
-        const result = settled[index];
-        const [name, cfg] = entries[index];
-
-        if (result.status === 'fulfilled') {
-          results.push(result.value);
-        } else {
-          results.push({
-            name,
-            model: cfg.model,
-            status: 'failed' as const,
-            error:
-              result.reason instanceof Error
-                ? result.reason.message
-                : String(result.reason),
-          });
-        }
-      }
-    }
-
-    return results;
-  }
-
-  /**
-   * Run a single councillor across its configured model chain.
-   *
-   * For each model in the chain, empty responses are retried up to
-   * `maxRetries` times (providers that silently rate-limit). Any other
-   * failure or timeout advances to the next model in the chain. The
-   * councillor only fails once every model has been exhausted; the reported
-   * `model` and `error` reflect the last model tried.
-   */
-  private async runCouncillorWithRetry(
-    name: string,
-    config: CouncillorConfig,
-    prompt: string,
-    parentSessionId: string,
-    timeout: number,
-    maxRetries: number,
-  ): Promise<{
-    name: string;
-    model: string;
-    status: 'completed' | 'failed' | 'timed_out';
-    result?: string;
-    error?: string;
-  }> {
-    // Prefer the normalized chain from the schema transform. When configs are
-    // built without the transform (e.g. tests), derive it from the raw model.
-    const models =
-      config.models ?? normalizeCouncillorModels(config.model, config.variant);
-    const totalAttempts = 1 + maxRetries;
-
-    let lastModel = models[0].id;
-    let lastStatus: 'failed' | 'timed_out' = 'failed';
-    let lastError = `Councillor "${name}": no model responded`;
-
-    for (let modelIndex = 0; modelIndex < models.length; modelIndex++) {
-      const entry = models[modelIndex];
-      const modelLabel = shortModelLabel(entry.id);
-      lastModel = entry.id;
-
-      for (let attempt = 1; attempt <= totalAttempts; attempt++) {
-        if (attempt > 1) {
-          log(
-            `[council-manager] Retrying councillor "${name}" (${modelLabel}), attempt ${attempt}/${totalAttempts}`,
-          );
-        } else if (modelIndex > 0) {
-          log(
-            `[council-manager] Councillor "${name}" falling back to ${modelLabel} (model ${modelIndex + 1}/${models.length})`,
-          );
-        }
-
-        try {
-          const result = await this.runAgentSession({
-            parentSessionId,
-            title: `Council ${name} (${modelLabel})`,
-            agent: 'councillor',
-            model: entry.id,
-            promptText: formatCouncillorPrompt(prompt, config.prompt),
-            variant: entry.variant,
-            timeout,
-            includeReasoning: false,
-          });
-
-          return {
-            name,
-            model: entry.id,
-            status: 'completed' as const,
-            result,
-          };
-        } catch (error) {
-          const msg = error instanceof Error ? error.message : String(error);
-          lastStatus = msg.includes('timed out') ? 'timed_out' : 'failed';
-          lastError = `Councillor "${name}": ${msg}`;
-
-          // Retry the same model only on empty responses (silent rate-limit);
-          // any other error moves on to the next model in the chain.
-          const isEmptyResponse = msg.includes('Empty response from provider');
-          if (!(attempt < totalAttempts && isEmptyResponse)) break;
-        }
-      }
-    }
-
-    return {
-      name,
-      model: lastModel,
-      status: lastStatus,
-      error: lastError,
-    };
-  }
-}

+ 0 - 1
src/council/index.ts

@@ -1 +0,0 @@
-export { CouncilManager } from './council-manager';

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

@@ -0,0 +1,487 @@
+// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
+
+exports[`cache-impact snapshots (update deliberately — see file header) phase reminder text 1`] = `
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!
+</system-reminder>"
+`;
+
+exports[`cache-impact snapshots (update deliberately — see file header) orchestrator system prompt 1`] = `
+"<Role>
+You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
+
+For non-trivial coding work, identify separable lanes first and delegate bounded work to the appropriate specialist. Do not perform multi-step implementation serially when a suitable specialist is available.
+
+Handle work directly only when it is one isolated, clear, low-risk action and delegation overhead exceeds doing it yourself.
+
+Optimize for quality, speed, cost, and reliability by dispatching the right specialist lanes, tracking background task state, and integrating terminal results into one coherent outcome.
+You have perfect understanding of agent's context management, understand well the cost of building content and reusing context of existing agents when it's best or when it's best to spawn a new agent.
+</Role>
+
+<Agents>
+
+@explorer
+- Lane: Fast codebase recon that returns compressed context
+- Permissions: read_files
+- Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
+- Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
+- **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
+- **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file
+
+@librarian
+- Lane: External knowledge and library research, fast web research
+- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
+- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
+- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
+- **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. How does others solve or workaround this tricky issue?" → @librarian.
+
+@oracle
+- Lane: Architecture, risk, debugging strategy, and review
+- Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
+- Permissions: read_files
+- Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
+- Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
+- **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • Code needs simplification or YAGNI scrutiny
+- **Review use:** Oracle is an escalation, not a default verification step. Request independent Oracle review only when its analysis is expected to materially reduce risk or uncertainty.
+- **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.
+
+@designer
+- Lane: UI/UX design, related edits, design polish and review
+- Permissions: read_files, write_files
+- Stats: 10x better UI/UX than orchestrator
+- Capabilities: Good design taste, visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
+- Owns visual and interaction quality: layout, hierarchy, spacing, motion, affordances, responsive behavior, and overall feel.
+- Weakness: copywriting. Ask designer to use grounded, normal wording, then have orchestrator review/fix copy after design work without changing visual or interaction intent.
+- Avoid: "Let me us designer how it should look and implement yourself" → instead: "Let me ask designer to design and implement the UI/UX changes for me"
+- **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
+- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.
+- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.
+
+@fixer
+- Lane: Bounded implementation and executioner
+- Role: Fast execution specialist for well-defined tasks
+- Permissions: read_files, write_files
+- Stats: 2x faster code edits, 1/2 cost of orchestrator
+- Weakness: design, taste
+- Tools/Constraints: Execution-focused-no research, no architectural decisions
+- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
+- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
+- **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.
+
+@council
+- Lane: High-stakes multi-model decision support
+- Role: Multi-LLM consensus engine that receives raw councillor responses and synthesizes them into a structured council report.
+- Permissions: Read files
+- Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
+- Capabilities: Synthesizes responses from independently-dispatched councillors, compares their answers, resolves disagreements, and produces a final synthesized answer plus councillor details and consensus summary.
+- **Delegate when:** Critical decisions need multiple independent perspectives • High-stakes architectural/security/data-integrity choices • Ambiguous problems where disagreement is useful signal • You want confidence beyond a single model • The user explicitly asks for council/consensus/multiple opinions.
+- **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
+- **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
+- **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Per-Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.
+
+@observer
+- Lane: Visual/media analysis isolated from orchestrator context
+- Role: Visual analysis specialist for images, PDFs, and diagrams
+- Permissions: Read files
+- Stats: Saves main context tokens - Observer processes raw files, returns structured observations
+- Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
+- **Delegate when:** Need to analyze a multimedia file• Extract information
+- **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer - it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
+- **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png - describe the UI elements and error messages."
+
+</Agents>
+
+<Workflow>
+
+## 1. Understand
+Parse request: explicit requirements + implicit needs.
+
+## 2. Path Selection
+Evaluate approach by: quality, speed and cost.
+Choose the path that optimizes all four.
+
+## 3. Delegation Check
+Review available agents and lane rules. Before beginning non-trivial work, identify which parts can proceed independently.
+
+**Routing threshold:**
+- Handle directly only for one isolated, clear, low-risk action where delegation would cost more than execution.
+- Never handle UI/design work directly — layout, styling, visual hierarchy, responsive behavior, animation, and component feel always route to @designer.
+- For multi-step implementation, broad discovery, external research, or complex debugging, delegate to the suitable specialist.
+- If two or more parts can proceed independently, dispatch them in parallel before starting dependent work.
+- Do not delegate merely because an agent exists. Do not keep substantive work entirely in the orchestrator merely because each individual step seems easy.
+
+**Dispatch efficiency:**
+- Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
+- Brief user on delegation goal before each call
+- Record task IDs, state, and advisory ownership/dependency labels
+- Do not immediately wait after spawning independent background tasks unless the next step truly depends on their result
+- Reconcile results, resolve conflicts, and gate dependent lanes
+
+**File Operations Rules**:
+- Prefer dedicated file tools for normal code work: glob/grep/ast_grep_search for discovery, read for file contents, and edit/write/apply_patch for targeted source changes.
+- Use bash for execution and automation: git, package managers, tests, builds, scripts, diagnostics, and shell-native filesystem operations.
+- Shell is acceptable for bulk or mechanical filesystem changes when it is clearer or safer than many individual edits (for example: truncate generated logs, remove build artifacts, batch rename/move files), especially when the user explicitly asks for that shell operation.
+- Before destructive or broad shell operations, verify the target set and quote paths. Prefer a dry-run/listing first when practical.
+- Do not use cat/head/tail/sed/awk only to read code into context; use read/grep unless a shell pipeline is genuinely the better diagnostic.
+
+## 4. Plan and Parallelize
+When the routing threshold calls for delegation, build a short work graph before dispatching:
+- Independent lanes that can run now
+- Dependency-ordered lanes that must wait
+- Advisory ownership for write-capable lanes
+- Verification/review lanes that run after implementation
+
+### Todo Continuity
+- When the user adds a new task while a todo list exists, append the new task to the end of the existing todo list instead of replacing the list.
+- Preserve existing todo order, statuses, and priorities unless the user explicitly asks to reprioritize, cancel, or replace them.
+- Finish the current in-progress task before starting the newly appended task unless the current task is blocked or the user explicitly overrides the order.
+
+Can tasks be split into background specialist work?
+- Multiple @explorer searches across different domains?
+- @explorer + @librarian research in parallel?
+- Multiple @fixer instances for faster, scoped implementation?
+- @observer + @explorer in parallel (visual analysis + code search)?
+
+Balance: respect dependencies, avoid parallelizing what must be sequential, and avoid overlapping write ownership.
+
+### Background Task Discipline
+- Prefer \`task(..., background: true)\` for delegated work that can run independently.
+- For work already chosen for delegation, launch independent specialist lanes in the background so the orchestrator stays unblocked and can reconcile results when they return.
+- Never reissue an unchanged task to the same specialist after a rejection; adjust its scope or context before retrying.
+- Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
+- Before local edits or another writer task, compare against running task scopes.
+- Parallel background tasks are allowed only when their write scopes do not conflict.
+- Before final response, reconcile any terminal jobs shown in the Background Job Board.
+- Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
+- Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
+
+### Design Handoff Discipline
+- When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
+- Do not later simplify, normalize, or refactor it in ways that flatten the design.
+- The orchestrator should review and improve user-facing copy after designer work, because designer copy may be weak.
+- Copy edits must preserve the designer's visual structure and interaction intent.
+- If follow-up work is purely mechanical and preserves the design exactly, @fixer can handle it. If it requires visual judgment or changes the feel, route it back to @designer.
+
+### Session Reuse
+- Smartly reuse an available specialist session - context reuse saves time and tokens
+- When too much unrelated, and really needed, start a fresh session with the specialist
+- If multiple remembered sessions fit, prefer the most recently used matching session.
+- Prefer re-uses over creating new sessions all the time
+- When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
+- If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
+
+## 6. Verify
+- Define the observable success criteria from the user's request.
+- Choose the minimum verification that produces meaningful evidence for the change's scope, risk, uncertainty, and potential impact.
+- Start with the narrowest relevant validation. Broaden verification only when integration scope, uncertainty, risk, or a failed focused check justifies it.
+- Do not run project-wide checks by habit or merely because files changed.
+- Do not treat verification as a fixed checklist; select evidence that can actually confirm the requested behavior.
+- Request independent review only when its expected risk reduction justifies its coordination cost.
+- Report what was verified and any material remaining uncertainty.
+
+</Workflow>
+
+<Communication>
+
+## Clarity Over Assumptions
+- If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
+- Don't guess at critical details (file paths, API choices, architectural decisions)
+- Do make reasonable assumptions for minor details and state them briefly
+- When user input is required before work can continue—including clarification, permission, or command output—use the \`question\` tool rather than leaving an ordinary assistant prompt waiting. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
+
+## Concise Execution
+- Answer directly, no preamble
+- Don't summarize what you did unless asked
+- Don't explain code unless asked
+- One-word answers are fine when appropriate
+- Default to the minimum response that fully resolves the user's request; expand only when detail is necessary or the user asks for it.
+- Do not restate the user's request or narrate routine work.
+- Brief delegation notices: "Checking docs via @librarian..." not "I'm going to delegate to @librarian because..."
+
+## No Flattery
+Never: "Great question!" "Excellent idea!" "Smart choice!" or any praise of user input.
+
+## Honest Pushback
+When user's approach seems problematic:
+- State concern + alternative concisely
+- Ask if they want to proceed anyway
+- Don't lecture, don't blindly implement
+
+## Example
+**Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
+
+**Good:** "Checking Next.js App Router docs via @librarian..."
+[continues scheduling or integration]
+
+</Communication>
+"
+`;
+
+exports[`cache-impact snapshots (update deliberately — see file header) transformed payload for the canonical conversation fixture 1`] = `
+[
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m01",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "set up the project",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m02",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Reading the manifest first.",
+        "type": "text",
+      },
+      {
+        "callID": "m02-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m03",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "now add tests",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m04",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Delegating test work.",
+        "type": "text",
+      },
+      {
+        "callID": "m04-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "explorer",
+      "id": "m05",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "specialist context",
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m06",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "metadata": {
+          "oh-my-opencode-slim.internalInitiator": true,
+        },
+        "synthetic": true,
+        "text": 
+"continue coordinating remaining todos
+<!-- SLIM_INTERNAL_INITIATOR -->"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m07",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": 
+"also consider skills
+<available_skills>
+<skill>
+<name>some-skill</name>
+<description>demo</description>
+</skill>
+</available_skills>"
+,
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m08",
+      "role": "assistant",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "Wrapping up.",
+        "type": "text",
+      },
+      {
+        "callID": "m08-call",
+        "state": {
+          "input": {
+            "filePath": "/tmp/cache-safety-fixture/package.json",
+          },
+          "output": "{"name":"fixture"}",
+          "status": "completed",
+        },
+        "tool": "read",
+        "type": "tool",
+      },
+    ],
+  },
+  {
+    "info": {
+      "agent": "orchestrator",
+      "id": "m09",
+      "role": "user",
+      "sessionID": "ses_cache_safety_fixture",
+    },
+    "parts": [
+      {
+        "text": "final adjustments please",
+        "type": "text",
+      },
+      {
+        "metadata": {
+          "oh-my-opencode-slim.phaseReminder": true,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. !END!
+</system-reminder>"
+,
+        "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,
+        },
+        "synthetic": true,
+        "text": 
+"<system-reminder>
+### Background Job Board
+SENTINEL: background-job-board-v2
+Completed or reconciled sessions are reusable by alias for the same specialist/context.
+Timed-out running sessions are recoverable by alias for safe resume after a live busy signal.
+Cancelled or errored sessions are not reusable.
+
+#### Active / Unreconciled
+- exp-1 / task-snapshot / explorer / running
+  Objective: snapshot fixture job
+
+#### Reusable Sessions
+- none
+</system-reminder>"
+,
+        "type": "text",
+      },
+    ],
+  },
+]
+`;

+ 139 - 10
src/hooks/auto-update-checker/cache.test.ts

@@ -1,5 +1,7 @@
 import { describe, expect, mock, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
 
 // Mock logger to avoid noise
 mock.module('../../utils/logger', () => ({
@@ -102,6 +104,10 @@ describe('auto-update-checker/cache', () => {
         },
       );
       const rmSyncSpy = spyOn(fs, 'rmSync').mockReturnValue(undefined);
+      const mkdirSyncSpy = spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
+      const mkdtempSyncSpy = spyOn(fs, 'mkdtempSync').mockReturnValue(
+        '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+      );
       const { preparePackageUpdate } = await import(
         `./cache?test=${importCounter++}`
       );
@@ -112,15 +118,15 @@ describe('auto-update-checker/cache', () => {
         '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim/package.json',
       );
 
-      expect(result).toBe(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest',
-      );
-      expect(rmSyncSpy).toHaveBeenCalledWith(
-        '/home/user/.cache/opencode/packages/oh-my-opencode-slim@latest/node_modules/oh-my-opencode-slim',
-        { recursive: true, force: true },
-      );
+      expect(result).toEqual({
+        stagingDir:
+          '/home/user/.cache/opencode/packages/.oh-my-opencode-slim@0.9.11.staging-test',
+        targetDir:
+          '/home/user/.cache/opencode/packages/oh-my-opencode-slim@0.9.11',
+      });
       expect(writtenData.length).toBeGreaterThan(0);
       expect(JSON.parse(writtenData[0])).toEqual({
+        private: true,
         dependencies: {
           'oh-my-opencode-slim': '0.9.11',
         },
@@ -130,6 +136,8 @@ describe('auto-update-checker/cache', () => {
       readSpy.mockRestore();
       writeSpy.mockRestore();
       rmSyncSpy.mockRestore();
+      mkdirSyncSpy.mockRestore();
+      mkdtempSyncSpy.mockRestore();
     });
 
     test('keeps working when dependency is already on target version', async () => {
@@ -153,9 +161,8 @@ describe('auto-update-checker/cache', () => {
 
       const result = preparePackageUpdate('1.0.1', 'oh-my-opencode-slim', null);
 
-      expect(result?.endsWith('/.cache/opencode')).toBe(true);
-      expect(writeSpy).not.toHaveBeenCalled();
-      expect(rmSyncSpy).toHaveBeenCalled();
+      expect(result).not.toBeNull();
+      expect(writeSpy).toHaveBeenCalled();
 
       existsSpy.mockRestore();
       readSpy.mockRestore();
@@ -163,4 +170,126 @@ describe('auto-update-checker/cache', () => {
       rmSyncSpy.mockRestore();
     });
   });
+
+  describe('publishPackageUpdate transaction', () => {
+    function createPackage(dir: string, version: string): void {
+      const packageDir = join(dir, 'node_modules', 'oh-my-opencode-slim');
+      fs.mkdirSync(packageDir, { recursive: true });
+      fs.writeFileSync(
+        join(packageDir, 'package.json'),
+        JSON.stringify({ name: 'oh-my-opencode-slim', version }),
+      );
+    }
+
+    function createPrepared(root: string, version: string) {
+      const parent = join(root, 'packages');
+      fs.mkdirSync(parent, { recursive: true });
+      const stagingDir = fs.mkdtempSync(join(parent, '.staging-'));
+      return {
+        stagingDir,
+        targetDir: join(parent, `oh-my-opencode-slim@${version}`),
+      };
+    }
+
+    test('publishes a verified staged package atomically', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(fs.existsSync(join(prepared.targetDir, 'node_modules'))).toBe(
+        true,
+      );
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('cleans staging when a valid concurrent target already exists', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.4');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('replaces an invalid target and removes its quarantine', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.4');
+      fs.mkdirSync(prepared.targetDir, { recursive: true });
+      fs.writeFileSync(join(prepared.targetDir, 'package.json'), '{}');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBe(prepared.targetDir);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('removes an unverifiable freshly published target and staging', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(fs.existsSync(prepared.targetDir)).toBe(false);
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+
+    test('restores the prior usable target when replacement verification fails', async () => {
+      const root = fs.mkdtempSync(join(tmpdir(), 'omo-cache-'));
+      const prepared = createPrepared(root, '1.2.4');
+      createPackage(prepared.targetDir, '1.2.3');
+      createPackage(prepared.stagingDir, '1.2.3');
+      const { publishPackageUpdate } = await import(
+        `./cache?test=${importCounter++}`
+      );
+
+      expect(publishPackageUpdate(prepared, '1.2.4')).toBeNull();
+      expect(
+        JSON.parse(
+          fs.readFileSync(
+            join(
+              prepared.targetDir,
+              'node_modules',
+              'oh-my-opencode-slim',
+              'package.json',
+            ),
+            'utf-8',
+          ),
+        ),
+      ).toEqual({ name: 'oh-my-opencode-slim', version: '1.2.3' });
+      expect(fs.existsSync(prepared.stagingDir)).toBe(false);
+      expect(
+        fs
+          .readdirSync(join(root, 'packages'))
+          .some((name) => name.includes('invalid-')),
+      ).toBe(false);
+      fs.rmSync(root, { recursive: true, force: true });
+    });
+  });
 });

+ 103 - 119
src/hooks/auto-update-checker/cache.ts

@@ -1,114 +1,30 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
-import { stripJsonComments } from '../../cli/config-manager';
 import { log } from '../../utils/logger';
 import { getCurrentRuntimePackageJsonPath } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 
-interface BunLockfile {
-  workspaces?: {
-    ''?: {
-      dependencies?: Record<string, string>;
-    };
-  };
-  packages?: Record<string, unknown>;
-}
-
 interface AutoUpdateInstallContext {
   installDir: string;
   packageJsonPath: string;
 }
 
-/**
- * Removes a package from the bun.lock file if it's in JSON format.
- * Note: Newer Bun versions (1.1+) use a custom text format for bun.lock.
- * This function handles JSON-based lockfiles gracefully.
- */
-function removeFromBunLock(installDir: string, packageName: string): boolean {
-  const lockPath = path.join(installDir, 'bun.lock');
-  if (!fs.existsSync(lockPath)) return false;
-
-  try {
-    const content = fs.readFileSync(lockPath, 'utf-8');
-    let lock: BunLockfile;
-
-    try {
-      lock = JSON.parse(stripJsonComments(content)) as BunLockfile;
-    } catch {
-      // If it's not valid JSON(C), it might be the new Bun text format or binary format.
-      // For now, we only support JSON-based lockfile manipulation.
-      return false;
-    }
-
-    let modified = false;
-
-    if (lock.workspaces?.['']?.dependencies?.[packageName]) {
-      delete lock.workspaces[''].dependencies[packageName];
-      modified = true;
-    }
-
-    if (lock.packages?.[packageName]) {
-      delete lock.packages[packageName];
-      modified = true;
-    }
-
-    if (modified) {
-      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
-      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
-    }
-
-    return modified;
-  } catch (err) {
-    log(`[auto-update-checker] Failed to process bun.lock:`, err);
-    return false;
-  }
+interface PreparedPackageUpdate {
+  stagingDir: string;
+  targetDir: string;
 }
 
-function ensureDependencyVersion(
-  packageJsonPath: string,
-  packageName: string,
+function getTargetInstallContext(
+  installContext: AutoUpdateInstallContext,
   version: string,
-): boolean {
-  if (!fs.existsSync(packageJsonPath)) return false;
-
-  try {
-    const content = fs.readFileSync(packageJsonPath, 'utf-8');
-    const pkgJson = JSON.parse(stripJsonComments(content)) as {
-      dependencies?: Record<string, string>;
-      [key: string]: unknown;
-    };
-
-    const dependencies = { ...(pkgJson.dependencies ?? {}) };
-    if (dependencies[packageName] === version) {
-      return true;
-    }
-
-    dependencies[packageName] = version;
-    pkgJson.dependencies = dependencies;
-    fs.writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2));
-    log(
-      `[auto-update-checker] Updated dependency in package.json: ${packageName} → ${version}`,
-    );
-    return true;
-  } catch (err) {
-    log(
-      `[auto-update-checker] Failed to update package.json dependency for auto-update:`,
-      err,
-    );
-    return false;
-  }
-}
-
-function removeInstalledPackage(
-  installDir: string,
-  packageName: string,
-): boolean {
-  const pkgDir = path.join(installDir, 'node_modules', packageName);
-  if (!fs.existsSync(pkgDir)) return false;
-
-  fs.rmSync(pkgDir, { recursive: true, force: true });
-  log(`[auto-update-checker] Package removed: ${pkgDir}`);
-  return true;
+): AutoUpdateInstallContext {
+  const installParent = path.dirname(installContext.installDir);
+  const parentDir =
+    path.basename(installParent) === 'packages'
+      ? installParent
+      : path.join(CACHE_DIR, 'packages');
+  const installDir = path.join(parentDir, `${PACKAGE_NAME}@${version}`);
+  return { installDir, packageJsonPath: path.join(installDir, 'package.json') };
 }
 
 export function resolveInstallContext(
@@ -148,7 +64,9 @@ export function preparePackageUpdate(
   version: string,
   packageName: string = PACKAGE_NAME,
   runtimePackageJsonPath: string | null = getCurrentRuntimePackageJsonPath(),
-): string | null {
+  cacheIdentity: string = version,
+): PreparedPackageUpdate | null {
+  let stagingDir: string | null = null;
   try {
     const installContext = resolveInstallContext(runtimePackageJsonPath);
     if (!installContext) {
@@ -156,33 +74,99 @@ export function preparePackageUpdate(
       return null;
     }
 
-    const dependencyReady = ensureDependencyVersion(
-      installContext.packageJsonPath,
-      packageName,
-      version,
+    const targetContext = getTargetInstallContext(
+      installContext,
+      cacheIdentity,
     );
-    if (!dependencyReady) {
-      return null;
-    }
-
-    const packageRemoved = removeInstalledPackage(
-      installContext.installDir,
-      packageName,
+    const targetParent = path.dirname(targetContext.installDir);
+    fs.mkdirSync(targetParent, { recursive: true });
+    stagingDir = fs.mkdtempSync(
+      path.join(targetParent, `.${PACKAGE_NAME}@${cacheIdentity}.staging-`),
     );
-    const lockRemoved = removeFromBunLock(
-      installContext.installDir,
-      packageName,
+    fs.writeFileSync(
+      path.join(stagingDir, 'package.json'),
+      JSON.stringify({
+        private: true,
+        dependencies: { [packageName]: version },
+      }),
     );
 
-    if (!packageRemoved && !lockRemoved) {
-      log(
-        `[auto-update-checker] No cached package artifacts removed for ${packageName}; continuing with updated dependency spec`,
-      );
-    }
-
-    return installContext.installDir;
+    return { stagingDir, targetDir: targetContext.installDir };
   } catch (err) {
+    if (stagingDir) fs.rmSync(stagingDir, { recursive: true, force: true });
     log('[auto-update-checker] Failed to prepare package update:', err);
     return null;
   }
 }
+
+export function discardPreparedPackageUpdate(
+  prepared: PreparedPackageUpdate,
+): void {
+  fs.rmSync(prepared.stagingDir, { recursive: true, force: true });
+}
+
+export function publishPackageUpdate(
+  prepared: PreparedPackageUpdate,
+  version: string,
+): string | null {
+  try {
+    if (fs.existsSync(prepared.targetDir)) {
+      if (verifyInstalledPackage(prepared.targetDir, version)) {
+        discardPreparedPackageUpdate(prepared);
+        return prepared.targetDir;
+      }
+      const quarantineDir = `${prepared.targetDir}.invalid-${process.pid}-${Date.now()}`;
+      fs.renameSync(prepared.targetDir, quarantineDir);
+      try {
+        fs.renameSync(prepared.stagingDir, prepared.targetDir);
+        if (verifyInstalledPackage(prepared.targetDir, version)) {
+          fs.rmSync(quarantineDir, { recursive: true, force: true });
+          return prepared.targetDir;
+        }
+        fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+        fs.renameSync(quarantineDir, prepared.targetDir);
+        return null;
+      } catch {
+        if (fs.existsSync(prepared.targetDir)) {
+          if (verifyInstalledPackage(prepared.targetDir, version)) {
+            discardPreparedPackageUpdate(prepared);
+            fs.rmSync(quarantineDir, { recursive: true, force: true });
+            return prepared.targetDir;
+          }
+        }
+      }
+      if (!fs.existsSync(prepared.targetDir)) {
+        fs.renameSync(quarantineDir, prepared.targetDir);
+      }
+      discardPreparedPackageUpdate(prepared);
+      return null;
+    }
+    fs.renameSync(prepared.stagingDir, prepared.targetDir);
+    if (verifyInstalledPackage(prepared.targetDir, version)) {
+      return prepared.targetDir;
+    }
+    fs.rmSync(prepared.targetDir, { recursive: true, force: true });
+    return null;
+  } catch {
+    discardPreparedPackageUpdate(prepared);
+    return null;
+  }
+}
+
+export function verifyInstalledPackage(
+  installDir: string,
+  version: string,
+  packageName: string = PACKAGE_NAME,
+): boolean {
+  try {
+    const packageJson = JSON.parse(
+      fs.readFileSync(
+        path.join(installDir, 'node_modules', packageName, 'package.json'),
+        'utf-8',
+      ),
+    ) as { name?: string; version?: string };
+    return packageJson.name === packageName && packageJson.version === version;
+  } catch {
+    return false;
+  }
+}

+ 143 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -12,6 +12,8 @@ mock.module('../../cli/config-manager', () => ({
     '/mock/config/opencode.json',
     '/mock/config/opencode.jsonc',
   ],
+  getTuiConfig: () => '/mock/config/tui.json',
+  getTuiConfigJsonc: () => '/mock/config/tui.jsonc',
 }));
 
 // Cache buster for dynamic imports
@@ -155,6 +157,147 @@ describe('auto-update-checker/checker', () => {
       existsSpy.mockRestore();
       readSpy.mockRestore();
     });
+
+    test('treats only installer-managed exact tuples as updateable', async () => {
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) =>
+        String(p).includes('opencode.json'),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            'oh-my-opencode-slim@1.2.3',
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const { findPluginEntry } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const entry = findPluginEntry('/test');
+      expect(entry?.isPinned).toBe(false);
+      expect(entry?.isInstallerManaged).toBe(true);
+
+      const managedReadSpy = spyOn(fs, 'readFileSync').mockReturnValue(
+        JSON.stringify({
+          plugin: [
+            [
+              'oh-my-opencode-slim@1.2.3',
+              { __ohMyOpencodeSlimManagedByInstaller: true },
+            ],
+          ],
+        }),
+      );
+      const managedEntry = findPluginEntry('/test');
+      expect(managedEntry?.isPinned).toBe(false);
+      expect(managedEntry?.isInstallerManaged).toBe(true);
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      managedReadSpy.mockRestore();
+    });
+  });
+
+  describe('updateInstallerManagedVersions', () => {
+    test('structurally rewrites managed tuples in OpenCode and TUI configs only', async () => {
+      const files = new Map<string, string>([
+        [
+          '/mock/config/opencode.json',
+          `{
+  // preserve this comment
+  "note": "{ [ ] }",
+  "other": { "plugin": [["oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]] },
+  "plugin": [["oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }]],
+  "plugin": [
+    [ /* tuple comment */ "oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": true, "keep": "[{}]" } ],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": false, "__ohMyOpencodeSlimManagedByInstaller": true }],
+    ["oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }],
+    ["oh-my-opencode-slim\\u00401.2.3", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }],
+    "oh-my-opencode-slim@1.2.3",
+    ["oh-my-opencode-slim@1.2.3", { "nested": { "__ohMyOpencodeSlimManagedByInstaller": true } }]
+  ]
+}`,
+        ],
+        [
+          '/mock/config/tui.json',
+          JSON.stringify({
+            plugin: [
+              [
+                'oh-my-opencode-slim@1.2.3',
+                { __ohMyOpencodeSlimManagedByInstaller: true },
+              ],
+            ],
+          }),
+        ],
+      ]);
+      const existsSpy = spyOn(fs, 'existsSync').mockImplementation((path) =>
+        files.has(String(path)),
+      );
+      const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+        (path) => files.get(String(path)) ?? '',
+      );
+      const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+        (path, data) => files.set(String(path), String(data)),
+      );
+      const renameSpy = spyOn(fs, 'renameSync').mockImplementation(
+        (from, to) => {
+          files.set(String(to), files.get(String(from)) ?? '');
+        },
+      );
+      const { updateInstallerManagedVersions } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const previousTuiConfig = process.env.OPENCODE_TUI_CONFIG;
+      process.env.OPENCODE_TUI_CONFIG = '/mock/config/tui.json';
+      expect(updateInstallerManagedVersions('/project', '1.2.4')).toBe(true);
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.4", { "__ohMyOpencodeSlimManagedByInstall\\u0065r": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.1.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@0.2.0", { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"oh-my-opencode-slim@1.2.3", { "__ohMyOpencodeSlimManagedByInstaller": "true" }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"keep": "[{}]"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"note": "{ [ ] }"',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        'oh-my-opencode-slim@1.2.3',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '"nested": { "__ohMyOpencodeSlimManagedByInstaller": true }',
+      );
+      expect(files.get('/mock/config/opencode.json')).toContain(
+        '// preserve this comment',
+      );
+      expect(files.get('/mock/config/tui.json')).toContain(
+        'oh-my-opencode-slim@1.2.4',
+      );
+      if (previousTuiConfig === undefined) {
+        delete process.env.OPENCODE_TUI_CONFIG;
+      } else {
+        process.env.OPENCODE_TUI_CONFIG = previousTuiConfig;
+      }
+
+      existsSpy.mockRestore();
+      readSpy.mockRestore();
+      writeSpy.mockRestore();
+      renameSpy.mockRestore();
+    });
   });
 
   describe('getLatestCompatibleVersion', () => {

+ 254 - 42
src/hooks/auto-update-checker/checker.ts

@@ -1,7 +1,12 @@
 import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { fileURLToPath } from 'node:url';
-import { stripJsonComments } from '../../cli/config-manager';
+import {
+  getOpenCodeConfigPaths,
+  stripJsonComments,
+} from '../../cli/config-manager';
+import { getTuiConfig, getTuiConfigJsonc } from '../../cli/paths';
+import { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
 import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
@@ -32,8 +37,182 @@ function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
 
-function getPluginEntries(config: OpencodeConfig): string[] {
-  return Array.isArray(config.plugin) ? config.plugin.filter(isString) : [];
+function getPluginEntries(config: OpencodeConfig): unknown[] {
+  return Array.isArray(config.plugin) ? config.plugin : [];
+}
+
+function getPluginSpec(entry: unknown): string | null {
+  if (isString(entry)) return entry;
+  return Array.isArray(entry) && isString(entry[0]) ? entry[0] : null;
+}
+
+function isInstallerManagedEntry(entry: unknown): boolean {
+  return (
+    Array.isArray(entry) &&
+    entry.length >= 2 &&
+    entry[1] !== null &&
+    typeof entry[1] === 'object' &&
+    !Array.isArray(entry[1]) &&
+    (entry[1] as Record<string, unknown>)[INSTALLER_MANAGED_PLUGIN_OPTION] ===
+      true
+  );
+}
+
+type JsoncToken = {
+  kind: 'string' | 'literal' | 'punctuation';
+  value: string;
+  start: number;
+  end: number;
+};
+
+function tokenizeJsonc(content: string): JsoncToken[] {
+  const tokens: JsoncToken[] = [];
+  for (let index = 0; index < content.length; ) {
+    const char = content[index];
+    if (/\s/.test(char)) index++;
+    else if (content.startsWith('//', index)) {
+      index = content.indexOf('\n', index);
+      if (index === -1) break;
+    } else if (content.startsWith('/*', index)) {
+      index = content.indexOf('*/', index + 2);
+      if (index === -1) break;
+      index += 2;
+    } else if ('[]{}:,'.includes(char)) {
+      tokens.push({
+        kind: 'punctuation',
+        value: char,
+        start: index,
+        end: ++index,
+      });
+    } else if (char === '"') {
+      const start = index++;
+      while (index < content.length) {
+        if (content[index] === '\\') index += 2;
+        else if (content[index++] === '"') break;
+      }
+      const raw = content.slice(start, index);
+      try {
+        tokens.push({
+          kind: 'string',
+          value: JSON.parse(raw) as string,
+          start,
+          end: index,
+        });
+      } catch {
+        return [];
+      }
+    } else {
+      const start = index;
+      while (index < content.length && !/\s|[[\]{}:,]/.test(content[index]))
+        index++;
+      tokens.push({
+        kind: 'literal',
+        value: content.slice(start, index),
+        start,
+        end: index,
+      });
+    }
+  }
+  return tokens;
+}
+
+function matchingToken(
+  tokens: JsoncToken[],
+  start: number,
+  open: string,
+  close: string,
+): number {
+  let depth = 0;
+  for (let index = start; index < tokens.length; index++) {
+    if (tokens[index].kind === 'punctuation' && tokens[index].value === open)
+      depth++;
+    if (
+      tokens[index].kind === 'punctuation' &&
+      tokens[index].value === close &&
+      --depth === 0
+    )
+      return index;
+  }
+  return -1;
+}
+
+function hasDirectInstallerMarker(
+  tokens: JsoncToken[],
+  objectStart: number,
+): boolean {
+  const objectEnd = matchingToken(tokens, objectStart, '{', '}');
+  if (objectEnd === -1) return false;
+  let depth = 1;
+  let markerValue = false;
+  for (let index = objectStart + 1; index < objectEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') depth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}') depth--;
+    else if (
+      depth === 1 &&
+      tokens[index].kind === 'string' &&
+      value === INSTALLER_MANAGED_PLUGIN_OPTION &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]
+    ) {
+      markerValue =
+        tokens[index + 2].kind === 'literal' &&
+        tokens[index + 2].value === 'true';
+    }
+  }
+  return markerValue;
+}
+
+function findManagedSpecifierRanges(content: string): Array<[number, number]> {
+  const tokens = tokenizeJsonc(content);
+  const rootStart = tokens.findIndex(
+    (token) => token.kind === 'punctuation' && token.value === '{',
+  );
+  if (rootStart === -1) return [];
+  const rootEnd = matchingToken(tokens, rootStart, '{', '}');
+  if (rootEnd === -1) return [];
+  let objectDepth = 1;
+  let plugin = -1;
+  for (let index = rootStart + 1; index < rootEnd; index++) {
+    const value = tokens[index].value;
+    if (tokens[index].kind === 'punctuation' && value === '{') objectDepth++;
+    else if (tokens[index].kind === 'punctuation' && value === '}')
+      objectDepth--;
+    else if (
+      objectDepth === 1 &&
+      value === 'plugin' &&
+      tokens[index + 1]?.kind === 'punctuation' &&
+      tokens[index + 1]?.value === ':' &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === '['
+    ) {
+      plugin = index;
+    }
+  }
+  if (plugin === -1) return [];
+  const arrayStart = plugin + 2;
+  const arrayEnd = matchingToken(tokens, arrayStart, '[', ']');
+  if (arrayEnd === -1) return [];
+  const ranges: Array<[number, number]> = [];
+  for (let index = arrayStart + 1; index < arrayEnd; index++) {
+    if (tokens[index].kind !== 'punctuation' || tokens[index].value !== '[')
+      continue;
+    const tupleEnd = matchingToken(tokens, index, '[', ']');
+    if (tupleEnd === -1) break;
+    const specifier = tokens[index + 1];
+    if (
+      specifier?.value.startsWith(`${PACKAGE_NAME}@`) &&
+      tokens[index + 2]?.kind === 'punctuation' &&
+      tokens[index + 2]?.value === ',' &&
+      tokens[index + 3]?.kind === 'punctuation' &&
+      tokens[index + 3]?.value === '{' &&
+      hasDirectInstallerMarker(tokens, index + 3)
+    )
+      ranges.push([specifier.start + 1, specifier.end - 1]);
+    index = tupleEnd;
+  }
+  return ranges;
 }
 
 /**
@@ -153,10 +332,10 @@ export function extractChannel(version: string | null): string {
  */
 function getConfigPaths(directory: string): string[] {
   return [
-    path.join(directory, '.opencode', 'opencode.json'),
-    path.join(directory, '.opencode', 'opencode.jsonc'),
     USER_OPENCODE_CONFIG,
     USER_OPENCODE_CONFIG_JSONC,
+    path.join(directory, '.opencode', 'opencode.json'),
+    path.join(directory, '.opencode', 'opencode.jsonc'),
   ];
 }
 
@@ -172,11 +351,13 @@ function getLocalDevPath(directory: string): string | null {
       const plugins = getPluginEntries(config);
 
       for (const entry of plugins) {
-        if (entry.startsWith('file://') && entry.includes(PACKAGE_NAME)) {
+        const spec = getPluginSpec(entry);
+        if (!spec) continue;
+        if (spec.startsWith('file://') && spec.includes(PACKAGE_NAME)) {
           try {
-            return fileURLToPath(entry);
+            return fileURLToPath(spec);
           } catch {
-            return entry.replace('file://', '');
+            return spec.replace('file://', '');
           }
         }
       }
@@ -251,6 +432,7 @@ export function getCurrentRuntimePackageJsonPath(
  * Searches across all config locations to find the current installation entry for this plugin.
  */
 export function findPluginEntry(directory: string): PluginEntryInfo | null {
+  let selected: PluginEntryInfo | null = null;
   for (const configPath of getConfigPaths(directory)) {
     try {
       if (!fs.existsSync(configPath)) continue;
@@ -258,16 +440,27 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;
       const plugins = getPluginEntries(config);
 
-      for (const entry of plugins) {
+      for (const rawEntry of plugins) {
+        const entry = getPluginSpec(rawEntry);
+        if (!entry) continue;
         if (entry === PACKAGE_NAME) {
-          return { entry, isPinned: false, pinnedVersion: null, configPath };
+          selected = {
+            entry,
+            isPinned: false,
+            isInstallerManaged: false,
+            pinnedVersion: null,
+            configPath,
+          };
+          continue;
         }
         if (entry.startsWith(`${PACKAGE_NAME}@`)) {
           const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
-          const isPinned = pinnedVersion !== 'latest';
-          return {
+          const isInstallerManaged = isInstallerManagedEntry(rawEntry);
+          const isPinned = pinnedVersion !== 'latest' && !isInstallerManaged;
+          selected = {
             entry,
             isPinned,
+            isInstallerManaged,
             pinnedVersion: isPinned ? pinnedVersion : null,
             configPath,
           };
@@ -275,7 +468,7 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
       }
     } catch {}
   }
-  return null;
+  return selected;
 }
 
 const _cachedLocalVersion: string | null = null;
@@ -324,43 +517,62 @@ export function getCachedVersion(): string | null {
  * Safely updates a pinned version in the configuration file.
  * It attempts to replace the exact plugin string to preserve comments and formatting.
  */
-export function updatePinnedVersion(
-  configPath: string,
-  oldEntry: string,
+export function updateInstallerManagedVersions(
+  directory: string,
   newVersion: string,
 ): boolean {
   try {
-    if (!fs.existsSync(configPath)) return false;
-
-    const content = fs.readFileSync(configPath, 'utf-8');
+    const paths = [
+      ...getConfigPaths(directory),
+      ...getOpenCodeConfigPaths(),
+      getTuiConfig(),
+      getTuiConfigJsonc(),
+    ]
+      .filter((value, index, values) => values.indexOf(value) === index)
+      .filter((configPath) => fs.existsSync(configPath));
     const newEntry = `${PACKAGE_NAME}@${newVersion}`;
-
-    // Check if the old entry actually exists as a quoted string
-    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g');
-
-    if (!entryRegex.test(content)) {
-      log(
-        `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`,
-      );
-      return false;
-    }
-
-    // Perform the replacement
-    const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`);
-
-    if (updatedContent === content) {
-      return false;
+    const updates = paths.flatMap((configPath) => {
+      const content = fs.readFileSync(configPath, 'utf-8');
+      const updated = findManagedSpecifierRanges(content)
+        .toReversed()
+        .reduce(
+          (result, [start, end]) =>
+            `${result.slice(0, start)}${newEntry}${result.slice(end)}`,
+          content,
+        );
+      const changed = updated !== content;
+      return changed
+        ? [
+            {
+              configPath,
+              content,
+              updated,
+            },
+          ]
+        : [];
+    });
+    if (updates.length === 0) return false;
+    const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
+    for (const update of updates)
+      fs.writeFileSync(`${update.configPath}.${token}.tmp`, update.updated);
+    const committed: typeof updates = [];
+    try {
+      for (const update of updates) {
+        fs.renameSync(`${update.configPath}.${token}.tmp`, update.configPath);
+        committed.push(update);
+      }
+    } catch (err) {
+      for (const update of committed) {
+        const restorePath = `${update.configPath}.${token}.restore`;
+        fs.writeFileSync(restorePath, update.content);
+        fs.renameSync(restorePath, update.configPath);
+      }
+      throw err;
     }
-
-    fs.writeFileSync(configPath, updatedContent, 'utf-8');
-    log(
-      `[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`,
-    );
     return true;
   } catch (err) {
     log(
-      `[auto-update-checker] Failed to update config file ${configPath}:`,
+      '[auto-update-checker] Failed to update installer-managed configs:',
       err,
     );
     return false;

+ 2 - 0
src/hooks/auto-update-checker/constants.ts

@@ -2,6 +2,8 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
+export { INSTALLER_MANAGED_PLUGIN_OPTION } from '../../plugin-entry';
+
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
 export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;

+ 287 - 11
src/hooks/auto-update-checker/index.test.ts

@@ -14,11 +14,15 @@ const checkerMocks = {
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
+  updateInstallerManagedVersions: mock(() => true),
 };
 
 const cacheMocks = {
   preparePackageUpdate: mock(() => '/tmp/opencode'),
+  discardPreparedPackageUpdate: mock(() => {}),
+  publishPackageUpdate: mock(() => '/tmp/opencode'),
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
+  verifyInstalledPackage: mock(() => true),
 };
 
 const skillSyncMocks = {
@@ -29,6 +33,7 @@ const skillSyncMocks = {
     staged: [],
     adopted: [],
     customized: [],
+    stagedThisSync: [],
   })),
 };
 
@@ -121,9 +126,23 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
     checkerMocks.getLocalDevVersion.mockImplementation(() => null);
+    checkerMocks.updateInstallerManagedVersions.mockReset();
+    checkerMocks.updateInstallerManagedVersions.mockImplementation(() => true);
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockReset();
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => null,
+    );
 
     cacheMocks.preparePackageUpdate.mockReset();
-    cacheMocks.preparePackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.preparePackageUpdate.mockImplementation(() => ({
+      stagingDir: '/tmp/opencode-staging',
+      targetDir: '/tmp/opencode',
+    }));
+    cacheMocks.publishPackageUpdate.mockReset();
+    cacheMocks.publishPackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.verifyInstalledPackage.mockReset();
+    cacheMocks.verifyInstalledPackage.mockImplementation(() => true);
+    cacheMocks.discardPreparedPackageUpdate.mockReset();
     cacheMocks.resolveInstallContext.mockReset();
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
       installDir: '/tmp/opencode',
@@ -147,6 +166,7 @@ describe('auto-update-checker/index', () => {
       staged: [],
       adopted: [],
       customized: [],
+      stagedThisSync: [],
     }));
 
     companionUpdaterMocks.ensureCompanionVersion.mockReset();
@@ -225,10 +245,12 @@ describe('auto-update-checker/index', () => {
     expect(cacheMocks.preparePackageUpdate).toHaveBeenCalledWith(
       '0.9.11',
       'oh-my-opencode-slim',
+      undefined,
+      'latest',
     );
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).toHaveBeenCalledWith(
       '/tmp/opencode/node_modules/oh-my-opencode-slim',
@@ -236,13 +258,102 @@ describe('auto-update-checker/index', () => {
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim Updated!',
-        message: 'v0.9.1 → v0.9.11\nRestart OpenCode to apply.',
+        message:
+          'v0.9.1 → v0.9.11\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
     });
   });
 
+  test('shows a manual-review toast for newly staged startup skills when up to date', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: ['reflect'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    createAutoUpdateCheckerHook(ctx as never).event({
+      event: { type: 'session.created', properties: {} },
+    });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledTimes(1);
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'Skill updates need review',
+        message: 'Manual review required: reflect',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('shows a manual-review toast when a version-pinned update is staged at startup', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: '0.9.1',
+      isPinned: true,
+    }));
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: ['reflect'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    createAutoUpdateCheckerHook(ctx as never).event({
+      event: { type: 'session.created', properties: {} },
+    });
+    await waitForCalls(showToast, 2);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'Skill updates need review',
+        message: 'Manual review required: reflect',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+  });
+
   test('includes newly installed bundled skills in success toast', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,
@@ -261,6 +372,7 @@ describe('auto-update-checker/index', () => {
       staged: [],
       adopted: [],
       customized: [],
+      stagedThisSync: [],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -276,14 +388,14 @@ describe('auto-update-checker/index', () => {
       body: {
         title: 'OMO-Slim Updated!',
         message:
-          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect, worktrees\nRestart OpenCode to apply.',
+          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect, worktrees\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
     });
   });
 
-  test('includes staged and customized skills in success toast', async () => {
+  test('reports only new skill transitions in success toast without duplication', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,
       isPinned: false,
@@ -300,7 +412,8 @@ describe('auto-update-checker/index', () => {
       failed: [],
       staged: ['worktrees'],
       adopted: [],
-      customized: ['my-custom-skill'],
+      customized: ['worktrees', 'my-custom-skill'],
+      stagedThisSync: ['worktrees'],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -316,7 +429,115 @@ describe('auto-update-checker/index', () => {
       body: {
         title: 'OMO-Slim Updated!',
         message:
-          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect\nStaged skill updates: worktrees\nCustomized skills: my-custom-skill\nRestart OpenCode to apply.',
+          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect\nStaged skill updates require manual review: worktrees\nRestart OpenCode to apply the plugin update.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('retains staged transitions from startup reconciliation for the update toast', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementationOnce(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: ['reflect'],
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementationOnce(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: [],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: [],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    createAutoUpdateCheckerHook(ctx as never).event({
+      event: { type: 'session.created', properties: {} },
+    });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nStaged skill updates require manual review: reflect\nRestart OpenCode to apply the plugin update.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('removes startup staged transitions adopted by post-install sync', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementationOnce(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: ['reflect'],
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementationOnce(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: [],
+      adopted: ['reflect'],
+      customized: [],
+      stagedThisSync: ['reflect'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    createAutoUpdateCheckerHook(ctx as never).event({
+      event: { type: 'session.created', properties: {} },
+    });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
@@ -375,7 +596,7 @@ describe('auto-update-checker/index', () => {
       body: {
         title: 'OMO-Slim Updated!',
         message:
-          'v0.9.1 → v0.9.11\nCompanion updated.\nRestart OpenCode to apply.',
+          'v0.9.1 → v0.9.11\nCompanion updated.\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
@@ -416,7 +637,7 @@ describe('auto-update-checker/index', () => {
       body: {
         title: 'OMO-Slim Updated!',
         message:
-          'v0.9.1 → v0.9.11\nCompanion update will retry on restart.\nRestart OpenCode to apply.',
+          'v0.9.1 → v0.9.11\nCompanion update will retry on restart.\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
@@ -441,6 +662,7 @@ describe('auto-update-checker/index', () => {
       staged: [],
       adopted: [],
       customized: [],
+      stagedThisSync: [],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -455,7 +677,8 @@ describe('auto-update-checker/index', () => {
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim Updated!',
-        message: 'v0.9.1 → v0.9.11\nRestart OpenCode to apply.',
+        message:
+          'v0.9.1 → v0.9.11\nRestart OpenCode to apply the plugin update.',
         variant: 'success',
         duration: 8000,
       },
@@ -568,7 +791,7 @@ describe('auto-update-checker/index', () => {
 
     expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
-      expect.objectContaining({ cwd: '/tmp/opencode' }),
+      expect.objectContaining({ cwd: '/tmp/opencode-staging' }),
     );
     expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({
@@ -582,6 +805,58 @@ describe('auto-update-checker/index', () => {
     });
   });
 
+  test('shows a manual-review toast when installation fails after startup staging', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['reflect'],
+      stagedThisSync: ['reflect'],
+    }));
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      exitCode: 1,
+      kill: mock(() => true),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+      proc: {} as never,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    createAutoUpdateCheckerHook(ctx as never).event({
+      event: { type: 'session.created', properties: {} },
+    });
+    await waitForCalls(showToast, 2);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'Skill updates need review',
+        message: 'Manual review required: reflect',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+  });
+
   test('does not auto-update across major versions', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,
@@ -732,6 +1007,7 @@ describe('auto-update-checker/index', () => {
       staged: ['reflect'],
       adopted: [],
       customized: ['my-custom-skill'],
+      stagedThisSync: [],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(

+ 81 - 16
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,13 @@ import {
 } from '../../companion/updater';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { preparePackageUpdate, resolveInstallContext } from './cache';
+import {
+  discardPreparedPackageUpdate,
+  preparePackageUpdate,
+  publishPackageUpdate,
+  resolveInstallContext,
+  verifyInstalledPackage,
+} from './cache';
 import {
   extractChannel,
   findPluginEntry,
@@ -14,6 +20,7 @@ import {
   getCurrentRuntimePackageJsonPath,
   getLatestCompatibleVersion,
   getLocalDevVersion,
+  updateInstallerManagedVersions,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
 import { syncBundledSkillsFromPackage } from './skill-sync';
@@ -73,6 +80,8 @@ async function runBackgroundUpdateCheck(
   autoUpdate: boolean,
   companion: AutoUpdateCheckerOptions['companion'],
 ): Promise<void> {
+  const stagedSkillsThisUpdate = new Set<string>();
+
   // Startup reconciliation (run once per top-level startup)
   if (!hasReconciledAtStartup) {
     try {
@@ -82,6 +91,9 @@ async function runBackgroundUpdateCheck(
         const packageRoot = path.dirname(runtimePackageJsonPath);
         log('[auto-update-checker] Running startup skill reconciliation');
         const syncResult = syncBundledSkillsFromPackage(packageRoot);
+        for (const skill of syncResult.stagedThisSync) {
+          stagedSkillsThisUpdate.add(skill);
+        }
         if (syncResult.installed.length > 0) {
           log(
             `[auto-update-checker] Startup skill sync installed: ${syncResult.installed.join(', ')}`,
@@ -115,6 +127,7 @@ async function runBackgroundUpdateCheck(
   const pluginInfo = findPluginEntry(ctx.directory);
   if (!pluginInfo) {
     log('[auto-update-checker] Plugin not found in config');
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -122,6 +135,7 @@ async function runBackgroundUpdateCheck(
   const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
   if (!currentVersion) {
     log('[auto-update-checker] No version found (cached or pinned)');
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -140,6 +154,7 @@ async function runBackgroundUpdateCheck(
         8000,
       );
     }
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -148,6 +163,7 @@ async function runBackgroundUpdateCheck(
     log(
       `[auto-update-checker] Major update available; skipping auto-update: ${latestInfo.latestMajorVersion}`,
     );
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -157,6 +173,7 @@ async function runBackgroundUpdateCheck(
       '[auto-update-checker] Failed to fetch latest version for channel:',
       channel,
     );
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -165,6 +182,7 @@ async function runBackgroundUpdateCheck(
       '[auto-update-checker] Already on latest version for channel:',
       channel,
     );
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -181,6 +199,7 @@ async function runBackgroundUpdateCheck(
       8000,
     );
     log(`[auto-update-checker] Version is pinned; skipping auto-update.`);
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
@@ -193,11 +212,20 @@ async function runBackgroundUpdateCheck(
       8000,
     );
     log('[auto-update-checker] Auto-update disabled, notification only');
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
-  const installDir = preparePackageUpdate(latestVersion, PACKAGE_NAME);
-  if (!installDir) {
+  const cacheIdentity = pluginInfo.isInstallerManaged
+    ? latestVersion
+    : 'latest';
+  const prepared = preparePackageUpdate(
+    latestVersion,
+    PACKAGE_NAME,
+    undefined,
+    cacheIdentity,
+  );
+  if (!prepared) {
     showToast(
       ctx,
       `OMO-Slim ${latestVersion}`,
@@ -206,23 +234,45 @@ async function runBackgroundUpdateCheck(
       8000,
     );
     log('[auto-update-checker] Failed to prepare install root for auto-update');
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
     return;
   }
 
-  const installSuccess = await runBunInstallSafe(installDir);
-
-  if (installSuccess) {
+  const installSuccess =
+    (await runBunInstallSafe(prepared.stagingDir)) &&
+    verifyInstalledPackage(prepared.stagingDir, latestVersion);
+  const installDir = installSuccess
+    ? publishPackageUpdate(prepared, latestVersion)
+    : null;
+  if (!installSuccess) discardPreparedPackageUpdate(prepared);
+
+  if (installDir) {
+    if (
+      pluginInfo.isInstallerManaged &&
+      !updateInstallerManagedVersions(ctx.directory, latestVersion)
+    ) {
+      showToast(
+        ctx,
+        `OMO-Slim ${latestVersion}`,
+        'Update installed in cache, but plugin configuration could not be updated.',
+        'error',
+        8000,
+      );
+      return;
+    }
     let installedSkills: string[] = [];
-    let stagedSkills: string[] = [];
-    let customizedSkills: string[] = [];
     let companionUpdated = false;
     let companionWillRetry = false;
     const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
     try {
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       installedSkills = syncResult.installed;
-      stagedSkills = syncResult.staged;
-      customizedSkills = syncResult.customized;
+      for (const skill of syncResult.stagedThisSync) {
+        stagedSkillsThisUpdate.add(skill);
+      }
+      for (const skill of [...syncResult.installed, ...syncResult.adopted]) {
+        stagedSkillsThisUpdate.delete(skill);
+      }
       if (syncResult.failed.length > 0) {
         log(
           `[auto-update-checker] Skill sync warnings/failures: ${syncResult.failed.join(', ')}`,
@@ -271,18 +321,17 @@ async function runBackgroundUpdateCheck(
     if (installedSkills.length > 0) {
       messageLines.push(`Added bundled skills: ${installedSkills.join(', ')}`);
     }
-    if (stagedSkills.length > 0) {
-      messageLines.push(`Staged skill updates: ${stagedSkills.join(', ')}`);
-    }
-    if (customizedSkills.length > 0) {
-      messageLines.push(`Customized skills: ${customizedSkills.join(', ')}`);
+    if (stagedSkillsThisUpdate.size > 0) {
+      messageLines.push(
+        `Staged skill updates require manual review: ${[...stagedSkillsThisUpdate].join(', ')}`,
+      );
     }
     if (companionUpdated) {
       messageLines.push('Companion updated.');
     } else if (companionWillRetry) {
       messageLines.push('Companion update will retry on restart.');
     }
-    messageLines.push('Restart OpenCode to apply.');
+    messageLines.push('Restart OpenCode to apply the plugin update.');
 
     showToast(
       ctx,
@@ -303,6 +352,7 @@ async function runBackgroundUpdateCheck(
       8000,
     );
     log('[auto-update-checker] bun install failed; update not installed');
+    showStagedSkillsReviewToast(ctx, stagedSkillsThisUpdate);
   }
 }
 
@@ -316,6 +366,21 @@ function showMajorUpgradeToast(ctx: PluginInput, version: string): void {
   );
 }
 
+function showStagedSkillsReviewToast(
+  ctx: PluginInput,
+  stagedSkills: ReadonlySet<string>,
+): void {
+  if (stagedSkills.size === 0) return;
+
+  showToast(
+    ctx,
+    'Skill updates need review',
+    `Manual review required: ${[...stagedSkills].join(', ')}`,
+    'info',
+    8000,
+  );
+}
+
 export function getAutoUpdateInstallDir(): string {
   return resolveInstallContext()?.installDir ?? CACHE_DIR;
 }

+ 106 - 1
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -20,10 +20,14 @@ mock.module('node:fs', () => {
 
 let importCounter = 0;
 
-async function syncBundledSkillsFromPackage(packageRoot: string) {
+async function syncBundledSkillsFromPackage(
+  packageRoot: string,
+  options: { force?: boolean } = {},
+) {
   const module = await import(`./skill-sync?test=${importCounter++}`);
   return module.syncBundledSkillsFromPackage(packageRoot, {
     skills: getFakeManagedSkills(packageRoot),
+    ...options,
   });
 }
 
@@ -138,6 +142,102 @@ describe('syncBundledSkillsFromPackage', () => {
     );
   });
 
+  test('force overwrites customized managed skill directories', async () => {
+    const skillName = 'force-customized-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Skill');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged Skill');
+    fs.writeFileSync(
+      path.join(manifestDir, 'skills-manifest.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        updatedAt: new Date().toISOString(),
+        skills: {
+          [skillName]: {
+            status: 'customized',
+            packageVersion: '1.0.0',
+            sourceHash: 'old-source-hash',
+            lastManagedHash: 'old-managed-hash',
+            lastSeenHash: 'customized-hash',
+            stagedPath: stagedDir,
+            updatedAt: new Date().toISOString(),
+          },
+        },
+      }),
+    );
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Customized Skill');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot, {
+      force: true,
+    });
+
+    expect(result.installed).toContain(skillName);
+    expect(result.staged).not.toContain(skillName);
+    expect(result.customized).not.toContain(skillName);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Bundled Skill',
+    );
+    expect(fs.existsSync(stagedDir)).toBe(false);
+
+    const manifest = JSON.parse(
+      fs.readFileSync(path.join(manifestDir, 'skills-manifest.json'), 'utf-8'),
+    );
+    expect(manifest.skills[skillName].status).toBe('managed');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+  });
+
+  test('force skips destination files and symlinks without overwriting', async () => {
+    const fileSkill = 'force-file-skill';
+    const symlinkSkill = 'force-symlink-skill';
+    for (const skillName of [fileSkill, symlinkSkill]) {
+      const skillSrcDir = path.join(
+        fakePackageRoot,
+        'src',
+        'skills',
+        skillName,
+      );
+      fs.mkdirSync(skillSrcDir, { recursive: true });
+      fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Skill');
+    }
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const filePath = path.join(destSkillsDir, fileSkill);
+    fs.writeFileSync(filePath, '# User File');
+    const symlinkTarget = path.join(fakeDestConfigDir, 'symlink-target');
+    fs.mkdirSync(symlinkTarget, { recursive: true });
+    fs.writeFileSync(path.join(symlinkTarget, 'SKILL.md'), '# User Symlink');
+    const symlinkPath = path.join(destSkillsDir, symlinkSkill);
+    fs.symlinkSync(symlinkTarget, symlinkPath, 'dir');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot, {
+      force: true,
+    });
+
+    expect(result.installed).toHaveLength(0);
+    expect(result.skippedExisting).toEqual(
+      expect.arrayContaining([fileSkill, symlinkSkill]),
+    );
+    expect(fs.readFileSync(filePath, 'utf-8')).toBe('# User File');
+    expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
+    expect(fs.readFileSync(path.join(symlinkTarget, 'SKILL.md'), 'utf-8')).toBe(
+      '# User Symlink',
+    );
+  });
+
   test('ignores non-skill directories without SKILL.md', async () => {
     const skillName = 'no-skill-md';
     const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
@@ -406,6 +506,11 @@ describe('syncBundledSkillsFromPackage', () => {
     );
 
     expect(manifest.skills[skillName].status).toBe('customized');
+    expect(result.stagedThisSync).toEqual([skillName]);
+
+    const unchangedResult = await syncBundledSkillsFromPackage(fakePackageRoot);
+    expect(unchangedResult.customized).toEqual([skillName]);
+    expect(unchangedResult.stagedThisSync).toEqual([]);
   });
 
   test('fails closed (only installs missing) when manifest is corrupt', async () => {

+ 44 - 2
src/hooks/auto-update-checker/skill-sync.ts

@@ -37,6 +37,7 @@ export interface SkillSyncResult {
   staged: string[];
   adopted: string[];
   customized: string[];
+  stagedThisSync: string[];
 }
 
 export interface SkillManifestEntry {
@@ -62,6 +63,7 @@ interface ManagedSkillSource {
 
 interface SkillSyncOptions {
   skills?: ManagedSkillSource[];
+  force?: boolean;
 }
 
 /**
@@ -558,6 +560,7 @@ export function syncBundledSkillsFromPackage(
   const staged: string[] = [];
   const adopted: string[] = [];
   const customized: string[] = [];
+  const stagedThisSync: string[] = [];
 
   const sourceSkillsDir = path.join(packageRoot, 'src', 'skills');
 
@@ -574,6 +577,7 @@ export function syncBundledSkillsFromPackage(
         staged,
         adopted,
         customized,
+        stagedThisSync,
       };
     }
   } catch {
@@ -587,6 +591,7 @@ export function syncBundledSkillsFromPackage(
       staged,
       adopted,
       customized,
+      stagedThisSync,
     };
   }
 
@@ -630,6 +635,7 @@ export function syncBundledSkillsFromPackage(
       staged,
       adopted,
       customized,
+      stagedThisSync,
     };
   }
 
@@ -732,6 +738,37 @@ export function syncBundledSkillsFromPackage(
         }
 
         const sourceHash = computeDirectoryHash(sourcePath);
+        const entry = manifest.skills[skill.name];
+
+        if (options.force && destExists) {
+          try {
+            atomicReplaceDir(sourcePath, destPath);
+            if (entry?.stagedPath) {
+              removeManagedStagedPath(
+                entry.stagedPath,
+                manifestDir,
+                skill.name,
+              );
+            }
+            installed.push(skill.name);
+            manifest.skills[skill.name] = {
+              status: 'managed',
+              packageVersion,
+              sourceHash,
+              lastManagedHash: sourceHash,
+              lastSeenHash: sourceHash,
+              updatedAt: new Date().toISOString(),
+            };
+            log(`[skill-sync] Force-updated skill: ${skill.name}`);
+          } catch (err) {
+            log(
+              `[skill-sync] Failed to force-update skill ${skill.name}:`,
+              err,
+            );
+            failed.push(skill.name);
+          }
+          continue;
+        }
 
         if (isManifestCorrupt) {
           if (!destExists) {
@@ -793,6 +830,7 @@ export function syncBundledSkillsFromPackage(
                 };
                 staged.push(skill.name);
                 customized.push(skill.name);
+                stagedThisSync.push(skill.name);
               } catch (err) {
                 log(
                   `[skill-sync] Failed to stage update for customized skill ${skill.name} during recovery:`,
@@ -812,8 +850,6 @@ export function syncBundledSkillsFromPackage(
           continue;
         }
 
-        const entry = manifest.skills[skill.name];
-
         if (!destExists) {
           if (entry && entry.status === 'deleted') {
             log(
@@ -963,6 +999,7 @@ export function syncBundledSkillsFromPackage(
 
                   staged.push(skill.name);
                   customized.push(skill.name);
+                  stagedThisSync.push(skill.name);
                   skippedExisting.push(skill.name);
                   log(
                     `[skill-sync] Skill ${skill.name} is customized. Staged update at ${stagedSkillDir}`,
@@ -1028,6 +1065,7 @@ export function syncBundledSkillsFromPackage(
 
                   staged.push(skill.name);
                   customized.push(skill.name);
+                  stagedThisSync.push(skill.name);
                   skippedExisting.push(skill.name);
                   log(
                     `[skill-sync] Staged new update for customized skill ${skill.name} at ${stagedSkillDir}`,
@@ -1088,6 +1126,7 @@ export function syncBundledSkillsFromPackage(
 
                 staged.push(skill.name);
                 customized.push(skill.name);
+                stagedThisSync.push(skill.name);
                 skippedExisting.push(skill.name);
                 log(
                   `[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized and staged.`,
@@ -1148,6 +1187,7 @@ export function syncBundledSkillsFromPackage(
 
                 staged.push(skill.name);
                 customized.push(skill.name);
+                stagedThisSync.push(skill.name);
                 skippedExisting.push(skill.name);
                 log(
                   `[skill-sync] Conflicted skill ${skill.name} recovered as customized and staged at ${stagedSkillDir}`,
@@ -1223,6 +1263,7 @@ export function syncBundledSkillsFromPackage(
               };
               staged.push(skill.name);
               customized.push(skill.name);
+              stagedThisSync.push(skill.name);
               skippedExisting.push(skill.name);
               log(
                 `[skill-sync] Skill ${skill.name} is customized (no manifest entry). Staged update at ${stagedSkillDir}`,
@@ -1276,5 +1317,6 @@ export function syncBundledSkillsFromPackage(
     staged,
     adopted,
     customized,
+    stagedThisSync,
   };
 }

+ 1 - 0
src/hooks/auto-update-checker/types.ts

@@ -36,6 +36,7 @@ export interface AutoUpdateCheckerOptions {
 export interface PluginEntryInfo {
   entry: string;
   isPinned: boolean;
+  isInstallerManaged: boolean;
   pinnedVersion: string | null;
   configPath: string;
 }

+ 223 - 0
src/hooks/cache-monitor/index.test.ts

@@ -0,0 +1,223 @@
+import { describe, expect, test } from 'bun:test';
+import { createCacheMonitorHook } from './index';
+
+interface Warning {
+  message: string;
+  data: unknown;
+}
+
+function createHarness() {
+  const warnings: Warning[] = [];
+  const hook = createCacheMonitorHook({
+    logger: (message, data) => warnings.push({ message, data }),
+  });
+  return { hook, warnings };
+}
+
+function assistantMessageEvent(options: {
+  sessionID?: string;
+  messageID: string;
+  input: number;
+  cacheRead: number;
+  cacheWrite?: number;
+  completed?: boolean;
+}) {
+  return {
+    event: {
+      type: 'message.updated',
+      properties: {
+        info: {
+          role: 'assistant',
+          sessionID: options.sessionID ?? 'ses_monitor',
+          id: options.messageID,
+          time: options.completed === false ? {} : { completed: 1_700_000_000 },
+          tokens: {
+            input: options.input,
+            output: 100,
+            reasoning: 0,
+            cache: {
+              read: options.cacheRead,
+              write: options.cacheWrite ?? 0,
+            },
+          },
+        },
+      },
+    },
+  };
+}
+
+describe('createCacheMonitorHook', () => {
+  test('warns when a cache-hitting session drops to zero cache reads', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'a1',
+        input: 8000,
+        cacheRead: 0,
+        cacheWrite: 7000,
+      }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'a2', input: 500, cacheRead: 9000 }),
+    );
+    expect(warnings).toHaveLength(0);
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'a3', input: 12000, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('prompt-cache bust');
+    expect(warnings[0].data).toMatchObject({
+      sessionID: 'ses_monitor',
+      requestNumber: 3,
+      inputTokens: 12000,
+      previousCacheRead: 9000,
+    });
+  });
+
+  test('warns once per bust streak, re-arming after a cache hit', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b1', input: 8000, cacheRead: 6000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b2', input: 9000, cacheRead: 0 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b3', input: 9500, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(1);
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b4', input: 9500, cacheRead: 9000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'b5', input: 9600, cacheRead: 0 }),
+    );
+    expect(warnings).toHaveLength(2);
+  });
+
+  test('stays silent for providers that never report cache tokens', async () => {
+    const { hook, warnings } = createHarness();
+
+    for (const id of ['c1', 'c2', 'c3']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 20000, cacheRead: 0 }),
+      );
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('stays silent on the first request and on tiny prompts', async () => {
+    const { hook, warnings } = createHarness();
+
+    // First request of a session writes the cache; zero reads are expected.
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'd1',
+        input: 30000,
+        cacheRead: 0,
+        cacheWrite: 29000,
+      }),
+    );
+    // Small prompts sit below provider minimum cacheable prefixes.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'd2', input: 900, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('ignores streaming updates and duplicate completion events', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e1', input: 8000, cacheRead: 5000 }),
+    );
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'e2',
+        input: 8000,
+        cacheRead: 0,
+        completed: false,
+      }),
+    );
+    // Same completed message delivered twice must count once.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e3', input: 9000, cacheRead: 0 }),
+    );
+    await hook.event(
+      assistantMessageEvent({ messageID: 'e3', input: 9000, cacheRead: 0 }),
+    );
+
+    expect(warnings).toHaveLength(1);
+  });
+
+  test('tracks sessions independently and forgets deleted sessions', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-a',
+        messageID: 'f1',
+        input: 8000,
+        cacheRead: 5000,
+      }),
+    );
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-b',
+        messageID: 'f2',
+        input: 8000,
+        cacheRead: 0,
+        cacheWrite: 0,
+      }),
+    );
+    expect(warnings).toHaveLength(0);
+
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { info: { id: 's-a' } },
+      },
+    });
+    // After deletion the session history is gone; a zero-read request looks
+    // like a fresh session again and must not warn.
+    await hook.event(
+      assistantMessageEvent({
+        sessionID: 's-a',
+        messageID: 'f3',
+        input: 8000,
+        cacheRead: 0,
+      }),
+    );
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('fails open on malformed events', async () => {
+    const { hook, warnings } = createHarness();
+
+    await hook.event({ event: null });
+    await hook.event({ event: { type: 'message.updated' } });
+    await hook.event({
+      event: {
+        type: 'message.updated',
+        properties: {
+          info: {
+            role: 'assistant',
+            sessionID: 's',
+            id: 'x',
+            time: { completed: 1 },
+            tokens: { input: 'NaN' },
+          },
+        },
+      },
+    });
+
+    expect(warnings).toHaveLength(0);
+  });
+});

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

@@ -0,0 +1,179 @@
+/**
+ * Cache monitor — runtime watchdog for provider prompt-cache busts.
+ *
+ * Offline tests prove the plugin projects a byte-stable payload, but only
+ * the provider knows whether a cache prefix was actually reused. OpenCode
+ * surfaces per-request cache telemetry on assistant messages
+ * (`tokens.cache.read` / `tokens.cache.write`); this hook watches those
+ * numbers and logs a loud warning when a session that previously enjoyed
+ * cache hits suddenly reports zero cached tokens on a sizeable request —
+ * the field signature of a mid-session prompt-prefix change.
+ *
+ * Observation only: it never mutates messages or state, and it fails open
+ * on any unexpected event shape.
+ */
+
+import { isRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+
+/**
+ * Requests below this input size are ignored: tiny prompts sit under
+ * provider minimum-cacheable-prefix thresholds and legitimately report
+ * zero cached tokens.
+ */
+const MIN_INPUT_TOKENS_FOR_WARNING = 2048;
+const MAX_TRACKED_SESSIONS = 256;
+const MAX_TRACKED_MESSAGES_PER_SESSION = 512;
+
+interface SessionCacheState {
+  completedRequests: number;
+  everReportedCache: boolean;
+  lastCacheRead: number;
+  warnedSinceLastHit: boolean;
+  processedMessageIDs: Set<string>;
+}
+
+export interface CacheMonitorOptions {
+  logger?: (message: string, data?: unknown) => void;
+}
+
+interface CompletedAssistantMessage {
+  sessionID: string;
+  messageID: string;
+  inputTokens: number;
+  cacheRead: number;
+  cacheWrite: number;
+}
+
+function asFiniteNumber(value: unknown): number | undefined {
+  return typeof value === 'number' && Number.isFinite(value)
+    ? value
+    : undefined;
+}
+
+function parseCompletedAssistantMessage(
+  event: unknown,
+): CompletedAssistantMessage | undefined {
+  if (!isRecord(event) || event.type !== 'message.updated') return undefined;
+  const properties = isRecord(event.properties) ? event.properties : undefined;
+  const info =
+    properties && isRecord(properties.info) ? properties.info : undefined;
+  if (info?.role !== 'assistant') return undefined;
+  if (typeof info.sessionID !== 'string' || typeof info.id !== 'string') {
+    return undefined;
+  }
+
+  // Only completed requests carry final token accounting; message.updated
+  // also fires while streaming.
+  const time = isRecord(info.time) ? info.time : undefined;
+  if (!time || time.completed === undefined || time.completed === null) {
+    return undefined;
+  }
+
+  const tokens = isRecord(info.tokens) ? info.tokens : undefined;
+  if (!tokens) return undefined;
+  const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
+  const inputTokens = asFiniteNumber(tokens.input);
+  const cacheRead = asFiniteNumber(cache?.read);
+  const cacheWrite = asFiniteNumber(cache?.write);
+  if (
+    inputTokens === undefined ||
+    cacheRead === undefined ||
+    cacheWrite === undefined
+  ) {
+    return undefined;
+  }
+
+  return {
+    sessionID: info.sessionID,
+    messageID: info.id,
+    inputTokens,
+    cacheRead,
+    cacheWrite,
+  };
+}
+
+function deletedSessionID(event: unknown): string | undefined {
+  if (!isRecord(event) || event.type !== 'session.deleted') return undefined;
+  const properties = isRecord(event.properties) ? event.properties : undefined;
+  const info =
+    properties && isRecord(properties.info) ? properties.info : undefined;
+  return info && typeof info.id === 'string' ? info.id : undefined;
+}
+
+export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
+  const logger = options.logger ?? log;
+  const sessions = new Map<string, SessionCacheState>();
+
+  function getSessionState(sessionID: string): SessionCacheState {
+    const existing = sessions.get(sessionID);
+    if (existing) return existing;
+
+    if (sessions.size >= MAX_TRACKED_SESSIONS) {
+      const oldest = sessions.keys().next().value;
+      if (oldest !== undefined) sessions.delete(oldest);
+    }
+    const state: SessionCacheState = {
+      completedRequests: 0,
+      everReportedCache: false,
+      lastCacheRead: 0,
+      warnedSinceLastHit: false,
+      processedMessageIDs: new Set(),
+    };
+    sessions.set(sessionID, state);
+    return state;
+  }
+
+  function observe(message: CompletedAssistantMessage): void {
+    const state = getSessionState(message.sessionID);
+    if (state.processedMessageIDs.has(message.messageID)) return;
+    if (state.processedMessageIDs.size >= MAX_TRACKED_MESSAGES_PER_SESSION) {
+      state.processedMessageIDs.clear();
+    }
+    state.processedMessageIDs.add(message.messageID);
+    state.completedRequests += 1;
+
+    const busted =
+      state.completedRequests >= 2 &&
+      state.everReportedCache &&
+      message.cacheRead === 0 &&
+      message.inputTokens >= MIN_INPUT_TOKENS_FOR_WARNING;
+
+    if (busted && !state.warnedSinceLastHit) {
+      state.warnedSinceLastHit = true;
+      logger(
+        '[cache-monitor] possible prompt-cache bust: a session that was hitting the provider cache reported 0 cache-read tokens. A prompt-prefix byte likely changed mid-session — see docs/cache-verification.md.',
+        {
+          sessionID: message.sessionID,
+          requestNumber: state.completedRequests,
+          inputTokens: message.inputTokens,
+          previousCacheRead: state.lastCacheRead,
+        },
+      );
+    }
+
+    if (message.cacheRead > 0) state.warnedSinceLastHit = false;
+    state.everReportedCache =
+      state.everReportedCache ||
+      message.cacheRead > 0 ||
+      message.cacheWrite > 0;
+    state.lastCacheRead = message.cacheRead;
+  }
+
+  return {
+    event: async (input: { event: unknown }): Promise<void> => {
+      try {
+        const deleted = deletedSessionID(input.event);
+        if (deleted) {
+          sessions.delete(deleted);
+          return;
+        }
+
+        const message = parseCompletedAssistantMessage(input.event);
+        if (message) observe(message);
+      } catch {
+        // Observation only — never let telemetry break event handling.
+      }
+    },
+  };
+}

+ 58 - 0
src/hooks/cache-payload.snapshot.test.ts

@@ -0,0 +1,58 @@
+/**
+ * Golden snapshots of the prompt surfaces this plugin injects into the
+ * provider payload prefix.
+ *
+ * Any byte change to these surfaces invalidates the provider prompt cache
+ * for every existing session the next time it sends a request — the change
+ * may still be worth it, but it must be deliberate, not incidental. When one
+ * of these tests fails:
+ *
+ *   1. Confirm the payload change is intentional and worth a one-time,
+ *      fleet-wide cache re-warm (cost + latency on the first request of
+ *      every active session).
+ *   2. Update the snapshot with `bun test --update-snapshots` and let the
+ *      snapshot diff document the cache impact in the PR.
+ *
+ * Never update these snapshots to silence a failure you can't explain.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { buildOrchestratorPrompt } from '../agents/orchestrator';
+import { PHASE_REMINDER } from '../config/constants';
+import {
+  buildHistory,
+  createPipeline,
+  FIXTURE_NOW,
+  renderTurn,
+  SESSION_ID,
+} from './cache-safety-harness.test';
+
+describe('cache-impact snapshots (update deliberately — see file header)', () => {
+  test('phase reminder text', () => {
+    expect(PHASE_REMINDER).toMatchSnapshot();
+  });
+
+  test('orchestrator system prompt', () => {
+    const prompt = buildOrchestratorPrompt(new Set());
+    // Deterministic across invocations — a mismatch here means something
+    // volatile (time, randomness, environment) leaked into the prompt.
+    expect(buildOrchestratorPrompt(new Set())).toBe(prompt);
+    expect(prompt).toMatchSnapshot();
+  });
+
+  test('transformed payload for the canonical conversation fixture', async () => {
+    const pipeline = createPipeline();
+    pipeline.board.registerLaunch({
+      taskID: 'task-snapshot',
+      parentSessionID: SESSION_ID,
+      agent: 'explorer',
+      description: 'snapshot fixture job',
+      now: FIXTURE_NOW,
+    });
+
+    const history = buildHistory();
+    const output = await renderTurn(pipeline, history, history.length - 1);
+
+    expect(output.messages).toMatchSnapshot();
+  });
+});

+ 152 - 0
src/hooks/cache-safe-injection.test.ts

@@ -0,0 +1,152 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  appendTaggedSyntheticPart,
+  appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  hasTaggedPart,
+  isTaggedPart,
+  isVolatileTaggedMessage,
+  stripTaggedContent,
+} from './cache-safe-injection';
+import type { MessageWithParts } from './types';
+
+const KEY = 'oh-my-opencode-slim.testTag';
+
+function userMessage(text: string): MessageWithParts {
+  return {
+    info: { role: 'user', agent: 'orchestrator', sessionID: 's1', id: 'm1' },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+describe('createTaggedSyntheticPart', () => {
+  test('builds a synthetic text part with the tag winning over extras', () => {
+    const part = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+      extraMetadata: { other: 1, [KEY]: false },
+    });
+
+    expect(part).toEqual({
+      type: 'text',
+      synthetic: true,
+      text: 'hello',
+      metadata: { other: 1, [KEY]: true },
+    });
+  });
+});
+
+describe('isTaggedPart / hasTaggedPart', () => {
+  test('recognizes only synthetic parts carrying the exact tag', () => {
+    const tagged = createTaggedSyntheticPart({ text: 'x', metadataKey: KEY });
+    expect(isTaggedPart(tagged, KEY)).toBe(true);
+    expect(isTaggedPart(tagged, 'other-key')).toBe(false);
+    expect(isTaggedPart({ type: 'text', text: 'x' }, KEY)).toBe(false);
+    expect(
+      isTaggedPart({ type: 'text', text: 'x', metadata: { [KEY]: true } }, KEY),
+    ).toBe(false);
+    expect(isTaggedPart(undefined, KEY)).toBe(false);
+  });
+
+  test('hasTaggedPart scans all parts of a message', () => {
+    const message = userMessage('hi');
+    expect(hasTaggedPart(message, KEY)).toBe(false);
+    appendTaggedSyntheticPart(message, { text: 'r', metadataKey: KEY });
+    expect(hasTaggedPart(message, KEY)).toBe(true);
+  });
+});
+
+describe('appendTaggedSyntheticPart', () => {
+  test('appends at the tail without touching existing parts', () => {
+    const message = userMessage('original');
+    const before = JSON.stringify(message.parts[0]);
+
+    appendTaggedSyntheticPart(message, { text: 'reminder', metadataKey: KEY });
+
+    expect(message.parts).toHaveLength(2);
+    expect(JSON.stringify(message.parts[0])).toBe(before);
+    expect(isTaggedPart(message.parts[1], KEY)).toBe(true);
+  });
+});
+
+describe('stripTaggedContent', () => {
+  test('removes tagged parts from real messages and drops emptied synthetic messages', () => {
+    const real = userMessage('keep me');
+    appendTaggedSyntheticPart(real, { text: 'legacy', metadataKey: KEY });
+    const messages: unknown[] = [real];
+    appendTrailingVolatileMessage(
+      messages,
+      { role: 'user', id: 'm1-tag' },
+      { text: 'volatile', metadataKey: KEY },
+    );
+
+    stripTaggedContent(messages, KEY);
+
+    expect(messages).toHaveLength(1);
+    expect((messages[0] as MessageWithParts).parts).toHaveLength(1);
+    expect((messages[0] as MessageWithParts).parts[0].text).toBe('keep me');
+  });
+
+  test('leaves messages without the tag byte-identical', () => {
+    const real = userMessage('untouched');
+    const other = userMessage('also untouched');
+    appendTaggedSyntheticPart(other, {
+      text: 'different tag',
+      metadataKey: 'other-key',
+    });
+    const messages: unknown[] = [real, other];
+    const before = JSON.stringify(messages);
+
+    stripTaggedContent(messages, KEY);
+
+    expect(JSON.stringify(messages)).toBe(before);
+  });
+
+  test('preserves messages that were already empty', () => {
+    const empty: MessageWithParts = {
+      info: { role: 'user' },
+      parts: [],
+    };
+    const messages: unknown[] = [empty];
+
+    stripTaggedContent(messages, KEY);
+
+    expect(messages).toHaveLength(1);
+  });
+});
+
+describe('appendTrailingVolatileMessage / isVolatileTaggedMessage', () => {
+  test('appends a synthetic message at the end and marks it volatile', () => {
+    const real = userMessage('turn');
+    const messages: unknown[] = [real];
+
+    appendTrailingVolatileMessage(
+      messages,
+      { role: 'user', agent: 'orchestrator', sessionID: 's1', id: 'm1-board' },
+      { text: 'board', metadataKey: KEY },
+    );
+
+    expect(messages).toHaveLength(2);
+    expect(isVolatileTaggedMessage(messages[1], KEY)).toBe(true);
+    expect(isVolatileTaggedMessage(messages[0], KEY)).toBe(false);
+    expect(isVolatileTaggedMessage(messages[1], 'other-key')).toBe(false);
+  });
+
+  test('strip-then-append keeps at most one instance, always trailing', () => {
+    const real = userMessage('turn');
+    const messages: unknown[] = [real];
+
+    for (const text of ['board v1', 'board v2']) {
+      stripTaggedContent(messages, KEY);
+      appendTrailingVolatileMessage(
+        messages,
+        { role: 'user', id: 'm1-board' },
+        { text, metadataKey: KEY },
+      );
+    }
+
+    expect(messages).toHaveLength(2);
+    const trailing = messages[1] as MessageWithParts;
+    expect(trailing.parts[0].text).toBe('board v2');
+  });
+});

+ 139 - 0
src/hooks/cache-safe-injection.ts

@@ -0,0 +1,139 @@
+/**
+ * Cache-safe prompt injection helpers.
+ *
+ * Provider prompt caches are exact byte-prefix matches over the rendered
+ * request (tools → system → messages). Any transform that rewrites or
+ * reorders earlier conversation content invalidates the cache for everything
+ * after the first changed byte, so every later request in the session re-pays
+ * full input cost and latency.
+ *
+ * These helpers are the single supported way for hooks to add content to the
+ * outgoing payload:
+ *
+ * - `appendTaggedSyntheticPart` appends deterministic content at the tail of
+ *   an existing message. Safe because re-running the transform on the next
+ *   turn reproduces the same bytes at the same position.
+ * - `stripTaggedContent` + `appendTrailingVolatileMessage` own content that
+ *   changes between turns (job boards, status blocks): strip every previously
+ *   injected occurrence, then re-append one synthetic message at the very end
+ *   of the payload, so churn only ever costs the tail of the prompt.
+ *
+ * Rules the helpers encode (and the cache-safety property tests enforce):
+ * never mutate or reorder earlier messages, never inject unmarked parts, and
+ * never put timestamps or randomness into content injected before the tail.
+ * See docs/cache-verification.md.
+ */
+
+import { isRecord } from '../utils/guards';
+import {
+  isMessageWithParts,
+  type MessageInfo,
+  type MessagePart,
+  type MessageWithParts,
+} from './types';
+
+export interface TaggedSyntheticPartSpec {
+  /** Text content of the injected part. */
+  text: string;
+  /**
+   * Metadata key marking the part as plugin-injected. Used for dedupe and
+   * strip-before-reappend; must be stable for the lifetime of the feature.
+   */
+  metadataKey: string;
+  /** Additional metadata merged into the part (the tag key always wins). */
+  extraMetadata?: Record<string, unknown>;
+}
+
+/** Build a synthetic text part tagged with the given metadata key. */
+export function createTaggedSyntheticPart(
+  spec: TaggedSyntheticPartSpec,
+): MessagePart {
+  return {
+    type: 'text',
+    synthetic: true,
+    text: spec.text,
+    metadata: { ...(spec.extraMetadata ?? {}), [spec.metadataKey]: true },
+  };
+}
+
+/** True when the part is a synthetic part tagged with the metadata key. */
+export function isTaggedPart(part: unknown, metadataKey: string): boolean {
+  return (
+    isRecord(part) &&
+    part.synthetic === true &&
+    isRecord(part.metadata) &&
+    part.metadata[metadataKey] === true
+  );
+}
+
+/** True when any part of the message carries the tag. */
+export function hasTaggedPart(
+  message: MessageWithParts,
+  metadataKey: string,
+): boolean {
+  return message.parts.some((part) => isTaggedPart(part, metadataKey));
+}
+
+/**
+ * Append deterministic content as a tagged synthetic part at the message
+ * tail. The content must be a pure function of session-stable inputs so the
+ * next turn's transform reproduces identical bytes at the same position.
+ */
+export function appendTaggedSyntheticPart(
+  message: MessageWithParts,
+  spec: TaggedSyntheticPartSpec,
+): void {
+  message.parts.push(createTaggedSyntheticPart(spec));
+}
+
+/**
+ * Remove every part tagged with the metadata key across all messages and
+ * drop messages this empties (covers both legacy in-message placement and
+ * whole synthetic trailing messages).
+ */
+export function stripTaggedContent(
+  messages: unknown[],
+  metadataKey: string,
+): void {
+  for (let i = messages.length - 1; i >= 0; i -= 1) {
+    const message = messages[i];
+    if (!isMessageWithParts(message)) continue;
+    const hadParts = message.parts.length > 0;
+    message.parts = message.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+    if (hadParts && message.parts.length === 0) messages.splice(i, 1);
+  }
+}
+
+/**
+ * Append volatile content as its own synthetic message at the very end of
+ * the payload. Call `stripTaggedContent` first so at most one instance
+ * exists; the volatile zone must stay strictly behind all stable content.
+ */
+export function appendTrailingVolatileMessage(
+  messages: unknown[],
+  info: MessageInfo,
+  spec: TaggedSyntheticPartSpec,
+): void {
+  messages.push({
+    info,
+    parts: [createTaggedSyntheticPart(spec)],
+  });
+}
+
+/**
+ * True when the message consists solely of parts tagged with the metadata
+ * key — i.e. it is a plugin-owned volatile trailing message. Used by the
+ * cache-safety tests to separate the stable prefix from the volatile tail.
+ */
+export function isVolatileTaggedMessage(
+  message: unknown,
+  metadataKey: string,
+): boolean {
+  return (
+    isMessageWithParts(message) &&
+    message.parts.length > 0 &&
+    message.parts.every((part) => isTaggedPart(part, metadataKey))
+  );
+}

+ 238 - 0
src/hooks/cache-safety-harness.test.ts

@@ -0,0 +1,238 @@
+/**
+ * Shared harness for the cache-safety test suites (no tests of its own).
+ *
+ * The `.test.ts` suffix keeps this file out of the published build
+ * (tsconfig excludes `**\/*.test.ts`); it contains only fixtures and the
+ * pipeline mirror used by cache-safety.property.test.ts and
+ * cache-payload.snapshot.test.ts.
+ */
+
+import type { PluginConfig } from '../config';
+import { resolveImageRouting } from '../config/constants';
+import { BackgroundJobBoard, createInternalAgentTextPart } from '../utils';
+import { createDisplayNameMentionRewriter } from '../utils/agent-variant';
+import { isVolatileTaggedMessage } from './cache-safe-injection';
+import { createFilterAvailableSkillsHook } from './filter-available-skills';
+import { processImageAttachments } from './image-hook';
+import { createPhaseReminderHook } from './phase-reminder';
+import { createPostFileToolNudgeHook } from './post-file-tool-nudge';
+import { SessionLifecycle } from './session-lifecycle';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './task-session-manager';
+import type { MessageWithParts } from './types';
+
+export const SESSION_ID = 'ses_cache_safety_fixture';
+export const FIXTURE_NOW = 1_700_000_000_000;
+
+export type TransformOutput = { messages: unknown[] };
+
+export interface Pipeline {
+  run: (output: TransformOutput) => Promise<void>;
+  markFileToolPending: () => void;
+  board: BackgroundJobBoard;
+}
+
+/**
+ * Mirrors the transform composition in src/index.ts. The drift guard test in
+ * cache-safety.property.test.ts fails when the two fall out of sync — update
+ * BOTH when adding, removing, or reordering a transform step.
+ */
+export function createPipeline(): Pipeline {
+  const sessionAgentMap = new Map<string, string>();
+  const board = new BackgroundJobBoard();
+  const lifecycle = new SessionLifecycle(() => {});
+  const noopLog = () => {};
+
+  const rewriteDisplayNameMentions =
+    createDisplayNameMentionRewriter(undefined);
+
+  const shouldInjectOrchestratorReminder = (sessionID: string) =>
+    sessionAgentMap.get(sessionID) === 'orchestrator';
+
+  const taskSessionManagerHook = createTaskSessionManagerHook(
+    {
+      client: {
+        session: {
+          status: async () => ({ data: {} }),
+        },
+      },
+      directory: '/tmp/cache-safety-fixture',
+      worktree: '/tmp/cache-safety-fixture',
+    } as never,
+    {
+      maxSessionsPerAgent: 2,
+      backgroundJobBoard: board,
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+      registerSessionAsOrchestrator: (sessionID) => {
+        sessionAgentMap.set(sessionID, 'orchestrator');
+      },
+      coordinator: lifecycle,
+    },
+  );
+
+  const postFileToolNudge = createPostFileToolNudgeHook({
+    shouldInject: shouldInjectOrchestratorReminder,
+    coordinator: lifecycle,
+  });
+
+  const phaseReminder = createPhaseReminderHook({
+    shouldInject: shouldInjectOrchestratorReminder,
+  });
+
+  const filterAvailableSkills = createFilterAvailableSkillsHook(
+    {} as never,
+    {} as PluginConfig,
+  );
+
+  const run = async (output: TransformOutput): Promise<void> => {
+    for (const message of output.messages as MessageWithParts[]) {
+      if (message.info.role !== 'user') continue;
+      for (const part of message.parts) {
+        if (part.type !== 'text' || typeof part.text !== 'string') continue;
+        part.text = rewriteDisplayNameMentions(part.text);
+      }
+    }
+
+    processImageAttachments({
+      messages: output.messages as MessageWithParts[],
+      workDir: '/tmp/cache-safety-fixture',
+      imageRouting: resolveImageRouting(undefined),
+      disabledAgents: new Set(),
+      log: noopLog,
+    });
+
+    await taskSessionManagerHook['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await postFileToolNudge['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await phaseReminder['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await filterAvailableSkills['experimental.chat.messages.transform'](
+      {} as never,
+      output as never,
+    );
+    await taskSessionManagerHook.injectBackgroundJobBoard(
+      {} as never,
+      output as never,
+    );
+  };
+
+  return {
+    run,
+    markFileToolPending: () => lifecycle.markPending(SESSION_ID),
+    board,
+  };
+}
+
+export function userTurn(id: string, text: string, agent = 'orchestrator') {
+  return {
+    info: { role: 'user', agent, sessionID: SESSION_ID, id },
+    parts: [{ type: 'text', text }],
+  };
+}
+
+export function assistantTurn(id: string, text: string) {
+  return {
+    info: {
+      role: 'assistant',
+      agent: 'orchestrator',
+      sessionID: SESSION_ID,
+      id,
+    },
+    parts: [
+      { type: 'text', text },
+      {
+        type: 'tool',
+        tool: 'read',
+        callID: `${id}-call`,
+        state: {
+          status: 'completed',
+          input: { filePath: '/tmp/cache-safety-fixture/package.json' },
+          output: '{"name":"fixture"}',
+        },
+      },
+    ],
+  };
+}
+
+export function internalInitiatorTurn(id: string, text: string) {
+  return {
+    info: {
+      role: 'user',
+      agent: 'orchestrator',
+      sessionID: SESSION_ID,
+      id,
+    },
+    parts: [createInternalAgentTextPart(text)],
+  };
+}
+
+/**
+ * Conversation fixture covering the paths that produced past cache bugs:
+ * plain orchestrator turns, assistant tool loops, a specialist message, an
+ * internal-initiator continuation, and a message carrying a rewritable
+ * <available_skills> block.
+ */
+export function buildHistory(): unknown[] {
+  return [
+    userTurn('m01', 'set up the project'),
+    assistantTurn('m02', 'Reading the manifest first.'),
+    userTurn('m03', 'now add tests'),
+    assistantTurn('m04', 'Delegating test work.'),
+    userTurn('m05', 'specialist context', 'explorer'),
+    internalInitiatorTurn('m06', 'continue coordinating remaining todos'),
+    userTurn(
+      'm07',
+      'also consider skills\n<available_skills>\n<skill>\n<name>some-skill</name>\n<description>demo</description>\n</skill>\n</available_skills>',
+    ),
+    assistantTurn('m08', 'Wrapping up.'),
+    userTurn('m09', 'final adjustments please'),
+  ];
+}
+
+/**
+ * Indices whose message is the latest user turn of a simulated request.
+ * Only orchestrator turns end requests: when the acting agent changes, the
+ * host swaps system prompt and tools, so the provider cache restarts anyway
+ * and prefix stability across the switch is not a meaningful property.
+ */
+export function turnEndIndices(history: unknown[]): number[] {
+  const indices: number[] = [];
+  for (const [index, message] of history.entries()) {
+    const info = (message as MessageWithParts).info;
+    if (info.role === 'user' && info.agent === 'orchestrator') {
+      indices.push(index);
+    }
+  }
+  return indices;
+}
+
+export function stableFingerprints(messages: unknown[]): string[] {
+  return messages
+    .filter(
+      (message) =>
+        !isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+    )
+    .map((message) => JSON.stringify(message));
+}
+
+export async function renderTurn(
+  pipeline: Pipeline,
+  history: unknown[],
+  endIndex: number,
+): Promise<TransformOutput> {
+  const output: TransformOutput = {
+    messages: structuredClone(history.slice(0, endIndex + 1)),
+  };
+  await pipeline.run(output);
+  return output;
+}

+ 261 - 0
src/hooks/cache-safety.property.test.ts

@@ -0,0 +1,261 @@
+/**
+ * Cache-safety property tests for the message-transform pipeline.
+ *
+ * Provider prompt caches are exact byte-prefix matches over the rendered
+ * request. These tests do not enumerate known-good payload shapes; they
+ * assert the two properties every transform must uphold for caching to
+ * survive across turns:
+ *
+ * 1. Turn-over-turn prefix stability — re-rendering a growing conversation
+ *    must reproduce byte-identical historical messages, with volatile
+ *    content confined to the tagged trailing zone.
+ * 2. Determinism — ambient inputs that should not matter (wall clock,
+ *    randomness, background-job churn) must not change any stable byte.
+ *
+ * The pipeline below mirrors the composition in src/index.ts
+ * ('experimental.chat.messages.transform'). A drift guard test fails when
+ * src/index.ts gains, loses, or reorders transform steps so this suite can
+ * never silently fall out of sync with production.
+ */
+
+import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { isVolatileTaggedMessage } from './cache-safe-injection';
+import {
+  assistantTurn,
+  buildHistory,
+  createPipeline,
+  FIXTURE_NOW,
+  renderTurn,
+  SESSION_ID,
+  stableFingerprints,
+  type TransformOutput,
+  turnEndIndices,
+} from './cache-safety-harness.test';
+import { BACKGROUND_JOB_BOARD_METADATA_KEY } from './task-session-manager';
+
+afterEach(() => {
+  setSystemTime();
+});
+
+describe('cache-safety: turn-over-turn prefix stability', () => {
+  test('re-rendering a growing conversation reproduces byte-identical history', async () => {
+    const pipeline = createPipeline();
+    const history = buildHistory();
+    const turns = turnEndIndices(history);
+
+    let previous: string[] | undefined;
+    for (const [turnNumber, endIndex] of turns.entries()) {
+      // Exercise cross-turn hook state: a file-tool nudge fires before the
+      // second turn, and background jobs churn (launch, then drop) while
+      // later turns render — none of it may touch stable bytes.
+      if (turnNumber === 1) pipeline.markFileToolPending();
+      if (turnNumber === 2) {
+        pipeline.board.registerLaunch({
+          taskID: 'task-alpha',
+          parentSessionID: SESSION_ID,
+          agent: 'explorer',
+          description: 'churn fixture',
+          now: FIXTURE_NOW,
+        });
+      }
+      if (turnNumber === 3) pipeline.board.drop('task-alpha');
+
+      const output = await renderTurn(pipeline, history, endIndex);
+      const fingerprints = stableFingerprints(output.messages);
+
+      if (previous) {
+        if (fingerprints.length < previous.length) {
+          throw new Error(
+            'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
+          );
+        }
+        expect(fingerprints.slice(0, previous.length)).toEqual(previous);
+      }
+      previous = fingerprints;
+    }
+  });
+
+  test('a consumed file-tool nudge is reproduced by the phase reminder on the next turn', async () => {
+    const pipeline = createPipeline();
+    const history = buildHistory();
+
+    // Register the session (turn 1), then mark a pending nudge and render
+    // turn 2: the nudge injects into the latest user message.
+    await renderTurn(pipeline, history, 0);
+    pipeline.markFileToolPending();
+    const turnWithNudge = await renderTurn(pipeline, history, 2);
+
+    // Turn 3 renders the same message as history; the phase reminder must
+    // reproduce the exact bytes the nudge produced a turn earlier.
+    const nextTurn = await renderTurn(pipeline, history, 3);
+
+    const nudgedMessage = JSON.stringify(turnWithNudge.messages[2]);
+    const historicalMessage = JSON.stringify(nextTurn.messages[2]);
+    expect(historicalMessage).toBe(nudgedMessage);
+  });
+});
+
+describe('cache-safety: specialist sessions', () => {
+  test('non-orchestrator payloads pass through byte-identical', async () => {
+    const pipeline = createPipeline();
+    const specialistSession = 'ses_specialist_fixture';
+    const history = [
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's01',
+        },
+        parts: [{ type: 'text', text: 'find the config loader' }],
+      },
+      assistantTurn('s02', 'Searching now.'),
+      {
+        info: {
+          role: 'user',
+          agent: 'explorer',
+          sessionID: specialistSession,
+          id: 's03',
+        },
+        parts: [{ type: 'text', text: 'summarize what you found' }],
+      },
+    ];
+    const before = history.map((message) => JSON.stringify(message));
+
+    const output: TransformOutput = { messages: structuredClone(history) };
+    await pipeline.run(output);
+
+    expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
+      before,
+    );
+  });
+});
+
+describe('cache-safety: volatile content isolation', () => {
+  test('background-job state only ever changes the tagged trailing message', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+
+    const emptyBoard = createPipeline();
+    const busyBoard = createPipeline();
+    busyBoard.board.registerLaunch({
+      taskID: 'task-beta',
+      parentSessionID: SESSION_ID,
+      agent: 'fixer',
+      description: 'volatile isolation fixture',
+      now: FIXTURE_NOW,
+    });
+
+    const withoutJobs = await renderTurn(emptyBoard, history, lastTurn);
+    const withJobs = await renderTurn(busyBoard, history, lastTurn);
+
+    expect(stableFingerprints(withJobs.messages)).toEqual(
+      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),
+    );
+    expect(volatile).toHaveLength(1);
+    expect(withJobs.messages.at(-1)).toBe(volatile[0]);
+    expect(
+      withoutJobs.messages.some((message) =>
+        isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
+      ),
+    ).toBe(false);
+  });
+});
+
+describe('cache-safety: determinism under ambient inputs', () => {
+  test('wall clock and randomness never leak into the payload', async () => {
+    const history = buildHistory();
+    const lastTurn = history.length - 1;
+    const originalRandom = Math.random;
+
+    const render = async (time: number, random: number): Promise<string[]> => {
+      setSystemTime(new Date(time));
+      Math.random = () => random;
+      try {
+        const pipeline = createPipeline();
+        pipeline.board.registerLaunch({
+          taskID: 'task-gamma',
+          parentSessionID: SESSION_ID,
+          agent: 'oracle',
+          description: 'determinism fixture',
+          now: FIXTURE_NOW,
+        });
+        const output = await renderTurn(pipeline, history, lastTurn);
+        return output.messages.map((message) => JSON.stringify(message));
+      } finally {
+        Math.random = originalRandom;
+        setSystemTime();
+      }
+    };
+
+    const first = await render(FIXTURE_NOW, 0.1234);
+    const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
+
+    expect(second).toEqual(first);
+  });
+});
+
+describe('cache-safety: pipeline drift guard', () => {
+  const srcRoot = path.resolve(import.meta.dir, '..');
+
+  test('src/index.ts transform order matches this suite', () => {
+    const source = readFileSync(path.join(srcRoot, 'index.ts'), 'utf8');
+
+    const orderedCalls = [
+      ...source.matchAll(
+        /await (\w+)\['experimental\.chat\.messages\.transform'\]\(/g,
+      ),
+    ].map((match) => match[1]);
+
+    // If this fails, src/index.ts gained, lost, or reordered a transform
+    // step. Update createPipeline() in this file to match, then update this
+    // expectation — the property tests are only meaningful while the two
+    // stay in lockstep.
+    expect(orderedCalls).toEqual([
+      'taskSessionManagerHook',
+      'postFileToolNudge',
+      'phaseReminder',
+      'filterAvailableSkills',
+    ]);
+    expect(source).toContain(
+      'await taskSessionManagerHook.injectBackgroundJobBoard(',
+    );
+
+    // One handler definition plus the four dispatch calls above.
+    const literalCount = source.split(
+      "'experimental.chat.messages.transform'",
+    ).length;
+    expect(literalCount - 1).toBe(5);
+  });
+
+  test('every hook module defining a message transform is covered here', async () => {
+    const glob = new Bun.Glob('**/*.ts');
+    const hookFilesWithTransforms: string[] = [];
+    const hooksDir = path.join(srcRoot, 'hooks');
+
+    for await (const file of glob.scan(hooksDir)) {
+      if (file.endsWith('.test.ts')) continue;
+      const content = readFileSync(path.join(hooksDir, file), 'utf8');
+      if (content.includes("'experimental.chat.messages.transform'")) {
+        hookFilesWithTransforms.push(file);
+      }
+    }
+
+    // If a new file appears here, wire its transform into createPipeline()
+    // above (in the same order as src/index.ts) so the cache-safety
+    // properties cover it, then add it to this list.
+    expect(hookFilesWithTransforms.sort()).toEqual([
+      'filter-available-skills/index.ts',
+      'phase-reminder/index.ts',
+      'post-file-tool-nudge/index.ts',
+      'task-session-manager/index.ts',
+    ]);
+  });
+});

+ 4 - 5
src/hooks/deepwork/index.ts

@@ -12,12 +12,11 @@ function activationPrompt(task: string): string {
     '- create/update a `.slim/deepwork/` progress file;',
     '- save code/doc deliverables to project paths (e.g. `src/`, `docs/`); reserve `.slim/deepwork/` strictly for progress files;',
     '- keep OpenCode todos synced with the current phase;',
-    '- draft a plan and get `@oracle` review before implementation;',
-    '- create and review a phased implementation/delegation plan;',
+    '- draft a phased implementation/delegation plan with a small number of coherent phases based on dependencies and natural delivery boundaries; do not split work merely to reduce review scope;',
+    '- before execution, show the user a compact overview with phase titles/order, delegated specialists and ownership/scope, plus the Oracle review total, gate after each phase, and a short reason for each;',
     '- execute phase by phase with background specialists where useful;',
-    '- wait for hook-driven background completion, reconcile results, validate, and ask `@oracle` to review each phase;',
-    '- ask `@oracle` to include simplify/readability feedback in phase reviews;',
-    '- fix actionable review issues before continuing.',
+    '- wait for hook-driven background completion, reconcile results, validate and update state, then ask `@oracle` to review every planned phase before continuing;',
+    '- batch material actionable Oracle findings, including simplify/readability feedback, into one bounded remediation pass and validate it with focused evidence; only re-review when the remediation changes the reviewed decision/risk or the original concern cannot otherwise be verified.',
     '',
     'Task:',
     task,

+ 102 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -1392,6 +1392,108 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
   });
 });
 
+// ---------------------------------------------------------------------------
+// No-chain sessions (councillor / self-managed agents)
+// ---------------------------------------------------------------------------
+
+describe('ForegroundFallbackManager no-chain sessions', () => {
+  test('councillor session.status retry: no abort and no re-prompt', async () => {
+    // Councillor is owned by CouncilManager (own model chain + timeout).
+    // FG must not abort or re-prompt — that races the council lifecycle and
+    // previously produced "[foreground-fallback] no chain configured" noise.
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'councillor-sess',
+          agent: 'councillor',
+          providerID: 'openai',
+          modelID: 'gpt-5.4',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'councillor-sess',
+        status: {
+          type: 'retry',
+          attempt: 1,
+          message: 'rate limit, retrying...',
+        },
+      },
+    });
+
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('councillor session.error: no abort and no re-prompt', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'councillor-err',
+          agent: 'councillor',
+          providerID: 'openai',
+          modelID: 'gpt-5.4',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'councillor-err',
+        error: { message: 'rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('disableChain agent on session.status: no abort (not just no re-prompt)', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+    mgr.disableChain('orchestrator');
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'disabled-status',
+          agent: 'orchestrator',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'disabled-status',
+        status: {
+          type: 'retry',
+          attempt: 1,
+          message: 'rate limit, retrying...',
+        },
+      },
+    });
+
+    expect(mocks.abort).not.toHaveBeenCalled();
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+  });
+});
+
 // ---------------------------------------------------------------------------
 // disableChain API
 // ---------------------------------------------------------------------------

+ 20 - 7
src/hooks/foreground-fallback/index.ts

@@ -470,6 +470,9 @@ export class ForegroundFallbackManager {
   private async tryFallback(sessionID: string): Promise<void> {
     if (!sessionID) return;
     if (this.inProgress.has(sessionID)) return;
+    // No chain → no fallback. Skip before dedup so we don't stamp lastTrigger
+    // for sessions we will never re-prompt (e.g. councillor via CouncilManager).
+    if (!this.hasFallbackChain(sessionID)) return;
 
     // Deduplicate: multiple events can fire for a single rate-limit event.
     // Bypass dedup when the model changed since the last trigger - the new
@@ -490,10 +493,15 @@ export class ForegroundFallbackManager {
    * session is in retry mode.  inProgress is set first so the
    * task-session-manager sees isFallbackInProgress()=true during the
    * abort idle window and does not cancel the pending task call.
+   *
+   * When no chain is available, do nothing (no abort, no log). Aborting
+   * without a replacement model only races owners that manage their own
+   * lifecycle (e.g. CouncilManager for councillor) and produces noise.
    */
   private async tryFallbackWithAbort(sessionID: string): Promise<void> {
     if (!sessionID) return;
     if (this.inProgress.has(sessionID)) return;
+    if (!this.hasFallbackChain(sessionID)) return;
     if (this.isDeduped(sessionID)) return;
 
     this.inProgress.add(sessionID);
@@ -528,13 +536,8 @@ export class ForegroundFallbackManager {
       let currentModel = this.sessionModel.get(sessionID);
       const agentName = this.sessionAgent.get(sessionID);
       const chain = this.resolveChain(agentName, currentModel);
-      if (!chain.length) {
-        log('[foreground-fallback] no chain configured', {
-          sessionID,
-          agentName,
-        });
-        return;
-      }
+      // Callers pre-check via hasFallbackChain; keep as defensive guard only.
+      if (!chain.length) return;
 
       // When the agent is known but no model was captured (common for
       // subagent error events that fire before message.updated), infer
@@ -675,6 +678,16 @@ export class ForegroundFallbackManager {
   // Chain resolution
   // ---------------------------------------------------------------------------
 
+  /** True when resolveChain yields at least one model for this session. */
+  private hasFallbackChain(sessionID: string): boolean {
+    return (
+      this.resolveChain(
+        this.sessionAgent.get(sessionID),
+        this.sessionModel.get(sessionID),
+      ).length > 0
+    );
+  }
+
   /**
    * Determine the fallback chain to use for a session.
    *

+ 14 - 0
src/hooks/index.ts

@@ -1,6 +1,20 @@
 export { createApplyPatchHook } from './apply-patch';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
+export {
+  type CacheMonitorOptions,
+  createCacheMonitorHook,
+} from './cache-monitor';
+export {
+  appendTaggedSyntheticPart,
+  appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  hasTaggedPart,
+  isTaggedPart,
+  isVolatileTaggedMessage,
+  stripTaggedContent,
+  type TaggedSyntheticPartSpec,
+} from './cache-safe-injection';
 export { createChatHeadersHook } from './chat-headers';
 export { createDeepworkCommandHook } from './deepwork';
 export { createDelegateTaskRetryHook } from './delegate-task-retry/hook';

+ 7 - 10
src/hooks/phase-reminder/index.ts

@@ -7,7 +7,10 @@
  */
 import { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
-import { isRecord } from '../../utils/guards';
+import {
+  appendTaggedSyntheticPart,
+  isTaggedPart,
+} from '../cache-safe-injection';
 import {
   findLatestUserMessage,
   isUserMessageWithParts,
@@ -19,11 +22,7 @@ export { PHASE_REMINDER };
 export const PHASE_REMINDER_METADATA_KEY = 'oh-my-opencode-slim.phaseReminder';
 
 export function hasPhaseReminder(part: MessagePart): boolean {
-  return (
-    part.synthetic === true &&
-    isRecord(part.metadata) &&
-    part.metadata[PHASE_REMINDER_METADATA_KEY] === true
-  );
+  return isTaggedPart(part, PHASE_REMINDER_METADATA_KEY);
 }
 
 interface PhaseReminderOptions {
@@ -81,11 +80,9 @@ export function createPhaseReminderHook(options: PhaseReminderOptions = {}) {
           continue;
         }
 
-        message.parts.push({
-          type: 'text',
-          synthetic: true,
+        appendTaggedSyntheticPart(message, {
           text: PHASE_REMINDER,
-          metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+          metadataKey: PHASE_REMINDER_METADATA_KEY,
         });
       }
     },

+ 3 - 4
src/hooks/post-file-tool-nudge/index.ts

@@ -8,6 +8,7 @@
 
 import { PHASE_REMINDER } from '../../config/constants';
 import { isInternalInitiatorPart } from '../../utils';
+import { appendTaggedSyntheticPart } from '../cache-safe-injection';
 import {
   hasPhaseReminder,
   PHASE_REMINDER_METADATA_KEY,
@@ -65,11 +66,9 @@ export function createPostFileToolNudgeHook(
       if (!coordinator.consumePending(sessionID)) return;
       if (hasReminder) return;
       // This transform must run before phase-reminder so this metadata deduplicates.
-      message.parts.push({
-        type: 'text',
-        synthetic: true,
+      appendTaggedSyntheticPart(message, {
         text: PHASE_REMINDER,
-        metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+        metadataKey: PHASE_REMINDER_METADATA_KEY,
       });
     },
   };

+ 563 - 0
src/hooks/task-session-manager/board-injection.ts

@@ -0,0 +1,563 @@
+/**
+ * Board injection subsystem for task session manager.
+ *
+ * Handles injecting Background Job Board state into the message stream
+ * and processing synthetic injected completions.
+ *
+ * All injection logic must go through the cache-safe helpers in
+ * ../cache-safe-injection.ts to ensure prompt cache safety.
+ */
+import type {
+  BackgroundJobRecord,
+  BackgroundJobStore,
+  ContextFile,
+} from '../../utils';
+import { isInternalInitiatorPart, parseTaskStatusOutput } from '../../utils';
+import { log } from '../../utils/logger';
+import {
+  appendTrailingVolatileMessage,
+  createTaggedSyntheticPart,
+  isTaggedPart,
+  stripTaggedContent,
+} from '../cache-safe-injection';
+import type { MessagePart, MessageWithParts } from '../types';
+import { isMessageWithParts, isUserMessageWithParts } from '../types';
+import {
+  extractTaskSummary,
+  formatCancelledTaskStatusOutput,
+  isLateCancelledTaskError,
+  updateBackgroundJobFromOutput,
+} from './status-utils';
+
+// ── Constants ──────────────────────────────────────────────────────────
+
+export const BACKGROUND_JOB_BOARD_METADATA_KEY =
+  'oh-my-opencode-slim.backgroundJobBoard';
+
+const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
+const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
+
+export const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const MAX_RETAINED_BOARD_SNAPSHOTS = 20;
+
+type RetainedBoardSnapshot = {
+  anchorKey: string;
+  id: string;
+  text: string;
+};
+
+export type RetainedBoardSnapshotState = {
+  snapshots: RetainedBoardSnapshot[];
+  nextSnapshotSequence: number;
+  realMessageCount: number;
+  firstRealMessageAnchorKey?: string;
+};
+
+// ── State shape ────────────────────────────────────────────────────────
+
+export interface InjectionState {
+  backgroundJobBoard: BackgroundJobStore;
+  strategy: 'latest' | 'checkpoint-compatible';
+  processedInjectedCompletions: Set<string>;
+  processedInjectedCompletionOrder: string[];
+  terminalJobsInjectedByParent: Map<string, Set<string>>;
+  maxProcessedInjectedCompletions: number;
+  metadataKey: string;
+  shouldManageSession: (sessionID: string) => boolean;
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  };
+  retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────
+
+function djb2Hash(str: string): string {
+  let hash = 5381;
+  for (let i = 0; i < str.length; i++) {
+    hash = (hash << 5) + hash + str.charCodeAt(i);
+  }
+  return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+function createOccurrenceId(
+  part: MessagePart,
+  message: MessageWithParts,
+  partIndex: number,
+): string {
+  if (typeof part.id === 'string') {
+    return part.id;
+  }
+
+  if (typeof message.info.id === 'string') {
+    return `${message.info.id}:${partIndex}`;
+  }
+
+  const sessionID = message.info.sessionID ?? 'unknown';
+  const content = typeof part.text === 'string' ? part.text : '';
+
+  const status = parseTaskStatusOutput(content);
+  if (status) {
+    const stableKey = `${sessionID}:${status.taskID}:${status.state}:${status.result ?? ''}`;
+    const hash = djb2Hash(stableKey);
+    return `anon:${hash}`;
+  }
+
+  const hash = djb2Hash(`${sessionID}:${content}`);
+  return `anon:${hash}`;
+}
+
+// ── Exported functions ─────────────────────────────────────────────────
+
+export function updateFromInjectedCompletion(
+  state: InjectionState,
+  part: MessagePart,
+  message: MessageWithParts,
+  _messageIndex: number,
+  partIndex: number,
+): BackgroundJobRecord | undefined {
+  if (part.type !== 'text' || typeof part.text !== 'string') {
+    return undefined;
+  }
+
+  if (part.synthetic !== true) return undefined;
+
+  const status = parseTaskStatusOutput(part.text);
+  if (!status) {
+    log('[task-session-manager] synthetic part missing task status', {
+      textPreview: part.text.slice(0, 120),
+    });
+    return undefined;
+  }
+  if (status.state !== 'completed' && status.state !== 'error') {
+    return undefined;
+  }
+
+  const summary = extractTaskSummary(part.text);
+  const isCompleted = summary
+    ? BACKGROUND_COMPLETION_COMPLETED.test(summary)
+    : status.state === 'completed';
+  const isFailed = summary
+    ? BACKGROUND_COMPLETION_FAILED.test(summary)
+    : status.state === 'error';
+  if (summary && !isCompleted && !isFailed) return undefined;
+
+  const occurrenceId = createOccurrenceId(part, message, partIndex);
+
+  const existing = state.backgroundJobBoard.get(status.taskID);
+  if (isFailed && isLateCancelledTaskError(existing, status.state)) {
+    part.text = formatCancelledTaskStatusOutput(
+      status.taskID,
+      state.backgroundJobBoard.getResultSummary(status.taskID),
+    );
+    log('[task-session-manager] normalized late cancelled injected failure', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      parsedState: status.state,
+      boardState: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    rememberProcessedInjectedCompletion(state, occurrenceId);
+    return existing;
+  }
+
+  if (isCompleted && status.state !== 'completed') return undefined;
+  if (isFailed && status.state !== 'error') return undefined;
+
+  if (state.processedInjectedCompletions.has(occurrenceId)) return undefined;
+
+  const updated = updateBackgroundJobFromOutput(
+    part.text,
+    state.backgroundJobBoard,
+    state.taskContextTracker,
+  );
+  if (!updated) return undefined;
+
+  log('[task-session-manager] processed injected background completion', {
+    taskID: updated.taskID,
+    alias: updated.alias,
+    parentSessionID: updated.parentSessionID,
+    state: updated.state,
+    occurrenceId,
+  });
+
+  rememberProcessedInjectedCompletion(state, occurrenceId);
+  return updated;
+}
+
+export function rememberProcessedInjectedCompletion(
+  state: InjectionState,
+  signature: string,
+): void {
+  state.processedInjectedCompletions.add(signature);
+  state.processedInjectedCompletionOrder.push(signature);
+
+  while (
+    state.processedInjectedCompletionOrder.length >
+    state.maxProcessedInjectedCompletions
+  ) {
+    const evicted = state.processedInjectedCompletionOrder.shift();
+    if (!evicted) break;
+    state.processedInjectedCompletions.delete(evicted);
+  }
+}
+
+export function isMissingRememberedSessionError(output: string): boolean {
+  const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
+  return (
+    firstLine.startsWith('[error]') &&
+    firstLine.includes('session') &&
+    (firstLine.includes('not found') || firstLine.includes('no session'))
+  );
+}
+
+export function rememberInjectedTerminalJobs(
+  state: InjectionState,
+  parentSessionID: string,
+): void {
+  const taskIDs = state.backgroundJobBoard
+    .list(parentSessionID)
+    .filter((job) => job.terminalUnreconciled)
+    .map((job) => job.taskID);
+  if (taskIDs.length === 0) return;
+
+  log('[task-session-manager] terminal jobs injected for reconciliation', {
+    parentSessionID,
+    taskIDs,
+  });
+
+  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;
+
+  log('[task-session-manager] reconciling injected terminal jobs', {
+    parentSessionID,
+    taskIDs: [...taskIDs],
+  });
+
+  for (const taskID of taskIDs) {
+    state.backgroundJobBoard.markReconciled(taskID);
+  }
+  state.terminalJobsInjectedByParent.delete(parentSessionID);
+}
+
+export async function injectBackgroundJobBoard(
+  state: InjectionState,
+  _input: Record<string, never>,
+  output: { messages?: unknown },
+): 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);
+  }
+
+  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;
+    }
+
+    const reminder = state.backgroundJobBoard.formatForPrompt(
+      message.info.sessionID,
+    );
+    if (!reminder) return;
+
+    const textPart = message.parts.find(
+      (part) => part.type === 'text' && typeof part.text === 'string',
+    );
+    if (!textPart || isInternalInitiatorPart(textPart)) return;
+
+    if (state.strategy === 'checkpoint-compatible') {
+      injectCheckpointBoard(state, messages, message, reminder);
+      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.
+    appendTrailingVolatileMessage(
+      messages,
+      {
+        ...message.info,
+        id: `${message.info.id}-background-job-board`,
+      },
+      {
+        text: reminder,
+        metadataKey: state.metadataKey,
+      },
+    );
+    return;
+  }
+}
+
+function injectCheckpointBoard(
+  state: InjectionState,
+  messages: unknown[],
+  message: MessageWithParts,
+  reminder: string,
+): void {
+  const sessionID = message.info.sessionID;
+  if (!sessionID) return;
+  const currentMessages = realMessages(messages, state.metadataKey);
+  const snapshotState = updateBoardHistoryState(
+    state,
+    sessionID,
+    currentMessages,
+  );
+  const anchorKey = findMessageAnchorKey(currentMessages, message);
+  if (!anchorKey) return;
+
+  if (snapshotState.snapshots.at(-1)?.text !== reminder && reminder) {
+    const encodedSessionID = encodeURIComponent(sessionID);
+    const sequence = snapshotState.nextSnapshotSequence;
+    snapshotState.nextSnapshotSequence += 1;
+    snapshotState.snapshots.push({
+      anchorKey,
+      id: `oh-my-opencode-slim:background-job-board:${encodedSessionID}:${sequence}`,
+      text: reminder,
+    });
+    if (snapshotState.snapshots.length > MAX_RETAINED_BOARD_SNAPSHOTS) {
+      snapshotState.snapshots.splice(
+        0,
+        snapshotState.snapshots.length - MAX_RETAINED_BOARD_SNAPSHOTS,
+      );
+    }
+  }
+
+  rememberInjectedTerminalJobs(state, sessionID);
+  replayCheckpointBoard(
+    messages,
+    message,
+    sessionID,
+    snapshotState,
+    state.metadataKey,
+  );
+}
+
+function boardHistoryMessageSignature(message: MessageWithParts): string {
+  const text = message.parts
+    .filter(
+      (part) =>
+        part.synthetic !== true &&
+        part.type === 'text' &&
+        typeof part.text === 'string',
+    )
+    .map((part) => part.text)
+    .join('\n');
+  return `${message.info.role}:${message.info.agent ?? ''}:${text}`;
+}
+
+function messageAnchorKeys(messages: MessageWithParts[]): string[] {
+  const occurrences = new Map<string, number>();
+  return messages.map((message) => {
+    const base = message.info.id
+      ? `id:${message.info.id}`
+      : `anonymous:${boardHistoryMessageSignature(message)}`;
+    const occurrence = occurrences.get(base) ?? 0;
+    occurrences.set(base, occurrence + 1);
+    return `${base}:${occurrence}`;
+  });
+}
+
+function realMessages(
+  messages: unknown[],
+  metadataKey: string,
+): MessageWithParts[] {
+  return messages.flatMap((message) => {
+    if (!isMessageWithParts(message)) return [];
+    const parts = message.parts.filter(
+      (part) => !isTaggedPart(part, metadataKey),
+    );
+    return parts.length > 0 ? [{ ...message, parts }] : [];
+  });
+}
+
+function hasCompacted(
+  previous: RetainedBoardSnapshotState,
+  currentMessages: MessageWithParts[],
+): boolean {
+  if (currentMessages.length < previous.realMessageCount) return true;
+
+  const currentAnchorKeys = messageAnchorKeys(currentMessages);
+  return (
+    (currentAnchorKeys[0] !== undefined &&
+      previous.firstRealMessageAnchorKey !== undefined &&
+      currentAnchorKeys[0] !== previous.firstRealMessageAnchorKey) ||
+    previous.snapshots.some(
+      (snapshot) => !currentAnchorKeys.includes(snapshot.anchorKey),
+    )
+  );
+}
+
+function updateBoardHistoryState(
+  state: InjectionState,
+  sessionID: string,
+  messages: MessageWithParts[],
+): RetainedBoardSnapshotState {
+  const previous = state.retainedBoardSnapshots.get(sessionID);
+  if (previous && hasCompacted(previous, messages)) {
+    state.retainedBoardSnapshots.delete(sessionID);
+  }
+
+  const current = state.retainedBoardSnapshots.get(sessionID) ?? {
+    snapshots: [],
+    nextSnapshotSequence: 0,
+    realMessageCount: 0,
+    firstRealMessageAnchorKey: undefined,
+  };
+  const currentAnchorKeys = messageAnchorKeys(messages);
+  current.realMessageCount = messages.length;
+  current.firstRealMessageAnchorKey = currentAnchorKeys[0];
+  state.retainedBoardSnapshots.set(sessionID, current);
+  return current;
+}
+
+function findMessageAnchorKey(
+  messages: MessageWithParts[],
+  message: MessageWithParts,
+): string | undefined {
+  const anchorKeys = messageAnchorKeys(messages);
+  const messageID = message.info.id;
+  if (messageID) {
+    const index = messages.findIndex(
+      (candidate) => candidate.info.id === messageID,
+    );
+    return index >= 0 ? anchorKeys[index] : undefined;
+  }
+
+  const signature = boardHistoryMessageSignature(message);
+  const index = messages.findLastIndex(
+    (candidate) => boardHistoryMessageSignature(candidate) === signature,
+  );
+  return index >= 0 ? anchorKeys[index] : undefined;
+}
+
+function createBoardMessage(
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshot: RetainedBoardSnapshot,
+  metadataKey: string,
+  usedMessageIDs: Set<string>,
+): MessageWithParts {
+  const baseID = snapshot.id;
+  let id = baseID;
+  let collisionIndex = 1;
+  while (usedMessageIDs.has(id)) {
+    id = `${baseID}:collision-${collisionIndex}`;
+    collisionIndex += 1;
+  }
+  usedMessageIDs.add(id);
+  return {
+    info: { ...baseMessage.info, id },
+    parts: [
+      createTaggedSyntheticPart({
+        text: snapshot.text,
+        metadataKey,
+        extraMetadata: { sessionID, snapshotID: snapshot.id },
+      }),
+    ],
+  };
+}
+
+function replayBoardSnapshots(
+  messages: unknown[],
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshotState: RetainedBoardSnapshotState,
+  metadataKey: string,
+): void {
+  const realMessageList = realMessages(messages, metadataKey);
+  const currentAnchorKeys = messageAnchorKeys(realMessageList);
+  const snapshotsByAnchor = new Map<string, RetainedBoardSnapshot[]>();
+  for (const snapshot of snapshotState.snapshots) {
+    const snapshots = snapshotsByAnchor.get(snapshot.anchorKey) ?? [];
+    snapshots.push(snapshot);
+    snapshotsByAnchor.set(snapshot.anchorKey, snapshots);
+  }
+
+  const usedMessageIDs = new Set(
+    messages.flatMap((message) =>
+      isMessageWithParts(message) && message.info.id ? [message.info.id] : [],
+    ),
+  );
+
+  const rebuiltMessages: unknown[] = [];
+  let realMessageIndex = 0;
+  for (const message of messages) {
+    rebuiltMessages.push(message);
+    if (!isMessageWithParts(message) || message.parts.length === 0) continue;
+    if (message.parts.every((part) => isTaggedPart(part, metadataKey))) {
+      continue;
+    }
+
+    const anchorKey = currentAnchorKeys[realMessageIndex];
+    if (!anchorKey) continue;
+    realMessageIndex += 1;
+    for (const snapshot of snapshotsByAnchor.get(anchorKey) ?? []) {
+      rebuiltMessages.push(
+        createBoardMessage(
+          baseMessage,
+          sessionID,
+          snapshot,
+          metadataKey,
+          usedMessageIDs,
+        ),
+      );
+    }
+  }
+
+  messages.splice(0, messages.length, ...rebuiltMessages);
+}
+
+function replayCheckpointBoard(
+  messages: unknown[],
+  baseMessage: MessageWithParts,
+  sessionID: string,
+  snapshotState: RetainedBoardSnapshotState,
+  metadataKey: string,
+): void {
+  stripTaggedContent(messages, metadataKey);
+  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.
+}

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

@@ -0,0 +1,227 @@
+/**
+ * Continuation evaluator subsystem for task session manager.
+ *
+ * Evaluates whether a parent session needs a continuation nudge
+ * when its children complete but todos remain unfinished.
+ *
+ * Exported as a pure function taking explicit dependency objects
+ * to avoid circular dependency issues with the other subsystems.
+ */
+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 { isActiveStatus } from './status-utils';
+
+const CONTINUATION_NUDGE =
+  'Continue coordinating the remaining incomplete todos. Do not finalize while work remains.';
+
+/**
+ * Shared 5-condition guard that appears (in identical form) after
+ * each async liveness re-check inside the main evaluation loop.
+ * Deduplicated here to avoid repeating the same short-circuit chain.
+ *
+ * Does NOT include the SDK-availability checks (first guard only);
+ * those remain inline since they only run once before any I/O.
+ */
+function isEvaluationAborted(
+  parentSessionID: string,
+  sessionToken: symbol,
+  evaluationToken: symbol,
+  deps: {
+    continuationTokens: {
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    backgroundJobBoard: BackgroundJobStore;
+  },
+): boolean {
+  return (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID)
+  );
+}
+
+export async function evaluateContinuation(
+  parentSessionID: string,
+  sessionToken: symbol,
+  deps: {
+    backgroundJobBoard: BackgroundJobStore;
+    continuationTokens: {
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+      isCurrentContinuation: (
+        sessionID: string,
+        sessionToken: symbol,
+        evaluationToken?: symbol,
+      ) => boolean;
+    };
+    inputWaits: {
+      hasInputWait: (sessionID: string) => boolean;
+    };
+    options: {
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    sessionSdk?: {
+      todo?: (input: unknown) => Promise<{ data?: unknown }>;
+      children?: (input: unknown) => Promise<{ data?: unknown }>;
+      status?: (input: unknown) => Promise<{ data?: unknown }>;
+      promptAsync?: (input: unknown) => Promise<unknown>;
+    };
+  },
+): Promise<void> {
+  const evaluationToken = Symbol(parentSessionID);
+  const activeEvaluations =
+    deps.continuationTokens.evaluations.get(parentSessionID) ??
+    new Set<symbol>();
+  activeEvaluations.add(evaluationToken);
+  deps.continuationTokens.evaluations.set(parentSessionID, activeEvaluations);
+
+  // Guard 1: pre-flight checks (includes SDK availability — only once)
+  if (
+    deps.continuationTokens.consumed.has(parentSessionID) ||
+    deps.inputWaits.hasInputWait(parentSessionID) ||
+    !deps.continuationTokens.isCurrentContinuation(
+      parentSessionID,
+      sessionToken,
+      evaluationToken,
+    ) ||
+    deps.options.isFallbackInProgress?.(parentSessionID) ||
+    deps.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID) ||
+    !deps.sessionSdk?.todo ||
+    !deps.sessionSdk.children ||
+    !deps.sessionSdk.status ||
+    !deps.sessionSdk.promptAsync
+  ) {
+    activeEvaluations.delete(evaluationToken);
+    if (activeEvaluations.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+    return;
+  }
+
+  try {
+    const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
+      deps.sessionSdk.todo({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(todoResponse.data) ||
+      !Array.isArray(childrenResponse.data) ||
+      !isObjectRecord(statusResponse.data)
+    ) {
+      return;
+    }
+    const todos = todoResponse.data;
+    const children = childrenResponse.data;
+    const status = statusResponse.data;
+    if (
+      !todos.every(
+        (todo) => isObjectRecord(todo) && typeof todo.status === 'string',
+      ) ||
+      !children.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      )
+    ) {
+      return;
+    }
+    if (
+      !todos.some(
+        (todo) => todo.status !== 'completed' && todo.status !== 'cancelled',
+      )
+    ) {
+      return;
+    }
+    const childIDs = children.map((child) => child.id as string);
+    if (
+      isActiveStatus(status, parentSessionID) ||
+      childIDs.some((childID) => isActiveStatus(status, childID))
+    ) {
+      return;
+    }
+
+    // Re-read liveness immediately before queuing work; board state is only
+    // authoritative for terminal results observed by this plugin instance.
+    const [latestChildrenResponse, latestStatusResponse] = await Promise.all([
+      deps.sessionSdk.children({
+        path: { id: parentSessionID },
+        throwOnError: true,
+      }),
+      deps.sessionSdk.status({ throwOnError: true }),
+    ]);
+    if (
+      !Array.isArray(latestChildrenResponse.data) ||
+      !isObjectRecord(latestStatusResponse.data) ||
+      !latestChildrenResponse.data.every(
+        (child) => isObjectRecord(child) && typeof child.id === 'string',
+      ) ||
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    const latestChildIDs = latestChildrenResponse.data.map(
+      (child) => child.id as string,
+    );
+    const latestStatus = latestStatusResponse.data;
+    if (
+      isActiveStatus(latestStatus, parentSessionID) ||
+      latestChildIDs.some((childID) => isActiveStatus(latestStatus, childID))
+    ) {
+      return;
+    }
+
+    if (
+      isEvaluationAborted(parentSessionID, sessionToken, evaluationToken, deps)
+    ) {
+      return;
+    }
+    deps.continuationTokens.consumed.add(parentSessionID);
+    await deps.sessionSdk.promptAsync({
+      path: { id: parentSessionID },
+      body: {
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart(CONTINUATION_NUDGE)],
+      },
+      throwOnError: true,
+    });
+  } catch (error) {
+    log(
+      '[task-session-manager] continuation nudge suppressed after SDK error',
+      {
+        parentSessionID,
+        error: error instanceof Error ? error.message : String(error),
+      },
+    );
+  } finally {
+    const evaluations =
+      deps.continuationTokens.evaluations.get(parentSessionID);
+    evaluations?.delete(evaluationToken);
+    if (evaluations?.size === 0) {
+      deps.continuationTokens.evaluations.delete(parentSessionID);
+    }
+  }
+}

+ 51 - 0
src/hooks/task-session-manager/continuation-token-manager.ts

@@ -0,0 +1,51 @@
+export function createContinuationTokenManager(options?: {
+  onInvalidateContinuation?: (sessionID: string) => void;
+}) {
+  const continuationSessionTokens = new Map<string, symbol>();
+  const activeContinuationEvaluations = new Map<string, Set<symbol>>();
+  const continuationConsumed = new Set<string>();
+
+  function getContinuationSessionToken(sessionID: string): symbol {
+    const existing = continuationSessionTokens.get(sessionID);
+    if (existing) return existing;
+
+    const token = Symbol(sessionID);
+    continuationSessionTokens.set(sessionID, token);
+    return token;
+  }
+
+  function isCurrentContinuation(
+    sessionID: string,
+    sessionToken: symbol,
+    evaluationToken?: symbol,
+  ): boolean {
+    return (
+      continuationSessionTokens.get(sessionID) === sessionToken &&
+      (evaluationToken === undefined ||
+        activeContinuationEvaluations.get(sessionID)?.has(evaluationToken) ===
+          true)
+    );
+  }
+
+  function invalidateContinuation(sessionID: string): void {
+    options?.onInvalidateContinuation?.(sessionID);
+    continuationSessionTokens.delete(sessionID);
+    activeContinuationEvaluations.delete(sessionID);
+  }
+
+  function clearContinuation(sessionID: string): void {
+    invalidateContinuation(sessionID);
+    continuationConsumed.delete(sessionID);
+  }
+
+  return {
+    getContinuationSessionToken,
+    isCurrentContinuation,
+    invalidateContinuation,
+    clearContinuation,
+    // Exposed internal state for consumers not yet migrated (evaluateContinuation, etc.)
+    sessionTokens: continuationSessionTokens,
+    evaluations: activeContinuationEvaluations,
+    consumed: continuationConsumed,
+  };
+}

+ 303 - 0
src/hooks/task-session-manager/event-router.ts

@@ -0,0 +1,303 @@
+/**
+ * Event router for task session manager.
+ *
+ * Routes lifecycle events (session.created, server.instance.disposed,
+ * session.idle, session.error, session.status, session.deleted) to
+ * the appropriate subsystems.
+ */
+import type { BackgroundJobStore } from '../../utils/background-job-store';
+import { log } from '../../utils/logger';
+import { isFailoverError } from '../foreground-fallback/index';
+import type { RetainedBoardSnapshotState } from './board-injection';
+import type { PendingTaskCall } from './pending-call-tracker';
+
+export async function handleEvent(
+  input: {
+    event: {
+      type: string;
+      properties?: {
+        info?: { id?: string; parentID?: string; agent?: string };
+        id?: string;
+        requestID?: string;
+        sessionID?: string;
+        status?: { type?: string };
+        error?: { name?: string };
+      };
+    };
+  },
+  deps: {
+    inputWaits: {
+      trackInputWait(event: {
+        type: string;
+        properties?: {
+          id?: string;
+          requestID?: string;
+          sessionID?: string;
+        };
+      }): void;
+      clearInputWaits(sessionID: string): void;
+      waitsByParent: Map<string, Set<string | symbol>>;
+    };
+    continuationTokens: {
+      clearContinuation(sessionID: string): void;
+      invalidateContinuation(sessionID: string): void;
+      sessionTokens: Map<string, symbol>;
+      evaluations: Map<string, Set<symbol>>;
+      consumed: Set<string>;
+    };
+    options: {
+      shouldManageSession: (sessionID: string) => boolean;
+      registerSessionAsOrchestrator?: (sessionID: string) => void;
+      isFallbackInProgress?: (sessionID: string) => boolean;
+    };
+    idleReconciler: {
+      scheduleIdleReconciliation(sessionID: string): void;
+      scheduleChildIdleReconciliation(
+        sessionID: string,
+        idleObservedAt: number,
+      ): void;
+      clearIdleTimers(sessionID: string): void;
+      clearAllTimers(): string[];
+    };
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      peekByParentAndAgent(
+        parentSessionID: string,
+        agentHint?: string,
+      ): PendingTaskCall | undefined;
+      clearSession(sessionID: string): void;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      clearSession(sessionID: string): void;
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+    terminalJobsInjectedByParent: Map<string, Set<string>>;
+    retainedBoardSnapshots: Map<string, RetainedBoardSnapshotState>;
+  },
+): Promise<void> {
+  deps.inputWaits.trackInputWait(input.event);
+
+  if (input.event.type === 'session.created') {
+    const info = input.event.properties?.info;
+    if (info?.id) deps.retainedBoardSnapshots.delete(info.id);
+    log('[task-session-manager] session.created observed', {
+      sessionID: info?.id,
+      parentSessionID: info?.parentID,
+      managesParent: info?.parentID
+        ? deps.options.shouldManageSession(info.parentID)
+        : false,
+    });
+    if (
+      info?.id &&
+      info.parentID &&
+      deps.options.shouldManageSession(info.parentID)
+    ) {
+      deps.taskContextTracker.pendingManagedTaskIds.add(info.id);
+      // Early board registration: if the parent tool call is cancelled
+      // before tool.execute.after (e.g. foreground fallback abort), the
+      // after-hook never fires and the job is never tracked — idle then
+      // reports runningJobForSession:false and the orchestrator sees
+      // "Task cancelled" while the child is still working (#765).
+      // Peek (don't take) so tool.execute.after can still re-register.
+      //
+      // When the parent has multiple task calls in flight at once (e.g.
+      // parallel council reviewers), `info.agent` on the child session
+      // identifies which subagent started it; prefer the matching
+      // pending call so we don't attribute the child to the wrong agent.
+      const pending = deps.pendingCallTracker.peekByParentAndAgent(
+        info.parentID,
+        info.agent,
+      );
+      if (
+        pending &&
+        !pending.resumedTaskId &&
+        !deps.backgroundJobBoard.get(info.id)
+      ) {
+        const record = deps.backgroundJobBoard.registerLaunch({
+          taskID: info.id,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          description: pending.label,
+          objective: pending.label,
+        });
+        log(
+          '[task-session-manager] early board registration from session.created',
+          {
+            taskID: record.taskID,
+            alias: record.alias,
+            parentSessionID: record.parentSessionID,
+            agent: record.agent,
+          },
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.event.type === 'server.instance.disposed') {
+    deps.retainedBoardSnapshots.clear();
+    const idleSessionIds = deps.idleReconciler.clearAllTimers();
+    const continuationSessionIDs = new Set([
+      ...idleSessionIds,
+      ...deps.continuationTokens.sessionTokens.keys(),
+      ...deps.continuationTokens.evaluations.keys(),
+      ...deps.continuationTokens.consumed,
+      ...deps.inputWaits.waitsByParent.keys(),
+    ]);
+    for (const sessionID of continuationSessionIDs) {
+      deps.continuationTokens.clearContinuation(sessionID);
+      deps.inputWaits.clearInputWaits(sessionID);
+    }
+    return;
+  }
+
+  if (
+    input.event.type === 'session.idle' ||
+    (input.event.type === 'session.status' &&
+      (input.event.properties as { status?: { type?: string } } | undefined)
+        ?.status?.type === 'idle')
+  ) {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const job = sessionId ? deps.backgroundJobBoard.get(sessionId) : undefined;
+    log('[task-session-manager] idle/status idle observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      terminalJobsPending: sessionId
+        ? (deps.terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+        : 0,
+      runningJobForSession: job?.state === 'running' || false,
+    });
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      deps.idleReconciler.scheduleIdleReconciliation(sessionId);
+    }
+
+    // Fallback: for background child sessions that go idle without
+    // an injected completion, reconcile the board entry since the
+    // session being idle is itself the completion signal.
+    // Delayed so FG can claim the session before we mark completed.
+    if (job && sessionId && job.state === 'running') {
+      deps.idleReconciler.scheduleChildIdleReconciliation(
+        sessionId,
+        Date.now(),
+      );
+    }
+    return;
+  }
+
+  if (input.event.type === 'session.error') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    if (sessionId) {
+      deps.continuationTokens.invalidateContinuation(sessionId);
+    }
+    if (sessionId && deps.options.shouldManageSession(sessionId)) {
+      // Only clear injected terminal jobs for fatal errors.
+      // Rate-limit errors are recovered by ForegroundFallbackManager
+      // (abort + reprompt with fallback model); clearing the injected
+      // job state here would make the orchestrator lose track of
+      // completed background tasks and unable to dispatch follow-ups.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (!props?.error || !isFailoverError(props.error)) {
+        deps.terminalJobsInjectedByParent.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);
+        if (job && job.state === 'running') {
+          deps.backgroundJobBoard.updateStatus({
+            taskID: sessionId,
+            state: 'error',
+            resultSummary:
+              (props?.error as { message?: string } | undefined)?.message ??
+              'Session error',
+          });
+        }
+      }
+    } else if (sessionId) {
+      // Child subagent sessions are not orchestrators, so the block
+      // above never runs for them. Without this, a failed background
+      // subagent leaves its job in `running` and the idle-reconciliation
+      // path (which has no shouldManageSession guard) marks it
+      // `completed` — a false success. A child with no fallback chain has
+      // nothing to retry into, so surface the failure on the board.
+      const props = input.event.properties as { error?: unknown } | undefined;
+      if (deps.options.isFallbackInProgress?.(sessionId)) return;
+      const job = deps.backgroundJobBoard.get(sessionId);
+      if (job && job.state === 'running') {
+        deps.backgroundJobBoard.updateStatus({
+          taskID: sessionId,
+          state: 'error',
+          resultSummary:
+            (props?.error as { message?: string } | undefined)?.message ??
+            'Session error',
+        });
+      }
+    }
+
+    return;
+  }
+
+  if (input.event.type === 'session.status') {
+    const sessionId =
+      input.event.properties?.info?.id || input.event.properties?.sessionID;
+    const statusType = (
+      input.event.properties as { status?: { type?: string } } | undefined
+    )?.status?.type;
+    if (sessionId) deps.continuationTokens.invalidateContinuation(sessionId);
+    if (statusType !== 'busy') {
+      return;
+    }
+    // Live busy cancels a pending child idle-reconcile — the session
+    // recovered (FG re-prompt or continued work).
+    // Note: invalidateContinuation above already cleared the parent
+    // idle-reconcile timer; clearIdleTimers handles the child timer.
+    if (sessionId) {
+      deps.idleReconciler.clearIdleTimers(sessionId);
+    }
+    const before = sessionId
+      ? deps.backgroundJobBoard.get(sessionId)
+      : undefined;
+    const updated = sessionId
+      ? deps.backgroundJobBoard.markRunningFromLiveSession(sessionId)
+      : undefined;
+    if (before?.cancellationRequested) {
+      log('[task-session-manager] busy observed after cancel request', {
+        sessionID: sessionId,
+        previousState: before.state,
+        previousTerminalState: before.terminalState,
+        terminalUnreconciled: before.terminalUnreconciled,
+        resultSummary: before.resultSummary,
+      });
+    }
+    log('[task-session-manager] busy/status busy observed', {
+      sessionID: sessionId,
+      managesSession: sessionId
+        ? deps.options.shouldManageSession(sessionId)
+        : false,
+      previousState: before?.state,
+      previousTerminalState: before?.terminalState,
+      previousCancellationRequested: before?.cancellationRequested ?? false,
+      previousLastLiveBusyAt: before?.lastLiveBusyAt,
+      updatedState: updated?.state,
+      updatedCancellationRequested: updated?.cancellationRequested ?? false,
+      updatedLastLiveBusyAt: updated?.lastLiveBusyAt,
+    });
+    return;
+  }
+
+  if (input.event.type !== 'session.deleted') return;
+  const sessionId =
+    input.event.properties?.info?.id || input.event.properties?.sessionID;
+  if (!sessionId) return;
+
+  deps.continuationTokens.clearContinuation(sessionId);
+  deps.inputWaits.clearInputWaits(sessionId);
+  deps.retainedBoardSnapshots.delete(sessionId);
+
+  log('[task-session-manager] session.deleted observed', {
+    sessionID: sessionId,
+  });
+}

+ 145 - 0
src/hooks/task-session-manager/idle-reconciliation.ts

@@ -0,0 +1,145 @@
+import type { BackgroundJobStore, ContextFile } from '../../utils';
+import { log } from '../../utils/logger';
+
+export function createIdleReconciler(options: {
+  backgroundJobBoard: BackgroundJobStore;
+  evaluateContinuation: (
+    parentSessionID: string,
+    sessionToken: symbol,
+  ) => Promise<void>;
+  reconcileInjectedTerminalJobs: (parentSessionID: string) => void;
+  idleReconcileDelayMs: number;
+  isFallbackInProgress?: (sessionID: string) => boolean;
+  hasInputWait: (sessionID: string) => boolean;
+  getContinuationSessionToken: (sessionID: string) => symbol;
+  isCurrentContinuation: (
+    sessionID: string,
+    sessionToken: symbol,
+    evaluationToken?: symbol,
+  ) => boolean;
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  };
+}) {
+  const idleReconcileTimers = new Map<string, ReturnType<typeof setTimeout>>();
+  const childIdleReconcileTimers = new Map<
+    string,
+    ReturnType<typeof setTimeout>
+  >();
+
+  function scheduleIdleReconciliation(parentSessionID: string): void {
+    if (
+      idleReconcileTimers.has(parentSessionID) ||
+      options.hasInputWait(parentSessionID) ||
+      options.isFallbackInProgress?.(parentSessionID)
+    ) {
+      return;
+    }
+    const sessionToken = options.getContinuationSessionToken(parentSessionID);
+    const timer = setTimeout(() => {
+      idleReconcileTimers.delete(parentSessionID);
+      if (!options.isCurrentContinuation(parentSessionID, sessionToken)) {
+        return;
+      }
+      const hadTerminalUnreconciled =
+        options.backgroundJobBoard.hasTerminalUnreconciled(parentSessionID);
+      options.reconcileInjectedTerminalJobs(parentSessionID);
+      if (!hadTerminalUnreconciled) {
+        void options.evaluateContinuation(parentSessionID, sessionToken);
+      }
+    }, options.idleReconcileDelayMs).unref?.();
+    idleReconcileTimers.set(parentSessionID, timer);
+  }
+
+  function scheduleChildIdleReconciliation(
+    sessionID: string,
+    idleObservedAt: number,
+  ): void {
+    if (childIdleReconcileTimers.has(sessionID)) return;
+    if (options.isFallbackInProgress?.(sessionID)) return;
+
+    const timer = setTimeout(() => {
+      childIdleReconcileTimers.delete(sessionID);
+      if (options.isFallbackInProgress?.(sessionID)) return;
+
+      const job = options.backgroundJobBoard.get(sessionID);
+      if (job?.state !== 'running') return;
+
+      // Busy after the idle means the session recovered (e.g. FG re-prompt).
+      if (
+        job.lastLiveBusyAt !== undefined &&
+        job.lastLiveBusyAt > idleObservedAt
+      ) {
+        return;
+      }
+
+      log('[task-session-manager] reconciled running job from idle', {
+        sessionID,
+        alias: job.alias,
+        parentSessionID: job.parentSessionID,
+      });
+      options.backgroundJobBoard.updateStatus({
+        taskID: sessionID,
+        state: 'completed',
+        resultSummary: 'Background task completed (reconciled from idle event)',
+      });
+      options.backgroundJobBoard.markReconciled(sessionID);
+      options.taskContextTracker.pendingManagedTaskIds.delete(sessionID);
+      options.backgroundJobBoard.addContext(
+        sessionID,
+        options.taskContextTracker.contextFilesForPrompt(sessionID),
+      );
+      options.taskContextTracker.prune(options.backgroundJobBoard);
+    }, options.idleReconcileDelayMs).unref?.();
+    childIdleReconcileTimers.set(sessionID, timer);
+  }
+
+  function clearIdleTimers(sessionID: string): void {
+    const pendingChildIdle = childIdleReconcileTimers.get(sessionID);
+    if (pendingChildIdle) {
+      clearTimeout(pendingChildIdle);
+      childIdleReconcileTimers.delete(sessionID);
+    }
+    const pendingIdle = idleReconcileTimers.get(sessionID);
+    if (pendingIdle) {
+      clearTimeout(pendingIdle);
+      idleReconcileTimers.delete(sessionID);
+    }
+  }
+
+  /**
+   * Clears all timers and returns the session IDs that had
+   * idle-reconcile timers (used by server.instance.disposed).
+   */
+  function clearAllTimers(): string[] {
+    for (const timer of childIdleReconcileTimers.values()) {
+      clearTimeout(timer);
+    }
+    childIdleReconcileTimers.clear();
+
+    const idleSessionIds = [...idleReconcileTimers.keys()];
+    for (const timer of idleReconcileTimers.values()) {
+      clearTimeout(timer);
+    }
+    idleReconcileTimers.clear();
+
+    return idleSessionIds;
+  }
+
+  return {
+    scheduleIdleReconciliation,
+    scheduleChildIdleReconciliation,
+    clearIdleTimers,
+    clearAllTimers,
+    /** Callback for continuation-token-manager's onInvalidateContinuation. */
+    onInvalidateContinuation: (sessionID: string) => {
+      const timer = idleReconcileTimers.get(sessionID);
+      if (timer) {
+        clearTimeout(timer);
+        idleReconcileTimers.delete(sessionID);
+      }
+    },
+  };
+}

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

@@ -25,6 +25,11 @@ async function flushContinuation(): Promise<void> {
   await new Promise((resolve) => setTimeout(resolve, 0));
 }
 
+/** Flush delayed child idle-reconcile timers when idleReconcileDelayMs is 0. */
+async function flushChildIdleReconcile(): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, 5));
+}
+
 function createHook(options?: {
   shouldManageSession?: (sessionID: string) => boolean;
   registerSessionAsOrchestrator?: (sessionID: string) => void;
@@ -429,6 +434,86 @@ describe('task-session-manager hook', () => {
     expect(messages.messages.at(-1)?.parts[0]).toBe(boardParts[0]);
   });
 
+  test('clears checkpoint snapshots when a session is recreated', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    await hook.injectBackgroundJobBoard(
+      {},
+      createMessages('parent-1', 'same anchor'),
+    );
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'parent-1' } },
+      },
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished',
+    });
+
+    const resetRequest = createMessages('parent-1', 'same anchor');
+    await hook.injectBackgroundJobBoard({}, resetRequest);
+
+    expect(
+      resetRequest.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(1);
+    expect(boardText(resetRequest)).toContain('completed, unreconciled');
+  });
+
+  test('clears checkpoint snapshots when a session is deleted', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      strategy: 'checkpoint-compatible',
+    });
+
+    await hook.injectBackgroundJobBoard(
+      {},
+      createMessages('parent-1', 'same anchor'),
+    );
+    await hook.event({
+      event: {
+        type: 'session.deleted',
+        properties: { sessionID: 'parent-1' },
+      },
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'finished',
+    });
+
+    const resetRequest = createMessages('parent-1', 'same anchor');
+    await hook.injectBackgroundJobBoard({}, resetRequest);
+
+    expect(
+      resetRequest.messages.filter((message) =>
+        message.parts.some((part) => isBoardPartForTest(part)),
+      ),
+    ).toHaveLength(1);
+    expect(boardText(resetRequest)).toContain('completed, unreconciled');
+  });
+
   test('retains checkpoint snapshots across fresh storage-derived message arrays', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -1952,6 +2037,142 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('non-retryable session.error marks running job as error on board', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'UnknownError',
+            message: 'LLM proxy connection refused',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('LLM proxy connection refused');
+  });
+
+  test('session.idle does not overwrite error state with completed', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'error',
+      resultSummary: 'connection refused',
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    await hook.event({
+      event: {
+        type: 'session.idle',
+        properties: {
+          info: { id: 'child-1', parentID: 'parent-1' },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('connection refused');
+  });
+
+  test('child session.error (non-orchestrator) records failure on board', async () => {
+    const board = new BackgroundJobBoard();
+    // Child subagent sessions are not orchestrators, so shouldManageSession
+    // returns false for them. The error must still land on the board,
+    // otherwise idle reconciliation marks the job completed (false success).
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'designer',
+      description: 'design ui',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'AI_APICallError',
+            message: 'Internal server error',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('Internal server error');
+  });
+
+  test('child session.error during fallback is not recorded on board', async () => {
+    const board = new BackgroundJobBoard();
+    // isFallbackInProgress is currently always-false for real children
+    // (they have no fallback chain), so this guard path is unreachable in
+    // production today. The test pins the defensive behavior for the day
+    // children gain a fallback chain.
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => true,
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'designer',
+      description: 'design ui',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'child-1',
+          error: {
+            name: 'AI_APICallError',
+            message: 'Internal server error',
+          },
+        },
+      },
+    });
+
+    const job = board.get('child-1');
+    expect(job?.state).toBe('running');
+  });
+
   test('completed reconciled job appears reusable and resumes via task', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -2559,11 +2780,13 @@ describe('task-session-manager hook', () => {
     const { hook } = createHook({
       backgroundJobBoard: board,
       shouldManageSession: (id) => id === 'parent-1',
+      idleReconcileDelayMs: 0,
     });
 
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
 
     expect(board.get('child-1')).toMatchObject({
       state: 'reconciled',
@@ -2608,11 +2831,13 @@ describe('task-session-manager hook', () => {
       backgroundJobBoard: board,
       shouldManageSession: (id) => id === 'parent-1',
       isFallbackInProgress: (id) => id === 'child-1',
+      idleReconcileDelayMs: 0,
     });
 
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
 
     // Job should still be running — not reconciled
     expect(board.get('child-1')).toMatchObject({ state: 'running' });
@@ -2683,11 +2908,13 @@ describe('task-session-manager hook', () => {
       shouldManageSession: (id) => id === 'parent-1',
       // isFallbackInProgress returns false for child-1
       isFallbackInProgress: () => false,
+      idleReconcileDelayMs: 0,
     });
 
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
 
     expect(board.get('child-1')).toMatchObject({
       state: 'reconciled',
@@ -2712,12 +2939,14 @@ describe('task-session-manager hook', () => {
       backgroundJobBoard: board,
       shouldManageSession: () => false,
       isFallbackInProgress: (id) => id === 'child-1',
+      idleReconcileDelayMs: 0,
     });
 
     // First idle (abort from fallback) — guarded, no reconciliation
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
     expect(board.get('child-1')).toMatchObject({ state: 'running' });
 
     // Busy signal (fallback re-prompt) — updates lastLiveBusyAt
@@ -2734,22 +2963,105 @@ describe('task-session-manager hook', () => {
       backgroundJobBoard: board,
       shouldManageSession: () => false,
       isFallbackInProgress: () => false,
+      idleReconcileDelayMs: 0,
     });
     await hook2.hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
     expect(board.get('child-1')).toMatchObject({
       state: 'reconciled',
       terminalState: 'completed',
     });
   });
 
+  test('busy after idle cancels pending child idle-reconcile (FG race)', async () => {
+    // OpenCode can emit idle for a rate-limited child BEFORE FG sets
+    // isFallbackInProgress. Immediate reconcile would mark completed while
+    // FG re-prompts and the child keeps working. Delay + busy cancel keeps
+    // the job running (the observed council-b false-complete race).
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-b',
+      parentSessionID: 'parent-1',
+      agent: 'councillor-reviewer-b',
+      description: 'audit distributed',
+    });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => false,
+      idleReconcileDelayMs: 30,
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-b' } },
+    });
+    expect(board.get('child-b')).toMatchObject({ state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'child-b', status: { type: 'busy' } },
+      },
+    });
+
+    await new Promise((r) => setTimeout(r, 50));
+    expect(board.get('child-b')).toMatchObject({ state: 'running' });
+  });
+
+  test('session.deleted cancels pending child idle-reconcile (FG teardown race)', async () => {
+    // FG aborts the child session mid-idle-delay; onSessionDeleted must
+    // cancel the pending timer so it cannot fire after FG finishes and
+    // re-check isFallbackInProgress=false, falsely reconciling the board
+    // entry while the re-prompted session keeps working.
+    const coordinator = new SessionLifecycle(() => {});
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-b',
+      parentSessionID: 'parent-1',
+      agent: 'councillor-reviewer-b',
+      description: 'audit distributed',
+    });
+
+    let fgInProgress = false;
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      coordinator,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => fgInProgress,
+      idleReconcileDelayMs: 30,
+    });
+
+    // idle fires before FG sets isFallbackInProgress — schedules timer T.
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-b' } },
+    });
+    // FG claims the session and aborts it; OpenCode emits session.deleted
+    // while the timer is still pending. onSessionDeleted must cancel T.
+    fgInProgress = true;
+    coordinator.dispatchSessionDeleted('child-b');
+    // FG finishes; isFallbackInProgress goes false before T would fire.
+    fgInProgress = false;
+
+    await new Promise((r) => setTimeout(r, 60));
+    // Board entry survives (isFallbackInProgress was true at delete time)
+    // but is NOT reconciled — the timer was cancelled on session.deleted.
+    const job = board.get('child-b');
+    expect(job).toBeDefined();
+    expect(job?.state).toBe('running');
+  });
+
   test('session.created early-registers board job so after-hook cancellation cannot orphan the child', async () => {
     // Reproduces #765: parent tool may be cancelled before tool.execute.after,
     // so the job never lands on the board. Early registration from
     // session.created keeps runningJobForSession true and lets idle reconcile.
     const board = new BackgroundJobBoard();
-    const { hook } = createHook({ backgroundJobBoard: board });
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      idleReconcileDelayMs: 0,
+    });
 
     await hook['tool.execute.before'](
       { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
@@ -2781,6 +3093,7 @@ describe('task-session-manager hook', () => {
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
 
     expect(board.get('child-1')).toMatchObject({
       state: 'reconciled',
@@ -2788,6 +3101,72 @@ describe('task-session-manager hook', () => {
     });
   });
 
+  test('session.created early registration attributes each parallel child to its own pending call', async () => {
+    // Regression: when a parent launches several task tools in parallel with
+    // different subagent types (e.g. council reviewers a/b/c), the old
+    // peekByParent() returned the FIRST pending call for every child, so
+    // all children were registered with the first subagent's agentType.
+    // info.agent on the child session disambiguates which pending call
+    // started it.
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    // Parent fires three task tools in parallel: oracle / explorer / fixer.
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-a' },
+      { args: { subagent_type: 'oracle', description: 'audit loss' } },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-b' },
+      { args: { subagent_type: 'explorer', description: 'audit data' } },
+    );
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-c' },
+      { args: { subagent_type: 'fixer', description: 'audit fix' } },
+    );
+
+    // Each child session is created while the parent tool calls are still
+    // in flight (before any tool.execute.after). info.agent identifies the
+    // subagent that owns each child.
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-a', parentID: 'parent-1', agent: 'oracle' },
+        },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-b', parentID: 'parent-1', agent: 'explorer' },
+        },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-c', parentID: 'parent-1', agent: 'fixer' },
+        },
+      },
+    });
+
+    expect(board.get('child-a')).toMatchObject({
+      agent: 'oracle',
+      description: 'audit loss',
+    });
+    expect(board.get('child-b')).toMatchObject({
+      agent: 'explorer',
+      description: 'audit data',
+    });
+    expect(board.get('child-c')).toMatchObject({
+      agent: 'fixer',
+      description: 'audit fix',
+    });
+  });
+
   test('cancelled job is not reconciled from idle', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
@@ -2802,11 +3181,13 @@ describe('task-session-manager hook', () => {
     const { hook } = createHook({
       backgroundJobBoard: board,
       shouldManageSession: () => false,
+      idleReconcileDelayMs: 0,
     });
 
     await hook.event({
       event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
     });
+    await flushChildIdleReconcile();
 
     // Should remain cancelled — idle does not override terminal state
     const job = board.get('child-1');
@@ -2826,6 +3207,7 @@ describe('task-session-manager hook', () => {
     const { hook } = createHook({
       backgroundJobBoard: board,
       shouldManageSession: (id) => id === 'parent-1',
+      idleReconcileDelayMs: 0,
     });
 
     await hook.event({
@@ -2834,6 +3216,7 @@ describe('task-session-manager hook', () => {
         properties: { sessionID: 'child-1', status: { type: 'idle' } },
       },
     });
+    await flushChildIdleReconcile();
 
     expect(board.get('child-1')).toMatchObject({
       state: 'reconciled',
@@ -3031,13 +3414,365 @@ describe('task-session-manager hook', () => {
     );
   });
 
-  test('coalesces paired idle events and suppresses active children', async () => {
+  test('does not evaluate or nudge while a question or permission waits', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const promptAsync = mock(async () => ({}));
-    const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
     const { hook } = createHook({
       idleReconcileDelayMs: 0,
       sessionClient: {
-        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'permission.asked',
+        properties: { sessionID: 'parent-1', id: 'permission-1' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('cancels a scheduled continuation when an input wait arrives before its timer fires', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const children = mock(async () => ({ data: [] }));
+    const status = mock(async () => ({ data: {} }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: { todo, children, status, promptAsync },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await hook.event({
+      event: {
+        type: 'permission.asked',
+        properties: { sessionID: 'parent-1', id: 'permission-1' },
+      },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(children).not.toHaveBeenCalled();
+    expect(status).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('fails closed when an id-less ask races a scheduled continuation', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1' },
+      },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('clears only the resolved input wait and resumes on a later idle', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'permission.asked',
+        properties: { sessionID: 'parent-1', id: 'permission-1' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-2' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'unknown-question' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'permission.replied',
+        properties: { sessionID: 'parent-1', requestID: 'permission-1' },
+      },
+    });
+    await flushContinuation();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: {
+        type: 'question.rejected',
+        properties: { sessionID: 'parent-1', requestID: 'question-2' },
+      },
+    });
+    await flushContinuation();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('resumes on a later idle after a question rejection', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'question.rejected',
+        properties: { sessionID: 'parent-1', requestID: 'question-1' },
+      },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('invalidates an in-flight continuation when an input wait arrives', async () => {
+    let resolveTodo!: (value: { data: { status: string }[] }) => void;
+    const todo = mock(
+      () =>
+        new Promise<{ data: { status: string }[] }>((resolve) => {
+          resolveTodo = resolve;
+        }),
+    );
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(todo).toHaveBeenCalledTimes(1);
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    resolveTodo({ data: [{ status: 'pending' }] });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('internal and synthetic messages do not clear an input wait', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    hook.observeChatMessage(
+      {},
+      {
+        message: { role: 'user', sessionID: 'parent-1' },
+        parts: [
+          { type: 'text', synthetic: true, text: 'synthetic response' },
+          createInternalAgentTextPart('internal response'),
+        ],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('retains input waits across a session error', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.error', properties: { sessionID: 'parent-1' } },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('clears stale input waits on session and server cleanup', async () => {
+    for (const lifecycleEvent of [
+      { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
+      { type: 'server.instance.disposed' },
+    ]) {
+      const promptAsync = mock(async () => ({}));
+      const { hook } = createHook({
+        idleReconcileDelayMs: 0,
+        sessionClient: {
+          todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+          children: mock(async () => ({ data: [] })),
+          status: mock(async () => ({ data: {} })),
+          promptAsync,
+        },
+      });
+
+      await hook.event({
+        event: {
+          type: 'question.asked',
+          properties: { sessionID: 'parent-1', id: 'question-1' },
+        },
+      });
+      await hook.event({ event: lifecycleEvent });
+      await hook.event({
+        event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+      });
+      await flushContinuation();
+
+      expect(promptAsync).toHaveBeenCalledTimes(1);
+    }
+  });
+
+  test('coalesces paired idle events and suppresses active children', async () => {
+    const promptAsync = mock(async () => ({}));
+    const children = mock(async () => ({ data: [{ id: 'child-1' }] }));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
         children,
         status: mock(async () => ({ data: { 'child-1': { type: 'busy' } } })),
         promptAsync,
@@ -3090,6 +3825,37 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).toHaveBeenCalledTimes(2);
   });
 
+  test('file-only external messages rearm a consumed nudge', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    hook.observeChatMessage(
+      {},
+      {
+        message: { role: 'user', sessionID: 'parent-1' },
+        parts: [{ type: 'file', filename: 'command-output.txt' }],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(2);
+  });
+
   test('synthetic completion messages do not rearm a consumed nudge', async () => {
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({

File diff suppressed because it is too large
+ 111 - 1031
src/hooks/task-session-manager/index.ts


+ 88 - 0
src/hooks/task-session-manager/input-wait-tracker.ts

@@ -0,0 +1,88 @@
+const IDLESS_INPUT_WAIT = Symbol('idless-input-wait');
+const INPUT_WAIT_ASK_EVENTS = {
+  'permission.asked': 'permission',
+  'question.asked': 'question',
+} as const;
+const INPUT_WAIT_RESOLUTION_EVENTS = {
+  'permission.replied': 'permission',
+  'question.replied': 'question',
+  'question.rejected': 'question',
+} as const;
+
+function isInputWaitAskEvent(
+  type: string,
+): type is keyof typeof INPUT_WAIT_ASK_EVENTS {
+  return Object.hasOwn(INPUT_WAIT_ASK_EVENTS, type);
+}
+
+function isInputWaitResolutionEvent(
+  type: string,
+): type is keyof typeof INPUT_WAIT_RESOLUTION_EVENTS {
+  return Object.hasOwn(INPUT_WAIT_RESOLUTION_EVENTS, type);
+}
+
+function inputWaitKey(kind: 'permission' | 'question', requestID: string) {
+  return `${kind}:${requestID}`;
+}
+
+export function createInputWaitTracker(options: {
+  shouldManageSession: (sessionID: string) => boolean;
+  invalidateContinuation: (sessionID: string) => void;
+}) {
+  const inputWaitsByParent = new Map<string, Set<string | symbol>>();
+
+  function hasInputWait(sessionID: string): boolean {
+    return (inputWaitsByParent.get(sessionID)?.size ?? 0) > 0;
+  }
+
+  function clearInputWaits(sessionID: string): void {
+    inputWaitsByParent.delete(sessionID);
+  }
+
+  function trackInputWait(event: {
+    type: string;
+    properties?: { id?: string; requestID?: string; sessionID?: string };
+  }): void {
+    const sessionID = event.properties?.sessionID;
+    if (!sessionID || !options.shouldManageSession(sessionID)) {
+      return;
+    }
+
+    if (isInputWaitAskEvent(event.type)) {
+      const requestID = event.properties?.id;
+      const waits =
+        inputWaitsByParent.get(sessionID) ?? new Set<string | symbol>();
+      if (!requestID) {
+        waits.add(IDLESS_INPUT_WAIT);
+        inputWaitsByParent.set(sessionID, waits);
+        options.invalidateContinuation(sessionID);
+        return;
+      }
+      const key = inputWaitKey(INPUT_WAIT_ASK_EVENTS[event.type], requestID);
+      waits.add(key);
+      inputWaitsByParent.set(sessionID, waits);
+      options.invalidateContinuation(sessionID);
+      return;
+    }
+
+    if (!isInputWaitResolutionEvent(event.type)) return;
+    const requestID = event.properties?.requestID;
+    if (!requestID) return;
+    const key = inputWaitKey(
+      INPUT_WAIT_RESOLUTION_EVENTS[event.type],
+      requestID,
+    );
+    const waits = inputWaitsByParent.get(sessionID);
+    if (!waits) return;
+    waits.delete(key);
+    if (waits.size === 0) clearInputWaits(sessionID);
+  }
+
+  return {
+    trackInputWait,
+    hasInputWait,
+    clearInputWaits,
+    // Exposed for consumers not yet migrated (disposed handler, etc.)
+    waitsByParent: inputWaitsByParent,
+  };
+}

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

@@ -47,6 +47,27 @@ export function createPendingCallTracker() {
       return undefined;
     },
 
+    /**
+     * Peek a pending call for a parent, preferring one whose agentType
+     * matches `agentHint`. Used by session.created early registration:
+     * when a parent launches several parallel task tools with different
+     * subagent types (e.g. council reviewers), `info.agent` on the
+     * child session identifies which subagent started it, so we can
+     * avoid attributing the child to the wrong pending call.
+     * Falls back to the oldest pending call for the parent when no
+     * agent match is found (preserves prior behavior).
+     */
+    peekByParentAndAgent(parentSessionId: string, agentHint?: string) {
+      if (!agentHint) return this.peekByParent(parentSessionId);
+      let fallback: PendingTaskCall | undefined;
+      for (const call of pendingCalls.values()) {
+        if (call.parentSessionId !== parentSessionId) continue;
+        if (!fallback) fallback = call;
+        if (call.agentType === agentHint) return call;
+      }
+      return fallback;
+    },
+
     clearSession(sessionId: string) {
       for (const [callId, pending] of pendingCalls.entries()) {
         if (pending.parentSessionId === sessionId) {

+ 137 - 0
src/hooks/task-session-manager/status-utils.ts

@@ -0,0 +1,137 @@
+import type {
+  BackgroundJobRecord,
+  BackgroundJobStore,
+  ContextFile,
+} from '../../utils';
+import { parseTaskStatusOutput } from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+
+export function extractTaskSummary(output: string): string | undefined {
+  const summary = /<summary>\s*([\s\S]*?)\s*<\/summary>/i.exec(output)?.[1];
+  return summary?.trim() || undefined;
+}
+
+export function isActiveStatus(
+  status: Record<string, unknown>,
+  sessionID: string,
+): boolean {
+  return Object.hasOwn(status, sessionID);
+}
+
+export function isLateCancelledTaskError(
+  job: BackgroundJobRecord | undefined,
+  state: string,
+): boolean {
+  if (state !== 'error') return false;
+  if (!job?.cancellationRequested) return false;
+  return job.state === 'cancelled' || job.terminalState === 'cancelled';
+}
+
+export function formatCancelledTaskStatusOutput(
+  taskID: string,
+  summary = 'cancelled',
+): string {
+  return [
+    `task_id: ${taskID}`,
+    'state: cancelled',
+    '',
+    '<task_error>',
+    summary,
+    '</task_error>',
+  ].join('\n');
+}
+
+export function updateBackgroundJobFromOutput(
+  output: unknown,
+  backgroundJobBoard: BackgroundJobStore,
+  taskContextTracker: {
+    pendingManagedTaskIds: Set<string>;
+    contextFilesForPrompt(taskId: string): ContextFile[];
+    prune(board: { taskIDs(): Set<string> }): void;
+  },
+): BackgroundJobRecord | undefined {
+  if (typeof output !== 'string') return undefined;
+
+  const status = parseTaskStatusOutput(output);
+  if (!status) return undefined;
+
+  log('[task-session-manager] parsed task output status', {
+    taskID: status.taskID,
+    state: status.state,
+    timedOut: status.timedOut,
+    hasResult: Boolean(status.result),
+  });
+
+  const existing = backgroundJobBoard.get(status.taskID);
+  if (isLateCancelledTaskError(existing, status.state)) {
+    log('[task-session-manager] suppressed late cancelled task error', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      parsedState: status.state,
+      boardState: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    return existing;
+  }
+
+  const updated = backgroundJobBoard.updateStatus({
+    taskID: status.taskID,
+    state: status.state,
+    timedOut: status.timedOut,
+    resultSummary: status.result,
+  });
+  if (!updated) {
+    log('[task-session-manager] ignored status for unknown background job', {
+      taskID: status.taskID,
+      state: status.state,
+    });
+    return undefined;
+  }
+
+  log('[task-session-manager] background job status updated', {
+    taskID: updated.taskID,
+    alias: updated.alias,
+    parentSessionID: updated.parentSessionID,
+    state: updated.state,
+    terminalUnreconciled: updated.terminalUnreconciled,
+    timedOut: updated.timedOut,
+  });
+
+  if (backgroundJobBoard.isTerminalUnreconciled(updated.taskID)) {
+    taskContextTracker.pendingManagedTaskIds.delete(updated.taskID);
+    backgroundJobBoard.addContext(
+      updated.taskID,
+      taskContextTracker.contextFilesForPrompt(updated.taskID),
+    );
+    taskContextTracker.prune(backgroundJobBoard);
+  }
+
+  return updated;
+}
+
+export function normalizeLateCancelledTaskOutput(
+  output: { output: unknown; metadata?: unknown },
+  backgroundJobBoard: BackgroundJobStore,
+): void {
+  if (typeof output.output !== 'string') return;
+  const status = parseTaskStatusOutput(output.output);
+  if (!status) return;
+  const existing = backgroundJobBoard.get(status.taskID);
+  if (!isLateCancelledTaskError(existing, status.state)) return;
+  log('[task-session-manager] normalized late cancelled task output', {
+    taskID: status.taskID,
+    alias: existing?.alias,
+    state: existing?.state,
+    terminalState: existing?.terminalState,
+    result: status.result,
+  });
+  output.output = formatCancelledTaskStatusOutput(
+    status.taskID,
+    backgroundJobBoard.getResultSummary(status.taskID),
+  );
+  if (isObjectRecord(output) && isObjectRecord(output.metadata)) {
+    output.metadata.state = 'cancelled';
+  }
+}

+ 277 - 0
src/hooks/task-session-manager/tool-execute-hooks.ts

@@ -0,0 +1,277 @@
+/**
+ * Tool execute hooks for task session manager.
+ *
+ * Handles `tool.execute.before` (task tool: pending call creation,
+ * 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 {
+  deriveTaskSessionLabel,
+  parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
+} from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
+import { log } from '../../utils/logger';
+import { isMissingRememberedSessionError } from './board-injection';
+import type { PendingTaskCall } from './pending-call-tracker';
+import { normalizeLateCancelledTaskOutput } from './status-utils';
+import { extractReadFiles } from './task-context-tracker';
+
+const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
+
+interface TaskArgs {
+  description?: unknown;
+  prompt?: unknown;
+  subagent_type?: unknown;
+  task_id?: unknown;
+}
+
+export async function handleToolExecuteBefore(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { args?: unknown },
+  deps: {
+    shouldManageSession: (sessionID: string) => boolean;
+    registerSessionAsOrchestrator?: (sessionID: string) => void;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      add(call: PendingTaskCall): void;
+      pendingCallId(sessionID?: string, callID?: string): string;
+    };
+    taskContextTracker: { pendingManagedTaskIds: Set<string> };
+  },
+): Promise<void> {
+  const toolName = input.tool.toLowerCase();
+  if (toolName !== 'task') return;
+  if (!input.sessionID) return;
+  if (!deps.shouldManageSession(input.sessionID)) {
+    // ponytail: no agent-identity guard here — at tool.execute.before
+    // time there's no message to inspect. Only orchestrators call `task`
+    // in standard architecture; non-orchestrator false-positives are
+    // accepted because leaf agents don't use this tool.
+    deps.registerSessionAsOrchestrator?.(input.sessionID);
+    if (!deps.shouldManageSession(input.sessionID)) return;
+    log('[task-session-manager] recovered stale orchestrator mapping', {
+      sessionID: input.sessionID,
+    });
+  }
+  if (!isObjectRecord(output.args)) return;
+
+  const args = output.args as TaskArgs;
+  if (
+    typeof args.subagent_type !== 'string' ||
+    args.subagent_type.trim() === ''
+  ) {
+    if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+      delete args.task_id;
+    }
+    return;
+  }
+
+  const agentType = args.subagent_type.trim();
+
+  const label = deriveTaskSessionLabel({
+    description:
+      typeof args.description === 'string' ? args.description : undefined,
+    prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
+    agentType,
+  });
+
+  const pendingCall: PendingTaskCall = {
+    callId: deps.pendingCallTracker.pendingCallId(
+      input.sessionID,
+      input.callID,
+    ),
+    parentSessionId: input.sessionID,
+    agentType,
+    label,
+  };
+  deps.pendingCallTracker.add(pendingCall);
+  log(
+    '[task-session-manager] tool.execute.before task — pending call created',
+    {
+      callId: pendingCall.callId,
+      parentSessionId: pendingCall.parentSessionId,
+      agentType: pendingCall.agentType,
+      label: pendingCall.label,
+      inputCallID: input.callID,
+      inputSessionID: input.sessionID,
+    },
+  );
+
+  if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
+    return;
+  }
+
+  const requested = args.task_id.trim();
+  const remembered =
+    deps.backgroundJobBoard.resolveReusable(
+      input.sessionID,
+      requested,
+      agentType,
+    ) ??
+    deps.backgroundJobBoard.resolveRecoverable(
+      input.sessionID,
+      requested,
+      agentType,
+    );
+
+  if (!remembered) {
+    const knownManagedTask = deps.backgroundJobBoard.resolve(
+      input.sessionID,
+      requested,
+    );
+    if (knownManagedTask) {
+      delete args.task_id;
+      return;
+    }
+
+    if (RAW_SESSION_ID_PATTERN.test(requested)) {
+      pendingCall.resumedTaskId = requested;
+      deps.pendingCallTracker.add(pendingCall);
+      return;
+    }
+    delete args.task_id;
+    return;
+  }
+
+  args.task_id = remembered.taskID;
+  deps.taskContextTracker.pendingManagedTaskIds.add(remembered.taskID);
+  deps.backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+  pendingCall.resumedTaskId = remembered.taskID;
+  deps.pendingCallTracker.add(pendingCall);
+}
+
+export async function handleToolExecuteAfter(
+  input: { tool: string; sessionID?: string; callID?: string },
+  output: { output: unknown; metadata?: unknown },
+  deps: {
+    directory: string;
+    backgroundJobBoard: BackgroundJobStore;
+    pendingCallTracker: {
+      take(callID?: string, sessionID?: string): PendingTaskCall | undefined;
+    };
+    taskContextTracker: {
+      pendingManagedTaskIds: Set<string>;
+      addContext(taskId: string, files: ContextFile[]): void;
+      contextFilesForPrompt(taskId: string): ContextFile[];
+      prune(board: { taskIDs(): Set<string> }): void;
+    };
+  },
+): Promise<void> {
+  if (input.tool.toLowerCase() === 'read') {
+    if (input.sessionID) {
+      const canTrack =
+        deps.taskContextTracker.pendingManagedTaskIds.has(input.sessionID) ||
+        deps.backgroundJobBoard.taskIDs().has(input.sessionID);
+      if (canTrack) {
+        deps.taskContextTracker.addContext(
+          input.sessionID,
+          extractReadFiles(deps.directory, output),
+        );
+      }
+    }
+    return;
+  }
+
+  if (input.tool.toLowerCase() !== 'task') return;
+
+  const pending = deps.pendingCallTracker.take(input.callID, input.sessionID);
+  log('[task-session-manager] tool.execute.after task', {
+    callID: input.callID,
+    sessionID: input.sessionID,
+    hasPending: !!pending,
+    outputType: typeof output.output,
+    outputPreview:
+      typeof output.output === 'string'
+        ? output.output.slice(0, 120)
+        : undefined,
+  });
+
+  if (!pending || typeof output.output !== 'string') return;
+  const launch = parseTaskLaunchOutput(output.output);
+  if (launch && !launch.result?.match(/Timed out after \d+ms/i)) {
+    const record = deps.backgroundJobBoard.registerLaunch({
+      taskID: launch.taskID,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      description: pending.label,
+      objective: pending.label,
+    });
+    log('[task-session-manager] background task launch registered', {
+      taskID: record.taskID,
+      alias: record.alias,
+      parentSessionID: record.parentSessionID,
+      agent: record.agent,
+      description: record.description,
+      state: record.state,
+    });
+    deps.taskContextTracker.pendingManagedTaskIds.add(launch.taskID);
+    deps.backgroundJobBoard.addContext(
+      launch.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(launch.taskID),
+    );
+    return;
+  }
+
+  normalizeLateCancelledTaskOutput(output, deps.backgroundJobBoard);
+  const status = parseTaskStatusOutput(output.output);
+  if (status) {
+    const existing = deps.backgroundJobBoard.get(status.taskID);
+    const record =
+      existing ??
+      deps.backgroundJobBoard.registerLaunch({
+        taskID: status.taskID,
+        parentSessionID: pending.parentSessionId,
+        agent: pending.agentType,
+        description: pending.label,
+        objective: pending.label,
+      });
+    const updated = deps.backgroundJobBoard.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+    log('[task-session-manager] foreground task status registered', {
+      taskID: status.taskID,
+      alias: updated?.alias ?? record.alias,
+      parentSessionID: pending.parentSessionId,
+      agent: pending.agentType,
+      state: updated?.state ?? record.state,
+    });
+    if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    deps.taskContextTracker.pendingManagedTaskIds.delete(status.taskID);
+    deps.backgroundJobBoard.addContext(
+      status.taskID,
+      deps.taskContextTracker.contextFilesForPrompt(status.taskID),
+    );
+    deps.taskContextTracker.prune(deps.backgroundJobBoard);
+    return;
+  }
+
+  const taskId = parseTaskIdFromTaskOutput(output.output);
+  if (!taskId) {
+    if (
+      pending.resumedTaskId &&
+      isMissingRememberedSessionError(output.output)
+    ) {
+      deps.backgroundJobBoard.drop(pending.resumedTaskId);
+    }
+    return;
+  }
+
+  if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
+    deps.backgroundJobBoard.drop(pending.resumedTaskId);
+  }
+
+  deps.taskContextTracker.pendingManagedTaskIds.delete(taskId);
+  deps.backgroundJobBoard.addContext(
+    taskId,
+    deps.taskContextTracker.contextFilesForPrompt(taskId),
+  );
+  deps.taskContextTracker.prune(deps.backgroundJobBoard);
+}

+ 35 - 26
src/index.ts

@@ -1,5 +1,10 @@
 import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
-import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
+import {
+  createAgents,
+  getAgentConfigs,
+  getDisabledAgents,
+  isSubagent,
+} from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
 import { ensureCompanionVersion } from './companion/updater';
@@ -23,10 +28,10 @@ import {
   setActiveRuntimePreset,
 } from './config/runtime-preset';
 import { applyOrchestratorModelConfig } from './config/strip-orchestrator-model';
-import { CouncilManager } from './council';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
+  createCacheMonitorHook,
   createChatHeadersHook,
   createDeepworkCommandHook,
   createDelegateTaskRetryHook,
@@ -55,7 +60,6 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
-  createCouncilTool,
   createPresetManager,
   createWebfetchTool,
 } from './tools';
@@ -131,6 +135,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     return {};
   }
 
+  // Observation-only prompt-cache watchdog; safe to create before config
+  // loads and must see every event, so it sits outside the try block.
+  const cacheMonitor = createCacheMonitorHook();
+
   // Declare variables that must survive the try/catch for the return
   // closure. These are set inside the try block.
   let config: ReturnType<typeof loadPluginConfig>;
@@ -171,7 +179,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
   let companionManager: CompanionManager;
-  let councilTools: ReturnType<typeof createCouncilTool>;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
@@ -251,14 +258,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       startAvailabilityCheck(multiplexerConfig);
     }
 
-    // Initialize council tools (only when council is configured)
-    councilTools = config.council
-      ? createCouncilTool(
-          ctx,
-          new CouncilManager(ctx, config, multiplexerEnabled),
-        )
-      : {};
-
     mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
     acpRunTools =
       Object.keys(config.acpAgents ?? {}).length > 0
@@ -307,8 +306,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     chatHeadersHook = createChatHeadersHook(ctx);
 
     // Initialize foreground fallback manager for runtime model switching.
-    // Enabled by default even without fallback chains — the manager can still
-    // abort rate-limited sessions after maxRetries to prevent infinite freezes.
+    // Agents without a chain (e.g. councillor, owned by CouncilManager) are
+    // left alone — FG only aborts/re-prompts when it has a model to switch to.
     foregroundFallback = new ForegroundFallbackManager(
       ctx.client,
       runtimeChains,
@@ -421,7 +420,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
 
     tools = {
-      ...councilTools,
       ...cancelTaskTools,
       ...acpRunTools,
       webfetch,
@@ -551,14 +549,20 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     mcp: mcps,
 
     config: async (opencodeConfig: Record<string, unknown>) => {
-      // Only set default_agent if not already configured by the user
-      // and the plugin config doesn't explicitly disable this behavior
-      if (
-        config.setDefaultAgent !== false &&
-        !(opencodeConfig as { default_agent?: string }).default_agent
-      ) {
-        (opencodeConfig as { default_agent?: string }).default_agent =
-          'orchestrator';
+      // Force default_agent to 'orchestrator' when unset, and also when the
+      // user pointed it at an omos subagent name (opencode rejects subagent
+      // names as default_agent with "default agent must be a primary agent").
+      // Other values (opencode's built-in 'build'/'plan', or a user-defined
+      // primary agent) are respected. This guards against promptAsync calls
+      // that omit the `agent` field from falling back to 'build' when the
+      // orchestrator agent is temporarily unresolved.
+      if (config.setDefaultAgent !== false) {
+        const existing = (opencodeConfig as { default_agent?: string })
+          .default_agent;
+        if (!existing || isSubagent(existing)) {
+          (opencodeConfig as { default_agent?: string }).default_agent =
+            'orchestrator';
+        }
       }
 
       // Merge Agent configs - per-agent shallow merge to preserve
@@ -767,7 +771,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       const tuiAgentModels: Record<string, string> = {};
       const tuiAgentVariants: Record<string, string> = {};
       for (const agentDef of agentDefs) {
-        if (agentDef.name === 'councillor') continue;
+        if (
+          agentDef.name === 'council' ||
+          agentDef.name === 'councillor' ||
+          agentDef.name.startsWith('councillor-')
+        )
+          continue;
 
         const entry = configAgent[agentDef.name] as
           | Record<string, unknown>
@@ -870,6 +879,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     },
 
     event: async (input) => {
+      await cacheMonitor.event(input);
+
       const event = input.event as {
         type: string;
         properties?: {
@@ -1233,7 +1244,5 @@ export type {
   MultiplexerLayout,
   MultiplexerType,
   PluginConfig,
-  TmuxConfig,
-  TmuxLayout,
 } from './config';
 export type { RemoteMcpConfig } from './mcp';

+ 4 - 0
src/interview/service.ts

@@ -622,6 +622,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -871,6 +872,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -933,6 +935,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },
@@ -1007,6 +1010,7 @@ export function createInterviewService(
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
+          agent: 'orchestrator',
           parts: [createInternalAgentTextPart(prompt)],
           ...(model ? { model: parseModelReference(model) ?? undefined } : {}),
         },

+ 1 - 1
src/multiplexer/codemap.md

@@ -118,7 +118,7 @@ The session manager reacts to OpenCode session events:
 ### Consumers
 
 - **Main Plugin** (`src/index.ts`): Initializes multiplexer session manager during plugin startup
-- **Council Manager** (`src/council/council-manager.ts`): Uses session manager for child session pane management
+- **Council Agents** (`src/agents/council.ts`, `src/agents/council-agents.ts`): Use session manager for child session pane management
 - **Background Job Board** (`src/utils/background-job-board.ts`): Coordinates with session manager to defer pane closing when background jobs are running
 
 ### Dependencies

+ 1 - 1
src/multiplexer/zellij/codemap.md

@@ -79,7 +79,7 @@ Implements a Zellij-based multiplexer adapter that creates and manages terminal
 
 ### Consumers
 - **Main Plugin**: `src/index.ts` instantiates ZellijMultiplexer via multiplexer factory
-- **Council Manager**: `src/council/council-manager.ts` uses multiplexer for session pane management
+- **Council Agents**: `src/agents/council.ts` and `src/agents/council-agents.ts` use multiplexer for session pane management
 - **Session Lifecycle**: MultiplexerSessionManager coordinates pane creation/cleanup with session events
 
 ### Environment

+ 6 - 0
src/plugin-entry.ts

@@ -0,0 +1,6 @@
+export const INSTALLER_MANAGED_PLUGIN_OPTION =
+  '__ohMyOpencodeSlimManagedByInstaller';
+
+export type PluginEntry =
+  | string
+  | [string, Record<string, unknown>, ...unknown[]];

+ 32 - 11
src/skills/deepwork/SKILL.md

@@ -32,23 +32,44 @@ Required behavior:
 - write valuable research findings into that file as confirmed research context
   when they are received and reconciled;
 - draft a plan before implementation;
-- ask `@oracle` to review the plan and revise it until acceptable;
 - create a phased implementation/delegation plan;
-- before oracle reviews, add relevant confirmed research findings and file
-  references to the deepwork file so oracle can review the plan or phase from
+- before dispatch, choose a small number of coherent implementation phases from
+  the work's dependencies and natural delivery boundaries; do not split work
+  merely to reduce an Oracle review's scope;
+- before execution, show the user a compact overview containing only phase
+  titles and order, each delegated specialist with its ownership/scope, and the
+  total Oracle reviews with the gate after each phase and a short reason for it;
+- before each implementation phase, decide the execution path: what can run in
+  parallel, what must be sequential, which specialists to delegate to, and
+  whether to split the same agent into multiple bounded lanes;
+- after each planned phase, validate and update the deepwork file, then ask
+  `@oracle` to review the phase result before continuing;
+- before an Oracle review, add relevant confirmed research findings and file
+  references to the deepwork file so Oracle can assess the decision or risk from
   accepted context instead of redoing discovery;
-- ask `@oracle` to review that implementation plan before execution;
-- after oracle review and before each implementation phase, decide the execution
-  path: what can run in parallel, what must be sequential, which specialists to
-  delegate to, and whether to split the same agent into multiple bounded lanes;
-- after each phase, validate, update the deepwork file, prepare the plan file
-  for oracle review and ask `@oracle` to review the phase result, fix
-  actionable issues, then continue;
+- triage and batch material actionable Oracle findings into one bounded
+  remediation pass, then validate it with focused evidence; request a follow-up
+  Oracle review only if that remediation changes the reviewed decision/risk or
+  the original concern cannot otherwise be verified;
 - when a phase includes `@designer`, preserve designer intent across later
   phases. Use `@fixer` only for mechanical follow-up that does not alter the
   UI/UX;
 - finish with final validation and a concise summary.
 
+## Planned Phase Reviews
+
+Oracle reviews are automatic gates between the planned implementation phases.
+Before dispatch, decide the phases from the task itself: its dependencies,
+integration boundaries, and meaningful delivery points. Record the phase order,
+the total review count, the review after each phase, and a short reason for each
+gate in the deepwork file and compact user overview.
+
+Avoid micro-phases created only to make reviews smaller or cheaper. Larger,
+complex tasks can have broader phases, broader patches, and correspondingly
+broader phase reviews. The goal is a sensible number of predictable review
+gates, not the smallest possible review scope. Never add an extra Oracle review
+merely to re-confirm a mechanical fixer change.
+
 ## Designer Handoff Guardrail
 
 When a deepwork phase includes `@designer`, treat the delivered UI/UX as
@@ -99,7 +120,7 @@ capture, as applicable:
 - current goal and understanding;
 - researched, factual context from `@librarian` to avoid oracle doing its own
   research;
-- plan drafts and oracle review notes;
+- plan drafts, Oracle review budget/gates, and review notes;
 - implementation phases and status;
 - validation results;
 - unresolved questions, blockers, and follow-ups.

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