瀏覽代碼

Merge pull request #683 from mhenke/feature/background-job-coordinator

feat(multiplexer): move lifecycle policy from multiplexer to coordinator
Alvin 1 月之前
父節點
當前提交
73f83c4bb0

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

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

+ 2 - 1
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,
@@ -82,7 +83,7 @@ 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

+ 11 - 5
src/index.ts

@@ -53,6 +53,7 @@ import {
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   BackgroundJobBoard,
+  BackgroundJobCoordinator,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -256,15 +257,20 @@ 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);
     });
 
     // Initialize auto-update checker hook
@@ -314,7 +320,7 @@ 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) =>
@@ -329,7 +335,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     );
     cancelTaskTools = createCancelTaskTool({
       client: ctx.client,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });

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

+ 27 - 24
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,
@@ -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';