Browse Source

merge: resolve conflicts with upstream (SessionLifecycle coordinator)

dragon-Elec 1 month ago
parent
commit
57574f8711

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

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

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

+ 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

+ 23 - 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({

+ 19 - 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'];
@@ -127,7 +128,20 @@ export class ForegroundFallbackManager {
      * that are members of the chain always fall back regardless.
      */
     private readonly runtimeOverride: boolean = true,
-  ) {}
+    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);
+      });
+    }
+  }
 
   /**
    * Process an OpenCode plugin event.
@@ -231,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;
       }

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

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

@@ -7,7 +7,7 @@
  */
 import { PHASE_REMINDER } from '../../config/constants';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
-import { hasPendingSession } from '../post-file-tool-nudge';
+import type { SessionLifecycle } from '../session-lifecycle';
 import { isUserMessageWithParts } from '../types';
 
 export { PHASE_REMINDER };
@@ -17,7 +17,7 @@ export { PHASE_REMINDER };
  * 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 +55,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;
       }
 

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

+ 12 - 18
src/hooks/task-session-manager/index.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, mock, test } from 'bun:test';
+import { SessionLifecycle } from '../../hooks/session-lifecycle';
 import { BackgroundJobBoard } from '../../utils';
 import { createTaskSessionManagerHook } from './index';
 
@@ -9,6 +10,7 @@ function createHook(options?: {
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
   isFallbackInProgress?: (sessionID: string) => boolean;
+  coordinator?: SessionLifecycle;
 }) {
   const hook = createTaskSessionManagerHook(
     {
@@ -27,6 +29,7 @@ function createHook(options?: {
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       isFallbackInProgress: options?.isFallbackInProgress,
+      coordinator: options?.coordinator,
     },
   );
 
@@ -1743,7 +1746,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'](
       {
@@ -1770,12 +1774,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);
@@ -1784,7 +1783,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'](
       {
@@ -1800,12 +1800,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'](
       {
@@ -2028,8 +2023,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' } },
@@ -2041,9 +2037,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') },

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

@@ -2,6 +2,7 @@ import type { PluginInput } from '@opencode-ai/plugin';
 import {
   BackgroundJobBoard,
   type BackgroundJobRecord,
+  type BackgroundJobStore,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
   parseTaskLaunchOutput,
@@ -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,
@@ -82,14 +84,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 +110,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 {
@@ -579,7 +593,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,
@@ -632,7 +646,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
@@ -656,7 +670,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;
@@ -690,34 +704,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,
+      });
     },
   };
 

+ 111 - 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.
@@ -306,6 +300,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       config.fallback?.enabled !== false,
       config.fallback?.maxRetries ?? 3,
       config.fallback?.runtimeOverride ?? true,
+      sessionLifecycle,
     );
 
     deepworkCommandHook = createDeepworkCommandHook();
@@ -315,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(
@@ -330,7 +385,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     );
     cancelTaskTools = createCancelTaskTool({
       client: ctx.client,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
@@ -847,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'
@@ -887,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);
         }
@@ -906,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) => {
@@ -1053,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.
@@ -1098,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);
     },
   };
 };

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

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

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

@@ -1,3 +1,4 @@
+import type { BackgroundJobStore } from './background-job-store';
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
 export interface ContextFile {
@@ -80,7 +81,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[] = [];
@@ -514,6 +515,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';