Эх сурвалжийг харах

Merge master into fix/issue-667-reminder-injection-format

Alvin Unreal 1 сар өмнө
parent
commit
f8c3cbd1f7
46 өөрчлөгдсөн 4031 нэмэгдсэн , 645 устгасан
  1. 1 0
      .gitignore
  2. 91 78
      .slim/codemap.json
  3. 91 0
      CONTEXT.md
  4. 33 0
      docs/agents/triage-labels.md
  5. 43 4
      docs/installation.md
  6. 735 0
      docs/superpowers/plans/2026-07-06-background-job-coordinator.md
  7. 801 0
      docs/superpowers/plans/2026-07-06-hook-registry-session-lifecycle.md
  8. 351 0
      docs/superpowers/plans/2026-07-08-share-closepane-shutdown.md
  9. 232 0
      docs/superpowers/specs/2026-07-06-hook-registry-session-lifecycle-design.md
  10. 5 0
      oh-my-opencode-slim.schema.json
  11. 5 1
      src/agents/index.test.ts
  12. 18 4
      src/agents/index.ts
  13. 11 0
      src/config/schema.ts
  14. 19 1
      src/hooks/chat-headers.test.ts
  15. 2 2
      src/hooks/chat-headers.ts
  16. 6 3
      src/hooks/codemap.md
  17. 287 16
      src/hooks/foreground-fallback/index.test.ts
  18. 52 15
      src/hooks/foreground-fallback/index.ts
  19. 1 0
      src/hooks/index.ts
  20. 56 3
      src/hooks/phase-reminder/index.test.ts
  21. 18 8
      src/hooks/phase-reminder/index.ts
  22. 31 20
      src/hooks/post-file-tool-nudge/index.test.ts
  23. 13 48
      src/hooks/post-file-tool-nudge/index.ts
  24. 53 0
      src/hooks/session-lifecycle.test.ts
  25. 51 0
      src/hooks/session-lifecycle.ts
  26. 59 23
      src/hooks/task-session-manager/index.test.ts
  27. 31 41
      src/hooks/task-session-manager/index.ts
  28. 112 153
      src/index.ts
  29. 13 1
      src/interview/interview.test.ts
  30. 10 5
      src/interview/service.ts
  31. 18 46
      src/multiplexer/herdr/index.ts
  32. 41 29
      src/multiplexer/session-manager.test.ts
  33. 28 25
      src/multiplexer/session-manager.ts
  34. 146 0
      src/multiplexer/shared.test.ts
  35. 55 1
      src/multiplexer/shared.ts
  36. 11 48
      src/multiplexer/tmux/index.ts
  37. 7 27
      src/multiplexer/zellij/index.ts
  38. 2 2
      src/tools/cancel-task.ts
  39. 4 1
      src/tui.ts
  40. 16 1
      src/utils/background-job-board.ts
  41. 122 0
      src/utils/background-job-coordinator.test.ts
  42. 238 0
      src/utils/background-job-coordinator.ts
  43. 79 0
      src/utils/background-job-store.ts
  44. 2 0
      src/utils/index.ts
  45. 23 19
      src/utils/internal-initiator.test.ts
  46. 8 20
      src/utils/internal-initiator.ts

+ 1 - 0
.gitignore

@@ -101,3 +101,4 @@ companion/target/
 .slim/deepwork/
 .codegraph/
 companion/VIDEOS/
+.worktrees/

+ 91 - 78
.slim/codemap.json

@@ -1,7 +1,7 @@
 {
   "metadata": {
     "version": "1.0.0",
-    "last_run": "2026-07-02T23:16:30.036Z",
+    "last_run": "2026-07-06T18:55:02.720Z",
     "root": "/home/mhenke/Projects/oh-my-opencode-slim",
     "include_patterns": [
       "src/**/*.ts",
@@ -27,49 +27,50 @@
     "exceptions": []
   },
   "file_hashes": {
-    "AGENTS.md": "75f71915cf0ad65e1168c3d50741f9f1",
-    "README.md": "29242ede8361ccee21d0e053df8eed53",
+    "AGENTS.md": "e2448f31cc3ee2cd0176538784373271",
+    "README.md": "f4b9788a277ec3c405ed390b73be1f78",
     "biome.json": "b68da34425b83fddbde5718ac6eb82f9",
-    "package.json": "b14793518ed7fad4a5f057fdefb9e017",
+    "package.json": "d1cee3e0421a042698bd306d73197ae5",
     "scripts/generate-schema.ts": "007f340e39adf6c3fd76feda72b71df1",
     "scripts/verify-opencode-host-smoke.ts": "a87fdb08b123501edf81618a49bc421d",
-    "scripts/verify-release-artifact.ts": "14373deac536d31ad0cc4516f1aa98ce",
-    "src/agents/council.ts": "410d3d621738c68af1ffc54389d9cc49",
-    "src/agents/councillor.ts": "4b95f3d807762ac7fc2e2684140cf624",
-    "src/agents/designer.ts": "dc615fc8fdb9b9c218c1f002530d1f56",
+    "scripts/verify-release-artifact.ts": "119eb43328189d4a57c12c784f17ac9b",
+    "src/agents/council.ts": "209ef5af0a5c3c0273c9d9db890ad6b8",
+    "src/agents/councillor.ts": "f2a0b7d0f9722e1b32e4b385d6b7b716",
+    "src/agents/designer.ts": "aa9cea2bea0f1732965739114559956d",
     "src/agents/explorer.ts": "d0852357f5f54d9091ce32a7576cbd4e",
-    "src/agents/fixer.ts": "f59568120a49fd4530b25322d9cdaea5",
-    "src/agents/index.ts": "60672ff6d7d628527f61610e91b55cfd",
+    "src/agents/fixer.ts": "1717c67d9900a9d1fe8d99f2f3b968af",
+    "src/agents/index.ts": "410d82467609dfca47fc288aa73f843c",
     "src/agents/librarian.ts": "25e64317fd9ef5f8b6759150c44c0ff8",
-    "src/agents/observer.ts": "1bdc85a13c59c05055e47aec8eefff54",
+    "src/agents/observer.ts": "5387a97e67b194f12c6a57a35b9d8025",
     "src/agents/oracle.ts": "ef1581f9c8f06cfcec1837f85f69d06e",
-    "src/agents/orchestrator.ts": "e83a943def94c654dc2558167bf99e44",
+    "src/agents/orchestrator.ts": "da4f99e9634c3c5f3b6ed52a68f7a286",
     "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.ts": "dca013d57a18e036b781f0471023c103",
+    "src/cli/custom-skills-registry.ts": "e2157e98eb57e75b24df7819d692f9c3",
+    "src/cli/custom-skills.ts": "105f2bd9a36490bfd35858ac36bf5bd6",
     "src/cli/doctor.ts": "deb359777d243984b6a6ced6aee32651",
-    "src/cli/index.ts": "e5cca0018fa250a341fe5a10c4c5a789",
-    "src/cli/install.ts": "8849ad8934ad6d68807b0c3aca50031a",
+    "src/cli/index.ts": "45abb38d42c39c37066a309376381188",
+    "src/cli/install.ts": "379516229bd985e99d518aceb0fe424c",
     "src/cli/model-key-normalization.ts": "7f988cc8109c95382b9ece9730e2a7a5",
     "src/cli/paths.ts": "dd032ba57b84ab4a3a8437d51600acd7",
-    "src/cli/providers.ts": "96dbb99a74bf5bc04336273ef78cc887",
-    "src/cli/skills.ts": "e1147d45d69355379f47d4a8146404c6",
+    "src/cli/providers.ts": "9eee6be90e4c2d972fa73b0892ddf030",
+    "src/cli/skills.ts": "b867492bc2ef473116d5e39e38957b0c",
     "src/cli/system.ts": "b5464d7661ab1c8e196159641ee3bbed",
     "src/cli/types.ts": "7fb0770e7aa0e010f0107df45ab5572b",
-    "src/companion/manager.ts": "dc16e79ccff1c67c6980a0d295f3044c",
+    "src/companion/manager.ts": "aa8590d929339e7c88bea307dc418ff8",
     "src/companion/updater.ts": "45bb856e88a75e07426936b505b2f973",
     "src/config/agent-mcps.ts": "ce2c54b4f82a8a6ab42ed7acb1fc58bf",
-    "src/config/constants.ts": "38310819e904fc349ff9d46ae3e17e50",
-    "src/config/council-schema.ts": "d180ec95197e173d21bc6f6dc6a5fcc6",
+    "src/config/constants.ts": "907b20f9587b0af529b683cb097eeb7f",
+    "src/config/council-schema.ts": "51c3e6bd9aec8ac5c98043d5b7df7b2f",
     "src/config/index.ts": "8a61e02aa676fc86cc8d9d6d30a2e617",
-    "src/config/loader.ts": "e1fa6142444980bfa667c9e2af06055e",
+    "src/config/loader.ts": "5757c143083364fb6352d7701ff1b2b2",
     "src/config/runtime-preset.ts": "7f924629c21ed1f438bcea8f4a54da02",
-    "src/config/schema.ts": "1e7481057f6bcf025b697a23006d7dc6",
+    "src/config/schema.ts": "84ea141440fa89245dd324fa03d30dfb",
     "src/config/utils.ts": "ea6fe8ef6dff0848f42f03c7d6983727",
-    "src/council/council-manager.ts": "6443262ab1d50680f0b97e735cd90255",
+    "src/council/council-manager.ts": "771f4feb3f709d410ef0208b9996881b",
     "src/council/index.ts": "24cab5b06b4bfd91d2496692650eb18a",
     "src/hooks/apply-patch/codec.ts": "ba2086f51f88c47a67ccf930f0b1e268",
     "src/hooks/apply-patch/errors.ts": "fd2c9d9d185494f2f8b22862bd14700b",
@@ -85,48 +86,54 @@
     "src/hooks/auto-update-checker/cache.ts": "306b85a4beef7fd9959ecdfc655f8c3c",
     "src/hooks/auto-update-checker/checker.ts": "616bd0fa5e2d00464ef0f2b99ed47a3b",
     "src/hooks/auto-update-checker/constants.ts": "22f2a2bd7f617601ccb329acd01b85a4",
-    "src/hooks/auto-update-checker/index.ts": "ea76c239104a3eaa547b9e800bfdba61",
-    "src/hooks/auto-update-checker/skill-sync.ts": "f5b348860c1f475587717627d24b2378",
+    "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/delegate-task-retry/hook.ts": "310c87963909ab3f40da5a61a50f5df0",
     "src/hooks/delegate-task-retry/patterns.ts": "5e4919da29af630e4e2ec37df0b58025",
-    "src/hooks/filter-available-skills/index.ts": "9be66b5a605e22c7b61ff3089201200d",
-    "src/hooks/foreground-fallback/index.ts": "1aae65cad931221b7d6545f9d285a4d6",
-    "src/hooks/image-hook.ts": "4de4f0267c016f23a6a1ed1869dc976e",
-    "src/hooks/index.ts": "41f1c44b8b5cdbe2e4e0ea9ad634bddd",
+    "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/json-error-recovery/hook.ts": "6b86f68cdf202725ed856c07de622b62",
-    "src/hooks/phase-reminder/index.ts": "699cb05c721c04204987a613fb3d3253",
-    "src/hooks/post-file-tool-nudge/index.ts": "fd2b3ace9dcb74c024e622f5ff9c3c4e",
-    "src/hooks/reflect/index.ts": "0f000a38f6f365eeeb1761d537168777",
-    "src/hooks/task-session-manager/index.ts": "b4d529be147d25f8d05cb37d1cc4ecbb",
+    "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/task-session-manager/task-context-tracker.ts": "76edc7671beeb4cca127332d2cf5912a",
-    "src/hooks/types.ts": "38ab21f1e4bbe67d0bff116101fec718",
-    "src/index.ts": "7c7c2d10da21705f2653452d0f4f9d8e",
-    "src/interview/dashboard.ts": "6b426f870d383740bdb411003493c442",
-    "src/interview/document.ts": "3a39c23006e7dfce8cf9a3e950e6f4da",
+    "src/hooks/task-session-manager/task-context-tracker.ts": "e6dc74e72aedd014643134afb2c123a6",
+    "src/hooks/types.ts": "0a80ebc8b12032bd9cc810e5fab6b6a1",
+    "src/index.ts": "b1f9feda05098802712f89990c1c209b",
+    "src/interview/dashboard-manager.ts": "40475436feb2773a825db89827ab28f5",
+    "src/interview/dashboard.ts": "cfff71b7ca3a38b9e58e3ed96262ccea",
+    "src/interview/document.ts": "29e6a6d42c0d0b502dd25a022c4e1de4",
     "src/interview/helpers.ts": "b95a7e299bb4ab38ab66a272b3ba3612",
     "src/interview/index.ts": "ab5c9a50b6c08826cfd53233cac75f38",
-    "src/interview/manager.ts": "1139da725bf396115968aeb163fd8d2a",
+    "src/interview/manager.ts": "c9c61d5a5914c6cffba33ff6962e93aa",
     "src/interview/parser.ts": "aa6101cf5bebfafcbca845ba532856cf",
     "src/interview/prompts.ts": "b94ef5117d4e720cb5045080b240d890",
     "src/interview/server.ts": "fe5230962e2d44c6bec9909049c971f6",
-    "src/interview/service.ts": "5e69ae78f3e4c75d11463d818406420a",
-    "src/interview/types.ts": "411d646f2d515996bf4c88d6797e6aac",
-    "src/interview/ui.ts": "7d37945ca5c837fa305a0121ba8042c7",
+    "src/interview/service.ts": "ebd799bbd76a4a08fda5d29965e0477e",
+    "src/interview/session-server.ts": "b1e0169aa0720bfc92c63c09007890db",
+    "src/interview/types.ts": "c09bb5def45c25f2a9add1327d88d881",
+    "src/interview/ui.ts": "1a19d9d5543e6bc706794fcc1adada8e",
+    "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": "65b42f20889779cd9cd2ec89f1521f14",
-    "src/multiplexer/index.ts": "252b8f5d0d6f8e6c3408eed47791bf67",
-    "src/multiplexer/session-manager.ts": "589609cb19f1dff1c26c32e34609781a",
-    "src/multiplexer/tmux/index.ts": "5f9ffa6c9f4c0d72535e025ea6a5def0",
-    "src/multiplexer/types.ts": "2269f67f16fad8f60d92fb389cf3519b",
-    "src/multiplexer/zellij/index.ts": "f8c7d178aa873b481ac1b0ba1a0b8bdb",
+    "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/skills/clonedeps/README.md": "1e7ee3fb1032ca64141fe133a3af1cc7",
     "src/skills/codemap/README.md": "fbb3e9fd31ae685b87e630df96c3c60a",
     "src/skills/simplify/README.md": "2786c6e4e6b9f972193353b49741c8e3",
@@ -138,10 +145,10 @@
     "src/tools/ast-grep/tools.ts": "a0d7b252fb2240c8e064b495c19e1f26",
     "src/tools/ast-grep/types.ts": "34ad28b5b1e9617b584f082dba9a427c",
     "src/tools/ast-grep/utils.ts": "1dd3b2133c4b8c847a26eea0423bc0b2",
-    "src/tools/cancel-task.ts": "fa1a70f89869eb56846effa50845ffe4",
-    "src/tools/council.ts": "303471abd91c423f5ca294fbcd144935",
+    "src/tools/cancel-task.ts": "c55e4173b619dcf199d72c6ecd6f4d09",
+    "src/tools/council.ts": "5178d86b649777c753ffb4cc9a31dcfc",
     "src/tools/index.ts": "b562a39a524d55c1b0b33041b62437e8",
-    "src/tools/preset-manager.ts": "50367ad256f0d4209569cdb51bef21b1",
+    "src/tools/preset-manager.ts": "5dc472acedb4fb5e17a584bbb7e190e0",
     "src/tools/smartfetch/binary.ts": "a65d816f46ebef11c39bda1764f82bb7",
     "src/tools/smartfetch/cache.ts": "9a4e272b897b6914f0925919357bfce1",
     "src/tools/smartfetch/constants.ts": "1ba20e00a4d3f4717eba62f381f9cd4c",
@@ -152,17 +159,20 @@
     "src/tools/smartfetch/types.ts": "2576efe959365f34b7160c409fb54d26",
     "src/tools/smartfetch/utils.ts": "ab169376765be6079f24f55862d9a90b",
     "src/tui-state.ts": "dd8cbf2d515085edc548cdbecfd1ba09",
-    "src/tui.ts": "b475492ec7192ec8109d6acbee3169a5",
+    "src/tui.ts": "12d1e248f4eba8d6f54b164a742cab9c",
     "src/utils/agent-variant.ts": "6e112fb56a0eef55c8c1dbff3e9d7c8e",
-    "src/utils/background-job-board.ts": "69ecb5da1da658086d4f5c6c3e6a8d1a",
+    "src/utils/background-job-board.ts": "fcdd672d9f3a8730d3db39470e0da61d",
+    "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/guards.ts": "83af4d036dd573e9008f0c1125e4918c",
-    "src/utils/index.ts": "9658d64a4ef4ec45b14b331feaa88f35",
+    "src/utils/index.ts": "42b1f364b48e75faa9cfeb44c6ad2660",
     "src/utils/internal-initiator.ts": "013b87f387555db563b0241645d638b1",
-    "src/utils/logger.ts": "497874c667bd534ed8effbf046cb09dc",
+    "src/utils/logger.ts": "4e33d48e57ab1ea40cfe07a796d4ac44",
     "src/utils/polling.ts": "b1d9c52df1fae7391234d0f5476d53b5",
-    "src/utils/session.ts": "5e99ac85890d4a756585452d0093b82f",
+    "src/utils/session.ts": "6f277d3a687e019cc47a2ed412310c1b",
     "src/utils/subagent-depth.ts": "f925bd47ed5ffb67039508bedb14ac25",
     "src/utils/system-collapse.ts": "05370b9db1a8dbd4ace4958cc807b912",
     "src/utils/task.ts": "379ec59e07b805ecc4516387a301c9c2",
@@ -170,38 +180,41 @@
     "tsconfig.json": "1d2bb6e93a43366843785a156c8e538a"
   },
   "folder_hashes": {
-    ".": "46a9e1a263dccb4e1d175d9ae3610b38",
-    "scripts": "ef995a5bcb4a311c670cfc53bf6c169d",
-    "src": "6770ae70da9bf9043f75f2b5dc254796",
-    "src/agents": "b4baa555e356b548e3333caf6984c441",
-    "src/cli": "e4c5d373ed083b765e1826c7e74b6864",
-    "src/companion": "1b6f2aa30c60de428b60005f62a42bac",
-    "src/config": "b1a4248f255324af18c182004b6c00d9",
-    "src/council": "0a5229eb3778c0497e1d0b989dd4d8c5",
-    "src/hooks": "fa6d6583be8b609745b603ff3085e1c3",
+    ".": "f4a663f6aac32f8ce0db004493bf6297",
+    "scripts": "362e9fb6832c103e1434719916be283d",
+    "src": "a05b32fdbbecf0039f0a25353464ca1f",
+    "src/agents": "14bf78ef0216501e70602f12d62e9839",
+    "src/cli": "68e0366396ce0091fbfc8693500ef242",
+    "src/companion": "489154aba4297da2a659a31bd91beb15",
+    "src/config": "9cd27db0477bccd91e793a4b51aee90c",
+    "src/council": "a25cae62a980ff28f7d1834236c46730",
+    "src/hooks": "89952c2777fb40f35be388f989e4b1b0",
     "src/hooks/apply-patch": "d20e3c103082283c3c126b7e936bc041",
-    "src/hooks/auto-update-checker": "7b296221c92eafe16f39818b544740c1",
+    "src/hooks/auto-update-checker": "afe6d8213cc900b772b9c04ab6a31fd6",
     "src/hooks/deepwork": "4698a85b598d3158d038313680762c1b",
     "src/hooks/delegate-task-retry": "7bd4abeb2dbfc4e7aaed701de08b7509",
-    "src/hooks/filter-available-skills": "d27655bbe7a8a807367eb8aa2cdbfdb7",
-    "src/hooks/foreground-fallback": "caa7388b788f230ebba7512fc815530d",
+    "src/hooks/filter-available-skills": "2f0f2cbced0fbe91931733f645209019",
+    "src/hooks/foreground-fallback": "0f4879b5d2ae0a9fbceef13fd7357635",
     "src/hooks/json-error-recovery": "fbe725b787123f203b78dd8dfd47db67",
-    "src/hooks/phase-reminder": "91decaaf41bd64430a7d24f5d3780a51",
-    "src/hooks/post-file-tool-nudge": "7e271eb8f0f7fe6259d878ae610c2990",
-    "src/hooks/reflect": "88bba64c2989d7c70a9188edc2e80f47",
-    "src/hooks/task-session-manager": "365429564ba1d1ec58247e9b748d3d3d",
-    "src/interview": "423eecf5cf02812b95b81e8374a2eceb",
+    "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/loop": "366bf2141b605deb1849f9783c993810",
     "src/mcp": "1db30ec46ae0b577ec22b74e2b4d19ea",
-    "src/multiplexer": "1858ddff246c97ee920b09825750eca0",
-    "src/multiplexer/tmux": "b36a4e4636d659afc9f326aa263251b9",
-    "src/multiplexer/zellij": "4ac57e1d5fdd9368abd8b138ea82db62",
+    "src/multiplexer": "a8ea1efc12f7d75a9af06b25da7d5664",
+    "src/multiplexer/herdr": "e9ca0fd14736da1ea49ccca1a1736733",
+    "src/multiplexer/tmux": "f98f01b8553bf556ec302744cf61bf45",
+    "src/multiplexer/zellij": "d01383270dbd0f1fad6b67d85a69fe30",
     "src/skills": "3afb58b43174496617ece428d8deb50d",
     "src/skills/clonedeps": "d1d19753438fdb845f4efca93314a147",
     "src/skills/codemap": "1e82ef833612703b786daceb091f2422",
     "src/skills/simplify": "9c745d8113135e3103af5f1a49d67dfe",
-    "src/tools": "cc6bb384c43c6dd7fdc00476310ecec4",
+    "src/tools": "cd35887510b0bab8c89cba8c71a8456a",
     "src/tools/ast-grep": "7091c20c0d028c22effa2b5c1e64cc58",
     "src/tools/smartfetch": "b71f3f9464bb203a3ae3a4521eea01a6",
-    "src/utils": "513c051e502068a189e56a9fff73a252"
+    "src/utils": "cc5583330e527a04184e4a1136e721d4"
   }
 }

+ 91 - 0
CONTEXT.md

@@ -0,0 +1,91 @@
+# CONTEXT.md — Domain Glossary
+
+A glossary of the terms used in this project's domain. Definitions describe what a term means, not how it is implemented.
+
+## Agents
+
+- **Agent** — A named LLM role with a defined lane (permissions, tools, prompt); the unit of work delegation in the system.
+- **Orchestrator** — The primary agent. Plans work, delegates to subagents, monitors them, and reconciles their results. One per session; cannot be disabled.
+- **Subagent** — A specialist agent the orchestrator delegates bounded work to.
+- **Explorer** — Subagent for fast codebase search and pattern matching.
+- **Librarian** — Subagent for external documentation and library research.
+- **Oracle** — Subagent for architecture, debugging strategy, and code review.
+- **Designer** — Subagent for UI/UX design and visual polish.
+- **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.
+- **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).
+- **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.
+- **Agent alias** — A legacy or alternate name that maps to a built-in agent. Rejected synonyms: `explore` (use `explorer`), `frontend-ui-ux-engineer` (use `designer`).
+
+## Council
+
+- **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
+
+- **Multiplexer** — A terminal backend (tmux, zellij, or herdr) that hosts child agent panes. Set via \`multiplexer.type\`, which also accepts \`auto\` (auto-detect) and \`none\` (disabled).
+- **Multiplexer type** — The selected backend: `auto`, `tmux`, `zellij`, `herdr`, or `none`.
+- **Pane** — A terminal region spawned by the multiplexer to run a child agent session.
+- **Child session** — A background agent session hosted in a multiplexer pane and tracked by the session manager.
+- **Session manager** — Tracks child sessions, spawns and closes multiplexer panes, and reacts to session lifecycle events. Note: `TmuxSessionManager` is a deprecated alias — use `MultiplexerSessionManager`.
+- **Close reason** — Why a pane is closed: `idle` or `deleted`.
+
+## Background Jobs
+
+- **Background job** — A delegated specialist task that runs asynchronously; tracked until its result is reconciled into the orchestrator's response.
+- **Background Job Board** — The store of background job state and metadata.
+- **Background Job Coordinator** — The layer that owns background-job lifecycle policy and deferred-close state, writing through the board.
+- **Job state** — A background job's status: `running`, `completed`, `error`, `cancelled`, or `reconciled`. `reconciled` is a distinct post-consumption phase marking that a terminal job's result has been folded into the orchestrator's response; it is not a terminal outcome itself.
+- **Job alias** — A short human-readable identifier for a background job (e.g., `fix-1`, `exp-2`).
+- **Terminal state** — A job state from which no further transition occurs (`completed`, `error`, `cancelled`).
+
+## Skills
+
+- **Skill** — A bundled, self-contained workflow or capability shipped with the plugin. Bundled skills: codemap, clonedeps, simplify, deepwork, reflect, worktrees, oh-my-opencode-slim, release-smoke-test. Note: `loop-engineering` exists on disk but is not registered as a bundled skill.
+
+## Hooks
+
+- **Hook** — A plugin extension point that reacts to OpenCode lifecycle events (e.g., apply-patch, filter-available-skills, loop-command, session-lifecycle).
+
+## Loop
+
+- **Loop** — An auto-iterative run that executes work with an agent, verifies it against success criteria, and repeats until done or escalated.
+- **Loop session** — The state of one loop run (goal, current phase, attempts, history).
+- **Loop phase** — A stage of a loop: `executing`, `verifying`, `done`, `escalated`, or `cancelled`.
+- **Execute agent** — The agent that performs loop work (`fixer`, `designer`, `explorer`, or `librarian`).
+- **Verify agent** — The agent or strategy that verifies loop output (`oracle`, `observer`, or `test`).
+- **Success criterion** — A check that decides whether a loop iteration passed (test, build, lint, fileExists, command, oracle, observer, or manual).
+
+## Interview
+
+- **Interview** — A question/answer flow that builds a persistent specification document from an idea.
+- **Spec block** — A named section within a generated specification document.
+- **Interview dashboard** — The web UI for managing an interview and entering answers.
+
+## Companion
+
+- **Companion** — A native desktop mascot that reflects agent activity; launched and tracked by the companion manager.
+
+## Config
+
+- **Plugin config** — The user-facing configuration loaded from `oh-my-opencode-slim.jsonc`.
+- **Preset** — A named set of per-agent overrides. The same word also names council councillor lineups (see Flagged).
+- **Model entry** — A normalized model reference with an optional variant, used in fallback chains.
+- **Variant** — An optional model qualifier (e.g., a preview build) used in fallback resolution.
+- **Fallback / failover** — The mechanism that switches models when a call is rate-limited or returns empty.
+- **Disabled agents** — Agents turned off via config; `observer` is disabled by default.
+
+## Flagged
+
+Terms with genuine but non-blocking collisions or historical drift. Noted for awareness; no change required:
+
+- **"Presets" means two things** — A plugin *preset* is a set of agent overrides; a council *preset* is a lineup of councillor models. Same word, different JSON paths and types; no structural conflict, but easy to confuse.
+- **Config naming convention** — Config keys mix snake_case (`disabled_agents`, `main_pane_size`) with camelCase (`autoUpdate`, `backgroundJobs`) with no documented rule. Historical drift; `disabled_*` keys are uniformly snake_case while the rest is mixed even within sub-objects.

+ 33 - 0
docs/agents/triage-labels.md

@@ -0,0 +1,33 @@
+# Triage Label Mapping
+
+Maps the **canonical triage roles** (defined in the `triage` skill from
+`mattpocock/skills`) to the actual GitHub label strings used in this repo's
+issue tracker. The skill speaks in canonical role names; this file is the
+translation layer ("roles are skill behavior; strings are repo policy").
+
+| Label in `mattpocock/skills` | Label in our tracker | Meaning |
+| ---------------------------- | -------------------- | ------- |
+| `bug`                        | `bug`                | Something is broken |
+| `enhancement`                | `enhancement`        | New feature or improvement |
+| `needs-triage`               | *(unlabeled)*        | Maintainer needs to evaluate |
+| `needs-info`                 | `needs-info`         | Waiting on reporter for more information |
+| `ready-for-agent`            | `good-to-code`       | Fully specified, ready for an AFK agent |
+| `ready-for-human`            | `good-to-code`       | Needs human implementation |
+| `wontfix`                    | `wontfix`            | Will not be actioned |
+
+## Notes
+
+- `needs-triage` has **no label** by design: an unlabeled issue is implicitly in
+  the `needs-triage` state.
+- `ready-for-agent` and `ready-for-human` both map to `good-to-code`. The
+  difference is who implements: an agent picks up `ready-for-agent`; a human
+  implements `ready-for-human`. `status:in-review` is a separate human-review
+  state (for when code already exists and awaits review) — do not apply it to
+  issues that still need implementation.
+- The following repo labels are intentionally **outside** the triage taxonomy
+  and should not be applied by `/triage`:
+  - `confirmed` — maintainer-acknowledged signal after `needs-triage`
+  - `status:in-review` — human review state (optional overlay on `good-to-code`)
+  - `P0` — priority overlay; apply manually alongside any role for urgent items
+  - `release` — release management
+  - `Share Your Thoughts` — open-ended community feedback

+ 43 - 4
docs/installation.md

@@ -331,17 +331,43 @@ See the [Multiplexer Integration Guide](multiplexer-integration.md) for more det
 
 ## Uninstallation
 
-1. **Remove the plugin from your OpenCode config**:
+### Required
 
-   Edit `~/.config/opencode/opencode.json` and remove `"oh-my-opencode-slim"` from the `plugin` array.
+1. Remove the plugin from your OpenCode config:
 
-2. **Remove configuration files (optional)**:
+   Edit `~/.config/opencode/opencode.json` and remove `"oh-my-opencode-slim"` from the `plugin` array. If the installer enabled LSP (it only does so when no explicit `lsp` setting exists), set `"lsp": false` or remove the `"lsp"` key.
+
+2. Remove the TUI badge:
+
+   Edit `~/.config/opencode/tui.json` and remove `"oh-my-opencode-slim"` from the `plugin` array.
+
+### Optional Cleanup
+
+3. Re-enable default agents:
+
+   In `~/.config/opencode/opencode.json`, remove the `disable: true` entries the installer added under `agent.explore` and `agent.general`.
+
+4. Remove the environment variable:
+
+   The installer may have added an export to your shell startup file. Remove the `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` line from your shell config:
+   - `~/.zshrc` (Zsh)
+   - `~/.bashrc` (Bash)
+   - `~/.config/fish/conf.d/opencode-background-subagents.fish` (Fish) — also remove `set -gx OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS true`
+
+   Restart your terminal or `source` the file.
+
+5. Clear the plugin cache:
+   ```bash
+   rm -rf ~/.cache/opencode/packages/oh-my-opencode-slim@*
+   ```
+
+6. Remove configuration files:
    ```bash
    rm -f ~/.config/opencode/oh-my-opencode-slim.json
    rm -f ~/.config/opencode/oh-my-opencode-slim.json.bak
    ```
 
-3. **Remove skills (optional)**:
+7. Remove skills installed by the installer:
    ```bash
    rm -rf ~/.config/opencode/skills/simplify
    rm -rf ~/.config/opencode/skills/codemap
@@ -351,3 +377,16 @@ See the [Multiplexer Integration Guide](multiplexer-integration.md) for more det
    rm -rf ~/.config/opencode/skills/worktrees
    rm -rf ~/.config/opencode/skills/oh-my-opencode-slim
    ```
+
+   > **Note:** The installer manages these specific skills. If you added others manually, they won't be affected.
+
+8. Remove the desktop companion binary (if installed):
+
+   The companion is optional and not installed by default. If you installed it:
+   ```bash
+   rm -rf ~/.local/share/opencode/storage/oh-my-opencode-slim
+   ```
+
+### Verify
+
+Run `opencode auth status` and confirm oh-my-opencode-slim agents no longer appear.

+ 735 - 0
docs/superpowers/plans/2026-07-06-background-job-coordinator.md

@@ -0,0 +1,735 @@
+# BackgroundJobCoordinator: Move Lifecycle Policy from Multiplexer to Coordinator
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Centralize background job lifecycle policy (deferred close decisions) in the coordinator, not the multiplexer.
+
+**Architecture:** The coordinator owns the `deferredIdleCloses` tracking and decides when sessions should close. The multiplexer becomes a thin pane manager that queries the coordinator before closing. The subscription wiring stays in `index.ts` (not in the multiplexer constructor) to avoid multi-instance issues.
+
+**Tech Stack:** TypeScript, Bun
+
+## Global Constraints
+
+- No new dependencies
+- All existing tests must pass
+- Follow ponytail principles: minimal code, YAGNI
+- Match existing code style (biome formatter)
+
+---
+
+## File Map
+
+| File | Action | Responsibility |
+|------|--------|----------------|
+| `src/utils/background-job-coordinator.ts` | Modify | Add `deferredIdleCloses` tracking, `deferIfRunning()`, `retryDeferredClose()`, `clearDeferredClose()` |
+| `src/utils/background-job-coordinator.test.ts` | Create | Test lifecycle policy logic |
+| `src/utils/background-job-store.ts` | Modify | Add lifecycle methods to interface |
+| `src/utils/background-job-board.ts` | Modify | Add stubs to satisfy interface |
+| `src/multiplexer/session-manager.ts` | Modify | Remove `deferredIdleCloses`, remove `retryDeferredIdleClose()`, query coordinator |
+| `src/multiplexer/session-manager.test.ts` | Modify | Update test setup to use coordinator |
+| `src/index.ts` | Modify | Update wiring: keep subscription, remove retryDeferredIdleClose call |
+
+---
+
+### Task 1: Add lifecycle methods to BackgroundJobStore interface
+
+**Files:**
+- Modify: `src/utils/background-job-store.ts`
+
+**Interfaces:**
+- Produces: `deferIfRunning(sessionId: string): boolean`, `retryDeferredClose(sessionId: string): boolean`, `clearDeferredClose(sessionId: string): void`
+
+- [ ] **Step 1: Add new methods to BackgroundJobStore interface**
+
+```typescript
+// In src/utils/background-job-store.ts, add after existing methods:
+
+  // ── Lifecycle policy ─────────────────────────────────────────────
+  /** Evaluate close policy. Returns true if session should close now.
+   *  Mutates deferred state: adds to deferred set if running, removes if not. */
+  deferIfRunning(sessionId: string): boolean;
+  /** Retry closing a deferred session. Returns true if session should now close. */
+  retryDeferredClose(sessionId: string): boolean;
+  /** Clear deferred close state for a session being deleted. */
+  clearDeferredClose(sessionId: string): void;
+```
+
+- [ ] **Step 2: Run typecheck to verify interface change**
+
+Run: `bun run typecheck`
+Expected: FAIL - BackgroundJobBoard and BackgroundJobCoordinator don't implement new methods yet
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/utils/background-job-store.ts
+git commit -m "feat: add lifecycle methods to BackgroundJobStore interface"
+```
+
+---
+
+### Task 2: Implement lifecycle policy in BackgroundJobCoordinator
+
+**Files:**
+- Modify: `src/utils/background-job-coordinator.ts`
+
+**Interfaces:**
+- Consumes: `BackgroundJobStore` interface (Task 1)
+- Produces: Implemented `deferIfRunning()`, `retryDeferredClose()`, `clearDeferredClose()`
+
+- [ ] **Step 1: Add deferredIdleCloses tracking to coordinator**
+
+```typescript
+// In src/utils/background-job-coordinator.ts, add to class properties:
+
+  // Stores session IDs (which equal task IDs) awaiting close after background job completes
+  private readonly deferredIdleCloses = new Set<string>();
+```
+
+- [ ] **Step 2: Implement deferIfRunning method**
+
+```typescript
+// In src/utils/background-job-coordinator.ts, add method:
+
+  /**
+   * Evaluate close policy. Returns true if session should close now.
+   * Mutates deferred state: adds to deferred set if running, removes if not.
+   */
+  deferIfRunning(sessionId: string): boolean {
+    if (!this.board.isRunning(sessionId)) {
+      this.deferredIdleCloses.delete(sessionId);
+      return true;
+    }
+    this.deferredIdleCloses.add(sessionId);
+    return false;
+  }
+```
+
+- [ ] **Step 3: Implement retryDeferredClose method**
+
+```typescript
+// In src/utils/background-job-coordinator.ts, add method:
+
+  /**
+   * Retry closing a deferred session. Called when a background job completes.
+   * Returns true if the session should now close.
+   */
+  retryDeferredClose(sessionId: string): boolean {
+    if (!this.deferredIdleCloses.has(sessionId)) return false;
+    return this.deferIfRunning(sessionId);
+  }
+```
+
+- [ ] **Step 4: Implement clearDeferredClose method**
+
+```typescript
+// In src/utils/background-job-coordinator.ts, add method:
+
+  /**
+   * Clear deferred close state for a session being deleted.
+   */
+  clearDeferredClose(sessionId: string): void {
+    this.deferredIdleCloses.delete(sessionId);
+  }
+```
+
+- [ ] **Step 5: Update handleTerminalState to notify listeners**
+
+```typescript
+// In src/utils/background-job-coordinator.ts, update handleTerminalState:
+
+  private handleTerminalState(taskID: string): void {
+    // Re-check board state to handle races
+    const state = this.board.getState(taskID);
+    if (state === undefined) return;
+
+    // Check if this session should now close
+    if (this.retryDeferredClose(taskID)) {
+      // Notify listeners that session should close
+      for (const listener of this.terminalStateListeners) {
+        listener(taskID);
+      }
+    }
+  }
+```
+
+- [ ] **Step 6: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/utils/background-job-coordinator.ts
+git commit -m "feat: implement lifecycle policy in BackgroundJobCoordinator"
+```
+
+---
+
+### Task 3: Write coordinator tests
+
+**Files:**
+- Create: `src/utils/background-job-coordinator.test.ts`
+
+**Interfaces:**
+- Consumes: BackgroundJobCoordinator (Task 2)
+
+- [ ] **Step 1: Create test file with mock board**
+
+```typescript
+// In src/utils/background-job-coordinator.test.ts:
+
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobCoordinator } from './background-job-coordinator';
+
+function createMockBoard(isRunning = false) {
+  return {
+    isRunning: mock(() => isRunning),
+    getState: mock(() => (isRunning ? 'running' : 'completed')),
+    addTerminalStateListener: mock(() => {}),
+    removeTerminalStateListener: mock(() => {}),
+    // ... other methods as needed
+  } as any;
+}
+```
+
+- [ ] **Step 2: Test deferIfRunning returns false when job is running**
+
+```typescript
+test('deferIfRunning returns false when job is running', () => {
+  const board = createMockBoard(true);
+  const coordinator = new BackgroundJobCoordinator(board);
+  expect(coordinator.deferIfRunning('ses_123')).toBe(false);
+});
+```
+
+- [ ] **Step 3: Test deferIfRunning returns true when job is not running**
+
+```typescript
+test('deferIfRunning returns true when job is not running', () => {
+  const board = createMockBoard(false);
+  const coordinator = new BackgroundJobCoordinator(board);
+  expect(coordinator.deferIfRunning('ses_123')).toBe(true);
+});
+```
+
+- [ ] **Step 4: Test retryDeferredClose returns false when not in deferred set**
+
+```typescript
+test('retryDeferredClose returns false when not in deferred set', () => {
+  const board = createMockBoard(false);
+  const coordinator = new BackgroundJobCoordinator(board);
+  expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
+});
+```
+
+- [ ] **Step 5: Test retryDeferredClose calls deferIfRunning internally**
+
+```typescript
+test('retryDeferredClose returns true after job completes', () => {
+  const board = createMockBoard(true);
+  const coordinator = new BackgroundJobCoordinator(board);
+
+  // First call defers (job running)
+  expect(coordinator.deferIfRunning('ses_123')).toBe(false);
+
+  // Now simulate job completion
+  board.isRunning.mockReturnValue(false);
+  expect(coordinator.retryDeferredClose('ses_123')).toBe(true);
+});
+```
+
+- [ ] **Step 6: Test clearDeferredClose removes from set**
+
+```typescript
+test('clearDeferredClose removes from deferred set', () => {
+  const board = createMockBoard(true);
+  const coordinator = new BackgroundJobCoordinator(board);
+
+  coordinator.deferIfRunning('ses_123');
+  coordinator.clearDeferredClose('ses_123');
+
+  // Now retryDeferredClose should return false (not in set)
+  board.isRunning.mockReturnValue(false);
+  expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
+});
+```
+
+- [ ] **Step 7: Test handleTerminalState notifies listeners when retryDeferredClose returns true**
+
+```typescript
+test('handleTerminalState notifies listeners when retryDeferredClose returns true', () => {
+  const board = createMockBoard(true);
+  const coordinator = new BackgroundJobCoordinator(board);
+  const listener = mock(() => {});
+
+  coordinator.addTerminalStateListener(listener);
+
+  // Defer the session
+  coordinator.deferIfRunning('ses_123');
+
+  // Simulate terminal state notification from board
+  board.getState.mockReturnValue('completed');
+  board.isRunning.mockReturnValue(false);
+
+  // Trigger handleTerminalState via board's listener callback
+  const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
+  boardListener?.('ses_123');
+
+  expect(listener).toHaveBeenCalledWith('ses_123');
+});
+```
+
+- [ ] **Step 8: Test handleTerminalState does not notify when retryDeferredClose returns false**
+
+```typescript
+test('handleTerminalState does not notify when not in deferred set', () => {
+  const board = createMockBoard(false);
+  const coordinator = new BackgroundJobCoordinator(board);
+  const listener = mock(() => {});
+
+  coordinator.addTerminalStateListener(listener);
+
+  // Simulate terminal state notification without deferring first
+  board.getState.mockReturnValue('completed');
+  const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
+  boardListener?.('ses_123');
+
+  expect(listener).not.toHaveBeenCalled();
+});
+```
+
+- [ ] **Step 9: Run tests**
+
+Run: `bun test src/utils/background-job-coordinator.test.ts`
+Expected: PASS
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add src/utils/background-job-coordinator.test.ts
+git commit -m "test: add BackgroundJobCoordinator lifecycle tests"
+```
+
+---
+
+### Task 4: Update BackgroundJobBoard to satisfy interface
+
+**Files:**
+- Modify: `src/utils/background-job-board.ts`
+
+**Interfaces:**
+- Consumes: `BackgroundJobStore` interface (Task 1)
+- Produces: Implemented stubs
+
+- [ ] **Step 1: Add stub implementations to BackgroundJobBoard**
+
+```typescript
+// In src/utils/background-job-board.ts, add methods:
+
+  /**
+   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
+   * Returns false (safe default: don't close) if accidentally called.
+   */
+  deferIfRunning(_sessionId: string): boolean {
+    log('[background-job-board] WARN: deferIfRunning called on board, not coordinator');
+    return false;  // ponytail: safe default - don't close
+  }
+
+  /**
+   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
+   * Returns false (don't close) if accidentally called.
+   */
+  retryDeferredClose(_sessionId: string): boolean {
+    log('[background-job-board] WARN: retryDeferredClose called on board, not coordinator');
+    return false;
+  }
+
+  /**
+   * Stub: lifecycle policy is owned by BackgroundJobCoordinator.
+   */
+  clearDeferredClose(_sessionId: string): void {
+    log('[background-job-board] WARN: clearDeferredClose called on board, not coordinator');
+  }
+```
+
+- [ ] **Step 2: Add log import if not present**
+
+```typescript
+// In src/utils/background-job-board.ts, check if log is imported.
+// If not, add:
+import { log } from './logger';
+```
+
+- [ ] **Step 3: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/utils/background-job-board.ts
+git commit -m "feat: add stub lifecycle methods to BackgroundJobBoard"
+```
+
+---
+
+### Task 5: Update MultiplexerSessionManager to use coordinator
+
+**Files:**
+- Modify: `src/multiplexer/session-manager.ts`
+
+**Interfaces:**
+- Consumes: `BackgroundJobReader` with `deferIfRunning()`, `clearDeferredClose()`
+- Produces: Simplified `closeSession()` that queries coordinator
+
+- [ ] **Step 1: Update BackgroundJobReader interface**
+
+```typescript
+// In src/multiplexer/session-manager.ts, update interface:
+
+interface BackgroundJobReader {
+  getState(sessionId: string): BackgroundJobState | undefined;
+  isRunning(sessionId: string): boolean;
+  deferIfRunning(sessionId: string): boolean;
+  clearDeferredClose(sessionId: string): void;
+}
+```
+
+- [ ] **Step 2: Remove deferredIdleCloses from SharedSessionState**
+
+```typescript
+// In src/multiplexer/session-manager.ts, remove from SharedSessionState interface:
+
+  // deferredIdleCloses: Set<string>;  // DELETE THIS LINE
+```
+
+- [ ] **Step 3: Remove deferredIdleCloses from getSharedState and resetMultiplexerSessionManagerState**
+
+```typescript
+// In getSharedState(), remove:
+  // deferredIdleCloses: new Set(),  // DELETE THIS LINE
+
+// In resetMultiplexerSessionManagerState(), remove:
+  // state.deferredIdleCloses.clear();  // DELETE THIS LINE
+```
+
+- [ ] **Step 4: Remove deferredIdleCloses from class properties**
+
+```typescript
+// In MultiplexerSessionManager class, remove:
+  // private deferredIdleCloses: SharedSessionState['deferredIdleCloses'];  // DELETE THIS LINE
+
+// In constructor, remove:
+  // this.deferredIdleCloses = sharedState.deferredIdleCloses;  // DELETE THIS LINE
+```
+
+- [ ] **Step 5: Update closeSession deleted block**
+
+```typescript
+// In closeSession method, replace the deleted block (lines 420-423):
+
+// OLD:
+    if (reason === 'deleted') {
+      this.knownSessions.delete(sessionId);
+      this.deferredIdleCloses.delete(sessionId);
+    }
+
+// NEW:
+    if (reason === 'deleted') {
+      this.knownSessions.delete(sessionId);
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
+    }
+```
+
+- [ ] **Step 6: Update closeSession idle check**
+
+```typescript
+// In closeSession method, replace the isRunningBackgroundJob check:
+
+// OLD:
+    if (reason === 'idle' && this.isRunningBackgroundJob(sessionId)) {
+      this.deferredIdleCloses.add(sessionId);
+      log(
+        '[multiplexer-session-manager] close skipped; background job running',
+        {
+          instanceId: this.instanceId,
+          sessionId,
+          paneId: tracked.paneId,
+          reason,
+          backgroundJobState: this.backgroundJobState(sessionId),
+        },
+      );
+      return;
+    }
+
+    this.deferredIdleCloses.delete(sessionId);
+
+// NEW:
+    if (reason === 'idle' && !this.shouldCloseNow(sessionId)) {
+      log(
+        '[multiplexer-session-manager] close skipped; background job running',
+        {
+          instanceId: this.instanceId,
+          sessionId,
+          paneId: tracked.paneId,
+          reason,
+          backgroundJobState: this.backgroundJobState(sessionId),
+        },
+      );
+      return;
+    }
+```
+
+- [ ] **Step 7: Add shouldCloseNow helper method**
+
+```typescript
+// In MultiplexerSessionManager class, add method:
+
+  private shouldCloseNow(sessionId: string): boolean {
+    return this.backgroundJobBoard?.deferIfRunning(sessionId) ?? true;
+  }
+```
+
+- [ ] **Step 8: Remove retryDeferredIdleClose method**
+
+```typescript
+// In MultiplexerSessionManager class, DELETE the retryDeferredIdleClose method:
+
+  // async retryDeferredIdleClose(sessionId: string): Promise<void> {  // DELETE
+  //   if (!this.enabled) return;  // DELETE
+  //   if (!this.deferredIdleCloses.has(sessionId)) return;  // DELETE
+  //   await this.closeSession(sessionId, 'idle');  // DELETE
+  // }  // DELETE
+```
+
+- [ ] **Step 9: Update onSessionDeleted to clear via coordinator**
+
+```typescript
+// In onSessionDeleted method, replace:
+    this.deferredIdleCloses.delete(sessionId);
+
+// WITH:
+    this.backgroundJobBoard?.clearDeferredClose(sessionId);
+```
+
+- [ ] **Step 10: Update onSessionStatus to clear via coordinator**
+
+```typescript
+// In onSessionStatus method, replace (line 293):
+        this.deferredIdleCloses.delete(sessionId);
+
+// WITH:
+        this.backgroundJobBoard?.clearDeferredClose(sessionId);
+```
+
+- [ ] **Step 11: Update pollSessions to clear via coordinator**
+
+```typescript
+// In pollSessions method, replace (line 377):
+          this.deferredIdleCloses.delete(sessionId);
+
+// WITH:
+          this.backgroundJobBoard?.clearDeferredClose(sessionId);
+```
+
+- [ ] **Step 12: Update respawnIfKnown to clear via coordinator**
+
+```typescript
+// In respawnIfKnown method, replace (line 589):
+      this.deferredIdleCloses.delete(sessionId);
+
+// WITH:
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
+```
+
+- [ ] **Step 13: Update cleanup to clear via coordinator**
+
+```typescript
+// In cleanup method, replace (line 662):
+    this.deferredIdleCloses.clear();
+
+// WITH:
+    // ponytail: deferred state lives in coordinator, not here
+    // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
+```
+
+- [ ] **Step 14: Remove isRunningBackgroundJob method**
+
+```typescript
+// In MultiplexerSessionManager class, DELETE the isRunningBackgroundJob method:
+
+  // private isRunningBackgroundJob(sessionId: string): boolean {  // DELETE
+  //   return this.backgroundJobBoard?.isRunning(sessionId) ?? false;  // DELETE
+  // }  // DELETE
+```
+
+- [ ] **Step 15: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: PASS
+
+- [ ] **Step 16: Commit**
+
+```bash
+git add src/multiplexer/session-manager.ts
+git commit -m "feat: multiplexer queries coordinator for close decisions"
+```
+
+---
+
+### Task 6: Update index.ts wiring
+
+**Files:**
+- Modify: `src/index.ts`
+
+**Interfaces:**
+- Consumes: Coordinator with `addTerminalStateListener`, MultiplexerSessionManager with `closeSession`
+
+- [ ] **Step 1: Update terminalStateListener to call closeSession directly**
+
+```typescript
+// In src/index.ts, replace:
+
+    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.retryDeferredIdleClose(taskID);
+    });
+
+// WITH:
+
+    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.closeSession(taskID, 'idle');
+    });
+```
+
+Note: `closeSession` is private. We need to either:
+- (a) Make it public, or
+- (b) Add a public `closeSessionFromCoordinator(taskID: string)` method, or
+- (c) Keep the subscription in index.ts but call a new public method
+
+Option (b) is cleanest:
+
+```typescript
+// In MultiplexerSessionManager, add method:
+
+  async closeSessionFromCoordinator(taskID: string): Promise<void> {
+    if (!this.enabled) return;
+    await this.closeSession(taskID, 'idle');
+  }
+```
+
+Then in index.ts:
+
+```typescript
+    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
+    });
+```
+
+- [ ] **Step 2: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/index.ts src/multiplexer/session-manager.ts
+git commit -m "feat: update wiring to use coordinator lifecycle"
+```
+
+---
+
+### Task 7: Update tests
+
+**Files:**
+- Modify: `src/multiplexer/session-manager.test.ts`
+
+**Interfaces:**
+- Consumes: Updated MultiplexerSessionManager API
+
+- [ ] **Step 1: Update test setup to use BackgroundJobReader mock**
+
+```typescript
+// In session-manager.test.ts, add mock:
+
+const mockBackgroundJobBoard = {
+  isRunning: mock(() => false),
+  getState: mock(() => undefined),
+  deferIfRunning: mock(() => true),
+  retryDeferredClose: mock(() => false),
+  clearDeferredClose: mock(() => {}),
+};
+```
+
+- [ ] **Step 2: Run tests**
+
+Run: `bun test src/multiplexer/session-manager.test.ts`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/multiplexer/session-manager.test.ts
+git commit -m "test: update session manager tests for coordinator lifecycle"
+```
+
+---
+
+### Task 8: Run full test suite and verify
+
+**Files:** None (verification only)
+
+- [ ] **Step 1: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: PASS
+
+- [ ] **Step 2: Run linter**
+
+Run: `bun run check:ci`
+Expected: PASS
+
+- [ ] **Step 3: Run full test suite**
+
+Run: `bun test`
+Expected: PASS (1367+ tests)
+
+- [ ] **Step 4: Build**
+
+Run: `bun run build`
+Expected: PASS
+
+---
+
+### Task 9: Final commit and push
+
+- [ ] **Step 1: Stage all changes**
+
+```bash
+git add -A
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git commit -m "feat: centralize lifecycle policy in BackgroundJobCoordinator
+
+- Move deferredIdleCloses tracking from multiplexer to coordinator
+- Coordinator owns deferIfRunning(), retryDeferredClose(), clearDeferredClose()
+- Multiplexer queries coordinator before closing panes
+- Subscription wiring stays in index.ts (avoids multi-instance issues)
+- Type-level single-writer contract via BackgroundJobStore interface
+- Board stubs return safe defaults (false) if called directly
+- Added coordinator lifecycle tests
+
+Closes #677"
+```
+
+- [ ] **Step 3: Push**
+
+```bash
+git push origin feature/background-job-coordinator
+```

+ 801 - 0
docs/superpowers/plans/2026-07-06-hook-registry-session-lifecycle.md

@@ -0,0 +1,801 @@
+# HookRegistry + SessionLifecycle Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eliminate manual hook wiring, scattered session.deleted cleanup, and the reversed-priority session ID bug.
+
+**Architecture:** Three-phase build: (1) `extractSessionId` utility replacing 8 duplicated sites, (2) `SessionLifecycle` coordinator owning cleanup callbacks + signaling channel with timestamp TTL, (3) `HookRegistry` for all async hook dispatch.
+
+**Tech Stack:** TypeScript, Bun, Biome
+
+## Global Constraints
+
+- Line width: 80 chars, 2-space indent, trailing commas
+- No explicit `any` (linter warning)
+- Biome organizes imports, run `bun run check:ci` before commit
+- Commit after every green test run, wait for user "proceed" at each task boundary
+
+---
+## File Structure
+
+### New files
+| File | Responsibility |
+|------|---------------|
+| `src/utils/extract-session-id.ts` | `extractSessionId(info, sessionID)` — priority `info?.id ?? sessionID` |
+| `src/utils/extract-session-id.test.ts` | Tests for priority, null/undefined, edge cases |
+| `src/hooks/session-lifecycle.ts` | `SessionLifecycle` class — cleanup callback registry + signaling channel with timestamp TTL |
+| `src/hooks/session-lifecycle.test.ts` | Tests for cleanup registration/dispatch, signaling, TTL expiry |
+| `src/hooks/hook-registry.ts` | `HookRegistry` class — ordered dispatcher with late-registration warning |
+| `src/hooks/hook-registry.test.ts` | Tests for registration order, late-registration, no-op dispatch |
+
+### Modified files
+| File | Changes |
+|------|---------|
+| `src/index.ts` | Delete `let` hook declarations, use `const` inside try, register with HookRegistry, replace manual dispatch with `registry.dispatch()`, wire SessionLifecycle for session.deleted |
+| `src/hooks/post-file-tool-nudge/index.ts` | Accept `SessionLifecycle`, delegate Sets to coordinator, use `extractSessionId`, remove `event()` method, remove `hasPendingSession` export |
+| `src/hooks/phase-reminder/index.ts` | Accept `SessionLifecycle` param, import `hasPendingSession` from `session-lifecycle` |
+| `src/hooks/task-session-manager/index.ts` | Use `extractSessionId`, register cleanup callback with coordinator |
+| `src/hooks/foreground-fallback/index.ts` | Accept `SessionLifecycle`, register cleanup callback, use `extractSessionId` |
+| `src/hooks/post-file-tool-nudge/index.test.ts` | Pass coordinator to factory |
+| `src/hooks/phase-reminder/index.test.ts` | Pass coordinator to factory, update `hasPendingSession` import |
+| `src/hooks/task-session-manager/index.test.ts` | Verify cleanup through coordinator |
+| `src/multiplexer/session-manager.ts` | Use `extractSessionId` (line 610) |
+
+---
+### Task 0: Baseline test run
+
+- [ ] **Step 1: Run baseline tests**
+
+Run: `bun test`
+Expected: 1367 pass, 0 fail
+
+- [ ] **Step 2: Record output reference**
+
+---
+### Task 1: `src/utils/extract-session-id.ts`
+
+**Files:**
+- Create: `src/utils/extract-session-id.ts`
+- Create: `src/utils/extract-session-id.test.ts`
+- Modify: `src/index.ts` (lines 889, 897)
+- Modify: `src/multiplexer/session-manager.ts` (line 610)
+- Modify: `src/hooks/task-session-manager/index.ts` (lines 582, 635, 659, 693)
+- Modify: `src/hooks/foreground-fallback/index.ts` (line 236)
+- Modify: `src/hooks/post-file-tool-nudge/index.ts` (line 77 — reversed priority)
+
+**Interfaces:**
+- Produces: `export function extractSessionId(info: { id?: string } | undefined | null, sessionID: string | undefined | null): string | undefined`
+
+- [ ] **Step 1: Create the utility**
+
+```typescript
+export function extractSessionId(
+  info: { id?: string } | undefined | null,
+  sessionID: string | undefined | null,
+): string | undefined {
+  return info?.id ?? sessionID;
+}
+```
+
+- [ ] **Step 2: Create tests**
+
+```typescript
+import { describe, expect, test } from 'bun:test';
+import { extractSessionId } from './extract-session-id';
+
+describe('extractSessionId', () => {
+  test('prefers info.id over sessionID', () => {
+    expect(extractSessionId({ id: 'i' }, 's')).toBe('i');
+  });
+
+  test('falls back to sessionID when info.id missing', () => {
+    expect(extractSessionId({}, 's')).toBe('s');
+    expect(extractSessionId({ id: undefined }, 's')).toBe('s');
+  });
+
+  test('returns undefined when both missing', () => {
+    expect(extractSessionId(undefined, undefined)).toBeUndefined();
+    expect(extractSessionId(null, null)).toBeUndefined();
+    expect(extractSessionId({}, undefined)).toBeUndefined();
+  });
+
+  test('handles null info', () => {
+    expect(extractSessionId(null, 's')).toBe('s');
+  });
+});
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `bun test src/utils/extract-session-id.test.ts`
+Expected: FAIL (module not found)
+
+- [ ] **Step 4: Replace all 8 manual extraction sites**
+
+Each `props?.info?.id ?? props?.sessionID` → `extractSessionId(props?.info, props?.sessionID)`.
+
+Fix the reversed-priority site at `src/hooks/post-file-tool-nudge/index.ts:77`:
+```typescript
+input.event.properties?.sessionID ?? input.event.properties?.info?.id
+```
+→
+```typescript
+extractSessionId(
+  input.event.properties?.info,
+  input.event.properties?.sessionID,
+)
+```
+
+Deduplicate the two adjacent `session.deleted` blocks in `src/index.ts:885-905` into one block using `extractSessionId`.
+
+- [ ] **Step 5: Run all tests**
+
+Run: `bun test`
+Expected: Same count as baseline, all pass
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/utils/extract-session-id.ts src/utils/extract-session-id.test.ts src/index.ts src/multiplexer/session-manager.ts src/hooks/task-session-manager/index.ts src/hooks/foreground-fallback/index.ts src/hooks/post-file-tool-nudge/index.ts
+bun run check:ci
+git commit -m "feat: add extractSessionId utility, fix reversed-priority session ID bug"
+```
+
+---
+### Task 2: SessionLifecycle coordinator class + tests
+
+**Files:**
+- Create: `src/hooks/session-lifecycle.ts`
+- Create: `src/hooks/session-lifecycle.test.ts`
+
+**Interfaces:**
+- Produces:
+```typescript
+export class SessionLifecycle {
+  static readonly PENDING_TTL_MS: number;
+  constructor(log: (msg: string, meta?: Record<string, unknown>) => void);
+  onSessionDeleted(callback: (sessionId: string) => void): void;
+  dispatchSessionDeleted(sessionId: string): void;
+  markPending(sessionId: string): void;
+  /** Returns true only once per markPending call. */
+  consumePending(sessionId: string): boolean;
+  hasPendingSession(sessionId: string): boolean;
+  clearSession(sessionId: string): void;
+}
+```
+
+- [ ] **Step 1: Create the class**
+
+```typescript
+// src/hooks/session-lifecycle.ts
+export class SessionLifecycle {
+  static readonly PENDING_TTL_MS = 5 * 60 * 1000;
+
+  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
+  #pendingSessionIds = new Set<string>();
+  #everPendingSessionIds = new Set<string>();
+  #pendingTimestamps = new Map<string, number>();
+  #log: (msg: string, meta?: Record<string, unknown>) => void;
+
+  constructor(
+    log: (msg: string, meta?: Record<string, unknown>) => void,
+  ) {
+    this.#log = log;
+  }
+
+  onSessionDeleted(callback: (sessionId: string) => void): void {
+    this.#cleanupCallbacks.push(callback);
+  }
+
+  dispatchSessionDeleted(sessionId: string): void {
+    for (const cb of this.#cleanupCallbacks) {
+      try {
+        cb(sessionId);
+      } catch (error) {
+        this.#log(
+          `[session-lifecycle] cleanup callback failed for session ${sessionId}`,
+          { error },
+        );
+      }
+    }
+  }
+
+  markPending(sessionId: string): void {
+    this.#pendingSessionIds.add(sessionId);
+    this.#everPendingSessionIds.add(sessionId);
+    this.#pendingTimestamps.set(sessionId, Date.now());
+  }
+
+  /** Atomic — only one caller gets true per markPending call. */
+  consumePending(sessionId: string): boolean {
+    const had = this.#pendingSessionIds.has(sessionId);
+    this.#pendingSessionIds.delete(sessionId);
+    this.#pendingTimestamps.delete(sessionId);
+    return had;
+  }
+
+  hasPendingSession(sessionId: string): boolean {
+    const ts = this.#pendingTimestamps.get(sessionId);
+    if (ts && Date.now() - ts > SessionLifecycle.PENDING_TTL_MS) {
+      this.#pendingTimestamps.delete(sessionId);
+      this.#pendingSessionIds.delete(sessionId);
+      return false;
+    }
+    return (
+      this.#everPendingSessionIds.has(sessionId)
+      && !this.#pendingSessionIds.has(sessionId)
+    );
+  }
+
+  clearSession(sessionId: string): void {
+    this.#pendingSessionIds.delete(sessionId);
+    this.#everPendingSessionIds.delete(sessionId);
+    this.#pendingTimestamps.delete(sessionId);
+  }
+}
+```
+
+- [ ] **Step 2: Create tests**
+
+```typescript
+import { describe, expect, test } from 'bun:test';
+import { SessionLifecycle } from './session-lifecycle';
+
+const noop = () => {};
+
+describe('SessionLifecycle', () => {
+  test('dispatchSessionDeleted runs callbacks in order', () => {
+    const lc = new SessionLifecycle(noop);
+    const ran: string[] = [];
+    lc.onSessionDeleted((id) => ran.push(`a:${id}`));
+    lc.onSessionDeleted((id) => ran.push(`b:${id}`));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['a:s1', 'b:s1']);
+  });
+
+  test('dispatchSessionDeleted continues after callback error', () => {
+    const lc = new SessionLifecycle(() => {});
+    const ran: string[] = [];
+    lc.onSessionDeleted(() => { throw new Error('fail'); });
+    lc.onSessionDeleted((id) => ran.push(id));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['s1']);
+  });
+
+  test('consumePending is atomic', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    expect(lc.consumePending('s1')).toBe(true);
+    expect(lc.consumePending('s1')).toBe(false);
+  });
+
+  test('hasPendingSession after consume', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    expect(lc.hasPendingSession('s1')).toBe(true);
+  });
+
+  test('hasPendingSession false for unknown session', () => {
+    const lc = new SessionLifecycle(noop);
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+
+  test('clearSession removes all state', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    lc.clearSession('s1');
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `bun test src/hooks/session-lifecycle.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/hooks/session-lifecycle.ts src/hooks/session-lifecycle.test.ts
+bun run check:ci
+git commit -m "feat: add SessionLifecycle coordinator"
+```
+
+---
+### Task 3: Update hooks to use SessionLifecycle + extractSessionId
+
+**Files:**
+- Modify: `src/hooks/post-file-tool-nudge/index.ts`
+- Modify: `src/hooks/post-file-tool-nudge/index.test.ts`
+- Modify: `src/hooks/phase-reminder/index.ts`
+- Modify: `src/hooks/phase-reminder/index.test.ts`
+- Modify: `src/hooks/task-session-manager/index.ts`
+- Modify: `src/hooks/task-session-manager/index.test.ts`
+- Modify: `src/hooks/foreground-fallback/index.ts`
+
+**Interfaces:**
+- Consumes: `SessionLifecycle` from `../session-lifecycle`, `extractSessionId` from `../../utils/extract-session-id`
+
+- [ ] **Step 1: Update post-file-tool-nudge/index.ts**
+
+Remove module-scoped Sets, `hasPendingSession` export, and `event()` method (only handled session.deleted). Accept `coordinator?: SessionLifecycle` in factory options. Cleanup is handled via coordinator callback. Use `coordinator.markPending()` and `coordinator.consumePending()` instead of module-scoped Sets.
+
+```typescript
+import { PHASE_REMINDER } from '../../config/constants';
+import type { SessionLifecycle } from '../session-lifecycle';
+
+const FILE_TOOLS = new Set(['Read', 'read', 'Write', 'write']);
+
+interface PostFileToolNudgeOptions {
+  shouldInject?: (sessionID: string) => boolean;
+  coordinator?: SessionLifecycle;
+}
+
+export function createPostFileToolNudgeHook(
+  options: PostFileToolNudgeOptions = {},
+) {
+  const { coordinator } = options;
+
+  if (coordinator) {
+    coordinator.onSessionDeleted(
+      (sid) => coordinator.clearSession(sid),
+    );
+  }
+
+  return {
+    'tool.execute.after': async (
+      input: { tool: string; sessionID?: string; callID?: string },
+    ): Promise<void> => {
+      if (!FILE_TOOLS.has(input.tool) || !input.sessionID) return;
+      coordinator?.markPending(input.sessionID);
+    },
+    'experimental.chat.system.transform': async (
+      input: { sessionID?: string },
+      output: { system: string[] },
+    ): Promise<void> => {
+      if (!input.sessionID || !coordinator?.consumePending(input.sessionID)) {
+        return;
+      }
+      if (options.shouldInject && !options.shouldInject(input.sessionID)) {
+        return;
+      }
+      output.system.push(PHASE_REMINDER);
+    },
+  };
+}
+```
+
+Note `_output` param removed from `tool.execute.after` since it was unused (was `_output: unknown`).
+
+- [ ] **Step 2: Update phase-reminder/index.ts**
+
+Accept `coordinator?: SessionLifecycle` parameter. Import `hasPendingSession` from the coordinator instead of `../post-file-tool-nudge`. Remove the `import { hasPendingSession }` line.
+
+```typescript
+import type { SessionLifecycle } from '../session-lifecycle';
+
+export function createPhaseReminderHook(
+  coordinator?: SessionLifecycle,
+) {
+  return {
+    'experimental.chat.messages.transform': async (
+      _input: Record<string, never>,
+      output: { messages?: unknown },
+    ): Promise<void> => {
+      // ... existing logic ...
+      if (sessionId && coordinator?.hasPendingSession(sessionId)) {
+        return;
+      }
+      // ... rest unchanged ...
+    },
+  };
+}
+```
+
+- [ ] **Step 3: Update task-session-manager/index.ts**
+
+All 4 `info?.id ?? sessionID` sites are already replaced with `extractSessionId` (Task 1). The `session.deleted` case in `.event()` (lines 691-721) is replaced by registering a cleanup callback with the coordinator. Add `coordinator?: SessionLifecycle` to factory options.
+
+```typescript
+interface TaskSessionManagerOptions {
+  // ... existing options ...
+  coordinator?: SessionLifecycle;
+}
+```
+
+Register cleanup in the factory:
+```typescript
+if (options.coordinator) {
+  options.coordinator.onSessionDeleted((sessionId) => {
+    backgroundJobBoard.drop(sessionId);
+    backgroundJobBoard.clearParent(sessionId);
+    terminalJobsInjectedByParent.delete(sessionId);
+    taskContextTracker.clearSession(sessionId);
+    taskContextTracker.prune(backgroundJobBoard);
+    pendingCallTracker.clearSession(sessionId);
+  });
+}
+```
+
+The `session.deleted` case in `.event()` is reduced to just logging (no cleanup ops):
+```typescript
+if (input.event.type !== 'session.deleted') return;
+const sessionId = extractSessionId(
+  input.event.properties?.info,
+  input.event.properties?.sessionID,
+);
+if (!sessionId) return;
+log('[task-session-manager] session.deleted observed', { sessionID: sessionId });
+return;
+```
+
+- [ ] **Step 4: Update foreground-fallback/index.ts**
+
+Accept `coordinator?: SessionLifecycle` in the constructor. Register cleanup callbacks. Use `extractSessionId` (already done in Task 1).
+
+```typescript
+constructor(
+  // ... existing params ...
+  private coordinator?: SessionLifecycle,
+) {
+  if (coordinator) {
+    coordinator.onSessionDeleted((id) => {
+      this.sessionModel.delete(id);
+      this.sessionAgent.delete(id);
+      this.sessionTried.delete(id);
+      this.inProgress.delete(id);
+      this.lastTrigger.delete(id);
+      this.lastTriggerModel.delete(id);
+      this.sessionRetries.delete(id);
+    });
+  }
+  // ... rest of constructor ...
+}
+```
+
+The `session.deleted` case in `handleEvent` (lines 226-247) is reduced to logging:
+```typescript
+case 'session.deleted': {
+  const props = event.properties as
+    | { sessionID?: string; info?: { id?: string } }
+    | undefined;
+  const id = extractSessionId(props?.info, props?.sessionID);
+  if (id) {
+    log('[foreground-fallback] session.deleted observed', { sessionID: id });
+  }
+  break;
+}
+```
+
+- [ ] **Step 5: Update post-file-tool-nudge tests**
+
+Each test that creates hooks with `createPostFileToolNudgeHook()` now needs a shared coordinator:
+
+```typescript
+import { SessionLifecycle } from '../session-lifecycle';
+
+test('records pending session on Read tool', async () => {
+  const coordinator = new SessionLifecycle(() => {});
+  const hook = createPostFileToolNudgeHook({ coordinator });
+  // ... rest same ...
+});
+```
+
+The "composed" test (line 153) needs a coordinator shared between both hooks:
+
+```typescript
+test('composed: phase-reminder skips when post-file-tool-nudge handles system', async () => {
+  const coordinator = new SessionLifecycle(() => {});
+  const nudgeHook = createPostFileToolNudgeHook({ coordinator });
+  const phaseHook = createPhaseReminderHook(coordinator);
+  // ... rest same ...
+});
+```
+
+- [ ] **Step 6: Update phase-reminder tests**
+
+Tests that call `createPhaseReminderHook()` now pass the coordinator:
+```typescript
+const coordinator = new SessionLifecycle(() => {});
+const phaseHook = createPhaseReminderHook(coordinator);
+```
+
+Import changes: `hasPendingSession` no longer needs to be imported from `../post-file-tool-nudge` — it's on the coordinator instance.
+
+- [ ] **Step 7: Update task-session-manager tests**
+
+If any test verifies cleanup via `.event()` with `session.deleted`, it now needs to verify cleanup through the coordinator callback instead. The `event()` method no longer performs cleanup ops.
+
+- [ ] **Step 8: Run all tests**
+
+Run: `bun test`
+Expected: All pass
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/hooks/post-file-tool-nudge/ src/hooks/phase-reminder/ src/hooks/task-session-manager/ src/hooks/foreground-fallback/
+bun run check:ci
+git commit -m "refactor: migrate hooks to SessionLifecycle coordinator"
+```
+
+---
+### Task 4: Wire SessionLifecycle into src/index.ts
+
+**Files:**
+- Modify: `src/index.ts`
+
+- [ ] **Step 1: Instantiate SessionLifecycle before hook factories**
+
+Inside the `try` block, before any hook factory calls:
+```typescript
+const sessionLifecycle = new SessionLifecycle(log);
+```
+
+- [ ] **Step 2: Pass coordinator to hook factories**
+
+`postFileToolNudgeHook = createPostFileToolNudgeHook({
+  shouldInject: (sessionID) => sessionAgentMap.get(sessionID) === 'orchestrator',
+  coordinator: sessionLifecycle,
+});`
+
+`taskSessionManagerHook = createTaskSessionManagerHook(ctx, { /* ...existing... */, coordinator: sessionLifecycle });`
+
+`phaseReminderHook = createPhaseReminderHook(sessionLifecycle);`
+
+`ForegroundFallbackManager` constructor: add `sessionLifecycle` as a parameter.
+
+- [ ] **Step 3: Add session.deleted dispatch via coordinator**
+
+In the `event` handler, add a dispatch block for `session.deleted`:
+```typescript
+if (input.event.type === 'session.deleted') {
+  const props = input.event.properties as ...;
+  const sessionID = extractSessionId(props?.info, props?.sessionID);
+  if (sessionID) {
+    sessionLifecycle.dispatchSessionDeleted(sessionID);
+  }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `bun test`
+Expected: All pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/index.ts
+bun run check:ci
+git commit -m "feat: wire SessionLifecycle coordinator into plugin"
+```
+
+---
+### Task 5: HookRegistry class + tests
+
+**Files:**
+- Create: `src/hooks/hook-registry.ts`
+- Create: `src/hooks/hook-registry.test.ts`
+
+**Interfaces:**
+- Produces:
+```typescript
+export class HookRegistry {
+  register(hookPoint: string, handler: (i: unknown, o: unknown) => Promise<void>): void;
+  dispatch(hookPoint: string, input: unknown, output: unknown): Promise<void>;
+  handlers(hookPoint: string): ReadonlyArray<(i: unknown, o: unknown) => Promise<void>>;
+}
+```
+
+- [ ] **Step 1: Create the class**
+
+```typescript
+export class HookRegistry {
+  #handlers = new Map<
+    string,
+    Array<(input: unknown, output: unknown) => Promise<void>>
+  >();
+  #firedHookPoints = new Set<string>();
+
+  register(
+    hookPoint: string,
+    handler: (input: unknown, output: unknown) => Promise<void>,
+  ): void {
+    if (this.#firedHookPoints.has(hookPoint)) {
+      console.warn(
+        `[hook-registry] "${hookPoint}" already dispatched; late registration may miss events`,
+      );
+    }
+    const group = this.#handlers.get(hookPoint);
+    if (group) {
+      group.push(handler);
+    } else {
+      this.#handlers.set(hookPoint, [handler]);
+    }
+  }
+
+  async dispatch(
+    hookPoint: string,
+    input: unknown,
+    output: unknown,
+  ): Promise<void> {
+    this.#firedHookPoints.add(hookPoint);
+    const group = this.#handlers.get(hookPoint);
+    if (!group) return;
+    for (const handler of group) {
+      await handler(input, output);
+    }
+  }
+
+  handlers(
+    hookPoint: string,
+  ): ReadonlyArray<(input: unknown, output: unknown) => Promise<void>> {
+    return this.#handlers.get(hookPoint) ?? [];
+  }
+}
+```
+
+- [ ] **Step 2: Create tests**
+
+```typescript
+import { describe, expect, test } from 'bun:test';
+import { HookRegistry } from './hook-registry';
+
+describe('HookRegistry', () => {
+  test('dispatch runs handlers in registration order', async () => {
+    const r = new HookRegistry();
+    const order: number[] = [];
+    r.register('test', async () => { order.push(1); });
+    r.register('test', async () => { order.push(2); });
+    await r.dispatch('test', {}, {});
+    expect(order).toEqual([1, 2]);
+  });
+
+  test('unregistered hook point is no-op', async () => {
+    const r = new HookRegistry();
+    await r.dispatch('none', {}, {});
+  });
+
+  test('handlers returns empty for unregistered point', () => {
+    const r = new HookRegistry();
+    expect(r.handlers('x')).toEqual([]);
+  });
+
+  test('dispatch passes input and output to handlers', async () => {
+    const r = new HookRegistry();
+    const captured: unknown[] = [];
+    r.register('test', async (i, o) => { captured.push(i, o); });
+    await r.dispatch('test', { a: 1 }, { b: 2 });
+    expect(captured).toEqual([{ a: 1 }, { b: 2 }]);
+  });
+});
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `bun test src/hooks/hook-registry.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/hooks/hook-registry.ts src/hooks/hook-registry.test.ts
+bun run check:ci
+git commit -m "feat: add HookRegistry for ordered handler dispatch"
+```
+
+---
+### Task 6: Wire HookRegistry into src/index.ts (biggest task)
+
+**Files:**
+- Modify: `src/index.ts`
+
+Goal: Replace manual `let` declarations + per-hook dispatch with `registry.dispatch()` calls.
+
+- [ ] **Step 1: Understand the current pattern**
+
+Currently the plugin function has:
+1. ~15 `let xHook: ReturnType<...>` declarations outside try (lines 138-153)
+2. Factory calls inside try (lines 271-322) that assign to those variables
+3. 5 dispatch blocks in the return object (lines 910-1186) that call hook methods individually
+
+- [ ] **Step 2: Convert pattern**
+
+Replace:
+```typescript
+let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
+// ... in try block ...
+phaseReminderHook = createPhaseReminderHook(sessionLifecycle);
+// ... in return block ...
+await phaseReminderHook['experimental.chat.messages.transform'](input, typedOutput);
+```
+
+With:
+```typescript
+// In try block:
+const phaseReminder = createPhaseReminderHook(sessionLifecycle);
+hookRegistry.register(
+  'experimental.chat.messages.transform',
+  (i, o) => phaseReminder['experimental.chat.messages.transform'](i, o as any),
+);
+// ... repeat for other hooks ...
+```
+
+Note: The `hookRegistry` is instantiated inside the try block. The return block only needs closure on `hookRegistry`, not on individual hook instances.
+
+- [ ] **Step 3: Map each hook point to its dispatches**
+
+| Hook point | Hooks that implement it |
+|---|---|
+| `experimental.chat.messages.transform` | taskSessionManager, phaseReminder, filterAvailableSkills |
+| `experimental.chat.system.transform` | postFileToolNudge |
+| `tool.execute.before` | applyPatch, taskSessionManager |
+| `tool.execute.after` | delegateTaskRetry, jsonErrorRecovery, postFileToolNudge, taskSessionManager |
+| `command.execute.before` | deepworkCommand, reflectCommand, loopCommand |
+| `event` | foregroundFallback, taskSessionManager (session.idle/status/error only — no longer session.deleted) |
+| `chat.headers` | chatHeaders (sync, stays manual) |
+
+- [ ] **Step 4: Replace each dispatch block in the return object**
+
+Each becomes:
+```typescript
+'experimental.chat.messages.transform':
+  (input, output) => hookRegistry.dispatch('experimental.chat.messages.transform', input, output),
+```
+
+Note: `event` handler is special — it still dispatches to non-hook consumers (multiplexer, companion, autoUpdateChecker, interview). Only the hook portions go through the registry.
+
+- [ ] **Step 5: Delete unused `let` declarations**
+
+Remove the hook variable `let` declarations from the outer scope (lines 138-153). Keep non-hook `let` declarations (managers, boards, tools).
+
+- [ ] **Step 6: Delete unused imports**
+
+Remove any `ReturnType<typeof createXHook>` from imports that are no longer used as types.
+
+- [ ] **Step 7: Run tests**
+
+Run: `bun test`
+Expected: All 1367+ pass
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/index.ts
+bun run check:ci
+git commit -m "refactor: wire HookRegistry, delete manual hook dispatching"
+```
+
+---
+### Task 7: Final verification
+
+- [ ] **Step 1: Run full test suite**
+
+Run: `bun test`
+Expected: All pass, same count as baseline
+
+- [ ] **Step 2: Run typecheck**
+
+Run: `bun run typecheck`
+Expected: No errors
+
+- [ ] **Step 3: Run linter**
+
+Run: `bun run check:ci`
+Expected: No errors
+
+- [ ] **Step 4: Update codemap if needed**
+
+Check if `src/hooks/codemap.md` needs updating to reflect the new registry + coordinator architecture.
+
+- [ ] **Step 5: Final commit**
+
+```bash
+git add -A
+bun run check:ci
+git commit -m "chore: final cleanup after HookRegistry+SessionLifecycle migration"
+```

+ 351 - 0
docs/superpowers/plans/2026-07-08-share-closepane-shutdown.md

@@ -0,0 +1,351 @@
+# Share closePane graceful-shutdown across multiplexer backends
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extract the duplicated Ctrl+C → 250ms → kill/close pane lifecycle from tmux/zellij/herdr `closePane` into one `gracefulClosePane` helper in `shared.ts`, and force the hidden exit-code/guard inconsistencies into the open via an `options` param.
+
+**Architecture:** One shared helper takes the binary, paneId, and the two backend-specific command arrays (`ctrlC`, `close`) plus an `options` object for the divergent bits (accept exit code 1, empty-paneId returns true). Each backend's `closePane` shrinks to: guard → `getBinary()` → `return gracefulClosePane(...)`. No base class, no interface change. `session-manager.ts` is untouched (it already uses the `Multiplexer` interface).
+
+**Tech Stack:** TypeScript, Bun test, existing `crossSpawn` from `../utils/compat`, existing `log` from `../utils/logger`.
+
+---
+
+### Task 1: Add `gracefulClosePane` helper + tests
+
+**Files:**
+- Modify: `src/multiplexer/shared.ts`
+- Create: `src/multiplexer/shared.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
+import { gracefulClosePane } from './shared';
+import { crossSpawn } from '../utils/compat';
+
+const DELAY_MS = 250;
+
+function fakeProc(exitCode: number, stderr = '') {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(stderr),
+  } as unknown as ReturnType<typeof crossSpawn>;
+}
+
+describe('gracefulClosePane', () => {
+  beforeEach(() => mock.module('../utils/compat', () => ({ crossSpawn: mock() })));
+  afterEach(() => mock.restore());
+
+  it('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
+    const calls: string[][] = [];
+    let ctrlCTime = 0;
+    let closeTime = 0;
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation((args: string[]) => {
+      if (args.includes('C-c') || args.includes('\u0003') || args.includes('ctrl+c')) {
+        ctrlCTime = Date.now();
+      } else {
+        closeTime = Date.now();
+      }
+      calls.push(args);
+      return fakeProc(0);
+    });
+
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+
+    expect(ok).toBe(true);
+    expect(calls).toHaveLength(2);
+    expect(closeTime - ctrlCTime).toBeGreaterThanOrEqual(DELAY_MS - 20);
+  });
+
+  it('returns true when acceptExitCode1 and exit code is 1', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(1));
+    const ok = await gracefulClosePane('zellij', 'terminal_1', {
+      ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
+      acceptExitCode1: true,
+    });
+    expect(ok).toBe(true);
+  });
+
+  it('returns false on exit 1 when acceptExitCode1 is false', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(1));
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+    expect(ok).toBe(false);
+  });
+
+  it('returns emptyPaneReturnsTrue when paneId is empty', async () => {
+    (crossSpawn as unknown as ReturnType<typeof mock>).mockImplementation(() => fakeProc(0));
+    const ok = await gracefulClosePane('zellij', '', {
+      ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', ''],
+      emptyPaneReturnsTrue: true,
+    });
+    expect(ok).toBe(true);
+    expect((crossSpawn as unknown as ReturnType<typeof mock>).mock.calls).toHaveLength(0);
+  });
+
+  it('returns false when binary is null', async () => {
+    const ok = await gracefulClosePane(null, '%1', {
+      ctrlC: ['x'],
+      close: ['y'],
+    });
+    expect(ok).toBe(false);
+  });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `bun test src/multiplexer/shared.test.ts`
+Expected: FAIL — `gracefulClosePane` is not exported.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `src/multiplexer/shared.ts`:
+
+```typescript
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `bun test src/multiplexer/shared.test.ts`
+Expected: PASS (5 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/multiplexer/shared.ts src/multiplexer/shared.test.ts
+git commit -m "feat(multiplexer): add gracefulClosePane helper with tests"
+```
+
+---
+
+### Task 2: Rewrite tmux `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/tmux/index.ts:115-164`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 115-164 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const tmux = await this.getBinary();
+    return gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+      // tmux: empty paneId is a real error, exit 0 only.
+      emptyPaneReturnsTrue: false,
+    });
+  }
+```
+
+Note: tmux used to call `this.scheduleLayout()` on success. That rebalance is a tmux-specific concern, not part of the shared shutdown. Preserve it by wrapping:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const tmux = await this.getBinary();
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/tmux/index.ts`, add to the shared import (or new line):
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + existing tmux tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: typecheck clean, all multiplexer tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/tmux/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in tmux"
+```
+
+---
+
+### Task 3: Rewrite zellij `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/zellij/index.ts:496-525`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 496-525 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const zellij = await this.getBinary();
+    return gracefulClosePane(zellij, paneId, {
+      ctrlC: ['action', 'write', '--pane-id', paneId, '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/zellij/index.ts`:
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: clean + pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/zellij/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in zellij"
+```
+
+---
+
+### Task 4: Rewrite herdr `closePane` to use the helper
+
+**Files:**
+- Modify: `src/multiplexer/herdr/index.ts:155-200`
+
+- [ ] **Step 1: Replace the closePane body**
+
+Replace lines 155-200 with:
+
+```typescript
+  async closePane(paneId: string): Promise<boolean> {
+    const herdr = await this.getBinary();
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
+  }
+```
+
+- [ ] **Step 2: Add the import**
+
+At top of `src/multiplexer/herdr/index.ts`:
+
+```typescript
+import { gracefulClosePane } from '../shared';
+```
+
+- [ ] **Step 3: Verify typecheck + tests**
+
+Run: `bun run typecheck && bun test src/multiplexer/`
+Expected: clean + pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/multiplexer/herdr/index.ts
+git commit -m "refactor(multiplexer): use gracefulClosePane in herdr"
+```
+
+---
+
+### Task 5: Final verification + lint
+
+**Files:**
+- None new
+
+- [ ] **Step 1: Run full check + test suite**
+
+Run: `bun run check:ci && bun run typecheck && bun test`
+Expected: Biome clean, types clean, all tests pass.
+
+- [ ] **Step 2: Confirm no orphaned duplicate logic**
+
+Run: `grep -rn "setTimeout(r, 250)" src/multiplexer/`
+Expected: only the `GRACEFUL_SHUTDOWN_DELAY_MS` usage inside `shared.ts`. No per-backend 250ms copies remain.
+
+- [ ] **Step 3: Commit (if any lint auto-fix applied) and push branch**
+
+```bash
+git add -A
+git commit -m "chore(multiplexer): lint fixes for closePane refactor" || echo "nothing to commit"
+git push -u origin fix/703-share-closepane-shutdown
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:** Issue #703 asks for one `gracefulClosePane(binary, paneId, { ctrlC, close }, options?)` helper in `shared.ts`. Task 1 delivers exactly that signature + tests. Tasks 2-4 wire all three backends. Task 5 verifies. The "inconsistencies forced into the open" (exit 0 vs 0||1, empty guard false vs true) are handled by `acceptExitCode1` / `emptyPaneReturnsTrue` options, preserving each backend's real behavior rather than silently unifying it. Covered.
+
+**2. Placeholder scan:** No TBD/TODO. Every step has code or exact command. The tmux `scheduleLayout` nuance is explicitly handled, not deferred.
+
+**3. Type consistency:** `gracefulClosePane(binary: string | null, paneId: string, options: GracefulClosePaneOptions)` is defined in Task 1 and called identically in Tasks 2-4. `ctrlC`/`close` are `string[]`. `acceptExitCode1`/`emptyPaneReturnsTrue` are optional booleans. Consistent.
+
+**Ponytail note:** Deliberately NOT extracting `spawnPane` (diverges too much per @oracle) and NOT building a `MultiplexerBase` class (over-engineering for this scope). The single helper is the smallest change that removes the 3-place shutdown hazard. `session-manager.ts` is correctly left alone.

+ 232 - 0
docs/superpowers/specs/2026-07-06-hook-registry-session-lifecycle-design.md

@@ -0,0 +1,232 @@
+# HookRegistry + SessionLifecycle Coordinator
+
+**Category:** enhancement
+**Author:** mhenke
+**Date:** 2026-07-06
+**Issue:** #675
+**Status:** approved
+
+## Problem
+
+Four verified problems in the hooks architecture of `oh-my-opencode-slim`:
+
+1. **Manual wiring friction.** Adding a new hook requires touching 6-10 locations: export from `src/hooks/index.ts`, import in `src/index.ts`, variable declaration, factory call, and a dispatch call for each hook point. Verified at `src/index.ts:6-34` (imports), `138-153` (declarations), `271-322` (factory calls), `820-1186` (dispatch sites). 13 hooks currently exist; this friction scales linearly.
+
+2. **Scattered session.deleted cleanup.** Three hooks each implement their own cleanup:
+   - `task-session-manager`: 6 ops at `src/hooks/task-session-manager/index.ts:715-720`
+   - `foreground-fallback`: 7 ops at `src/hooks/foreground-fallback/index.ts:238-244`
+   - `post-file-tool-nudge`: 2 ops at `src/hooks/post-file-tool-nudge/index.ts:79-80`
+   A new stateful hook that forgets `session.deleted` leaks memory silently.
+
+3. **Reversed-priority session ID bug.** `info?.id ?? sessionID` is duplicated 8 times across 4 files. One location (`src/hooks/post-file-tool-nudge/index.ts:77`) uses the reversed priority `sessionID ?? info?.id`. During session transitions when both fields differ, this picks the wrong session ID, causing missed cleanup or stale pending state.
+
+4. **Module-scoped Sets with no TTL.** `post-file-tool-nudge` owns `pendingSessionIds` and `everPendingSessionIds` at module scope. `phase-reminder` imports `hasPendingSession` from `post-file-tool-nudge` (`src/hooks/phase-reminder/index.ts:10`). Consumption is a side effect of `.delete()`. If the handler throws or is skipped, the session stays pending forever.
+
+## Solution
+
+Three modules:
+
+### 1. `src/utils/extract-session-id.ts`
+
+Single function that replaces all 8 manual extractions:
+
+```typescript
+export function extractSessionId(
+  info: { id?: string } | undefined | null,
+  sessionID: string | undefined | null,
+): string | undefined {
+  return info?.id ?? sessionID;
+}
+```
+
+- Priority: `info?.id` wins over `sessionID` (matches the 7 correct locations).
+- Located in `src/utils/` because `src/multiplexer/session-manager.ts:610` also uses it.
+- Fixes the reversed-priority bug at `post-file-tool-nudge/index.ts:77`.
+- Also deduplicates the two adjacent `session.deleted` blocks in `src/index.ts:885-905`.
+
+### 2. `src/hooks/session-lifecycle.ts` — SessionLifecycle coordinator
+
+Two responsibilities:
+
+**Cleanup callback registry.** Stateful hooks register a callback instead of implementing their own `session.deleted` handler. The coordinator runs all registered callbacks when `dispatchSessionDeleted(sessionId)` is called. If a callback throws, the error is logged and the remaining callbacks still run — one failure does not block others.
+
+```typescript
+class SessionLifecycle {
+  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
+  #pendingSessionIds = new Set<string>();
+  #everPendingSessionIds = new Set<string>();
+  #pendingTimestamps = new Map<string, number>();
+
+  static readonly PENDING_TTL_MS = 5 * 60 * 1000;
+
+  // -- Cleanup API --
+  onSessionDeleted(callback: (sessionId: string) => void): void;
+  dispatchSessionDeleted(sessionId: string): void;
+
+  // -- Signaling API --
+  /** Mark sessionId as having pending file-tool state. */
+  markPending(sessionId: string): void;
+  /**
+   * Atomically consume pending state for sessionId.
+   * Returns true if this call consumed the pending state,
+   * false if it was already consumed or never pending.
+   * Only one caller will get true per markPending call.
+   */
+  consumePending(sessionId: string): boolean;
+  /** True if sessionId had pending state that was consumed (checked with TTL). */
+  hasPendingSession(sessionId: string): boolean;
+  /** Remove all state for sessionId (called on session.deleted). */
+  clearSession(sessionId: string): void;
+}
+```
+
+**Pending-session signaling channel.** The module-scoped Sets from `post-file-tool-nudge` move here. TTL uses timestamp + lazy expiry on read (no `setTimeout`, no timer lifecycle bugs):
+
+```typescript
+hasPendingSession(sessionId: string): boolean {
+  const ts = this.#pendingTimestamps.get(sessionId);
+  if (ts && Date.now() - ts > SessionLifecycle.PENDING_TTL_MS) {
+    this.#pendingTimestamps.delete(sessionId);
+    this.#pendingSessionIds.delete(sessionId);
+    return false;
+  }
+  return this.#everPendingSessionIds.has(sessionId)
+    && !this.#pendingSessionIds.has(sessionId);
+}
+
+dispatchSessionDeleted(sessionId: string): void {
+  for (const callback of this.#cleanupCallbacks) {
+    try {
+      callback(sessionId);
+    } catch (error) {
+      log.error(`cleanup callback failed for session ${sessionId}`, error);
+    }
+  }
+}
+```
+
+Hooks that use it:
+- `task-session-manager`: registers 6 cleanup ops as one callback
+- `foreground-fallback`: registers 7 cleanup ops as one callback
+- `post-file-tool-nudge`: registers cleanup of pending state; imports `markPending`, `consumePending` from coordinator
+- `phase-reminder`: imports `hasPendingSession` from coordinator instead of `../post-file-tool-nudge` — and nothing else. No need for `markPending` or `consumePending`.
+
+The coordinator is instantiated in `src/index.ts` before hook factories that need it, passed as a parameter.
+
+### 3. `src/hooks/hook-registry.ts` — HookRegistry
+
+Simple ordered handler registry:
+
+```typescript
+class HookRegistry {
+  #handlers = new Map<string, Array<(input: unknown, output: unknown) => Promise<void>>>();
+  #firedHookPoints = new Set<string>();
+
+  register(
+    hookPoint: string,
+    handler: (input: unknown, output: unknown) => Promise<void>,
+  ): void {
+    if (this.#firedHookPoints.has(hookPoint)) {
+      log.warn(`hook "${hookPoint}" already dispatched; late registration may miss events`);
+    }
+    // ...
+  }
+
+  dispatch(hookPoint: string, input: unknown, output: unknown): Promise<void>;
+  getHandlers(hookPoint: string): ReadonlyArray<...>;
+}
+```
+
+Loose typing (`(input: unknown, output: unknown)`) is intentional — typed wrappers per hook point would add ceremony without proportional value for a codebase where call sites are already close to the cast. If typing becomes painful, add typed wrapper methods.
+
+- Registration order = dispatch order.
+- All async hook points dispatch through the registry.
+- `chat.headers` at `src/index.ts:980` stays manual (sync property, not async). A comment at the dispatch site explains why.
+- Non-hook event handling (multiplexer, companion, interview, preset, depthTracker) stays manual.
+
+Touch-point reduction for adding a new hook:
+- Export from `src/hooks/index.ts`: still required
+- Import in `src/index.ts`: still required
+- Variable declaration: **removed**
+- Factory call: still required
+- Registration: **added** (`registry.register(hookPoint, handler)`)
+- Dispatch-site wiring: **removed**
+
+Net: ~3 touch points eliminated. Dispatch code shrinks from ~121 lines to a few `registry.dispatch()` calls.
+
+## Changes by file
+
+### Phase 0: Baseline
+
+Run `bun test` and record the output. This ensures regressions in Phase 3 can be bisected.
+
+### Phase 1: `src/utils/extract-session-id.ts` (new)
+
+- Create file with `extractSessionId` function.
+- Tests in `src/utils/extract-session-id.test.ts`.
+
+### Phase 2: `src/hooks/session-lifecycle.ts` (new)
+
+- Create file with `SessionLifecycle` class.
+- Tests in `src/hooks/session-lifecycle.test.ts`.
+
+### Phase 2: Update hooks
+
+- `src/hooks/post-file-tool-nudge/index.ts`: delete module-scoped Sets, delete `hasPendingSession` export, delete reversed-priority `sessionID ?? info?.id`, use `extractSessionId`, add `coordinator: SessionLifecycle` param to factory, register cleanup callback.
+- `src/hooks/phase-reminder/index.ts`: import `hasPendingSession` from `session-lifecycle` instead of `../post-file-tool-nudge`.
+- `src/hooks/task-session-manager/index.ts`: replace 4 `info?.id ?? sessionID` with `extractSessionId`. Replace inline cleanup with coordinator callback registration.
+- `src/hooks/foreground-fallback/index.ts`: replace `info?.id ?? sessionID` with `extractSessionId`. Replace inline cleanup with coordinator callback registration.
+
+### Phase 3: `src/hooks/hook-registry.ts` (new)
+
+- Create file with `HookRegistry` class.
+- Tests in `src/hooks/hook-registry.test.ts`.
+
+### Phase 3: Update `src/index.ts`
+
+- Delete variable declarations for hooks (lines 138-153).
+- Delete imports for hook types/types that become unused.
+- Instantiate `SessionLifecycle` before hook factories.
+- Pass `SessionLifecycle` to hooks that need it.
+- Instantiate `HookRegistry` after all factories.
+- Register each hook's handlers with the registry.
+- Replace manual dispatch in `event` handler, `tool.execute.before`, `command.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.chat.messages.transform` with `registry.dispatch()`.
+- Keep `chat.headers` manual (sync). Add comment explaining why so maintainers don't try to "fix" it.
+- Keep non-hook dispatch manual (multiplexer, companion, interview, preset, depthTracker).
+- Deduplicate the two `session.deleted` blocks using `extractSessionId`.
+
+### Tests to update
+
+- `src/hooks/post-file-tool-nudge/index.test.ts`: pass coordinator to factory.
+- `src/hooks/phase-reminder/index.test.ts`: update import path for `hasPendingSession`.
+- `src/hooks/task-session-manager/index.test.ts`: verify cleanup through coordinator.
+- `src/index.ts` integration tests: nothing should break — the Plugin function returns the same shape.
+
+## Acceptance criteria
+
+- [ ] `extractSessionId` replaces all 8 instances, priority is always `info?.id ?? sessionID`
+- [ ] `extractSessionId` does not append redundant `?? undefined`
+- [ ] `post-file-tool-nudge` uses the same priority as all other locations
+- [ ] `SessionLifecycle.dispatchSessionDeleted` runs all registered cleanup callbacks
+- [ ] Cleanup callback errors are caught and logged, remaining callbacks still run
+- [ ] `consumePending` is atomic — only one caller gets `true` per `markPending` call
+- [ ] `SessionLifecycle.hasPendingSession` respects TTL and doesn't return stale entries
+- [ ] `SessionLifecycle.clearSession` cleans up pending state and timers
+- [ ] `HookRegistry` warns when a handler is registered after its hook point has dispatched
+- [ ] `HookRegistry.dispatch` runs handlers in registration order
+- [ ] Adding a new hook requires registering with the registry — no manual dispatch-site wiring
+- [ ] `chat.headers` still works (manual sync dispatch unchanged, with explanatory comment)
+- [ ] All 1367 existing tests pass
+- [ ] `bun run check:ci` passes
+- [ ] `bun run typecheck` passes
+
+## Out of scope
+
+- Changing the hook factory pattern (factories still return handler maps)
+- Adding new hooks to the codebase
+- Changing handler signatures (e.g., `experimental.chat.messages.transform`)
+- Non-hook event handling (multiplexer, companion, interview, preset, depthTracker)
+- Sync hook points like `chat.headers`
+- Configurable TTL (keep as static constant, YAGNI)
+- Dynamic hook registration after dispatch (runtime guard logs a warning, not an error)
+- Typed dispatch wrappers per hook point (YAGNI; add if casts become painful)

+ 5 - 0
oh-my-opencode-slim.schema.json

@@ -377,6 +377,11 @@
           "default": true,
           "description": "When true (default), empty provider responses are treated as failures, triggering fallback/retry. Set to false to treat them as successes.",
           "type": "boolean"
+        },
+        "runtimeOverride": {
+          "default": true,
+          "description": "When true (default), a runtime model selected via /model that is outside the configured fallback chain will still trigger the chain on rate-limit errors. When false, out-of-chain runtime picks are respected and the error surfaces instead of silently falling back to the chain. Models that are members of the chain always fall back regardless of this setting.",
+          "type": "boolean"
         }
       },
       "additionalProperties": false

+ 5 - 1
src/agents/index.test.ts

@@ -236,7 +236,11 @@ describe('orchestrator agent', () => {
       { id: 'github-copilot/claude-3.5-haiku' },
       { id: 'openai/gpt-4' },
     ]);
-    expect(orchestrator?.config.model).toBe('google/gemini-3-pro');
+    // orchestrator is the long-lived foreground agent: config.model must
+    // stay undefined so a user's runtime /model selection (tracked via
+    // opencodeConfig.agent.orchestrator.model) is never overwritten by
+    // the config's static array default. See src/agents/index.ts:166.
+    expect(orchestrator?.config.model).toBeUndefined();
   });
 });
 

+ 18 - 4
src/agents/index.ts

@@ -160,10 +160,24 @@ function applyOverrides(
       agent._modelArray = override.model.map((m) =>
         typeof m === 'string' ? { id: m } : m,
       );
-      // Set config.model to the primary entry so the subagent has a valid
-      // model at launch time. ForegroundFallbackManager handles runtime
-      // failover to the remaining entries in _modelArray.
-      agent.config.model = agent._modelArray[0].id;
+      // Subagents are ephemeral, freshly-created sessions with no prior
+      // runtime state to preserve, so giving them a concrete config.model
+      // at launch time (the array's primary entry) is safe — see #9100e59.
+      // ForegroundFallbackManager handles runtime failover to the
+      // remaining entries in _modelArray.
+      //
+      // The orchestrator is different: it's a long-lived, foreground
+      // session where a user's runtime `/model` selection must survive
+      // across plugin re-inits (triggered by client.config.update() ->
+      // Instance.dispose(), e.g. on every subagent dispatch). Setting
+      // config.model here unconditionally would stomp that live
+      // selection every time this function re-runs, because it runs
+      // BEFORE the config() hook's merge with the live
+      // opencodeConfig.agent.orchestrator.model (see src/index.ts:524-528,
+      // added by #639). Leaving it undefined for the orchestrator lets
+      // that later, precedence-aware guard be the sole source of truth.
+      agent.config.model =
+        agent.name === 'orchestrator' ? undefined : agent._modelArray[0].id;
     } else {
       agent.config.model = override.model;
     }

+ 11 - 0
src/config/schema.ts

@@ -197,6 +197,17 @@ export const FailoverConfigSchema = z
         'When true (default), empty provider responses are treated as failures, ' +
           'triggering fallback/retry. Set to false to treat them as successes.',
       ),
+    runtimeOverride: z
+      .boolean()
+      .default(true)
+      .describe(
+        'When true (default), a runtime model selected via /model that is ' +
+          'outside the configured fallback chain will still trigger the chain ' +
+          'on rate-limit errors. When false, out-of-chain runtime picks are ' +
+          'respected and the error surfaces instead of silently falling back ' +
+          'to the chain. Models that are members of the chain always fall back ' +
+          'regardless of this setting.',
+      ),
   })
   .strict();
 

+ 19 - 1
src/hooks/chat-headers.test.ts

@@ -102,7 +102,9 @@ describe('createChatHeadersHook', () => {
 
   test('sets x-initiator for marked Copilot messages', async () => {
     const ctx = createMockContext([
-      createInternalAgentTextPart('internal notification'),
+      JSON.parse(
+        JSON.stringify(createInternalAgentTextPart('internal notification')),
+      ),
     ]);
     const hook = createChatHeadersHook(ctx);
     const output = { headers: {} };
@@ -112,6 +114,22 @@ describe('createChatHeadersHook', () => {
     expect(output.headers['x-initiator']).toBe('agent');
   });
 
+  test('does not trust marker text from ordinary user parts', async () => {
+    const ctx = createMockContext([
+      {
+        type: 'text',
+        synthetic: true,
+        text: '<!-- SLIM_INTERNAL_INITIATOR -->',
+      },
+    ]);
+    const hook = createChatHeadersHook(ctx);
+    const output = { headers: {} };
+
+    await hook['chat.headers'](createInput(), output);
+
+    expect(output.headers['x-initiator']).toBeUndefined();
+  });
+
   test('skips non-Copilot providers', async () => {
     const ctx = createMockContext([
       createInternalAgentTextPart('internal notification'),

+ 2 - 2
src/hooks/chat-headers.ts

@@ -1,6 +1,6 @@
 import type { PluginInput, ProviderContext } from '@opencode-ai/plugin';
 import type { Model, UserMessage } from '@opencode-ai/sdk';
-import { hasInternalInitiatorMarker } from '../utils';
+import { isInternalInitiatorPart } from '../utils';
 
 interface ChatHeadersInput {
   sessionID: string;
@@ -47,7 +47,7 @@ async function hasInternalMarker(
       path: { id: sessionID, messageID },
     });
     const hasMarker = (response.data?.parts ?? []).some(
-      hasInternalInitiatorMarker,
+      isInternalInitiatorPart,
     );
 
     if (hasMarker) {

+ 6 - 3
src/hooks/codemap.md

@@ -7,6 +7,8 @@ Implements OpenCode lifecycle hooks that transform, process, and manage chat mes
 
 ### Core Architecture
 - **Factory Pattern**: Each hook is created via a factory function (e.g., `createApplyPatchHook()`, `createAutoUpdateCheckerHook()`) that returns a hook function matching the OpenCode hook signature.
+- **HookRegistry**: Central ordered dispatcher (`src/hooks/hook-registry.ts`). Hooks register handlers via `registry.register(hookPoint, handler)`; `src/index.ts` dispatches through `registry.dispatch()` instead of calling each hook directly.
+- **SessionLifecycle**: Coordinator (`src/hooks/session-lifecycle.ts`) that owns cleanup callback registration and pending-session signaling channel with timestamp TTL. Stateful hooks register cleanup callbacks instead of implementing their own `session.deleted` handlers.
 - **Stateful Factories**: Hook factories may maintain closure state between invocations (e.g., `createAutoUpdateCheckerHook` guards with `hasChecked`; `createTaskSessionManagerHook` manages session lifecycle). Other hooks remain stateless - each factory decides based on its needs.
 - **Message Transformation Pipeline**: Hooks operate on the `MessageWithParts[]` type, allowing transformation of user messages, assistant responses, and system messages.
 
@@ -51,9 +53,10 @@ Implements OpenCode lifecycle hooks that transform, process, and manage chat mes
 ### Hook Registration
 ```
 1. Plugin initializes (src/index.ts)
-2. Hook factories are called to create hook instances
-3. Hooks are registered with OpenCode via `experimental.chat.messages.transform`
-4. OpenCode invokes hooks during message lifecycle
+2. Hook factories are called, returning handler maps
+3. Handlers are registered with HookRegistry via `hookRegistry.register(hookPoint, handler)`
+4. `src/index.ts` dispatches via `hookRegistry.dispatch()` per hook point
+5. OpenCode invokes hooks during message lifecycle
 ```
 
 ## Integration

+ 287 - 16
src/hooks/foreground-fallback/index.test.ts

@@ -1,4 +1,5 @@
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { SessionLifecycle } from '../session-lifecycle';
 import { ForegroundFallbackManager, isRateLimitError } from './index';
 
 type ForegroundFallbackClient = ConstructorParameters<
@@ -735,9 +736,16 @@ describe('ForegroundFallbackManager subagent.session.created', () => {
 // ---------------------------------------------------------------------------
 
 describe('ForegroundFallbackManager session.deleted', () => {
-  test('cleans up session state on session.deleted preventing memory leaks', async () => {
+  test('cleans up session state on session.deleted via coordinator', async () => {
+    const coordinator = new SessionLifecycle(() => {});
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      coordinator,
+    );
 
     // Populate all maps for this session
     await mgr.handleEvent({
@@ -752,11 +760,8 @@ describe('ForegroundFallbackManager session.deleted', () => {
       },
     });
 
-    // Delete the session
-    await mgr.handleEvent({
-      type: 'session.deleted',
-      properties: { sessionID: 'sess-del' },
-    });
+    // Cleanup via coordinator
+    coordinator.dispatchSessionDeleted('sess-del');
 
     // After deletion, a new rate-limit on the same ID should behave as a fresh
     // session (no prior model known → uses chain from start, dedup cleared)
@@ -789,11 +794,16 @@ describe('ForegroundFallbackManager session.deleted', () => {
     ).resolves.toBeUndefined();
   });
 
-  test('cleans up state using info.id shape (top-level session deletion)', async () => {
-    // OpenCode emits { properties: { info: { id } } } for top-level sessions
-    // and { properties: { sessionID } } for subagent sessions. Both must clean up.
+  test('cleans up state using info.id shape via coordinator', async () => {
+    const coordinator = new SessionLifecycle(() => {});
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      coordinator,
+    );
 
     // Seed state for the session
     await mgr.handleEvent({
@@ -808,11 +818,8 @@ describe('ForegroundFallbackManager session.deleted', () => {
       },
     });
 
-    // Delete via the info.id shape
-    await mgr.handleEvent({
-      type: 'session.deleted',
-      properties: { info: { id: 'sess-info-del' } },
-    });
+    // Cleanup via coordinator
+    coordinator.dispatchSessionDeleted('sess-info-del');
 
     // State is cleared: a new rate-limit on same ID should behave as fresh session
     await mgr.handleEvent({
@@ -927,3 +934,267 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     expect(call[0].body.model.modelID).toBe('glm-5.2');
   });
 });
+
+// ---------------------------------------------------------------------------
+// runtimeOverride config
+// ---------------------------------------------------------------------------
+
+describe('ForegroundFallbackManager runtimeOverride', () => {
+  test('falls back for out-of-chain model when runtimeOverride=true (default)', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      true, // runtimeOverride
+    );
+
+    // Simulate session using a model NOT in any chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // runtimeOverride=true → should fall back even for out-of-chain model
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[0] = anthropic/claude-opus-4-5
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
+  });
+
+  test('skips fallback for out-of-chain model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using a model NOT in any chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-2',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // runtimeOverride=false + model not in chain → abort session, no fallback
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('always falls back for in-chain model regardless of runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using a model that IS in the chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-3',
+          agent: 'orchestrator',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain → should fall back regardless of runtimeOverride
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[1] = openai/gpt-4o (chain[0] is the current model)
+    expect(call[0].body.model.providerID).toBe('openai');
+    expect(call[0].body.model.modelID).toBe('gpt-4o');
+  });
+
+  test('falls back for in-chain secondary model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using chain[1] — still in chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-4',
+          agent: 'orchestrator',
+          providerID: 'openai',
+          modelID: 'gpt-4o',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain → should fall back
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[0] = anthropic/claude-opus-4-5 (chain[1] is tried)
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
+  });
+
+  test('falls back for unknown agent with in-chain model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate unknown agent (e.g. "compaction") using a model that IS in
+    // the orchestrator chain — resolveChain infers the chain from the model.
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-5',
+          agent: 'compaction',
+          providerID: 'openai',
+          modelID: 'gpt-4o',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain (resolved via model matching) → should fall back
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('session.error with runtimeOverride=false and out-of-chain model aborts session', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Seed session with out-of-chain model
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-err-override',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+        },
+      },
+    });
+
+    // Trigger session.error with rate limit
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-err-override',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('session.status with runtimeOverride=false and out-of-chain model aborts after retry budget exhausted', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3, // maxRetries
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Seed session with out-of-chain model
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-status-override',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+        },
+      },
+    });
+
+    // First retry (attempt 1) — absorbed by retry budget
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.abort).toHaveBeenCalledTimes(0);
+
+    // Second retry (attempt 2) — absorbed
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.abort).toHaveBeenCalledTimes(0);
+
+    // Third retry (attempt 3) — budget exhausted, tryFallback runs, guard aborts
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+});

+ 52 - 15
src/hooks/foreground-fallback/index.ts

@@ -21,6 +21,7 @@ import {
   abortSessionWithTimeout,
   parseModelReference,
 } from '../../utils/session';
+import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 
 type OpencodeClient = PluginInput['client'];
@@ -120,7 +121,27 @@ export class ForegroundFallbackManager {
     private readonly enabled: boolean,
     /** Consecutive 429s tolerated on the same model before swap/abort. */
     private readonly maxRetries: number = 3,
-  ) {}
+    coordinator?: SessionLifecycle,
+    /**
+     * When true (default), a runtime model outside the configured chain
+     * still triggers fallback on rate-limit errors. When false, out-of-chain
+     * runtime picks are respected and the error surfaces instead. Models
+     * that are members of the chain always fall back regardless.
+     */
+    private readonly runtimeOverride: boolean = true,
+  ) {
+    if (coordinator) {
+      coordinator.onSessionDeleted((id) => {
+        this.sessionModel.delete(id);
+        this.sessionAgent.delete(id);
+        this.sessionTried.delete(id);
+        this.inProgress.delete(id);
+        this.lastTrigger.delete(id);
+        this.lastTriggerModel.delete(id);
+        this.sessionRetries.delete(id);
+      });
+    }
+  }
 
   /**
    * Process an OpenCode plugin event.
@@ -224,24 +245,14 @@ export class ForegroundFallbackManager {
       }
 
       case 'session.deleted': {
-        // Clean up all per-session state to prevent unbounded memory growth
-        // in long-running instances with many subagent sessions.
-        // OpenCode emits two shapes depending on context:
-        //   { properties: { sessionID } }   - subagent / task sessions
-        //   { properties: { info: { id } } } - top-level session deletion
-        // Mirror the same dual-shape lookup used elsewhere in the plugin.
         const props = event.properties as
           | { sessionID?: string; info?: { id?: string } }
           | undefined;
-        const id = props?.info?.id ?? props?.sessionID;
+        const id = props?.info?.id || props?.sessionID;
         if (id) {
-          this.sessionModel.delete(id);
-          this.sessionAgent.delete(id);
-          this.sessionTried.delete(id);
-          this.inProgress.delete(id);
-          this.lastTrigger.delete(id);
-          this.lastTriggerModel.delete(id);
-          this.sessionRetries.delete(id);
+          log('[foreground-fallback] session.deleted observed', {
+            sessionID: id,
+          });
         }
         break;
       }
@@ -328,6 +339,32 @@ export class ForegroundFallbackManager {
         currentModel = chain[0];
       }
 
+      // Guard: when runtimeOverride is false, skip fallback for models
+      // that are not members of the configured chain. This respects a
+      // deliberate runtime `/model` pick (e.g. an expensive model outside
+      // the chain) and lets the error surface instead of silently swapping
+      // to the chain's default. Models that ARE in the chain always fall
+      // back normally regardless of this setting.
+      if (
+        !this.runtimeOverride &&
+        currentModel &&
+        !chain.includes(currentModel)
+      ) {
+        log(
+          '[foreground-fallback] current model not in chain, skipping fallback (runtimeOverride=false)',
+          {
+            sessionID,
+            agentName,
+            currentModel,
+            chain,
+          },
+        );
+        // Abort the session so the rate-limit error surfaces to the user
+        // instead of leaving the session in a silent retry loop.
+        await abortSessionWithTimeout(this.client, sessionID);
+        return;
+      }
+
       if (!this.sessionTried.has(sessionID)) {
         this.sessionTried.set(sessionID, new Set());
       }

+ 1 - 0
src/hooks/index.ts

@@ -15,4 +15,5 @@ export { createLoopCommandHook } from './loop-command';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
 export { createReflectCommandHook } from './reflect';
+export { SessionLifecycle } from './session-lifecycle';
 export { createTaskSessionManagerHook } from './task-session-manager';

+ 56 - 3
src/hooks/phase-reminder/index.test.ts

@@ -3,7 +3,11 @@ import {
   createInternalAgentTextPart,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
-import { createPhaseReminderHook, PHASE_REMINDER } from './index';
+import {
+  createPhaseReminderHook,
+  PHASE_REMINDER,
+  PHASE_REMINDER_METADATA_KEY,
+} from './index';
 
 describe('createPhaseReminderHook', () => {
   test('appends reminder as a separate part for orchestrator sessions', async () => {
@@ -25,6 +29,10 @@ describe('createPhaseReminderHook', () => {
     expect(output.messages[0].parts[1].text).toBe(PHASE_REMINDER);
     expect(output.messages[0].parts[1].text).toStartWith('<system-reminder>');
     expect(output.messages[0].parts[1].text).toEndWith('</system-reminder>');
+    expect(output.messages[0].parts[1]).toMatchObject({
+      synthetic: true,
+      metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+    });
   });
 
   test('skips non-orchestrator sessions', async () => {
@@ -64,6 +72,28 @@ describe('createPhaseReminderHook', () => {
     expect(output.messages[0].parts.length).toBe(1);
   });
 
+  test('does not mutate persisted internal notification turns', async () => {
+    const hook = createPhaseReminderHook();
+    const internalPart = JSON.parse(
+      JSON.stringify(createInternalAgentTextPart('internal notification')),
+    ) as ReturnType<typeof createInternalAgentTextPart>;
+    const output = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator' },
+          parts: [internalPart],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output);
+
+    expect(output.messages[0].parts).toHaveLength(1);
+    expect(
+      output.messages[0].parts.some((part) => part.text === PHASE_REMINDER),
+    ).toBe(false);
+  });
+
   test('does not let user-visible internal marker suppress injection', async () => {
     const hook = createPhaseReminderHook();
     const output = {
@@ -87,7 +117,7 @@ describe('createPhaseReminderHook', () => {
     expect(output.messages[0].parts[1].text).toBe(PHASE_REMINDER);
   });
 
-  test('does not append duplicate reminder', async () => {
+  test('does not append duplicate reminder after JSON persistence', async () => {
     const hook = createPhaseReminderHook();
     const output = {
       messages: [
@@ -95,7 +125,14 @@ describe('createPhaseReminderHook', () => {
           info: { role: 'user', agent: 'orchestrator' },
           parts: [
             { type: 'text', text: 'hello' },
-            { type: 'text', text: PHASE_REMINDER },
+            JSON.parse(
+              JSON.stringify({
+                type: 'text',
+                synthetic: true,
+                text: PHASE_REMINDER,
+                metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
+              }),
+            ),
           ],
         },
       ],
@@ -107,6 +144,22 @@ describe('createPhaseReminderHook', () => {
     expect(output.messages[0].parts[0].text).toBe('hello');
   });
 
+  test('does not trust ordinary reminder text for dedupe', async () => {
+    const hook = createPhaseReminderHook();
+    const output = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator' },
+          parts: [{ type: 'text', text: PHASE_REMINDER }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output);
+
+    expect(output.messages[0].parts).toHaveLength(2);
+  });
+
   test('does not modify original user message text (bug #448)', async () => {
     const hook = createPhaseReminderHook();
     const originalText = 'Hello world';

+ 18 - 8
src/hooks/phase-reminder/index.ts

@@ -6,18 +6,21 @@
  * of the user's actual turn.
  */
 import { PHASE_REMINDER } from '../../config/constants';
-import { isBrandedInternalInitiatorPart } from '../../utils';
-import { hasPendingSession } from '../post-file-tool-nudge';
+import { isInternalInitiatorPart } from '../../utils';
+import { isRecord } from '../../utils/guards';
+import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 
 export { PHASE_REMINDER };
 
+export const PHASE_REMINDER_METADATA_KEY = 'oh-my-opencode-slim.phaseReminder';
+
 /**
  * Creates the experimental.chat.messages.transform hook for phase reminder injection.
  * This hook runs right before sending to API, so it doesn't affect UI display.
  * Only injects for the orchestrator agent.
  */
-export function createPhaseReminderHook() {
+export function createPhaseReminderHook(coordinator?: SessionLifecycle) {
   return {
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
@@ -55,7 +58,7 @@ export function createPhaseReminderHook() {
       // injection via system prompt — skip message-level injection.
       const sessionId = (lastUserMessage as { info?: { sessionID?: string } })
         ?.info?.sessionID;
-      if (sessionId && hasPendingSession(sessionId)) {
+      if (sessionId && coordinator?.hasPendingSession(sessionId)) {
         return;
       }
 
@@ -68,12 +71,17 @@ export function createPhaseReminderHook() {
       }
 
       const originalPart = lastUserMessage.parts[textPartIndex];
-      if (isBrandedInternalInitiatorPart(originalPart)) {
+      if (isInternalInitiatorPart(originalPart)) {
         return;
       }
-      // Prevent duplicate injection: check if any existing part already contains
-      // the phase reminder (either merged into text or as a standalone part).
-      if (lastUserMessage.parts.some((p) => p.text?.includes(PHASE_REMINDER))) {
+      if (
+        lastUserMessage.parts.some(
+          (part) =>
+            part.synthetic === true &&
+            isRecord(part.metadata) &&
+            part.metadata[PHASE_REMINDER_METADATA_KEY] === true,
+        )
+      ) {
         return;
       }
 
@@ -82,7 +90,9 @@ export function createPhaseReminderHook() {
       // the UI display and chat history (issue #448).
       lastUserMessage.parts.push({
         type: 'text',
+        synthetic: true,
         text: PHASE_REMINDER,
+        metadata: { [PHASE_REMINDER_METADATA_KEY]: true },
       });
     },
   };

+ 31 - 20
src/hooks/post-file-tool-nudge/index.test.ts

@@ -1,11 +1,13 @@
 import { describe, expect, test } from 'bun:test';
 
 import { PHASE_REMINDER } from '../../config/constants';
+import { SessionLifecycle } from '../session-lifecycle';
 import { createPostFileToolNudgeHook } from './index';
 
 describe('post-file-tool-nudge hook', () => {
   test('records pending session on Read tool', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
     const output = { system: [] };
 
     await hook['tool.execute.after']({ tool: 'Read', sessionID: 's1' }, {});
@@ -18,7 +20,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('records pending session on Write tool', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
     const output = { system: [] };
 
     await hook['tool.execute.after']({ tool: 'Write', sessionID: 's1' }, {});
@@ -31,7 +34,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('does not mutate tool output', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
     const toolOutput = { output: 'real content' };
 
     await hook['tool.execute.after'](
@@ -43,7 +47,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('deduplicates multiple Read/Write calls in same session', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
 
     await hook['tool.execute.after']({ tool: 'read', sessionID: 's1' }, {});
     await hook['tool.execute.after']({ tool: 'write', sessionID: 's1' }, {});
@@ -59,7 +64,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('consumes pending marker after injection', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
 
     await hook['tool.execute.after']({ tool: 'Read', sessionID: 's1' }, {});
     await hook['experimental.chat.system.transform'](
@@ -78,7 +84,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('ignores non-file tools', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
     const output = { system: [] };
 
     await hook['tool.execute.after']({ tool: 'bash', sessionID: 's1' }, {});
@@ -91,7 +98,11 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('skips injection when shouldInject returns false', async () => {
-    const hook = createPostFileToolNudgeHook({ shouldInject: () => false });
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({
+      shouldInject: () => false,
+      coordinator,
+    });
     const output = { system: [] };
 
     await hook['tool.execute.after']({ tool: 'Read', sessionID: 's1' }, {});
@@ -104,7 +115,8 @@ describe('post-file-tool-nudge hook', () => {
   });
 
   test('ignores Read/Write without sessionID', async () => {
-    const hook = createPostFileToolNudgeHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
     const output = { system: [] };
 
     await hook['tool.execute.after']({ tool: 'read' }, {});
@@ -116,13 +128,12 @@ describe('post-file-tool-nudge hook', () => {
     expect(output.system).toHaveLength(0);
   });
 
-  test('cleans up pending marker on session.deleted', async () => {
-    const hook = createPostFileToolNudgeHook();
+  test('cleans up pending marker on session.deleted via coordinator', async () => {
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
 
     await hook['tool.execute.after']({ tool: 'Read', sessionID: 's1' }, {});
-    await hook.event({
-      event: { type: 'session.deleted', properties: { sessionID: 's1' } },
-    });
+    coordinator.dispatchSessionDeleted('s1');
 
     const output = { system: [] };
     await hook['experimental.chat.system.transform'](
@@ -133,13 +144,12 @@ describe('post-file-tool-nudge hook', () => {
     expect(output.system).toHaveLength(0);
   });
 
-  test('cleans up on session.deleted with info.id shape', async () => {
-    const hook = createPostFileToolNudgeHook();
+  test('cleans up pending marker via coordinator with info.id shape', async () => {
+    const coordinator = new SessionLifecycle(() => {});
+    const hook = createPostFileToolNudgeHook({ coordinator });
 
     await hook['tool.execute.after']({ tool: 'Read', sessionID: 's1' }, {});
-    await hook.event({
-      event: { type: 'session.deleted', properties: { info: { id: 's1' } } },
-    });
+    coordinator.dispatchSessionDeleted('s1');
 
     const output = { system: [] };
     await hook['experimental.chat.system.transform'](
@@ -152,8 +162,9 @@ describe('post-file-tool-nudge hook', () => {
 
   test('composed: phase-reminder skips when post-file-tool-nudge handles system', async () => {
     const { createPhaseReminderHook } = await import('../phase-reminder/index');
-    const nudgeHook = createPostFileToolNudgeHook();
-    const phaseHook = createPhaseReminderHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const nudgeHook = createPostFileToolNudgeHook({ coordinator });
+    const phaseHook = createPhaseReminderHook(coordinator);
 
     // Simulate Read tool call
     await nudgeHook['tool.execute.after'](

+ 13 - 48
src/hooks/post-file-tool-nudge/index.ts

@@ -7,78 +7,43 @@
  */
 
 import { PHASE_REMINDER } from '../../config/constants';
+import type { SessionLifecycle } from '../session-lifecycle';
 
-interface ToolExecuteAfterInput {
-  tool: string;
-  sessionID?: string;
-  callID?: string;
-}
+const FILE_TOOLS = new Set(['Read', 'read', 'Write', 'write']);
 
 interface PostFileToolNudgeOptions {
   shouldInject?: (sessionID: string) => boolean;
-}
-
-const FILE_TOOLS = new Set(['Read', 'read', 'Write', 'write']);
-
-// Module-scoped for coordination with phase-reminder hook.
-const pendingSessionIds = new Set<string>();
-const everPendingSessionIds = new Set<string>();
-
-/** Check if a session was marked pending by a file tool AND has not yet been
- *  consumed by system.transform. Allows phase-reminder to skip injection
- *  when post-file-tool-nudge already handles it. */
-export function hasPendingSession(sessionId: string): boolean {
-  return (
-    everPendingSessionIds.has(sessionId) && !pendingSessionIds.has(sessionId)
-  );
+  coordinator?: SessionLifecycle;
 }
 
 export function createPostFileToolNudgeHook(
   options: PostFileToolNudgeOptions = {},
 ) {
+  const { coordinator } = options;
+
+  if (coordinator) {
+    coordinator.onSessionDeleted((sid) => coordinator.clearSession(sid));
+  }
+
   return {
     'tool.execute.after': async (
-      input: ToolExecuteAfterInput,
+      input: { tool: string; sessionID?: string; callID?: string },
       _output: unknown,
     ): Promise<void> => {
-      if (!FILE_TOOLS.has(input.tool) || !input.sessionID) {
-        return;
-      }
-
-      pendingSessionIds.add(input.sessionID);
-      everPendingSessionIds.add(input.sessionID);
+      if (!FILE_TOOLS.has(input.tool) || !input.sessionID) return;
+      coordinator?.markPending(input.sessionID);
     },
     'experimental.chat.system.transform': async (
       input: { sessionID?: string },
       output: { system: string[] },
     ): Promise<void> => {
-      if (!input.sessionID || !pendingSessionIds.delete(input.sessionID)) {
+      if (!input.sessionID || !coordinator?.consumePending(input.sessionID)) {
         return;
       }
-
-      // Track consumption so phase-reminder can check without consuming.
-      // (already tracked via everPendingSessionIds — delete from pending is
-      // sufficient signal)
-
       if (options.shouldInject && !options.shouldInject(input.sessionID)) {
         return;
       }
-
       output.system.push(PHASE_REMINDER);
     },
-    event: async (input: {
-      event: {
-        type: string;
-        properties?: { info?: { id?: string }; sessionID?: string };
-      };
-    }): Promise<void> => {
-      if (input.event.type !== 'session.deleted') return;
-      const sid =
-        input.event.properties?.sessionID ?? input.event.properties?.info?.id;
-      if (sid) {
-        pendingSessionIds.delete(sid);
-        everPendingSessionIds.delete(sid);
-      }
-    },
   };
 }

+ 53 - 0
src/hooks/session-lifecycle.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from 'bun:test';
+import { SessionLifecycle } from './session-lifecycle';
+
+const noop = () => {};
+
+describe('SessionLifecycle', () => {
+  test('dispatchSessionDeleted runs callbacks in order', () => {
+    const lc = new SessionLifecycle(noop);
+    const ran: string[] = [];
+    lc.onSessionDeleted((id) => ran.push(`a:${id}`));
+    lc.onSessionDeleted((id) => ran.push(`b:${id}`));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['a:s1', 'b:s1']);
+  });
+
+  test('dispatchSessionDeleted continues after callback error', () => {
+    const lc = new SessionLifecycle(() => {});
+    const ran: string[] = [];
+    lc.onSessionDeleted(() => {
+      throw new Error('fail');
+    });
+    lc.onSessionDeleted((id) => ran.push(id));
+    lc.dispatchSessionDeleted('s1');
+    expect(ran).toEqual(['s1']);
+  });
+
+  test('consumePending is atomic', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    expect(lc.consumePending('s1')).toBe(true);
+    expect(lc.consumePending('s1')).toBe(false);
+  });
+
+  test('hasPendingSession after consume', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    expect(lc.hasPendingSession('s1')).toBe(true);
+  });
+
+  test('hasPendingSession false for unknown session', () => {
+    const lc = new SessionLifecycle(noop);
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+
+  test('clearSession removes all state', () => {
+    const lc = new SessionLifecycle(noop);
+    lc.markPending('s1');
+    lc.consumePending('s1');
+    lc.clearSession('s1');
+    expect(lc.hasPendingSession('s1')).toBe(false);
+  });
+});

+ 51 - 0
src/hooks/session-lifecycle.ts

@@ -0,0 +1,51 @@
+export class SessionLifecycle {
+  #cleanupCallbacks: Array<(sessionId: string) => void> = [];
+  #pendingSessionIds = new Set<string>();
+  #everPendingSessionIds = new Set<string>();
+  #log: (msg: string, meta?: Record<string, unknown>) => void;
+
+  constructor(log: (msg: string, meta?: Record<string, unknown>) => void) {
+    this.#log = log;
+  }
+
+  onSessionDeleted(callback: (sessionId: string) => void): void {
+    this.#cleanupCallbacks.push(callback);
+  }
+
+  dispatchSessionDeleted(sessionId: string): void {
+    for (const cb of this.#cleanupCallbacks) {
+      try {
+        cb(sessionId);
+      } catch (error) {
+        this.#log(
+          `[session-lifecycle] cleanup callback failed for session ${sessionId}`,
+          { error },
+        );
+      }
+    }
+  }
+
+  markPending(sessionId: string): void {
+    this.#pendingSessionIds.add(sessionId);
+    this.#everPendingSessionIds.add(sessionId);
+  }
+
+  /** Atomic — only one caller gets true per markPending call. */
+  consumePending(sessionId: string): boolean {
+    const had = this.#pendingSessionIds.has(sessionId);
+    this.#pendingSessionIds.delete(sessionId);
+    return had;
+  }
+
+  hasPendingSession(sessionId: string): boolean {
+    return (
+      this.#everPendingSessionIds.has(sessionId) &&
+      !this.#pendingSessionIds.has(sessionId)
+    );
+  }
+
+  clearSession(sessionId: string): void {
+    this.#pendingSessionIds.delete(sessionId);
+    this.#everPendingSessionIds.delete(sessionId);
+  }
+}

+ 59 - 23
src/hooks/task-session-manager/index.test.ts

@@ -1,9 +1,14 @@
 import { describe, expect, mock, test } from 'bun:test';
+import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import {
   BackgroundJobBoard,
+  createInternalAgentTextPart,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
-import { createTaskSessionManagerHook } from './index';
+import {
+  BACKGROUND_JOB_BOARD_METADATA_KEY,
+  createTaskSessionManagerHook,
+} from './index';
 
 function createHook(options?: {
   shouldManageSession?: (sessionID: string) => boolean;
@@ -12,6 +17,7 @@ function createHook(options?: {
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   isFallbackInProgress?: (sessionID: string) => boolean;
+  coordinator?: SessionLifecycle;
 }) {
   const hook = createTaskSessionManagerHook(
     {
@@ -30,6 +36,7 @@ function createHook(options?: {
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       isFallbackInProgress: options?.isFallbackInProgress,
+      coordinator: options?.coordinator,
     },
   );
 
@@ -133,6 +140,9 @@ describe('task-session-manager hook', () => {
     };
     expect(boardPart.text).toContain('### Background Job Board');
     expect(boardPart.synthetic).toBe(true);
+    expect(boardPart).toMatchObject({
+      metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
+    });
     expect(boardPart.text).toStartWith('<system-reminder>');
     expect(boardPart.text).toEndWith('</system-reminder>');
     expect(boardPart.text).toContain('exp-1 / child-1 / explorer / running');
@@ -178,7 +188,7 @@ describe('task-session-manager hook', () => {
     );
   });
 
-  test('does not duplicate board part after object spread normalization', async () => {
+  test('does not duplicate board part after JSON persistence', async () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({
       taskID: 'child-1',
@@ -190,9 +200,9 @@ describe('task-session-manager hook', () => {
     const messages = createMessages('parent-1', 'continue');
 
     await hook['experimental.chat.messages.transform']({}, messages);
-    messages.messages[0].parts = messages.messages[0].parts.map((part) => ({
-      ...part,
-    }));
+    messages.messages[0].parts = JSON.parse(
+      JSON.stringify(messages.messages[0].parts),
+    );
     await hook['experimental.chat.messages.transform']({}, messages);
 
     expect(
@@ -240,6 +250,41 @@ describe('task-session-manager hook', () => {
     );
   });
 
+  test('does not inject board context into persisted internal turns', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    const { hook } = createHook({ backgroundJobBoard: board });
+    const internalPart = JSON.parse(
+      JSON.stringify(createInternalAgentTextPart('internal notification')),
+    ) as ReturnType<typeof createInternalAgentTextPart>;
+    const messages = {
+      messages: [
+        {
+          info: {
+            role: 'user',
+            agent: 'orchestrator',
+            sessionID: 'parent-1',
+          },
+          parts: [internalPart],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(messages.messages[0].parts).toHaveLength(1);
+    expect(
+      messages.messages[0].parts.some((part) =>
+        part.text.includes('### Background Job Board'),
+      ),
+    ).toBe(false);
+  });
+
   test('updates background job board from task output', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });
@@ -1850,7 +1895,8 @@ describe('task-session-manager hook', () => {
   });
 
   test('cleans up background jobs when parent or child is deleted', async () => {
-    const { hook } = createHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const { hook } = createHook({ coordinator });
 
     await hook['tool.execute.before'](
       {
@@ -1877,12 +1923,7 @@ describe('task-session-manager hook', () => {
       },
     );
 
-    await hook.event({
-      event: {
-        type: 'session.deleted',
-        properties: { sessionID: 'child-1' },
-      },
-    });
+    coordinator.dispatchSessionDeleted('child-1');
 
     const messages = createMessages('parent-1', 'do something');
     await hook['experimental.chat.messages.transform']({}, messages);
@@ -1891,7 +1932,8 @@ describe('task-session-manager hook', () => {
   });
 
   test('cleans pending calls when parent session is deleted', async () => {
-    const { hook } = createHook();
+    const coordinator = new SessionLifecycle(() => {});
+    const { hook } = createHook({ coordinator });
 
     await hook['tool.execute.before'](
       {
@@ -1907,12 +1949,7 @@ describe('task-session-manager hook', () => {
       },
     );
 
-    await hook.event({
-      event: {
-        type: 'session.deleted',
-        properties: { sessionID: 'parent-1' },
-      },
-    });
+    coordinator.dispatchSessionDeleted('parent-1');
 
     await hook['tool.execute.after'](
       {
@@ -2135,8 +2172,9 @@ describe('task-session-manager hook', () => {
   });
 
   test('parent deletion clears jobs and pending calls', async () => {
+    const coordinator = new SessionLifecycle(() => {});
     const board = new BackgroundJobBoard();
-    const { hook } = createHook({ backgroundJobBoard: board });
+    const { hook } = createHook({ backgroundJobBoard: board, coordinator });
     await hook['tool.execute.before'](
       { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
       { args: { subagent_type: 'oracle', description: 'architecture review' } },
@@ -2148,9 +2186,7 @@ describe('task-session-manager hook', () => {
       description: 'architecture review',
     });
 
-    await hook.event({
-      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
-    });
+    coordinator.dispatchSessionDeleted('parent-1');
     await hook['tool.execute.after'](
       { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
       { output: ['task_id: child-2', 'state: running'].join('\n') },

+ 31 - 41
src/hooks/task-session-manager/index.ts

@@ -2,8 +2,9 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
   type BackgroundJobRecord,
+  type BackgroundJobStore,
   deriveTaskSessionLabel,
-  isBrandedInternalInitiatorPart,
+  isInternalInitiatorPart,
   parseTaskIdFromTaskOutput,
   parseTaskLaunchOutput,
   parseTaskStatusOutput,
@@ -11,6 +12,7 @@ import {
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import { isRateLimitError } from '../foreground-fallback/index';
+import type { SessionLifecycle } from '../session-lifecycle';
 import {
   isUserMessageWithParts,
   type MessagePart,
@@ -30,7 +32,8 @@ interface TaskArgs {
   task_id?: unknown;
 }
 
-const BACKGROUND_JOB_BOARD_PART_BRAND = Symbol('slim.backgroundJobBoardPart');
+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: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
@@ -82,14 +85,15 @@ export function createTaskSessionManagerHook(
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
-    backgroundJobBoard?: BackgroundJobBoard;
+    backgroundJobBoard?: BackgroundJobStore;
     shouldManageSession: (sessionID: string) => boolean;
     /** Optional guard: when provided, idle events for a session that is
      *  currently undergoing a foreground-fallback abort/re-prompt cycle
-     *  will NOT trigger idle reconciliation. Prevents marking a still-
+     *  will NOT trigger idle reconciliation. prevents marking a still-
      *  active child job as completed when the session was aborted for
      *  model fallback rather than natural completion. */
     isFallbackInProgress?: (sessionID: string) => boolean;
+    coordinator?: SessionLifecycle;
   },
 ) {
   const backgroundJobBoard =
@@ -107,6 +111,17 @@ export function createTaskSessionManagerHook(
   const processedInjectedCompletionOrder: string[] = [];
   const terminalJobsInjectedByParent = new Map<string, Set<string>>();
 
+  if (options.coordinator) {
+    options.coordinator.onSessionDeleted((sessionId) => {
+      backgroundJobBoard.drop(sessionId);
+      backgroundJobBoard.clearParent(sessionId);
+      terminalJobsInjectedByParent.delete(sessionId);
+      taskContextTracker.clearSession(sessionId);
+      taskContextTracker.prune(backgroundJobBoard);
+      pendingCallTracker.clearSession(sessionId);
+    });
+  }
+
   function updateBackgroundJobFromOutput(
     output: unknown,
   ): BackgroundJobRecord | undefined {
@@ -531,15 +546,15 @@ export function createTaskSessionManagerHook(
           (part) => part.type === 'text' && typeof part.text === 'string',
         );
         if (!textPart) return;
-        if (isBrandedInternalInitiatorPart(textPart)) {
+        if (isInternalInitiatorPart(textPart)) {
           return;
         }
         if (
           message.parts.some(
             (part) =>
-              (part as Record<PropertyKey, unknown>)[
-                BACKGROUND_JOB_BOARD_PART_BRAND
-              ] === true,
+              part.synthetic === true &&
+              isObjectRecord(part.metadata) &&
+              part.metadata[BACKGROUND_JOB_BOARD_METADATA_KEY] === true,
           )
         ) {
           return;
@@ -550,11 +565,8 @@ export function createTaskSessionManagerHook(
           type: 'text',
           synthetic: true,
           text: reminders.join('\n\n'),
+          metadata: { [BACKGROUND_JOB_BOARD_METADATA_KEY]: true },
         };
-        Object.defineProperty(boardPart, BACKGROUND_JOB_BOARD_PART_BRAND, {
-          enumerable: true,
-          value: true,
-        });
         message.parts.unshift(boardPart);
         return;
       }
@@ -597,7 +609,7 @@ export function createTaskSessionManagerHook(
             ?.status?.type === 'idle')
       ) {
         const sessionId =
-          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+          input.event.properties?.info?.id || input.event.properties?.sessionID;
         const job = sessionId ? backgroundJobBoard.get(sessionId) : undefined;
         log('[task-session-manager] idle/status idle observed', {
           sessionID: sessionId,
@@ -650,7 +662,7 @@ export function createTaskSessionManagerHook(
 
       if (input.event.type === 'session.error') {
         const sessionId =
-          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+          input.event.properties?.info?.id || input.event.properties?.sessionID;
         if (sessionId && options.shouldManageSession(sessionId)) {
           // Only clear injected terminal jobs for fatal errors.
           // Rate-limit errors are recovered by ForegroundFallbackManager
@@ -674,7 +686,7 @@ export function createTaskSessionManagerHook(
           ?.status?.type === 'busy'
       ) {
         const sessionId =
-          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+          input.event.properties?.info?.id || input.event.properties?.sessionID;
         const before = sessionId
           ? backgroundJobBoard.get(sessionId)
           : undefined;
@@ -708,34 +720,12 @@ export function createTaskSessionManagerHook(
 
       if (input.event.type !== 'session.deleted') return;
       const sessionId =
-        input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        input.event.properties?.info?.id || input.event.properties?.sessionID;
       if (!sessionId) return;
 
-      log(
-        '[task-session-manager] session.deleted observed; clearing job state',
-        {
-          sessionID: sessionId,
-          deletedJob: (() => {
-            const record = backgroundJobBoard.get(sessionId);
-            return record
-              ? {
-                  state: record.state,
-                  parentSessionID: record.parentSessionID,
-                  alias: record.alias,
-                }
-              : undefined;
-          })(),
-          childJobCount: backgroundJobBoard.list(sessionId).length,
-          managesSession: options.shouldManageSession(sessionId),
-        },
-      );
-
-      backgroundJobBoard.drop(sessionId);
-      backgroundJobBoard.clearParent(sessionId);
-      terminalJobsInjectedByParent.delete(sessionId);
-      taskContextTracker.clearSession(sessionId);
-      taskContextTracker.prune(backgroundJobBoard);
-      pendingCallTracker.clearSession(sessionId);
+      log('[task-session-manager] session.deleted observed', {
+        sessionID: sessionId,
+      });
     },
   };
 

+ 112 - 153
src/index.ts

@@ -31,6 +31,7 @@ import {
   createReflectCommandHook,
   createTaskSessionManagerHook,
   ForegroundFallbackManager,
+  SessionLifecycle,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
 import type { MessageWithParts } from './hooks/types';
@@ -53,6 +54,7 @@ import {
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
+  BackgroundJobCoordinator,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -136,21 +138,25 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let depthTracker: SubagentDepthTracker;
   let multiplexerSessionManager: MultiplexerSessionManager;
   let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
-  let phaseReminderHook: ReturnType<typeof createPhaseReminderHook>;
-  let filterAvailableSkillsHook: ReturnType<
-    typeof createFilterAvailableSkillsHook
-  >;
   let sessionAgentMap: Map<string, string>;
-  let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
+  let sessionLifecycle: SessionLifecycle;
+
   let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
-  let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
-  let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
-  let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
   let foregroundFallback: ForegroundFallbackManager;
   let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
   let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
   let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
+  let phaseReminder: ReturnType<typeof createPhaseReminderHook>;
+  let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
+  let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
+  let delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook>;
+  let applyPatch: ReturnType<typeof createApplyPatchHook>;
+  let jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook>;
+  let postFileToolNudgeAfter: (i: unknown, o: unknown) => Promise<void>;
+  let delegateTaskRetryAfter: (i: unknown, o: unknown) => Promise<void>;
+  let jsonErrorRecoveryAfter: (i: unknown, o: unknown) => Promise<void>;
+  let taskSessionManagerAfter: (i: unknown, o: unknown) => Promise<void>;
   let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
@@ -256,47 +262,35 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
     });
 
+    // Initialize coordinator as the sole writer to the board
+    const backgroundJobCoordinator = new BackgroundJobCoordinator(
+      backgroundJobBoard,
+    );
+
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
     multiplexerSessionManager = new MultiplexerSessionManager(
       ctx,
       multiplexerConfig,
-      backgroundJobBoard,
+      backgroundJobCoordinator,
     );
-    backgroundJobBoard.addTerminalStateListener((taskID) => {
-      void multiplexerSessionManager.retryDeferredIdleClose(taskID);
+    backgroundJobCoordinator.addTerminalStateListener((taskID) => {
+      void multiplexerSessionManager.closeSessionFromCoordinator(taskID);
     });
 
+    sessionLifecycle = new SessionLifecycle(log);
+
     // Initialize auto-update checker hook
     autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
       autoUpdate: config.autoUpdate ?? true,
       companion: config.companion,
     });
 
-    // Initialize phase reminder hook for workflow compliance
-    phaseReminderHook = createPhaseReminderHook();
-
-    // Initialize available skills filter hook
-    filterAvailableSkillsHook = createFilterAvailableSkillsHook(ctx, config);
-
     // Track session → agent mapping for serve-mode system prompt injection
     sessionAgentMap = new Map<string, string>();
 
-    // Initialize post-file-tool nudge hook
-    postFileToolNudgeHook = createPostFileToolNudgeHook({
-      shouldInject: (sessionID) =>
-        sessionAgentMap.get(sessionID) === 'orchestrator',
-    });
-
     chatHeadersHook = createChatHeadersHook(ctx);
 
-    // Initialize delegate-task retry guidance hook
-    delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
-
-    applyPatchHook = createApplyPatchHook(ctx);
-    // Initialize JSON parse error recovery hook
-    jsonErrorRecoveryHook = createJsonErrorRecoveryHook(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.
@@ -305,6 +299,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       runtimeChains,
       config.fallback?.enabled !== false,
       config.fallback?.maxRetries ?? 3,
+      sessionLifecycle,
+      config.fallback?.runtimeOverride ?? true,
     );
 
     deepworkCommandHook = createDeepworkCommandHook();
@@ -314,12 +310,72 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
       readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
       isFallbackInProgress: (sessionID) =>
         foregroundFallback.isFallbackInProgress(sessionID),
+      coordinator: sessionLifecycle,
     });
+
+    // Initialize hooks and wrapPostToolHook helper for error isolation
+
+    // Wrap tool.execute.after handlers with per-hook error isolation.
+    // Preserves the old runPostToolHook behavior: one failing hook doesn't
+    // block the rest.
+    const wrapPostToolHook = (
+      name: string,
+      fn: (i: unknown, o: unknown) => Promise<void>,
+    ): ((i: unknown, o: unknown) => Promise<void>) => {
+      return async (i, o) => {
+        try {
+          await fn(i, o);
+        } catch (error) {
+          const meta = i as {
+            tool?: string;
+            sessionID?: string;
+            callID?: string;
+          };
+          log('[plugin] post-tool hook failed open', {
+            hook: name,
+            tool: meta.tool,
+            sessionID: meta.sessionID,
+            callID: meta.callID,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
+      };
+    };
+
+    phaseReminder = createPhaseReminderHook(sessionLifecycle);
+
+    filterAvailableSkills = createFilterAvailableSkillsHook(ctx, config);
+
+    postFileToolNudge = createPostFileToolNudgeHook({
+      shouldInject: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+      coordinator: sessionLifecycle,
+    });
+
+    delegateTaskRetry = createDelegateTaskRetryHook(ctx);
+
+    applyPatch = createApplyPatchHook(ctx);
+
+    jsonErrorRecovery = createJsonErrorRecoveryHook(ctx);
+
+    // Pre-created wrapped handlers for tool.execute.after (error-isolated)
+    postFileToolNudgeAfter = wrapPostToolHook('post-file-tool-nudge', (i, o) =>
+      postFileToolNudge['tool.execute.after'](i as never, o as never),
+    );
+    delegateTaskRetryAfter = wrapPostToolHook('delegate-task-retry', (i, o) =>
+      delegateTaskRetry['tool.execute.after'](i as never, o as never),
+    );
+    jsonErrorRecoveryAfter = wrapPostToolHook('json-error-recovery', (i, o) =>
+      jsonErrorRecovery['tool.execute.after'](i as never, o as never),
+    );
+    taskSessionManagerAfter = wrapPostToolHook('task-session-manager', (i, o) =>
+      taskSessionManagerHook['tool.execute.after'](i as never, o as never),
+    );
     interviewManager = createInterviewManager(ctx, config);
     presetManager = createPresetManager(ctx, config);
     companionManager = new CompanionManager(
@@ -329,7 +385,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     );
     cancelTaskTools = createCancelTaskTool({
       client: ctx.client,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
@@ -846,15 +902,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
-      await postFileToolNudgeHook.event(
-        input as {
-          event: {
-            type: string;
-            properties?: { info?: { id?: string }; sessionID?: string };
-          };
-        },
-      );
-
       if (
         event.type === 'permission.asked' ||
         event.type === 'question.asked'
@@ -886,16 +933,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const props = input.event.properties as
           | { info?: { id?: string }; sessionID?: string }
           | undefined;
-        const sessionID = props?.info?.id ?? props?.sessionID;
-        companionManager.onSessionDeleted(sessionID);
-      }
-
-      if (input.event.type === 'session.deleted') {
-        const props = input.event.properties as
-          | { info?: { id?: string }; sessionID?: string }
-          | undefined;
-        const sessionID = props?.info?.id ?? props?.sessionID;
+        const sessionID = props?.info?.id || props?.sessionID;
 
+        if (sessionID) {
+          sessionLifecycle.dispatchSessionDeleted(sessionID);
+        }
+        companionManager.onSessionDeleted(sessionID);
         if (depthTracker && sessionID) {
           depthTracker.cleanup(sessionID);
         }
@@ -905,29 +948,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
     },
 
-    // Best-effort rescue only for stale apply_patch input before native
-    // execution
     'tool.execute.before': async (input, output) => {
-      await applyPatchHook['tool.execute.before'](
-        input as {
-          tool: string;
-          directory?: string;
-        },
-        output as {
-          args?: { patchText?: unknown; [key: string]: unknown };
-        },
-      );
-
+      await applyPatch['tool.execute.before'](input as never, output as never);
       await taskSessionManagerHook['tool.execute.before'](
-        input as {
-          tool: string;
-          sessionID?: string;
-          callID?: string;
-        },
-        output as { args?: unknown },
+        input as never,
+        output as never,
       );
-
-      // No-op for divoom
     },
 
     'command.execute.before': async (input, output) => {
@@ -1052,9 +1078,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       // Inject ephemeral post-file-tool-nudge reminder
-      await postFileToolNudgeHook['experimental.chat.system.transform'](
-        input,
-        output,
+      await postFileToolNudge['experimental.chat.system.transform'](
+        input as never,
+        output as never,
       );
 
       // Collapse to single system message for provider compatibility.
@@ -1097,92 +1123,25 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         log,
       });
 
-      await taskSessionManagerHook['experimental.chat.messages.transform'](
-        input,
-        typedOutput,
+      await phaseReminder['experimental.chat.messages.transform'](
+        input as never,
+        typedOutput as never,
       );
-      await phaseReminderHook['experimental.chat.messages.transform'](
-        input,
-        typedOutput,
+      await filterAvailableSkills['experimental.chat.messages.transform'](
+        input as never,
+        typedOutput as never,
       );
-      await filterAvailableSkillsHook['experimental.chat.messages.transform'](
-        input,
-        typedOutput,
+      await taskSessionManagerHook['experimental.chat.messages.transform'](
+        input as never,
+        typedOutput as never,
       );
     },
 
-    // Post-tool hooks: retry guidance for delegation errors + file-tool
-    // nudge
     'tool.execute.after': async (input, output) => {
-      const meta = input as {
-        tool?: string;
-        sessionID?: string;
-        callID?: string;
-      };
-      const runPostToolHook = async (
-        name: string,
-        fn: () => Promise<void>,
-      ): Promise<void> => {
-        try {
-          await fn();
-        } catch (error) {
-          log('[plugin] post-tool hook failed open', {
-            hook: name,
-            tool: meta.tool,
-            sessionID: meta.sessionID,
-            callID: meta.callID,
-            error: error instanceof Error ? error.message : String(error),
-          });
-        }
-      };
-
-      await runPostToolHook('delegate-task-retry', () =>
-        delegateTaskRetryHook['tool.execute.after'](
-          input as { tool: string },
-          output as { output: unknown },
-        ),
-      );
-
-      await runPostToolHook('json-error-recovery', () =>
-        jsonErrorRecoveryHook['tool.execute.after'](
-          input as {
-            tool: string;
-            sessionID: string;
-            callID: string;
-          },
-          output as {
-            title: string;
-            output: unknown;
-            metadata: unknown;
-          },
-        ),
-      );
-
-      await runPostToolHook('post-file-tool-nudge', () =>
-        postFileToolNudgeHook['tool.execute.after'](
-          input as {
-            tool: string;
-            sessionID?: string;
-            callID?: string;
-          },
-          output as {
-            title: string;
-            output: string;
-            metadata: Record<string, unknown>;
-          },
-        ),
-      );
-
-      await runPostToolHook('task-session-manager', () =>
-        taskSessionManagerHook['tool.execute.after'](
-          input as {
-            tool: string;
-            sessionID?: string;
-            callID?: string;
-          },
-          output as { output: unknown },
-        ),
-      );
+      await postFileToolNudgeAfter(input, output);
+      await delegateTaskRetryAfter(input, output);
+      await jsonErrorRecoveryAfter(input, output);
+      await taskSessionManagerAfter(input, output);
     },
   };
 };

+ 13 - 1
src/interview/interview.test.ts

@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises';
 import { createServer } from 'node:http';
 import * as path from 'node:path';
 import { InterviewConfigSchema } from '../config/schema';
+import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils';
 import { createInterviewServer } from './server';
 import {
   createInterviewService as createRealInterviewService,
@@ -130,7 +131,14 @@ describe('interview service', () => {
       const service = createInterviewService(ctx);
       // Set up base URL resolver to avoid server error
       service.setBaseUrlResolver(async () => 'http://localhost:9999');
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      const output = {
+        parts: [] as Array<{
+          type: string;
+          text?: string;
+          synthetic?: boolean;
+          metadata?: Record<string, unknown>;
+        }>,
+      };
 
       await service.handleCommandExecuteBefore(
         {
@@ -146,6 +154,10 @@ describe('interview service', () => {
       expect(output.parts[0].type).toBe('text');
       expect(output.parts[0].text).toContain('My App Idea');
       expect(output.parts[0].text).toContain('<interview_state>');
+      expect(output.parts[0]).toMatchObject({
+        synthetic: true,
+        metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
+      });
 
       // Should send UI notification prompt to session
       expect(ctx.client.session.prompt).toHaveBeenCalled();

+ 10 - 5
src/interview/service.ts

@@ -5,7 +5,7 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import type { InterviewConfig } from '../config';
 import {
   createInternalAgentTextPart,
-  hasInternalInitiatorMarker,
+  isInternalInitiatorPart,
   log,
 } from '../utils';
 import { parseModelReference } from '../utils/session';
@@ -135,7 +135,14 @@ export function createInterviewService(
   registerCommand: (config: Record<string, unknown>) => void;
   handleCommandExecuteBefore: (
     input: { command: string; sessionID: string; arguments: string },
-    output: { parts: Array<{ type: string; text?: string }> },
+    output: {
+      parts: Array<{
+        type: string;
+        text?: string;
+        synthetic?: boolean;
+        metadata?: Record<string, unknown>;
+      }>;
+    },
   ) => Promise<void>;
   handleEvent: (input: {
     event: { type: string; properties?: Record<string, unknown> };
@@ -287,9 +294,7 @@ export function createInterviewService(
   }
 
   function isUserVisibleMessage(message: InterviewMessage): boolean {
-    return !(message.parts ?? []).some((part) =>
-      hasInternalInitiatorMarker(part),
-    );
+    return !(message.parts ?? []).some((part) => isInternalInitiatorPart(part));
   }
 
   function getInterviewById(interviewId: string): InterviewRecord | null {

+ 18 - 46
src/multiplexer/herdr/index.ts

@@ -15,7 +15,12 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+  normalizePathForShell,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 type HerdrPaneDirection = 'right' | 'down';
@@ -70,6 +75,10 @@ export class HerdrMultiplexer implements Multiplexer {
     }
 
     try {
+      // Normalize Windows backslashes→/ so sh -lc (MSYS2) doesn't
+      // corrupt --cwd (issue #568).
+      const attachDir = normalizePathForShell(directory);
+
       // 1. Split the parent pane to create a new one
       const splitArgs = [
         herdr,
@@ -79,7 +88,7 @@ export class HerdrMultiplexer implements Multiplexer {
         '--direction',
         this.paneDirection,
         '--cwd',
-        directory,
+        attachDir,
         '--no-focus',
       ];
 
@@ -121,7 +130,7 @@ export class HerdrMultiplexer implements Multiplexer {
       const opencodeCmd = buildOpencodeAttachCommand(
         sessionId,
         serverUrl,
-        directory,
+        attachDir,
       );
 
       log('[herdr] spawnPane: running attach command', {
@@ -153,50 +162,13 @@ export class HerdrMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const herdr = await this.getBinary();
-    if (!herdr) {
-      log('[herdr] closePane: herdr binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[herdr] closePane: sending Ctrl+C', { paneId });
-      await crossSpawn([herdr, 'pane', 'send-keys', paneId, 'ctrl+c'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      log('[herdr] closePane: closing pane', { paneId });
-      const proc = crossSpawn([herdr, 'pane', 'close', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[herdr] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0 || exitCode === 1) {
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[herdr] closePane: failed (pane may already be closed)', {
-        paneId,
-      });
-      return false;
-    } catch (err) {
-      log('[herdr] closePane: exception', { error: String(err) });
-      return false;
-    }
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(

+ 41 - 29
src/multiplexer/session-manager.test.ts

@@ -1,5 +1,6 @@
 import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { BackgroundJobBoard } from '../utils/background-job-board';
+import { BackgroundJobCoordinator } from '../utils/background-job-coordinator';
 import {
   MultiplexerSessionManager,
   resetMultiplexerSessionManagerState,
@@ -413,6 +414,7 @@ describe('MultiplexerSessionManager', () => {
     test('timed out running jobs still close after safe recovery and completion', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'timedout-child',
         parentSessionID: 'parent-1',
@@ -431,10 +433,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -483,6 +485,7 @@ describe('MultiplexerSessionManager', () => {
         mockMultiplexer.closePane.mockClear();
         const ctx = createMockContext();
         const board = new BackgroundJobBoard();
+        const coordinator = new BackgroundJobCoordinator(board);
         const sessionId = `deferred-${state}`;
         board.registerLaunch({
           taskID: sessionId,
@@ -496,10 +499,10 @@ describe('MultiplexerSessionManager', () => {
         const manager = new MultiplexerSessionManager(
           ctx,
           defaultMultiplexerConfig,
-          board,
+          coordinator,
         );
-        board.setTerminalStateListener((taskID) => {
-          void manager.retryDeferredIdleClose(taskID);
+        coordinator.addTerminalStateListener((sessionId) => {
+          void manager.closeSessionFromCoordinator(sessionId);
         });
 
         await manager.onSessionCreated({
@@ -522,6 +525,7 @@ describe('MultiplexerSessionManager', () => {
     test('deferred idle close retries on markCancelled', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'deferred-cancel',
         parentSessionID: 'parent-1',
@@ -534,10 +538,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -562,6 +566,7 @@ describe('MultiplexerSessionManager', () => {
     test('terminal status without deferred idle close does not close pane', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'terminal-without-defer',
         parentSessionID: 'parent-1',
@@ -574,10 +579,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -598,6 +603,7 @@ describe('MultiplexerSessionManager', () => {
     test('deleted clears deferred idle close and later terminal update is no-op', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'deleted-deferred',
         parentSessionID: 'parent-1',
@@ -610,10 +616,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -640,6 +646,7 @@ describe('MultiplexerSessionManager', () => {
     test('retry while still running keeps deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'still-running-deferred',
         parentSessionID: 'parent-1',
@@ -652,10 +659,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -672,7 +679,8 @@ describe('MultiplexerSessionManager', () => {
         },
       });
 
-      await manager.retryDeferredIdleClose('still-running-deferred');
+      // The coordinator's terminal state listener will handle the close
+      // when the job completes, so we don't need to call retryDeferredIdleClose directly
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
 
       board.updateStatus({
@@ -688,6 +696,7 @@ describe('MultiplexerSessionManager', () => {
     test('disabled manager does not retry deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'disabled-retry-deferred',
         parentSessionID: 'parent-1',
@@ -700,7 +709,7 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
 
       await manager.onSessionCreated({
@@ -718,19 +727,21 @@ describe('MultiplexerSessionManager', () => {
       });
 
       mockMultiplexer.isInsideSession.mockReturnValue(false);
-      const disabledManager = new MultiplexerSessionManager(
+      const _disabledManager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      await disabledManager.retryDeferredIdleClose('disabled-retry-deferred');
 
+      // The coordinator's terminal state listener will handle the close
+      // when the job completes, but the disabled manager should not close
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
 
     test('explicit non-idle status event clears stale deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'retry-event-deferred',
         parentSessionID: 'parent-1',
@@ -743,10 +754,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({
@@ -793,6 +804,7 @@ describe('MultiplexerSessionManager', () => {
     test('explicit non-idle poll clears stale deferred idle close', async () => {
       const ctx = createMockContext();
       const board = new BackgroundJobBoard();
+      const coordinator = new BackgroundJobCoordinator(board);
       board.registerLaunch({
         taskID: 'resumed-deferred',
         parentSessionID: 'parent-1',
@@ -805,10 +817,10 @@ describe('MultiplexerSessionManager', () => {
       const manager = new MultiplexerSessionManager(
         ctx,
         defaultMultiplexerConfig,
-        board,
+        coordinator,
       );
-      board.setTerminalStateListener((taskID) => {
-        void manager.retryDeferredIdleClose(taskID);
+      coordinator.addTerminalStateListener((sessionId) => {
+        void manager.closeSessionFromCoordinator(sessionId);
       });
 
       await manager.onSessionCreated({

+ 28 - 25
src/multiplexer/session-manager.ts

@@ -6,12 +6,15 @@ import {
   isServerRunning,
   type Multiplexer,
 } from '../multiplexer';
-import type {
-  BackgroundJobBoard,
-  BackgroundJobState,
-} from '../utils/background-job-board';
+import type { BackgroundJobState } from '../utils/background-job-board';
+import type { BackgroundJobStore } from '../utils/background-job-store';
 import { log } from '../utils/logger';
 
+type BackgroundJobReader = Pick<
+  BackgroundJobStore,
+  'getState' | 'deferIfRunning' | 'clearDeferredClose'
+>;
+
 interface TrackedSession {
   sessionId: string;
   paneId: string;
@@ -32,7 +35,6 @@ interface SharedSessionState {
   knownSessions: Map<string, KnownSession>;
   spawningSessions: Set<string>;
   closingSessions: Map<string, Promise<void>>;
-  deferredIdleCloses: Set<string>;
 }
 
 interface SessionEvent {
@@ -65,7 +67,6 @@ function getSharedState(): SharedSessionState {
     knownSessions: new Map(),
     spawningSessions: new Set(),
     closingSessions: new Map(),
-    deferredIdleCloses: new Set(),
   };
 
   return globalWithState[SHARED_STATE_KEY];
@@ -77,7 +78,6 @@ export function resetMultiplexerSessionManagerState(): void {
   state.knownSessions.clear();
   state.spawningSessions.clear();
   state.closingSessions.clear();
-  state.deferredIdleCloses.clear();
 }
 
 /**
@@ -95,21 +95,19 @@ export class MultiplexerSessionManager {
   private knownSessions: SharedSessionState['knownSessions'];
   private spawningSessions: SharedSessionState['spawningSessions'];
   private closingSessions: SharedSessionState['closingSessions'];
-  private deferredIdleCloses: SharedSessionState['deferredIdleCloses'];
   private pollInterval?: ReturnType<typeof setInterval>;
   private enabled = false;
 
   constructor(
     ctx: PluginInput,
     config: MultiplexerConfig,
-    private readonly backgroundJobBoard?: BackgroundJobBoard,
+    private readonly backgroundJobBoard?: BackgroundJobReader,
   ) {
     const sharedState = getSharedState();
     this.sessions = sharedState.sessions;
     this.knownSessions = sharedState.knownSessions;
     this.spawningSessions = sharedState.spawningSessions;
     this.closingSessions = sharedState.closingSessions;
-    this.deferredIdleCloses = sharedState.deferredIdleCloses;
 
     this.directory = ctx.directory;
     const defaultPort = process.env.OPENCODE_PORT ?? '4096';
@@ -284,7 +282,7 @@ export class MultiplexerSessionManager {
 
     if (statusType) {
       if (statusType !== 'busy') {
-        this.deferredIdleCloses.delete(sessionId);
+        this.backgroundJobBoard?.clearDeferredClose(sessionId);
         return;
       }
 
@@ -316,7 +314,6 @@ export class MultiplexerSessionManager {
       backgroundJobState: this.backgroundJobState(sessionId),
     });
 
-    this.deferredIdleCloses.delete(sessionId);
     await this.closeSession(sessionId, 'deleted');
   }
 
@@ -368,7 +365,7 @@ export class MultiplexerSessionManager {
         if (!status) continue;
 
         if (status.type !== 'idle') {
-          this.deferredIdleCloses.delete(sessionId);
+          this.backgroundJobBoard?.clearDeferredClose(sessionId);
           continue;
         }
 
@@ -410,10 +407,11 @@ export class MultiplexerSessionManager {
   private async closeSession(
     sessionId: string,
     reason: CloseReason,
+    skipPolicyCheck = false,
   ): Promise<void> {
     if (reason === 'deleted') {
       this.knownSessions.delete(sessionId);
-      this.deferredIdleCloses.delete(sessionId);
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
     }
 
     const existingClose = this.closingSessions.get(sessionId);
@@ -451,8 +449,11 @@ export class MultiplexerSessionManager {
       });
     }
 
-    if (reason === 'idle' && this.isRunningBackgroundJob(sessionId)) {
-      this.deferredIdleCloses.add(sessionId);
+    if (
+      reason === 'idle' &&
+      !skipPolicyCheck &&
+      !this.shouldCloseNow(sessionId)
+    ) {
       log(
         '[multiplexer-session-manager] close skipped; background job running',
         {
@@ -466,7 +467,6 @@ export class MultiplexerSessionManager {
       return;
     }
 
-    this.deferredIdleCloses.delete(sessionId);
     this.sessions.delete(sessionId);
 
     log('[multiplexer-session-manager] closing session pane', {
@@ -580,7 +580,7 @@ export class MultiplexerSessionManager {
         directory: known.directory,
         ownerInstanceId: this.instanceId,
       });
-      this.deferredIdleCloses.delete(sessionId);
+      this.backgroundJobBoard?.clearDeferredClose(sessionId);
 
       log('[multiplexer-session-manager] pane respawned on busy', {
         instanceId: this.instanceId,
@@ -607,7 +607,7 @@ export class MultiplexerSessionManager {
   }
 
   private getSessionId(event: SessionEvent): string | undefined {
-    return event.properties?.info?.id ?? event.properties?.sessionID;
+    return event.properties?.info?.id || event.properties?.sessionID;
   }
 
   private backgroundJobState(
@@ -616,14 +616,16 @@ export class MultiplexerSessionManager {
     return this.backgroundJobBoard?.getState(sessionId);
   }
 
-  private isRunningBackgroundJob(sessionId: string): boolean {
-    return this.backgroundJobBoard?.isRunning(sessionId) ?? false; // ponytail: intent-revealing query
+  private shouldCloseNow(sessionId: string): boolean {
+    return this.backgroundJobBoard?.deferIfRunning(sessionId) ?? true;
   }
 
-  async retryDeferredIdleClose(sessionId: string): Promise<void> {
+  async closeSessionFromCoordinator(sessionId: string): Promise<void> {
     if (!this.enabled) return;
-    if (!this.deferredIdleCloses.has(sessionId)) return;
-    await this.closeSession(sessionId, 'idle');
+    // Coordinator already vetted lifecycle policy; skip re-check
+    // ponytail: theoretical race if new job starts between coordinator's
+    // retryDeferredClose() and this call, but session IDs are unique per launch
+    await this.closeSession(sessionId, 'idle', true);
   }
 
   async cleanup(): Promise<void> {
@@ -653,7 +655,8 @@ export class MultiplexerSessionManager {
     this.knownSessions.clear();
     this.spawningSessions.clear();
     this.closingSessions.clear();
-    this.deferredIdleCloses.clear();
+    // ponytail: deferred state lives in coordinator, not here
+    // Note: coordinator has same lifetime as plugin, so no explicit cleanup needed
 
     log('[multiplexer-session-manager] cleanup complete');
   }

+ 146 - 0
src/multiplexer/shared.test.ts

@@ -0,0 +1,146 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+};
+
+const crossSpawnMock = mock(
+  (_args: string[]): SpawnResult => ({
+    exited: Promise.resolve(0),
+    stdout: () => Promise.resolve(''),
+    stderr: () => Promise.resolve(''),
+  }),
+);
+
+mock.module('../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+async function importShared() {
+  return import(`./shared?test=${importCounter++}`);
+}
+
+describe('gracefulClosePane', () => {
+  afterEach(() => {
+    crossSpawnMock.mockReset();
+  });
+
+  test('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
+    const calls: string[][] = [];
+
+    crossSpawnMock.mockImplementation((args: string[]) => {
+      calls.push(args);
+      return {
+        exited: Promise.resolve(0),
+        stdout: () => Promise.resolve(''),
+        stderr: () => Promise.resolve(''),
+      };
+    });
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+
+    expect(ok).toBe(true);
+    expect(calls).toHaveLength(2);
+  });
+
+  test('returns true when acceptExitCode1 and exit code is 1', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', 'terminal_1', {
+      ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
+      acceptExitCode1: true,
+    });
+    expect(ok).toBe(true);
+  });
+
+  test('returns false on exit 1 when acceptExitCode1 is false', async () => {
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+    }));
+
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('tmux', '%1', {
+      ctrlC: ['send-keys', '-t', '%1', 'C-c'],
+      close: ['kill-pane', '-t', '%1'],
+    });
+    expect(ok).toBe(false);
+  });
+
+  test('returns emptyPaneReturnsTrue when paneId is empty', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane('zellij', '', {
+      ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', ''],
+      emptyPaneReturnsTrue: true,
+    });
+    expect(ok).toBe(true);
+    expect(crossSpawnMock.mock.calls).toHaveLength(0);
+  });
+
+  test('returns false when binary is null', async () => {
+    const { gracefulClosePane } = await importShared();
+    const ok = await gracefulClosePane(null, '%1', {
+      ctrlC: ['x'],
+      close: ['y'],
+    });
+    expect(ok).toBe(false);
+  });
+});
+
+describe('buildOpencodeAttachCommand', () => {
+  test('normalizes Windows backslash paths to forward slashes', async () => {
+    const original = process.platform;
+    Object.defineProperty(process, 'platform', {
+      value: 'win32',
+      configurable: true,
+    });
+    try {
+      const { buildOpencodeAttachCommand } = await importShared();
+      const cmd = buildOpencodeAttachCommand(
+        'sess',
+        'url',
+        'C:\\Users\\foo\\repo',
+      );
+      expect(cmd).toContain('C:/Users/foo/repo');
+    } finally {
+      Object.defineProperty(process, 'platform', {
+        value: original,
+        configurable: true,
+      });
+    }
+  });
+
+  test('leaves non-Windows paths unchanged', async () => {
+    const original = process.platform;
+    Object.defineProperty(process, 'platform', {
+      value: 'linux',
+      configurable: true,
+    });
+    try {
+      const { buildOpencodeAttachCommand } = await importShared();
+      const cmd = buildOpencodeAttachCommand('sess', 'url', '/home/user/repo');
+      expect(cmd).toContain('/home/user/repo');
+    } finally {
+      Object.defineProperty(process, 'platform', {
+        value: original,
+        configurable: true,
+      });
+    }
+  });
+});

+ 55 - 1
src/multiplexer/shared.ts

@@ -12,11 +12,19 @@ export function quoteShellArg(value: string): string {
   return `'${value.replace(/'/g, `'\\''`)}'`;
 }
 
+/** Normalize Windows backslashes to / so sh -lc (MSYS2/Git Bash) doesn't treat them as escape chars. */
+export function normalizePathForShell(directory: string): string {
+  return process.platform === 'win32'
+    ? directory.replace(/\\/g, '/')
+    : directory;
+}
+
 export function buildOpencodeAttachCommand(
   sessionId: string,
   serverUrl: string,
   directory: string,
 ): string {
+  const attachDir = normalizePathForShell(directory);
   return [
     'opencode',
     'attach',
@@ -24,7 +32,7 @@ export function buildOpencodeAttachCommand(
     '--session',
     quoteShellArg(sessionId),
     '--dir',
-    quoteShellArg(directory),
+    quoteShellArg(attachDir),
   ].join(' ');
 }
 
@@ -89,3 +97,49 @@ export async function findBinary(
     return null;
   }
 }
+
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}

+ 11 - 48
src/multiplexer/tmux/index.ts

@@ -5,7 +5,11 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
-import { buildOpencodeAttachCommand, findBinary } from '../shared';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 const TMUX_LAYOUT_DEBOUNCE_MS = 150;
@@ -113,54 +117,13 @@ export class TmuxMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId) {
-      log('[tmux] closePane: no paneId provided');
-      return false;
-    }
-
     const tmux = await this.getBinary();
-    if (!tmux) {
-      log('[tmux] closePane: tmux binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[tmux] closePane: sending Ctrl+C', { paneId });
-      const ctrlCProc = crossSpawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      await ctrlCProc.exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Kill the pane
-      log('[tmux] closePane: killing pane', { paneId });
-      const proc = crossSpawn([tmux, 'kill-pane', '-t', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0) {
-        // Rebalance panes after bursts of child sessions settle.
-        this.scheduleLayout();
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[tmux] closePane: failed (pane may already be closed)', { paneId });
-      return false;
-    } catch (err) {
-      log('[tmux] closePane: exception', { error: String(err) });
-      return false;
-    }
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
   }
 
   async applyLayout(

+ 7 - 27
src/multiplexer/zellij/index.ts

@@ -17,6 +17,7 @@ import { crossSpawn } from '../../utils/compat';
 import {
   buildOpencodeAttachCommand,
   findBinary,
+  gracefulClosePane,
   quoteShellArg,
 } from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
@@ -494,34 +495,13 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const zellij = await this.getBinary();
-    if (!zellij) return false;
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      await crossSpawn(
-        [zellij, 'action', 'write', '--pane-id', paneId, '\u0003'],
-        {
-          stdout: 'ignore',
-          stderr: 'ignore',
-        },
-      ).exited;
-
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      const proc = crossSpawn(
-        [zellij, 'action', 'close-pane', '--pane-id', paneId],
-        { stdout: 'pipe', stderr: 'pipe' },
-      );
-
-      const exitCode = await proc.exited;
-      return exitCode === 0 || exitCode === 1;
-    } catch {
-      return false;
-    }
+    return gracefulClosePane(zellij, paneId, {
+      ctrlC: ['action', 'write', '--pane-id', paneId, '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(

+ 2 - 2
src/tools/cancel-task.ts

@@ -3,7 +3,7 @@ import {
   type ToolDefinition,
   tool,
 } from '@opencode-ai/plugin';
-import type { BackgroundJobBoard } from '../utils/background-job-board';
+import type { BackgroundJobStore } from '../utils/background-job-store';
 import { isRecord as isObjectRecord } from '../utils/guards';
 import { log } from '../utils/logger';
 import { abortSessionWithTimeout, withTimeout } from '../utils/session';
@@ -12,7 +12,7 @@ const z = tool.schema;
 
 interface CancelTaskToolOptions {
   client: PluginInput['client'];
-  backgroundJobBoard: BackgroundJobBoard;
+  backgroundJobBoard: BackgroundJobStore;
   shouldManageSession: (sessionID: string) => boolean;
   abortTimeoutMs?: number;
   verifyAbortMs?: number;

+ 4 - 1
src/tui.ts

@@ -177,7 +177,10 @@ function renderSidebar(
         [
           box(
             { paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent },
-            [text({ fg: theme.background }, ['OMO-Slim'])],
+            // Use theme.text, not theme.background: when the theme background is
+            // "none" (transparent) the foreground becomes RGBA(0,0,0,0) and the
+            // badge text vanishes. See #582.
+            [text({ fg: theme.text }, ['OMO-Slim'])],
           ),
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],

+ 16 - 1
src/utils/background-job-board.ts

@@ -1,4 +1,5 @@
 import { formatSystemReminder } from '../config/constants';
+import type { BackgroundJobStore } from './background-job-store';
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
 export interface ContextFile {
@@ -81,7 +82,7 @@ const AGENT_PREFIX: Record<string, string> = {
   oracle: 'ora',
 };
 
-export class BackgroundJobBoard {
+export class BackgroundJobBoard implements BackgroundJobStore {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
   private terminalStateListeners: TerminalStateListener[] = [];
@@ -517,6 +518,20 @@ export class BackgroundJobBoard {
     this.jobs.delete(taskID);
   }
 
+  // ── Lifecycle policy (board = no policy, always close) ───────────
+
+  deferIfRunning(_sessionId: string): boolean {
+    return false; // ponytail: safe default - don't close
+  }
+
+  retryDeferredClose(_sessionId: string): boolean {
+    return false; // Nothing deferred at board level
+  }
+
+  clearDeferredClose(_sessionId: string): void {
+    // No-op at board level
+  }
+
   private trimReusable(taskID: string): void {
     const job = this.jobs.get(taskID);
     if (!job || !isReusable(job)) return;

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

@@ -0,0 +1,122 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from './background-job-board';
+import { BackgroundJobCoordinator } from './background-job-coordinator';
+
+function createMockBoard(isRunning = false) {
+  return {
+    isRunning: mock(() => isRunning),
+    getState: mock(() => (isRunning ? 'running' : 'completed')),
+    addTerminalStateListener: mock(() => {}),
+    removeTerminalStateListener: mock(() => {}),
+  } as any;
+}
+
+describe('BackgroundJobCoordinator', () => {
+  test('deferIfRunning returns false when job is running', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+    expect(coordinator.deferIfRunning('ses_123')).toBe(false);
+  });
+
+  test('deferIfRunning returns true when job is not running', () => {
+    const board = createMockBoard(false);
+    const coordinator = new BackgroundJobCoordinator(board);
+    expect(coordinator.deferIfRunning('ses_123')).toBe(true);
+  });
+
+  test('retryDeferredClose returns false when not in deferred set', () => {
+    const board = createMockBoard(false);
+    const coordinator = new BackgroundJobCoordinator(board);
+    expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
+  });
+
+  test('retryDeferredClose returns true after job completes', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+
+    // First call defers (job running)
+    expect(coordinator.deferIfRunning('ses_123')).toBe(false);
+
+    // Now simulate job completion
+    board.isRunning.mockReturnValue(false);
+    expect(coordinator.retryDeferredClose('ses_123')).toBe(true);
+  });
+
+  test('clearDeferredClose removes from deferred set', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+
+    coordinator.deferIfRunning('ses_123');
+    coordinator.clearDeferredClose('ses_123');
+
+    // Now retryDeferredClose should return false (not in set)
+    board.isRunning.mockReturnValue(false);
+    expect(coordinator.retryDeferredClose('ses_123')).toBe(false);
+  });
+
+  test('handleTerminalState notifies listeners when retryDeferredClose returns true', () => {
+    const board = createMockBoard(true);
+    const coordinator = new BackgroundJobCoordinator(board);
+    const listener = mock(() => {});
+
+    coordinator.addTerminalStateListener(listener);
+
+    // Defer the session
+    coordinator.deferIfRunning('ses_123');
+
+    // Simulate terminal state notification from board
+    board.getState.mockReturnValue('completed');
+    board.isRunning.mockReturnValue(false);
+
+    // Trigger handleTerminalState via board's listener callback
+    const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
+    boardListener?.('ses_123');
+
+    expect(listener).toHaveBeenCalledWith('ses_123');
+  });
+
+  test('handleTerminalState does not notify when not in deferred set', () => {
+    const board = createMockBoard(false);
+    const coordinator = new BackgroundJobCoordinator(board);
+    const listener = mock(() => {});
+
+    coordinator.addTerminalStateListener(listener);
+
+    // Simulate terminal state notification without deferring first
+    board.getState.mockReturnValue('completed');
+    const boardListener = board.addTerminalStateListener.mock.calls[0]?.[0];
+    boardListener?.('ses_123');
+
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  test('full chain: board terminal → coordinator → listener for deferred job', () => {
+    const board = new BackgroundJobBoard();
+    const coordinator = new BackgroundJobCoordinator(board);
+    const listener = mock(() => {});
+    coordinator.addTerminalStateListener(listener);
+
+    // Register and start a job
+    board.registerLaunch({
+      taskID: 'full-chain-test',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+    });
+    board.updateStatus({
+      taskID: 'full-chain-test',
+      state: 'running',
+    });
+
+    // Defer close while job is running
+    expect(coordinator.deferIfRunning('full-chain-test')).toBe(false);
+
+    // Transition to completed — board fires listener, coordinator re-checks
+    board.updateStatus({
+      taskID: 'full-chain-test',
+      state: 'completed',
+    });
+
+    expect(listener).toHaveBeenCalledWith('full-chain-test');
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+});

+ 238 - 0
src/utils/background-job-coordinator.ts

@@ -0,0 +1,238 @@
+import type {
+  BackgroundJobBoard,
+  BackgroundJobLaunchInput,
+  BackgroundJobRecord,
+  BackgroundJobStatusInput,
+  ContextFile,
+} from './background-job-board';
+import type { BackgroundJobStore } from './background-job-store';
+import type { TaskOutputState } from './task';
+
+type TerminalStateListener = (taskID: string) => void;
+
+/**
+ * BackgroundJobCoordinator owns the lifecycle policy for background jobs.
+ * It sits between the board and its consumers, providing:
+ * - Subscription interface for terminal state notifications (replaces fire-and-forget)
+ * - Lifecycle policy: determines when jobs are terminal, when closes should be deferred
+ * - Single-writer contract: coordinator is the sole writer to the board
+ *
+ * The board's guards prevent silent overwrites. The coordinator adds:
+ * - Centralized notification with guaranteed delivery
+ * - Re-checks board state before notifying (handles races)
+ */
+export class BackgroundJobCoordinator implements BackgroundJobStore {
+  private terminalStateListeners: TerminalStateListener[] = [];
+  // Stores session IDs (which equal task IDs) awaiting close after background job completes
+  private readonly deferredIdleCloses = new Set<string>();
+
+  constructor(private readonly board: BackgroundJobBoard) {
+    // Subscribe to the board's terminal state notifications
+    this.board.addTerminalStateListener((taskID) => {
+      this.handleTerminalState(taskID);
+    });
+  }
+
+  // ── Terminal state notification (guaranteed delivery) ─────────────
+
+  addTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners.push(listener);
+  }
+
+  removeTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners = this.terminalStateListeners.filter(
+      (entry) => entry !== listener,
+    );
+  }
+
+  /**
+   * Handle terminal state from board. Re-checks board state to handle races.
+   * This is the centralized lifecycle policy.
+   */
+  private handleTerminalState(taskID: string): void {
+    // Re-check board state to handle races
+    const state = this.board.getState(taskID);
+    if (state === undefined) return; // Job was already cleaned up
+
+    // Check if this session should now close
+    if (this.retryDeferredClose(taskID)) {
+      // Notify listeners that session should close
+      for (const listener of this.terminalStateListeners) {
+        listener(taskID);
+      }
+    }
+  }
+
+  // ── Lifecycle policy ─────────────────────────────────────────────
+
+  /**
+   * Evaluate close policy. Returns true if session should close now.
+   * Mutates deferred state: adds to deferred set if running, removes if not.
+   */
+  deferIfRunning(sessionId: string): boolean {
+    if (!this.board.isRunning(sessionId)) {
+      this.deferredIdleCloses.delete(sessionId);
+      return true;
+    }
+    this.deferredIdleCloses.add(sessionId);
+    return false;
+  }
+
+  /**
+   * Retry closing a deferred session. Called when a background job completes.
+   * Returns true if the session should now close.
+   */
+  retryDeferredClose(sessionId: string): boolean {
+    if (!this.deferredIdleCloses.has(sessionId)) return false;
+    return this.deferIfRunning(sessionId);
+  }
+
+  /**
+   * Clear deferred close state for a session being deleted.
+   */
+  clearDeferredClose(sessionId: string): void {
+    this.deferredIdleCloses.delete(sessionId);
+  }
+
+  // ── Mutation methods (sole writer to board) ──────────────────────
+
+  registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
+    return this.board.registerLaunch(input);
+  }
+
+  updateStatus(
+    input: BackgroundJobStatusInput,
+  ): BackgroundJobRecord | undefined {
+    return this.board.updateStatus(input);
+  }
+
+  updateFromStatusOutput(output: string): BackgroundJobRecord | undefined {
+    return this.board.updateFromStatusOutput(output);
+  }
+
+  markRunningFromLiveSession(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markRunningFromLiveSession(taskID, now);
+  }
+
+  markReconciled(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    return this.board.markReconciled(taskID, now);
+  }
+
+  markCancelled(
+    taskID: string,
+    reason?: string,
+    now = Date.now(),
+    options: { force?: boolean } = {},
+  ): BackgroundJobRecord | undefined {
+    return this.board.markCancelled(taskID, reason, now, options);
+  }
+
+  // ── Query methods ────────────────────────────────────────────────
+
+  get(taskID: string): BackgroundJobRecord | undefined {
+    return this.board.get(taskID);
+  }
+
+  field<K extends keyof BackgroundJobRecord>(
+    taskID: string,
+    key: K,
+  ): BackgroundJobRecord[K] | undefined {
+    return this.board.field(taskID, key);
+  }
+
+  isRunning(taskID: string): boolean {
+    return this.board.isRunning(taskID);
+  }
+
+  isTerminalUnreconciled(taskID: string): boolean {
+    return this.board.isTerminalUnreconciled(taskID);
+  }
+
+  getResultSummary(taskID: string): string | undefined {
+    return this.board.getResultSummary(taskID);
+  }
+
+  getLastLiveBusyAt(taskID: string): number | undefined {
+    return this.board.getLastLiveBusyAt(taskID);
+  }
+
+  getParentSessionID(taskID: string): string | undefined {
+    return this.board.getParentSessionID(taskID);
+  }
+
+  getState(taskID: string): TaskOutputState | 'reconciled' | undefined {
+    return this.board.getState(taskID);
+  }
+
+  resolve(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolve(parentSessionID, taskIDOrAlias);
+  }
+
+  resolveReusable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolveReusable(parentSessionID, taskIDOrAlias, agent);
+  }
+
+  resolveRecoverable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined {
+    return this.board.resolveRecoverable(parentSessionID, taskIDOrAlias, agent);
+  }
+
+  markUsed(parentSessionID: string, key: string, now = Date.now()): void {
+    this.board.markUsed(parentSessionID, key, now);
+  }
+
+  taskIDs(): Set<string> {
+    return this.board.taskIDs();
+  }
+
+  addContext(taskID: string, files: ContextFile[]): void {
+    this.board.addContext(taskID, files);
+  }
+
+  list(parentSessionID?: string): BackgroundJobRecord[] {
+    return this.board.list(parentSessionID);
+  }
+
+  hasRunning(parentSessionID: string): boolean {
+    return this.board.hasRunning(parentSessionID);
+  }
+
+  hasTerminalUnreconciled(parentSessionID: string): boolean {
+    return this.board.hasTerminalUnreconciled(parentSessionID);
+  }
+
+  hasConvergenceSignals(taskID: string, threshold = 3): boolean {
+    return this.board.hasConvergenceSignals(taskID, threshold);
+  }
+
+  formatForPrompt(
+    parentSessionID: string,
+    now = Date.now(),
+  ): string | undefined {
+    return this.board.formatForPrompt(parentSessionID, now);
+  }
+
+  clearParent(parentSessionID: string): void {
+    this.board.clearParent(parentSessionID);
+  }
+
+  drop(taskID: string): void {
+    this.board.drop(taskID);
+  }
+}

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

@@ -0,0 +1,79 @@
+import type {
+  BackgroundJobLaunchInput,
+  BackgroundJobRecord,
+  BackgroundJobStatusInput,
+  ContextFile,
+} from './background-job-board';
+import type { TaskOutputState } from './task';
+
+/**
+ * Unified interface for background job operations.
+ * Both BackgroundJobBoard and BackgroundJobCoordinator satisfy this.
+ *
+ * ponytail: single interface, both board and coordinator implement it.
+ */
+export interface BackgroundJobStore {
+  // ── Mutation methods ──────────────────────────────────────────────
+  registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord;
+  updateStatus(
+    input: BackgroundJobStatusInput,
+  ): BackgroundJobRecord | undefined;
+  updateFromStatusOutput(output: string): BackgroundJobRecord | undefined;
+  markRunningFromLiveSession(
+    taskID: string,
+    now?: number,
+  ): BackgroundJobRecord | undefined;
+  markReconciled(taskID: string, now?: number): BackgroundJobRecord | undefined;
+  markCancelled(
+    taskID: string,
+    reason?: string,
+    now?: number,
+    options?: { force?: boolean },
+  ): BackgroundJobRecord | undefined;
+  clearParent(parentSessionID: string): void;
+  drop(taskID: string): void;
+  addContext(taskID: string, files: ContextFile[]): void;
+  markUsed(parentSessionID: string, key: string, now?: number): void;
+
+  // ── Query methods ─────────────────────────────────────────────────
+  get(taskID: string): BackgroundJobRecord | undefined;
+  field<K extends keyof BackgroundJobRecord>(
+    taskID: string,
+    key: K,
+  ): BackgroundJobRecord[K] | undefined;
+  isRunning(taskID: string): boolean;
+  isTerminalUnreconciled(taskID: string): boolean;
+  getResultSummary(taskID: string): string | undefined;
+  getLastLiveBusyAt(taskID: string): number | undefined;
+  getParentSessionID(taskID: string): string | undefined;
+  getState(taskID: string): TaskOutputState | 'reconciled' | undefined;
+  resolve(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+  ): BackgroundJobRecord | undefined;
+  resolveReusable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined;
+  resolveRecoverable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined;
+  taskIDs(): Set<string>;
+  list(parentSessionID?: string): BackgroundJobRecord[];
+  hasRunning(parentSessionID: string): boolean;
+  hasTerminalUnreconciled(parentSessionID: string): boolean;
+  hasConvergenceSignals(taskID: string, threshold?: number): boolean;
+  formatForPrompt(parentSessionID: string, now?: number): string | undefined;
+
+  // ── Lifecycle policy ─────────────────────────────────────────────
+  /** Evaluate close policy. Returns true if session should close now.
+   *  Mutates deferred state: adds to deferred set if running, removes if not. */
+  deferIfRunning(sessionId: string): boolean;
+  /** Retry closing a deferred session. Returns true if session should now close. */
+  retryDeferredClose(sessionId: string): boolean;
+  /** Clear deferred close state for a session being deleted. */
+  clearDeferredClose(sessionId: string): void;
+}

+ 2 - 0
src/utils/index.ts

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

+ 23 - 19
src/utils/internal-initiator.test.ts

@@ -2,41 +2,45 @@ import { describe, expect, test } from 'bun:test';
 
 import {
   createInternalAgentTextPart,
-  hasInternalInitiatorMarker,
-  isBrandedInternalInitiatorPart,
+  INTERNAL_INITIATOR_METADATA_KEY,
+  isInternalInitiatorPart,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from './internal-initiator';
 
 describe('internal initiator markers', () => {
-  test('detects persisted marker text for reloaded client messages', () => {
-    expect(
-      hasInternalInitiatorMarker({
-        type: 'text',
-        text: `internal\n${SLIM_INTERNAL_INITIATOR_MARKER}`,
-      }),
-    ).toBe(true);
-  });
-
-  test('brands newly created internal parts for in-memory provenance checks', () => {
+  test('creates synthetic parts with persisted provenance metadata', () => {
     const part = createInternalAgentTextPart('internal');
 
-    expect(hasInternalInitiatorMarker(part)).toBe(true);
-    expect(isBrandedInternalInitiatorPart(part)).toBe(true);
+    expect(part.synthetic).toBe(true);
+    expect(part.metadata[INTERNAL_INITIATOR_METADATA_KEY]).toBe(true);
+    expect(isInternalInitiatorPart(part)).toBe(true);
   });
 
-  test('preserves branded provenance through object spread normalization', () => {
-    const normalized = { ...createInternalAgentTextPart('internal') };
+  test('preserves provenance through JSON persistence', () => {
+    const persisted = JSON.parse(
+      JSON.stringify(createInternalAgentTextPart('internal')),
+    );
 
-    expect(isBrandedInternalInitiatorPart(normalized)).toBe(true);
+    expect(isInternalInitiatorPart(persisted)).toBe(true);
   });
 
-  test('does not trust user-shaped marker text as branded provenance', () => {
+  test('does not trust marker text as provenance', () => {
     expect(
-      isBrandedInternalInitiatorPart({
+      isInternalInitiatorPart({
         type: 'text',
         synthetic: true,
         text: `spoof\n${SLIM_INTERNAL_INITIATOR_MARKER}`,
       }),
     ).toBe(false);
   });
+
+  test('requires synthetic true alongside metadata', () => {
+    expect(
+      isInternalInitiatorPart({
+        type: 'text',
+        text: 'spoof',
+        metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
+      }),
+    ).toBe(false);
+  });
 });

+ 8 - 20
src/utils/internal-initiator.ts

@@ -3,43 +3,31 @@ import { isRecord } from './guards';
 export const SLIM_INTERNAL_INITIATOR_MARKER =
   '<!-- SLIM_INTERNAL_INITIATOR -->';
 
-const INTERNAL_INITIATOR_BRAND = Symbol('slim.internalInitiatorPart');
+export const INTERNAL_INITIATOR_METADATA_KEY =
+  'oh-my-opencode-slim.internalInitiator';
 
 export function createInternalAgentTextPart(text: string): {
   type: 'text';
   text: string;
   synthetic: true;
+  metadata: { 'oh-my-opencode-slim.internalInitiator': true };
 } {
-  const part = {
+  return {
     type: 'text',
     synthetic: true,
     text: `${text}\n${SLIM_INTERNAL_INITIATOR_MARKER}`,
+    metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
   } as const;
-  Object.defineProperty(part, INTERNAL_INITIATOR_BRAND, {
-    enumerable: true,
-    value: true,
-  });
-  return part;
 }
 
-export function hasInternalInitiatorMarker(part: unknown): boolean {
+export function isInternalInitiatorPart(part: unknown): boolean {
   if (!isRecord(part) || part.type !== 'text') {
     return false;
   }
 
-  if (typeof part.text !== 'string') {
+  if (part.synthetic !== true || !isRecord(part.metadata)) {
     return false;
   }
 
-  return part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER);
-}
-
-export function isBrandedInternalInitiatorPart(part: unknown): boolean {
-  if (!isRecord(part) || part.type !== 'text') {
-    return false;
-  }
-
-  return (
-    (part as Record<PropertyKey, unknown>)[INTERNAL_INITIATOR_BRAND] === true
-  );
+  return part.metadata[INTERNAL_INITIATOR_METADATA_KEY] === true;
 }