فهرست منبع

merge: combine deferred reconcile + idle fallback; fix stray brace

Michael Henke 4 هفته پیش
والد
کامیت
291a357006
51فایلهای تغییر یافته به همراه4808 افزوده شده و 2120 حذف شده
  1. 1 0
      .gitignore
  2. 28 0
      .out-of-scope/hashline.md
  3. 30 0
      .out-of-scope/preset-fallback.md
  4. 91 78
      .slim/codemap.json
  5. 91 0
      CONTEXT.md
  6. 26 4
      companion/src/app.rs
  7. 33 0
      docs/agents/triage-labels.md
  8. 9 0
      docs/configuration.md
  9. 43 4
      docs/installation.md
  10. 1 1
      docs/loop-engineering-research.md
  11. 0 715
      docs/superpowers/plans/2026-06-25-loop-engineering-runtime.md
  12. 735 0
      docs/superpowers/plans/2026-07-06-background-job-coordinator.md
  13. 801 0
      docs/superpowers/plans/2026-07-06-hook-registry-session-lifecycle.md
  14. 351 0
      docs/superpowers/plans/2026-07-08-share-closepane-shutdown.md
  15. 0 625
      docs/superpowers/specs/2026-06-25-loop-engineering-runtime.md
  16. 232 0
      docs/superpowers/specs/2026-07-06-hook-registry-session-lifecycle-design.md
  17. 8 1
      oh-my-opencode-slim.schema.json
  18. 5 1
      src/agents/index.test.ts
  19. 18 4
      src/agents/index.ts
  20. 158 1
      src/companion/manager.test.ts
  21. 159 13
      src/companion/manager.ts
  22. 24 1
      src/config/schema.ts
  23. 6 3
      src/hooks/codemap.md
  24. 359 25
      src/hooks/foreground-fallback/index.test.ts
  25. 133 37
      src/hooks/foreground-fallback/index.ts
  26. 1 0
      src/hooks/index.ts
  27. 3 3
      src/hooks/phase-reminder/index.ts
  28. 31 20
      src/hooks/post-file-tool-nudge/index.test.ts
  29. 13 48
      src/hooks/post-file-tool-nudge/index.ts
  30. 53 0
      src/hooks/session-lifecycle.test.ts
  31. 51 0
      src/hooks/session-lifecycle.ts
  32. 215 18
      src/hooks/task-session-manager/index.test.ts
  33. 62 30
      src/hooks/task-session-manager/index.ts
  34. 119 156
      src/index.ts
  35. 6 2
      src/multiplexer/codemap.md
  36. 19 96
      src/multiplexer/herdr/index.ts
  37. 41 29
      src/multiplexer/session-manager.test.ts
  38. 28 25
      src/multiplexer/session-manager.ts
  39. 146 0
      src/multiplexer/shared.test.ts
  40. 145 0
      src/multiplexer/shared.ts
  41. 17 107
      src/multiplexer/tmux/index.ts
  42. 13 63
      src/multiplexer/zellij/index.ts
  43. 2 2
      src/tools/acp-run.ts
  44. 2 2
      src/tools/cancel-task.ts
  45. 30 0
      src/tui.test.ts
  46. 12 5
      src/tui.ts
  47. 16 1
      src/utils/background-job-board.ts
  48. 122 0
      src/utils/background-job-coordinator.test.ts
  49. 238 0
      src/utils/background-job-coordinator.ts
  50. 79 0
      src/utils/background-job-store.ts
  51. 2 0
      src/utils/index.ts

+ 1 - 0
.gitignore

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

+ 28 - 0
.out-of-scope/hashline.md

@@ -0,0 +1,28 @@
+# Hashline (content-hash line anchors for LLM edits)
+
+This project does not implement hashline / content-hash line anchoring for LLM edits.
+
+## Why this is out of scope
+
+Hashline is a technique where each line returned by the `read` tool is prefixed
+with a short content-hash anchor (e.g. `9#KT:  console.log(...)`), and the LLM
+references edits by `LINE#HASH` anchor instead of quoting raw text. The system
+validates the hash before applying an edit, so if the file changed between read
+and edit the hash mismatches and the edit is rejected before it can corrupt
+anything. Hashes are context-based (`xxh32(prev + curr + next)` over a 16-char
+alphabet), so editing line N only invalidates N-1/N/N+1.
+
+Implementing it requires wrapping OpenCode's core `read` and `edit` tools to
+inject and validate anchors and track file snapshots for stale-anchor recovery.
+That is a deep, behavior-changing modification to the fundamental edit loop —
+fragile to bolt onto a slim plugin that intentionally avoids reimplementing tool
+plumbing. It belongs in OpenCode core itself or a dedicated standalone plugin,
+not in oh-my-opencode-slim.
+
+Token savings are real (reported ~61% fewer output tokens on Grok 4 Fast, ~8%
+better on Gemini), but the integration cost and architectural fit put it
+outside this project's scope.
+
+## Prior requests
+
+- #141 — "Discussion about hashline" (feature proposal / discussion; closed as wontfix)

+ 30 - 0
.out-of-scope/preset-fallback.md

@@ -0,0 +1,30 @@
+# Preset Fallback & Preset-Scoped Mode
+
+This project does not support preset-to-preset fallback or preset-scoped mode.
+
+## Why this is out of scope
+
+Model-level fallback already exists in the plugin: when an agent's `model` is
+configured as an array, the entries form a fallback chain resolved at runtime by
+`ForegroundFallbackManager` (abort the failed session, re-prompt with the next
+untried model). Subagents not listed in the active preset also inherit the
+preset's primary model. So the runtime "if my model is unavailable, try another"
+surface is already covered.
+
+What was requested goes further and is a different shape:
+
+- **Preset-to-preset fallback** — a preset declaring it falls back to another
+  preset (e.g. `PresetSchema` gaining a `fallback`/`extends` field). This needs
+  schema changes plus resolution wiring in the preset manager and config hook,
+  and raises questions about which agents/settings the fallback preset supplies.
+- **Preset-scoped mode** — restricting a preset to certain agents, directories,
+  tasks, or conversation modes (a `scope` field on `PresetSchema`). This is a
+  meaningful design surface with no current implementation (zero matches in the
+  codebase) and no agreed semantics.
+
+The maintainer chose not to take on that design/implementation as `wontfix`
+(issue #638). If the need recurs with a concrete design, revisit.
+
+## Prior requests
+
+- #638 — "Support preset fallback and document preset-scoped mode"

+ 91 - 78
.slim/codemap.json

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

+ 91 - 0
CONTEXT.md

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

+ 26 - 4
companion/src/app.rs

@@ -261,9 +261,12 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
 
 fn choose_owned_session(sessions: &[SessionInfo], owner_session_id: Option<&str>) -> Option<usize> {
     if let Some(owner_session_id) = owner_session_id {
-        return sessions
+        if let Some(index) = sessions
             .iter()
-            .position(|session| session.session_id == owner_session_id);
+            .position(|session| session.session_id == owner_session_id)
+        {
+            return Some(index);
+        }
     }
 
     choose_session(sessions)
@@ -856,8 +859,9 @@ fn is_pid_alive(_pid: u32) -> bool {
 #[cfg(test)]
 mod tests {
     use super::{
-        apply_config, choose_session, config_key, grid_dims, place_window, restore_window_position,
-        size_from_config, window_size, ConfigKey, SessionInfo, WindowGeometryKey, GAP,
+        apply_config, choose_owned_session, choose_session, config_key, grid_dims, place_window,
+        restore_window_position, size_from_config, window_size, ConfigKey, SessionInfo,
+        WindowGeometryKey, GAP,
     };
     use crate::state::CompanionConfigState;
 
@@ -909,6 +913,24 @@ mod tests {
         assert_eq!(choose_session(&sessions), Some(1));
     }
 
+    #[test]
+    fn owned_session_wins_when_present() {
+        let sessions = vec![
+            session("first", "waiting-input", &["input"]),
+            session("owner", "idle", &["intro"]),
+        ];
+        assert_eq!(choose_owned_session(&sessions, Some("owner")), Some(1));
+    }
+
+    #[test]
+    fn missing_owner_falls_back_to_active_session() {
+        let sessions = vec![
+            session("idle", "idle", &["intro"]),
+            session("active", "busy", &["fixer"]),
+        ];
+        assert_eq!(choose_owned_session(&sessions, Some("gone")), Some(1));
+    }
+
     #[test]
     fn config_size_defaults_and_presets_work() {
         assert_eq!(size_from_config("small"), 80.0);

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

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

+ 9 - 0
docs/configuration.md

@@ -28,6 +28,15 @@ OH_MY_OPENCODE_SLIM_DISABLE=1 opencode
 
 If OmO-slim detects an invalid plugin config for the current project, the TUI sidebar shows a warning. Run `oh-my-opencode-slim doctor` from your project root for full diagnostics.
 
+The TUI sidebar uses the compact layout by default. Set `compactSidebar` to
+`false` in `oh-my-opencode-slim.jsonc` to use the expanded layout:
+
+```jsonc
+{
+  "compactSidebar": false
+}
+```
+
 ---
 
 ## Prompt Overriding

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

+ 1 - 1
docs/loop-engineering-research.md

@@ -222,7 +222,7 @@ In its purest form, Ralph is a Bash loop. That's it.
 
 ### What's Designed But Not Built
 
-The loop engineering spec (`docs/superpowers/specs/2026-06-25-loop-engineering-runtime.md`) defines:
+The planned loop engineering runtime includes:
 
 - **LoopEngine** class with event-driven orchestration
 - **LoopSession** state machine (executing <-> verifying binary oscillation)

+ 0 - 715
docs/superpowers/plans/2026-06-25-loop-engineering-runtime.md

@@ -1,715 +0,0 @@
-# Loop Engineering - Implementation Plan (Corrected)
-
-## Overview
-
-Runtime-first design: the loop engine is orchestration wiring that composes existing agents (fixer, oracle, council, explorer). The skill is a thin front-end (Grill interview) that feeds into the runtime.
-
-**Guiding principle:**
-> **The runtime owns control flow. The LLM owns strategy.**
-
-The runtime decides: what state comes next, when verification occurs, whether success criteria passed, whether another iteration is allowed, when escalation policies apply. The LLM decides: how to solve the problem, how to adapt after feedback, what implementation strategy to try next.
-
-**Core design principle:**
-> **Verification is the center of loop engineering - not execution.**
-
-Retries, failures, warnings, error counts, timeouts are **escalation signals**, not the loop itself. The loop is `Goal → Execute → Verify → Goal satisfied?` Everything else hangs off that.
-
-**Mechanism vs Policy:**
-The engine implements `verify()`. It should NOT implement `retry twice then escalate`. Instead, policy (maxAttempts, escalation targets, human gates) is externalized. This keeps the runtime generic and extensible.
-
-**Architectural corrections applied:**
-- Task 2 removed - `BackgroundJobState` stays clean (no loop phases pollute job primitives)
-- Event-driven model, not procedural `for` loop
-- Runtime is the constraint - no "signals not constraints" in Layer 1
-- Context compaction - engine synthesizes history before dispatching
-- Verification parsing fixed - JSON schema, not regex
-- BackgroundJobBoard event plumbing - callback array for multiple listeners (multiplexer + LoopEngine)
-- Binary oscillation `executing` ↔ `verifying` - no planning/improving phase
-- Dispatch failure handling - `try/catch` → `escalated` + system error
-- Context injection via `.loop-history-{loopID}.md` file, not job description
-- Fleet mapping - executeAgent/verifyAgent expanded for all specialist roles
-- Oracle retry-wrapper - `oracleRetryCount` persisted in session
-- Council restricted to Layer 0 escalation only
-- Cancellation lifecycle - `cancelled` is quiet terminal state, no `onEscalated`
-- Session cleanup - engine manages `.loop-history-{loopID}.md` only, orchestrator owns artifact lifecycle
-- Convergence signal scope - signals apply to `error` and `timeout` only, NOT `cancelled`
-- `totalErrors` (not `errorCount`) consistently used
-- `SuccessCriterion` as first-class type - engine routes by `success.type`
-- Deferred worktree/memory/trigger from core interfaces - Future Extensions section
-- Artifact lifecycle - engine signals `onArtifactWrite`, orchestrator owns filesystem
-- **Dispatch callback** - engine receives `dispatch(agent, prompt, contextFiles)` from orchestrator, no direct SDK access
-- **Manual verification** - no BackgroundJob created, engine manages waiting state in LoopSession
-- **Automated verification** - test/build/lint/command/fileExists dispatched to test-runner agent, not spawnSync
-- **LoopEngine location** - `src/loop/loop-engine.ts` (not src/council/)
-
-**Phased roadmap:**
-- Phase 1: Runtime loop engine (this PR)
-- Phase 2: Loop skill (Grill + Monitor)
-- Phase 3: Routine integration
-- Phase 4: Triggers (cron, webhooks)
-- Phase 5: Persistent memory (cross-loop)
-
----
-
-## Tasks
-
-### Task 1: Extend BackgroundJobRecord with Convergence Signals
-
-**File:** `src/utils/background-job-board.ts`
-
-Add three fields to `BackgroundJobRecord`:
-- `totalErrors: number` - accumulated errors across all attempts (not incremented on `cancelled`)
-- `timeoutCount: number` - consecutive timeouts, resets to 0 on `completed`
-- `lastErrorAt?: number` - timestamp of last error
-
-**Convergence signal scope:** Signals (`totalErrors`, `timeoutCount`) apply to `error` and `timeout` states only. The `cancelled` state is a quiet terminal state - it does NOT increment error counters. This prevents noisy escalation when users intentionally cancel.
-
-**Signal computation:** Convergence signals are computed from `BackgroundJobRecord` state transitions in `updateStatus()`, not explicit flags:
-- When `input.state === 'error'` → increment `totalErrors`, set `lastErrorAt = Date.now()`
-- When `input.timedOut === true` → increment `timeoutCount`
-- When `input.state === 'completed'` → reset `timeoutCount = 0`
-- `cancelled` state → no increments
-
-This avoids redundant `isError`/`isTimeout` fields on `BackgroundJobStatusInput` - the state machine already conveys this information.
-
-Update:
-- `registerLaunch()` - initialize `totalErrors = 0`, `timeoutCount = 0`
-- `updateStatus()` - compute signals from state transitions as above
-
-### Task 2: Add Convergence Helper Methods to BackgroundJobBoard
-
-**File:** `src/utils/background-job-board.ts`
-
-Add to `BackgroundJobBoard`:
-```typescript
-hasConvergenceSignals(taskID: string, threshold?: number): boolean
-```
-
-This enables the loop engine to detect stuck patterns and escalate. The engine reads `totalErrors` and `timeoutCount` fields directly for detailed checks.
-
-### Task 3: BackgroundJobBoard Event Plumbing
-
-**File:** `src/utils/background-job-board.ts`
-
-The current `setTerminalStateListener()` supports only a single listener. The multiplexer session manager already uses it at `src/index.ts:264`:
-```typescript
-backgroundJobBoard.setTerminalStateListener((taskID) => {
-  void multiplexerSessionManager.retryDeferredIdleClose(taskID);
-});
-```
-
-LoopEngine needs to be a second listener. Replace with callback array:
-
-```typescript
-addTerminalStateListener(listener: (taskID: string) => void): void;
-removeTerminalStateListener(listener: (taskID: string) => void): void;
-private notifyTerminalStateListeners(taskID: string): void;
-```
-
-Update existing callers to use `addTerminalStateListener()`. The multiplexer registration at `src/index.ts` must be updated.
-
-**If already used by other components:**
-Replace `setTerminalStateListener()` with `addTerminalStateListener()` that maintains an array:
-```typescript
-private terminalStateListeners: Array<(taskID: string) => void> = [];
-
-addTerminalStateListener(listener: (taskID: string) => void): void;
-removeTerminalStateListener(listener: (taskID: string) => void): void;
-private notifyTerminalStateListeners(taskID: string): void;
-```
-
-Update existing callers to use `addTerminalStateListener()`.
-
-**If not yet used:**
-Keep `setTerminalStateListener()` as-is. LoopEngine becomes the single subscriber.
-
-### Task 4: Create LoopSession State Machine
-
-**File:** `src/loop/loop-session.ts` (new file)
-
-```typescript
-export type LoopPhase =
-  | 'executing'
-  | 'verifying'
-  | 'done'
-  | 'escalated'
-  | 'cancelled';
-
-// Fleet mapping: executeAgent is dynamically selected based on task domain
-export type ExecuteAgent = 'fixer' | 'designer' | 'explorer' | 'librarian';
-// Fleet mapping: verifyAgent is dynamically selected based on task domain
-// Note: 'council' is NOT a verifyAgent inside the loop - it is Layer 0 escalation only
-export type VerifyAgent = 'oracle' | 'observer' | 'test';
-
-// Success criteria - first-class runtime type
-// The runtime evaluates these directly where possible. Only subjective criteria go to Oracle.
-export type SuccessCriterion =
-  | { type: 'test'; command: string }                         // exit code 0 = pass
-  | { type: 'build'; command: string }                        // exit code 0 = pass
-  | { type: 'lint'; command: string }                         // exit code 0 = pass
-  | { type: 'fileExists'; path: string }                      // file exists = pass
-  | { type: 'command'; command: string; expectExitCode?: number }  // customizable
-  | { type: 'oracle' }                                        // Oracle returns structured JSON (subjective)
-  | { type: 'observer' };                                     // Observer reads visual artifacts (subjective)
-  | { type: 'manual' };                                       // human reviews and decides
-
-// MVP only implements: 'test', 'oracle', 'observer', 'manual'
-// Others deferred.
-
-export interface LoopDefinition {
-  goal: string;
-  successCriteria: string;         // human-readable description (used by oracle/observer)
-  success: SuccessCriterion;       // machine-evaluable success criterion
-  maxAttempts: number;
-  executeAgent: ExecuteAgent;
-  verifyAgent: VerifyAgent;
-  contextFiles?: string[];
-}
-
-// Deferred interfaces (NOT in LoopDefinition - added later via extension)
-// See "Future Extensions" section below for: LoopTrigger, LoopWorktreeConfig, LoopMemoryConfig
-
-export interface AttemptRecord {
-  attemptNumber: number;
-  executionResult: string;
-  verificationResult: VerificationResult;
-  artifactPaths?: string[];  // visual artifacts from executing phase (for UI loops)
-}
-
-export type VerificationResult =
-  | { passed: true; reason: string }
-  | { passed: false; reason: string; suggestedFix?: string };
-
-export interface LoopSession {
-  loopID: string;
-  definition: LoopDefinition;
-  currentPhase: LoopPhase;
-  attempts: number;
-  activeJobID?: string;
-  history: AttemptRecord[];
-  historyFilePath: string;         // path to .loop-history-{loopID}.md in project root
-  oracleRetryCount: number;        // reset to 0 on each executing transition
-  // worktreeName: added when worktree integration is implemented (deferred)
-  // memoryLoaded: added when cross-loop memory is implemented (deferred)
-}
-```
-
-**Phase transition rules (enforced):**
-```
-executing  → verifying    (on job completed)
-verifying  → done         (on verification passed)
-verifying  → executing    (on verification failed, attempts < maxAttempts)
-verifying  → escalated    (on verification failed, attempts >= maxAttempts)
-*          → cancelled    (on manual cancel, job.cancelled state, or user abort)
-done       → (terminal)
-escalated  → (terminal)
-cancelled  → (terminal)
-```
-
-**No `planning` or `improving` phase** - binary oscillation between `executing` and `verifying`. `@oracle` only verifies, `@fixer` self-corrects using `.loop-history-{loopID}.md`. Loop starts in `executing`.
-
-**`oracleRetryCount` lifecycle:** Reset to `0` on every `executing` transition. Increment on each Oracle retry. If `oracleRetryCount >= 2` and parsing still fails → fail closed (verification = failed).
-
-**History file:** Each session writes `compactHistory()` to a virtual file (`.loop-history-{loopID}.md` in the project root). This file is appended to `contextFiles` for each `executing` dispatch. Models read file context reliably.
-
-**Worktree integration:** If `definition.worktree?.enabled = true`, orchestrator creates a dedicated worktree before dispatching. Engine tracks `session.worktreeName`. On `done` → orchestrator merges worktree to main. On `escalated`/`cancelled` → orchestrator abandons worktree. Prevents parallel loops from colliding on the same files. Uses existing `using-git-worktrees` skill via orchestrator.
-
-### Task 5: Worktree Integration (Deferred - MVP uses in-process execution)
-
-**Files:** `src/loop/loop-engine.ts` (update), `src/loop/worktree-manager.ts` (new)
-
-**Purpose:** Isolated execution environment per loop. Prevents parallel loops from modifying the same files.
-
-**Interface:**
-```typescript
-export interface LoopWorktreeConfig {
-  enabled: boolean;
-  branchName?: string;  // defaults to "loop-{loopID}"
-  mergeOnSuccess: boolean;  // merge to main on 'done', abandon on 'escalated'/'cancelled'
-}
-```
-
-**Handshake timing (critical for isolation):**
-
-```
-startLoop(definition) with worktree.enabled = true
-  → engine creates session, sets session.worktreeName = "loop-{loopID}"
-  → engine sets session.worktreeReady = false
-  → engine fires onWorktreeCreate(loopID, "loop-{loopID}") callback
-  → engine returns loopID immediately (non-blocking)
-  ↓
-Orchestrator receives callback → creates worktree via using-git-worktrees skill
-  ↓
-Orchestrator calls engine.setWorktreeReady(loopID)
-  → session.worktreeReady = true
-  ↓
-Engine dispatches first job (checks session.worktreeReady before dispatching)
-```
-
-**If worktree creation fails:** Orchestrator calls `engine.cancel(loopID)` with a reason. Engine transitions to `escalated` with system error, no merge/abandon attempted.
-
-**On terminal states:**
-- `done` → engine fires `onWorktreeMerge(loopID, branchName)`. Orchestrator merges to main via skill.
-- `escalated`/`cancelled` → engine fires `onWorktreeAbandon(loopID, branchName)`. Orchestrator abandons via skill.
-
-**Engine does not call git directly** - it delegates to orchestrator via callbacks (`onWorktreeCreate`, `onWorktreeMerge`, `onWorktreeAbandon`).
-
-**Validation:** `startLoop()` validates that `executeAgent !== verifyAgent`. If equal, throws `Error('executeAgent and verifyAgent must be different')`.
-
-**Note:** In MVP, `worktree.enabled = false` by default. Worktree isolation is opt-in per `LoopDefinition`.
-
-### Task 6: Cross-Loop Memory (Deferred - MVP uses per-session history only)
-
-**File:** `src/loop/loop-memory.ts` (new file)
-
-**Purpose:** Learn from prior loops. Store successful strategies, failure patterns, and convergence thresholds across sessions. File-based (`.loop-memory.md`) for MVP. Future: GitHub Issues, database. Enables learned strategies and tuned convergence thresholds.
-
-**Read timing (before first dispatch):**
-
-```
-startLoop(definition) with memory.enabled = true
-  → engine creates session, sets session.memoryLoaded = false
-  → engine reads storePath (defaults to .loop-memory.md)
-  → if file exists and valid: parses LoopMemoryStore
-  → engine fires onMemoryRead(loopID, memory) callback
-  → orchestrator calls engine.setMemoryLoaded(loopID, memory)
-  → session.memoryLoaded = true
-  ↓
-Engine dispatches first job (checks session.memoryLoaded before dispatching)
-```
-
-If memory file doesn't exist or is corrupt: engine fires `onMemoryRead(loopID, null)`. Orchestrator calls `setMemoryLoaded` with empty store. Loop proceeds with no prior patterns.
-
-**Write timing (on terminal state):**
-
-```
-on 'done':
-  → engine writes new LoopPattern to store (goal type, strategy, attemptsRequired, timestamp)
-  → engine fires onMemoryWrite(loopID, storePath) callback
-  → orchestrator writes file via fs
-
-on 'escalated':
-  → engine writes new FailureRecord to store (goal type, failureReason, what was attempted, occurrences++)
-  → engine fires onMemoryWrite(loopID, storePath) callback
-  → orchestrator writes file via fs
-```
-
-**Orchestrator does the actual file I/O** - engine delegates via callback, same pattern as worktree. This keeps the engine purely orchestration logic.
-
-**Future:** Memory store could be GitHub Issues (label-based), a database, or a dedicated file. File-based (`.loop-memory.md`) is MVP.
-
-**Note:** In MVP, `memory.enabled = false` by default. Cross-loop memory is opt-in per `LoopDefinition`.
-
-### Task 7: Create LoopEngine (Event-Driven)
-
-**File:** `src/loop/loop-engine.ts` (new file)
-
-The engine is **not** a procedural `for` loop. It is an event-driven state machine that reacts to `BackgroundJobBoard` terminal state events.
-
-```typescript
-import { LoopSession, type LoopPhase, type LoopDefinition, type AttemptRecord } from './loop-session';
-import { BackgroundJobBoard, type BackgroundJobRecord } from '../utils/background-job-board';
-
-export interface LoopEngineCallbacks {
-  onLoopComplete?: (loopID: string, success: boolean) => void;
-  onEscalated?: (loopID: string, reason: string) => void;
-  // Manual verification - orchestrator surfaces review to human, calls resolveManualReview
-  onManualReview?: (loopID: string, reason: string) => void;
-  // Artifact management - orchestrator owns filesystem, engine only signals
-  onArtifactWrite?: (loopID: string, artifactPath: string) => void;
-  // Deferred: onWorktreeCreate, onWorktreeMerge, onWorktreeAbandon
-  // Deferred: onMemoryRead, onMemoryWrite
-}
-
-export class LoopEngine {
-  private sessions: Map<string, LoopSession> = new Map();
-  private jobBoard: BackgroundJobBoard;
-  private callbacks: LoopEngineCallbacks;
-  // Dispatch callback provided by orchestrator - engine does not access SDK directly
-  private dispatch: (agent: string, prompt: string, contextFiles: string[]) => string;
-
-  constructor(jobBoard: BackgroundJobBoard, callbacks: LoopEngineCallbacks, dispatch: (agent: string, prompt: string, contextFiles: string[]) => string);
-
-  startLoop(definition: LoopDefinition): string;
-  cancel(loopID: string): void;
-  resolveManualReview(loopID: string, passed: boolean, reason?: string): void;
-  getSession(loopID: string): LoopSession | undefined;
-  listSessions(): LoopSession[];
-
-  private handleTerminalJob(job: BackgroundJobRecord): void;
-  private findSessionForJob(taskID: string): LoopSession | undefined;
-  private dispatchPhase(session: LoopSession): void;
-  private evaluateVerification(session: LoopSession, job: BackgroundJobRecord): void;
-
-  // Context compaction
-  private writeHistoryFile(session: LoopSession): void;
-  private compactHistory(session: LoopSession): string;
-}
-```
-
-**Layered architecture:**
-
-```
-Layer 0: Orchestrator - loads skill, delegates to LoopEngine, listens to callbacks, handles Grill + escalation
-Layer 1: LoopEngine - event-driven state machine, dispatches agents, manages artifacts, enforces circuit breaker
-Layer 2: Specialist agents - do the work
-  - @fixer, @designer, @explorer, @librarian - execute based on task domain
-  - @oracle, @observer, test - verify based on task domain
-  - @council - Layer 0 escalation ONLY, never inside the loop
-Skill - instructs orchestrator, never "does" anything itself
-```
-
-**Key design:**
-
-1. `startLoop(definition)` is **non-blocking** - creates session, validates inputs, writes history file, dispatches first job, returns `loopID` immediately. Orchestrator never hangs.
-
-   **Validation:**
-   ```typescript
-   if (definition.executeAgent === definition.verifyAgent) {
-     throw new Error('executeAgent and verifyAgent must be different agents');
-   }
-   ```
-   Prevents a single agent from verifying its own output (e.g., fixer checking fixer). The "student marking their own exam" problem is solved by design for code loops, but must be enforced for all loop types.
-
-**SuccessCriterion routing:** The engine routes based on `definition.success.type`:
-    - `'test'`, `'build'`, `'lint'`, `'command'`, `'fileExists'` → dispatch to test-runner agent (or `@fixer` with focused prompt) via BackgroundJobBoard. Agent runs command, evaluates exit code or file existence. Engine evaluates result - no LLM involved.
-    - `'oracle'` → dispatch to Oracle, parse JSON verification result
-    - `'observer'` → dispatch to Observer, parse JSON verification result
-    - `'manual'` → engine fires `onManualReview`, waits for `resolveManualReview`
-    This makes the engine extensible - new success criterion types can be added without changing the engine's core logic.
-
-2. Engine registers as the single terminal state listener on `BackgroundJobBoard`. All job completions route through `handleTerminalJob()`.
-
-3. Session lookup: `findSessionForJob(taskID)` - sessions track `activeJobID`, routes job events to the right session.
-
-4. Phase transitions driven by job terminal states, not by explicit loop control:
-
-```
-job completed (executing) → currentPhase = 'verifying' → dispatch verifyAgent
-job completed (verifying) → evaluateVerification()
-                              → passed? → 'done' → cleanup → onLoopComplete
-                              → !passed && canRetry → 'executing'
-                                → oracleRetryCount = 0
-                                → writeHistoryFile() → dispatch executeAgent (retry)
-                              → !passed && !canRetry → 'escalated' → cleanup → onEscalated
-job completed (cancelled) → 'cancelled' → cleanup → onLoopComplete(false)
-job completed (error)     → handleFailure() → may escalate
-```
-
-5. **No `improving` phase** - `@oracle` strictly verifies (returns `passed: false, reason: "X"`). `@fixer` self-corrects using `compactHistory()` from `.loop-history-{loopID}.md` + failure reason as input. No intermediate strategist.
-
-6. **Context injection** - text history and visual artifacts handled separately:
-
-   **`.loop-history-{loopID}.md`** - text compaction for all loop types:
-   ```typescript
-   private writeHistoryFile(session: LoopSession): void {
-     const content = this.compactHistory(session);
-      // Write to session.historyFilePath (.loop-history-{loopID}.md in project root)
-   }
-
-    private compactHistory(session: LoopSession): string {
-      if (session.history.length === 0) return '';
-      const lines = session.history.map((a, i) => {
-        const outcome = a.verificationResult.passed
-          ? 'PASS'
-          : `FAIL: ${a.verificationResult.reason}`;
-        const artifacts = a.artifactPaths?.length
-          ? ` → artifacts: ${a.artifactPaths.join(', ')}`
-          : '';
-        return `[Attempt ${i + 1}] ${outcome}${artifacts}`;
-      });
-      return `# Loop Attempt History\n\n${lines.join('\n')}\n`;
-    }
-   ```
-
-**Observer artifact transfer:** For UI loops, `verifyAgent = 'observer'`, the executing agent writes visual artifacts to paths. The engine signals `onArtifactWrite(loopID, artifactPath)` so orchestrator can manage artifact lifecycle. Engine does not own filesystem artifacts - only signals when they are written.
-
-**Manual verification:** When `success.type = 'manual'`, engine transitions to `verifying` but does NOT dispatch a verifyAgent. Instead, fires `onManualReview(loopID, reason)` and stops. Session waits. Orchestrator surfaces review to human. Human responds → orchestrator calls `engine.resolveManualReview(loopID, passed, reason)`. Engine resumes: `passed` → `done`, `!passed` → retry or escalate.
-
-    **No Council inside the loop** - Council with 360s+ latency stalls the rapid `executing ↔ verifying` oscillation. Council is reserved for Layer 0 escalation only.
-
-7. Hard circuit breaker: when `attempts >= maxAttempts` && verification fails → `escalated`. Loop stops dispatching. `onEscalated` callback fires.
-
-8. Convergence signals: before dispatching retry, engine checks `jobBoard.hasConvergenceSignals()`. If exceeded → `escalated` regardless of attempt count.
-
-9. **Dispatch failure handling** - `try/catch` around `dispatchPhase()`:
-   ```typescript
-   private dispatchPhase(session: LoopSession): void {
-     try {
-       // registerLaunch() and job dispatch
-     } catch (error) {
-       session.currentPhase = 'escalated';
-       this.callbacks.onEscalated?.(session.loopID, `Dispatch failed: ${error}`);
-       return;
-     }
-   }
-   ```
-   If dispatch throws (agent API down, token limit exceeded, etc.) → immediately `escalated` + `onEscalated` with system error. No orphaned session.
-
-10. **Cancellation lifecycle** - `cancelled` is a distinct terminal state, not an error:
-    ```typescript
-    private handleTerminalJob(job: BackgroundJobRecord): void {
-      const session = this.findSessionForJob(job.taskID);
-      if (!session) return;
-
-      if (job.state === 'cancelled') {
-        // Quiet shutdown - no escalation, no error increment
-        session.currentPhase = 'cancelled';
-        session.activeJobID = undefined;
-        this.cleanupSession(session);  // delete artifactDir and historyFile
-        this.callbacks.onLoopComplete?.(session.loopID, false);
-        return;
-      }
-
-      if (job.state === 'error') {
-        // Treat as verification failure - increment errors, potentially escalate
-        this.handleFailure(session, job);
-        return;
-      }
-
-      // job.state === 'completed' → normal phase transitions
-      this.handleTerminalJobCompleted(session, job);
-    }
-    ```
-    `cancel(loopID)` sets `cancellationRequested` on the job, which emits `cancelled` state. Engine catches it, transitions to `cancelled` terminal state, cleans up, fires `onLoopComplete(false)` (not `onEscalated`).
-
-11. **Oracle retry-wrapper for JSON parsing failures** - `oracleRetryCount` persisted in session:
-    ```typescript
-    private evaluateVerification(session: LoopSession, job: BackgroundJobRecord): void {
-      const result = this.tryParseVerification(job.resultSummary);
-      if (result !== null) {
-        this.transitionToNextPhase(session, result);
-        return;
-      }
-
-      // Parse failed
-      if (session.oracleRetryCount < 1) {
-        session.oracleRetryCount++;
-        this.dispatchPhase(session);  // re-send to Oracle
-        return;
-      }
-
-      // Retry exhausted → fail closed
-      session.oracleRetryCount = 0;
-      this.transitionToNextPhase(session, { passed: false, reason: 'Verification output unparseable after retry' });
-    }
-    ```
-    `oracleRetryCount` is reset to `0` on every `executing` transition (not on parse success). Max 1 retry (retry if count == 0, i.e. first failure). Handles the 12.5% Oracle error rate without infinite loops.
-
-12. **Session cleanup** - prevents memory leaks:
-    ```typescript
-    private cleanupSession(session: LoopSession): void {
-       // Delete .loop-history-{loopID}.md
-      fs.unlinkSync(session.historyFilePath);
-      // Orchestrator handles artifact cleanup via onArtifactWrite tracking
-    }
-    ```
-    Called on terminal states: `done`, `escalated`, `cancelled`. Also called on `cancel(loopID)`. Engine only manages `.loop-history-{loopID}.md` - orchestrator owns artifact filesystem lifecycle.
-
-    **"Modify definition and retry"** during `escalated`: Human decides to modify and retry → engine does NOT reuse the session. Instead:
-    1. Call `cancel(loopID)` → triggers `cancelled` cleanup
-    2. Call `startLoop(newDefinition)` → fresh `loopID`
-    This ensures no stale state from the failed loop leaks into the retry.
-
-**Structured verification parsing** (replaces brittle regex):
-
-Oracle must use a tool that returns JSON. The tool schema:
-```typescript
-const verifyTool = {
-  name: 'verify',
-  description: 'Structured verification result',
-  inputSchema: {
-    type: 'object',
-    properties: {
-      passed: { type: 'boolean' },
-      reason: { type: 'string' },
-      suggestedFix: { type: 'string' }
-    },
-    required: ['passed', 'reason']
-  }
-};
-```
-
-Engine reads `job.resultSummary` as JSON:
-```typescript
-private tryParseVerification(raw: string | undefined): VerificationResult | null {
-  try {
-    const parsed = JSON.parse(raw ?? '{}');
-    return {
-      passed: Boolean(parsed.passed),
-      reason: String(parsed.reason ?? ''),
-      suggestedFix: parsed.suggestedFix ? String(parsed.suggestedFix) : undefined
-    };
-  } catch {
-    return null;  // parsing failed, retry-wrapper handles
-  }
-}
-```
-
-**No regex matching** - if Oracle returns valid JSON, parsing succeeds. If not, retry once. If still fails, fail closed (not open).
-
-### Task 8: Create Loop Engineering Skill
-
-**File:** `src/skills/loop-engineering/SKILL.md` (new file)
-
-The skill instructs the orchestrator - it never "does" anything itself. Orchestrator follows the skill's guidance.
-
-Two parts:
-
-**Grill (human interview) - orchestrator follows these instructions:**
-- Conduct conversation to define `LoopDefinition` fields
-- Questions: goal, success criteria, max attempts, preferred agents, context files
-- Output structured JSON passed to `loopEngine.startLoop()`
-
-**Loop Monitor - orchestrator follows these instructions:**
-- Listen to engine callbacks (`onLoopComplete`, `onEscalated`)
-- Display current state, attempt count, verification result to human
-- On `onEscalated` - surface resolution options to human, await instruction
-- On human intervention (cancel, force pass, modify definition) - call appropriate engine method
-
-**Skill does NOT:**
-- Call `loopEngine` directly - orchestrator does that
-- Dispatch agents - engine does that
-- Evaluate verification - engine does that (via JSON parsing)
-- Manage state - engine does that
-
-### Task 9: Register /loop Command
-
-**Step A:** Research `/deepwork` registration pattern:
-```bash
-grep -r "deepwork" src/ --include="*.ts"
-```
-
-**Step B:** Create `src/tools/loop-command.ts` following the same pattern.
-
-**Step C:** Register in `src/index.ts` where other commands are wired.
-
-### Task 10: Add Tests
-
-**Files:** (new test files alongside implementation)
-
-- `src/loop/loop-session.test.ts` - state machine transitions, transition enforcement, attempt recording
-- `src/loop/loop-engine.test.ts` - event-driven flow, job completion handling, convergence escalation, context compaction, dispatch failure handling
-
----
-
-## PR Strategy
-
-**Two-PR approach:**
-
-**PR 1 - Convergence Signals (BackgroundJobBoard extension)**
-- Tasks 1, 2, 3 only
-- Extends `BackgroundJobRecord` with `totalErrors`, `timeoutCount`, `lastErrorAt`
-- Adds convergence helper methods to `BackgroundJobBoard`
-- Upgrades event plumbing to callback array
-- **Naming:** Use `totalErrors` (not `errorCount`) - aligns with LoopEngine spec
-- **Scope rule:** `cancelled` does NOT increment `totalErrors` - quiet terminal state, not an error
-- Ready to open now
-
-**PR 2 - Loop Engine (full runtime orchestration)**
-- Tasks 4, 7, 8, 9, 10
-- `LoopSession` + `LoopEngine` event-driven state machine
-- `SuccessCriterion` routing (test/build/lint evaluated directly, oracle/observer dispatched, manual waits for human)
-- Skill + `/loop` command
-- Tests
-- Depends on PR 1 merging first
-
-**Not in MVP PRs (deferred but architected):**
-- **Worktree isolation** - architected in Task 5, deferred to post-MVP. Orchestrator uses `using-git-worktrees` skill. Engine delegates worktree lifecycle via callbacks. Prevents parallel loop file collisions.
-- **Cross-loop memory** - architected in Task 6, deferred to post-MVP. `.loop-memory.md` file store (MVP). Future: GitHub Issues, database. Enables learned strategies and tuned convergence thresholds.
-
-**Not architected yet (deferred):**
-- Trigger automation (cron, webhooks) - `LoopTrigger` interface defined but only 'manual' implemented in MVP
-- Fuzzy verification - Oracle returns boolean only; no engagement metrics or content quality scoring
-- MCP connectors (GitHub Issues, Slack, Sentry) - no external integrations
-
-These are the remaining delta between MVP loop engineering and full theory compliance (6 building blocks).
-
----
-
-## Future Extensions (Deferred - Not in MVP)
-
-These features are deferred. Interfaces will be defined when implementation begins.
-
-- **Worktree isolation** - opt-in per LoopDefinition, uses `using-git-worktrees` skill. Prevents parallel loop file collisions.
-- **Cross-loop memory** - `.loop-memory.md` file store. Learns from prior loops: successful strategies, failure patterns, tuned convergence thresholds.
-- **Trigger automation** - cron, webhook, event-driven invocation. `LoopTrigger` interface defined in Phase 4.
-
-### LoopMemoryConfig
-```typescript
-// Cross-loop memory store
-export interface LoopMemoryConfig {
-  enabled: boolean;
-  storePath: string;  // defaults to .loop-memory.md in project root
-}
-```
-When implemented: Add `memory: LoopMemoryConfig` to `LoopDefinition`, `memoryLoaded` to `LoopSession`, and `onMemoryRead/Write` callbacks to `LoopEngineCallbacks`.
-
----
-
-## File Summary
-
-| File | Action |
-|------|--------|
-| `src/utils/background-job-board.ts` | Modify - convergence signals, helpers, event plumbing |
-| `src/loop/loop-session.ts` | Create - state machine class (binary oscillation, worktreeName, oracleRetryCount) |
-| `src/loop/loop-engine.ts` | Create - event-driven orchestration |
-| `src/loop/worktree-manager.ts` | Create - worktree lifecycle (create/merge/abandon, deferred) |
-| `src/loop/loop-memory.ts` | Create - cross-loop memory store (read/write patterns, deferred) |
-| `src/skills/loop-engineering/SKILL.md` | Create - Grill + Monitor prompts |
-| `src/tools/loop-command.ts` | Create - command definition |
-| `src/index.ts` | Modify - wire /loop command |
-| `src/loop/loop-session.test.ts` | Create - tests |
-| `src/loop/loop-engine.test.ts` | Create - tests |
-
----
-
-## Verification Commands
-
-After implementation:
-```bash
-bun run typecheck
-bun run check:ci
-bun test
-```
-
----
-
-## Dependencies
-
-- `BackgroundJobBoard` - already exists, extended with convergence signals and event plumbing
-- Agent dispatch - existing patterns in council/
-- Skill infrastructure - existing patterns in src/skills/
-- Oracle structured output tool - new tool definition in `src/tools/` (or reuse existing)
-
----
-
-## Out of Scope
-
-- **Worktree isolation** - deferred (would prevent parallel loop file collisions)
-- **Cross-loop persistent memory** - deferred (history dies with session)
-- **Trigger automation** - only manual `/loop` invocation in MVP (no cron/webhooks)
-- **Fuzzy verification** - Oracle returns boolean only (no engagement metrics)
-- **MCP connectors** - no GitHub Issues, Slack, Sentry integration
-- **Persistence** - in-memory only for MVP
-- **New hooks or infrastructure** - beyond orchestration wiring
-- **Visualization** - beyond skill prompts
-- Layer 1 (runtime) always enforces constraints - no "signals not constraints" in the engine layer
-
-**Signals vs constraints distinction:**
-- `BackgroundJobRecord` convergence signals (`totalErrors`, `timeoutCount`) → "signals not constraints" - warn LLM via `formatForPrompt()`, LLM decides
-- `LoopEngine` circuit breaker → hard constraints - `escalated` state is enforced, not signaled
-
----
-
-## Research Validation (June 2026)
-
-The loop engineering spec and plan were validated against real-world implementations:
-
-- **autoresearch** (Karpathy): Confirms MVP scope - skill + executor + git history is the proven minimum. Our LoopEngine + skill + `.loop-history-{loopID}.md` directly mirrors this pattern.
-- **Claude Code community**: `while True` loops in CLAUDE.md are the most common adoption pattern. Our `/loop` command formalizes what users already do manually.
-- **Ralph (Simon Willison)**: Simplest on-ramp - agent loop in a markdown file. Validates that skill-first approach (not infrastructure-first) is the right entry point.
-
-**Impact on plan:** No changes needed. The 5-phase roadmap (runtime engine → loop skill → routine integration → triggers → persistent memory) matches the proven adoption curve. Phase 1-2 (MVP) is where the value is.
-
-**Risk identified:** autoresearch shows that manual verification (human in the loop) is often "good enough" for autonomous loops. Our spec's automated verification (@oracle/@observer) is a differentiator but should not be a blocker - MVP could ship with manual verification as a fallback SuccessCriterion type.

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

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

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

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

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

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

+ 0 - 625
docs/superpowers/specs/2026-06-25-loop-engineering-runtime.md

@@ -1,625 +0,0 @@
-# Loop Engineering - Runtime-First Design
-
-## Core Insight
-
-The loop engine is **orchestration wiring**, not prompt engineering.
-
-**Guiding principle:**
-> **The runtime owns control flow. The LLM owns strategy.**
-
-The runtime decides: what state comes next, when verification occurs, whether success criteria passed, whether another iteration is allowed, when escalation policies apply. The LLM decides: how to solve the problem, how to adapt after feedback, what implementation strategy to try next.
-
-**Core design principle:**
-> **Verification is the center of loop engineering - not execution.**
-
-Retries, failures, warnings, error counts, timeouts are **escalation signals**, not the loop itself. The loop is `Goal → Execute → Verify → Goal satisfied?` Everything else hangs off that.
-
-**Mechanism vs Policy:**
-The engine implements `verify()`. Policy (maxAttempts, escalation targets, human gates) is externalized. This keeps the runtime generic and extensible across domains (code, docs, research, planning).
-
-```
-Layer 0 (Orchestrator): Trigger, Grill, escalation handling
-Layer 1 (LoopEngine):   Execute dispatch, Verify parsing, State transitions, Circuit breaker
-Layer 2 (Agents/Skill): Execute work, Verify output, Skill instructions
-```
-
-The orchestrator delegates to the engine. The engine dispatches agents. The skill instructs the orchestrator - it never acts directly.
-
-## Architecture
-
-### Three-Layer Design
-
-```
-Layer 0: Orchestrator - runtime that runs everything
-  - Loads and follows skill instructions
-  - Delegates to LoopEngine
-  - Collects LoopDefinition via Grill interview
-  - Listens to engine callbacks (onLoopComplete, onEscalated)
-  - Handles human-facing parts (Grill, escalation UI)
-  - Dispatches @council ONLY on Layer 0 escalation (never inside the loop)
-  - Never dispatches specialist agents during a loop - engine dispatches via BackgroundJobBoard
-  - Provides `dispatch(agent, prompt, contextFiles)` callback to engine for agent spawning
-
-Layer 1: LoopEngine - orchestration logic, framework-owned
-  - Location: `src/loop/loop-engine.ts` (not src/council/)
-  - Event-driven state machine
-  - Dispatches agents via orchestrator-provided callback (not direct SDK access)
-  - Owns phase transitions and verification parsing
-  - Manages context compaction via .loop-history-{loopID}.md
-  - Manages session artifact directory for visual artifact transfer
-  - Enforces hard circuit breaker (escalated state)
-  - Handles dispatch failures with try/catch → escalated
-  - Wraps Oracle verification with retry on JSON parse failure
-
-Layer 2: Specialist agents - do the work
-  - @fixer, @designer - execute implementation tasks
-  - @explorer, @librarian - execute research/gather loops
-  - @oracle - strict verification only (returns JSON, not strategy)
-  - @observer - visual verification (reads artifacts from session directory)
-  - test - automated verification (exit code parsing)
-  - @council - Layer 0 escalation ONLY (not inside the loop)
-  - Skill - instructs orchestrator, never "does" anything itself
-```
-
----
-
-## Runtime Loop Engine
-
-### LoopSession State Machine
-
-**Binary oscillation** - no planning or improving phase:
-
-```
-States: executing | verifying | done | escalated | cancelled
-
-Transitions:
-  executing  → verifying    (on job completed)
-  executing  → escalated    (on dispatch/execution error - API down, token limit, etc.)
-  verifying  → done         (on verification passed)
-  verifying  → executing    (on verification failed, attempts < maxAttempts)
-  verifying  → escalated    (on verification failed, attempts >= maxAttempts)
-  *          → cancelled    (on manual cancel or job.cancelled state)
-  done       → (terminal)
-  escalated  → (terminal)
-  cancelled  → (terminal)
-```
-
-**`oracleRetryCount` lifecycle:** Reset to `0` on every `executing` transition. Increment on Oracle retry. Max 1 retry (retry if count == 0, i.e. first failure). If second parse fails → fail closed.
-
-**Design decisions:**
-- `planning` removed - `LoopDefinition` is fully formed from Grill. Loop starts in `executing` immediately dispatching executeAgent.
-- `improving` removed - `@oracle` strictly verifies. `@fixer` self-corrects using `.loop-history.md` + failure reason.
-- Binary oscillation between `executing` and `verifying` is the complete state machine.
-- `cancelled` is a distinct terminal state, not an error - no `onEscalated` callback, quiet cleanup.
-- **Same-agent constraint:** `executeAgent` and `verifyAgent` MUST be different. Validation at `startLoop()` throws if equal. This prevents the "student marking their own exam" problem across all loop types (not just code loops).
-
-**God object risk:** Every responsibility in `LoopEngine` must be expressible as **state transition**, **event**, or **policy**. If a responsibility cannot be expressed this way, it belongs elsewhere (orchestrator, external policy store, dedicated service). This keeps the engine testable and maintainable.
-
-### Key Primitives
-
-**1. LoopDefinition** (input from Grill)
-```typescript
-// Success criteria - first-class runtime type
-// The runtime evaluates these directly where possible. Only subjective criteria go to Oracle.
-type SuccessCriterion =
-  | { type: 'test'; command: string }                         // exit code 0 = pass
-  | { type: 'build'; command: string }                        // exit code 0 = pass
-  | { type: 'lint'; command: string }                         // exit code 0 = pass
-  | { type: 'fileExists'; path: string }                      // file exists = pass
-  | { type: 'command'; command: string; expectExitCode?: number }  // customizable
-  | { type: 'oracle' }                                        // Oracle returns structured JSON (subjective)
-  | { type: 'observer' };                                     // Observer reads visual artifacts (subjective)
-  | { type: 'manual' };                                       // human reviews and decides
-
-// MVP implements: 'test', 'oracle', 'observer', 'manual'. Others deferred.
-
-interface LoopDefinition {
-  goal: string;
-  successCriteria: string;         // human-readable description (used by oracle/observer)
-  success: SuccessCriterion;       // machine-evaluable success criterion
-  maxAttempts: number;              // default 3
-  // executeAgent is dynamically selected based on task domain
-  executeAgent: 'fixer' | 'designer' | 'explorer' | 'librarian';
-  // verifyAgent is dynamically selected based on task domain
-  // Note: 'council' is NOT a verifyAgent inside the loop - Layer 0 escalation only
-  verifyAgent: 'oracle' | 'observer' | 'test';
-  // CONSTRAINT: executeAgent and verifyAgent MUST be different agents
-  // Validation: startLoop() throws if executeAgent === verifyAgent
-  // ROUTING: When success.type is test/build/lint/command/fileExists, engine runs command directly (no agent dispatch).
-  //          verifyAgent is only used when success.type is oracle or observer.
-  contextFiles?: string[];
-  // trigger, worktree, memory: deferred to Future Extensions (see below)
-}
-```
-
-**2. AttemptRecord** (per attempt)
-```typescript
-interface AttemptRecord {
-  attemptNumber: number;
-  executionResult: string;
-  verificationResult: VerificationResult;
-  artifactPaths?: string[];  // visual artifacts from executing phase (for UI loops)
-}
-```
-
-**3. VerificationResult** (framework-owned, not LLM opinion)
-```typescript
-type VerificationResult =
-  | { passed: true; reason: string }
-  | { passed: false; reason: string; suggestedFix?: string };
-```
-
-### Convergence Signals (from #611)
-
-Escalation primitives inside the loop, not the foundation of loop engineering:
-
-- `totalErrors` - accumulated errors across attempts (NOT incremented on `cancelled`)
-- `timeoutCount` - consecutive timeouts, resets to 0 on `completed`
-- `lastErrorAt` - timestamp of last error
-
-**Place in architecture:**
-```
-LoopEngine
-├── Goal
-├── Execution
-├── Verification  ← verification is the center
-├── State
-└── Escalation
-      └── Convergence Signals (#611)
-```
-
-Error tracking is an **implementation detail of escalation**, not the foundation. Verification is the center of loop engineering.
-
-**Convergence signal scope:** Signals apply to `error` and `timeout` states only. The `cancelled` state is a quiet terminal state - it does NOT increment error counters. This prevents noisy escalation when users intentionally cancel.
-
-**Signal computation:** Convergence signals are computed from `BackgroundJobRecord` state transitions, not explicit flags:
-- `totalErrors` - incremented when job state transitions to `error`
-- `timeoutCount` - incremented when `timedOut === true` on status update; reset to 0 on `completed`
-- `lastErrorAt` - timestamp of last `error` state transition
-
-This avoids redundant `isError`/`isTimeout` fields on status input - the state machine already conveys this information.
-
-When convergence signals exceed threshold:
-→ transition to `escalated` state
-→ circuit closed, human handoff required
-
-**Signals vs constraints distinction:**
-- `BackgroundJobRecord` convergence signals → "signals not constraints" - warn LLM via `formatForPrompt()`, LLM decides
-- `LoopEngine` circuit breaker (`escalated` state) → hard constraints - enforced, not signaled
-
-### Session Cleanup
-
-To prevent `/tmp/` memory leaks across multiple loops:
-
-- **Terminal states trigger cleanup:** When state transitions to `done`, `escalated`, or `cancelled`, the engine synchronously deletes:
-  - `.loop-history-{loopID}.md` (includes loopID to prevent collision across concurrent loops)
-
-- **Artifact cleanup is orchestrator-owned:** The engine does NOT manage artifact directories. Orchestrator tracks artifact paths via `onArtifactWrite` callbacks and handles cleanup independently.
-
-- **Cancellation also triggers cleanup:** If user triggers `cancel(loopID)`, the engine transitions to `cancelled`, cleans up, fires `onLoopComplete(false)` (not `onEscalated`). Quiet shutdown - no error escalation.
-
-- **"Modify definition and retry" during `escalated`:** Human decides to modify and retry → engine does NOT reuse the session. Instead:
-  1. Call `cancel(loopID)` → triggers `cancelled` cleanup
-  2. Call `startLoop(newDefinition)` → fresh `loopID`
-  This ensures no stale state from the failed loop leaks into the retry.
-
-### Context Compaction via .loop-history.md and Session Artifact Directory
-
-Text history and visual artifacts are handled separately:
-
-**`.loop-history-{loopID}.md`** - text compaction for all loop types:
-
-Written to the project root before each retry. Appended to `contextFiles` so agents read it as file context, not job description noise. Includes loopID in filename to prevent collision across concurrent loops.
-
-```typescript
-function compactHistory(history: AttemptRecord[]): string {
-  const lines = history.map((a, i) => {
-    const outcome = a.verificationResult.passed
-      ? 'PASS'
-      : `FAIL: ${a.verificationResult.reason}`;
-    const artifacts = a.artifactPaths?.length
-      ? ` → artifacts: ${a.artifactPaths.join(', ')}`
-      : '';
-    return `[Attempt ${i + 1}] ${outcome}${artifacts}`;
-  });
-  return `# Loop Attempt History\n\n${lines.join('\n')}\n`;
-}
-```
-
-**Session artifact directory** - for Observer visual artifact transfer (Designer → Observer):
-
-```
-/tmp/loop-{loopID}/
-  history.md           # compactHistory output
-  artifact-1.png       # screenshot from Designer
-  artifact-2.png       # another screenshot
-```
-
-Designer writes visual artifacts to `session.artifactDir` during `executing`. Artifact paths included in `resultSummary` or a dedicated `artifacts` field on `BackgroundJobRecord`.
-
-Engine reads artifact paths from completed job, includes them in Observer's `contextFiles` for `verifying`. Observer reads artifacts from session directory as file context - same mechanism as text files, works reliably for multimodal models.
-
-**Why file context, not job description:**
-- Models are optimized to read file context
-- `.loop-history.md` persists across jobs, reliably available
-- Session artifact directory is isolated per loop, no collision risk
-- Prepending to job description risks being ignored as noise
-
-### Trigger Architecture
-
-**MVP scope:** Only `manual` (`/loop` command) is implemented. Trigger types (`schedule`, `webhook`, `event`) will be defined in Phase 4 when automation is implemented.
-
-**Worktree isolation and cross-loop memory** are deferred to Future Extensions. See "Future Extensions" section below for interface definitions and implementation notes.
-
-### Dispatch Failure Handling
-
-If `dispatchPhase()` throws (agent API down, token limit exceeded, etc.):
-
-```typescript
-try {
-  // registerLaunch() + dispatch
-} catch (error) {
-  session.currentPhase = 'escalated';
-  callbacks.onEscalated?.(loopID, `Dispatch failed: ${error}`);
-  return;
-}
-```
-
-No orphaned sessions. Dispatch failure → `escalated` + `onEscalated` with system error immediately.
-
-### Orchestration Flow (Event-Driven)
-
-```
-user invokes /loop
-  ↓
-Orchestrator loads Loop Engineering skill
-  ↓
-Orchestrator follows skill's Grill instructions → collects LoopDefinition via conversation
-  ↓
-Orchestrator calls loopEngine.startLoop(definition)
-  → engine validates: executeAgent !== verifyAgent (throws if equal)
-  → engine creates LoopSession (phase: executing, attempts: 1)
-  → engine writes empty .loop-history.md
-  → engine dispatches executeAgent (execution job)
-  → returns loopID immediately to orchestrator (non-blocking)
-  ↓
-BackgroundJobBoard.runJob(executing)
-  ↓
-job completes → LoopEngine.handleTerminalJob()
-  → routing: findSessionForJob(taskID)
-  → currentPhase = 'verifying' → dispatch based on definition.success.type:
-      → 'test'/'build'/'lint'/'command'/'fileExists': run command directly, evaluate exit code
-      → 'oracle': dispatch to Oracle, parse JSON verification
-      → 'observer': dispatch to Observer, parse JSON verification
-  ↓
-job completes → LoopEngine.handleTerminalJob()
-  → engine evaluates result (JSON parse + retry if parse fails for oracle/observer)
-  → passed? → phase = 'done'
-  → !passed && canRetry:
-      → attempts++
-      → writeHistoryFile() → .loop-history.md with compactHistory()
-      → phase = 'executing' → dispatch executeAgent (retry with history context)
-  → !passed && !canRetry → phase = 'escalated' (circuit closed)
-      → engine fires onEscalated
-  ↓
-... continues event-driven until done/escalated/cancelled ...
-  ↓
-On escalated → Orchestrator dispatches @council (Layer 0 escalation) → human reviews → decides next action
-On cancelled → cleanup → onLoopComplete(false)
-```
-
----
-
-## Skill Layer
-
-### Loop Engineering Skill
-
-Location: `src/skills/loop-engineering/SKILL.md`
-
-The skill instructs the orchestrator - it never "does" anything itself.
-
-**Orchestrator follows skill's Grill instructions:**
-- Conduct conversation to define `LoopDefinition` fields
-- Questions: goal, success criteria, constraints, preferred agents, max attempts
-- Output structured JSON passed to `loopEngine.startLoop()`
-
-**Orchestrator follows skill's Loop Monitor instructions:**
-- Listen to engine callbacks (`onLoopComplete`, `onEscalated`)
-- Display current state, attempt count, verification result to human
-- On `onEscalated` - surface resolution options to human, await instruction
-- On human intervention (cancel, force pass, modify definition) - call appropriate engine method
-
-**Skill does NOT:**
-- Call `loopEngine` directly - orchestrator does that
-- Dispatch agents - engine does that
-- Evaluate verification - engine does that (via JSON parsing)
-- Manage state - engine does that
-
----
-
-## Interaction with BackgroundJobBoard
-
-- Each attempt's phases (`executing`, `verifying`) run as individual `BackgroundJob` records
-- `BackgroundJobRecord` extended with convergence signals:
-  - `totalErrors` - incremented on `error` state only (NOT on `cancelled`)
-  - `timeoutCount` - incremented on `timeout`, resets to 0 on `completed`
-  - `lastErrorAt` - timestamp of last error
-
-- **Note:** `cancelled` is a quiet terminal state - it does NOT increment `totalErrors` or fire `onEscalated`. This prevents noisy escalation on intentional user cancellations.
-
-- `BackgroundJobBoard` event plumbing updated to support multiple terminal state listeners (callback array instead of single listener)
-
-- `LoopSession` owns phase transitions. Jobs only know `running`, `completed`, `error`, `cancelled`. No loop states pollute the job primitive.
-
-- `.loop-history.md` written by engine, included in `contextFiles` for `executing` dispatches
-
----
-
-## Verification Implementation
-
-Verification is driven by `SuccessCriterion.type`. The engine routes to the appropriate evaluator:
-
-### Automated Verification (runtime-evaluated, no LLM)
-
-**`{ type: 'test' | 'build' | 'lint' | 'command' | 'fileExists' }`:**
-1. Engine dispatches to a test-runner agent (or `@fixer` with a focused prompt) via BackgroundJobBoard
-2. Agent runs the command, evaluates exit code or file existence
-3. Agent returns structured result: `{ passed: boolean, reason: string }`
-4. Engine evaluates result - no LLM involved. Deterministic. Fast.
-
-This uses the same dispatch mechanism as oracle/observer. The engine does not execute commands directly - it delegates to an agent that has shell access.
-
-### Subjective Verification (LLM-based)
-
-**`{ type: 'oracle' }`:**
-1. Engine dispatches to Oracle with `verifyTool` (structured JSON output)
-2. Oracle returns `{ passed: boolean, reason: string, suggestedFix?: string }`
-3. Engine parses JSON - if parse fails and `oracleRetryCount < 1`, re-dispatch once
-4. If second parse fails → fail closed (verification = failed)
-
-**`oracleRetryCount` lifecycle:** Persisted in `LoopSession`. Reset to `0` on every `executing` transition. Max 1 retry prevents infinite loops when Oracle consistently returns malformed JSON.
-
-**Oracle is strictly a verifier, not a strategist.** Oracle returns `passed: false, reason: "X"`. The engine takes the failure reason + `compactHistory()` and dispatches `@fixer` to self-correct. No intermediate agent between verification failure and retry.
-
-**`{ type: 'observer' }`:**
-1. Engine signals `onArtifactWrite(loopID, path)` so orchestrator can track artifact locations
-2. Engine includes artifact paths in Observer's `contextFiles` for `verifying`
-3. Observer returns structured JSON via `verifyTool` (same as oracle)
-4. Engine parses result, applies retry logic same as oracle
-
-**Observer artifact transfer:** Orchestrator owns the filesystem artifact lifecycle. Engine only signals when artifacts are written (`onArtifactWrite`). This prevents artifact management from bloating the engine's responsibilities.
-
-**`{ type: 'manual' }`:**
-1. Engine transitions to `verifying` phase
-2. Engine fires `onManualReview(loopID, reason)` callback
-3. Engine stops dispatching - session enters a waiting state (phase stays `verifying`, no active job, **no BackgroundJob created**)
-4. Orchestrator surfaces the review request to the human
-5. Human responds with pass/fail via orchestrator → orchestrator calls `engine.resolveManualReview(loopID, passed, reason)`
-6. Engine resumes: `passed` → `done`, `!passed` → retry or escalate based on attempt count
-
-**Manual verification is the simplest on-ramp.** No LLM involved. Human decides. Proven by autoresearch - Karpathy's entire loop is manual inspection. Use when automated verification isn't worth the setup cost, or when you want to eyeball results before committing to a verification criteria.
-
-**No BackgroundJob for manual verification.** The BackgroundJobBoard tracks running jobs only. Manual review is an engine-level waiting state, not a job.
-
-### Council - Layer 0 Escalation Only
-
-**Council is NOT a verifyAgent inside the loop.** Council with 360s+ latency would stall the rapid `executing ↔ verifying` oscillation.
-
-Council is reserved for Layer 0 escalation: when `escalated` fires, Orchestrator dispatches Council to synthesize all prior failures and devise a macro-strategy. Human reviews Council's output and decides next action (new loop with modified definition, abandon, or manual intervention).
-
-**On `escalated`:**
-1. Engine fires `onEscalated(loopID, reason)`
-2. Orchestrator surfaces options to human:
-   - "Modify definition and retry" → start fresh loop (cancel current, call `startLoop(newDefinition)`)
-   - "Escalate to Council" → Orchestrator dispatches @council for macro-strategy
-   - "Abandon" → Orchestrator calls `cancel(loopID)`, cleanup fires, loop ends
-
----
-
-## What Exists vs What Needs Building
-
-### Already Exists (Layer 0)
-- Orchestrator - already runs skills, delegates to components
-- `/loop` command slot - available for registration
-- @council - available for Layer 0 escalation
-
-### Already Exists (Layer 1)
-- `BackgroundJobBoard` - state tracking, event listener hook
-- `setTerminalStateListener` - single listener interface (may need upgrade to callback array)
-
-### Already Exists (Layer 2)
-- `@fixer`, `@oracle`, `@council`, `@explorer` agents
-- `@designer`, `@observer` - available for UI loops
-- `@librarian` - available for research loops
-- Skill infrastructure
-
-### Needs Building (PR 1 - Convergence Signals)
-1. `BackgroundJobRecord` extended with `totalErrors`, `timeoutCount`, `lastErrorAt`
-2. Convergence helper methods on `BackgroundJobBoard`
-3. BackgroundJobBoard callback array (if needed for multi-listener)
-
-### Needs Building (PR 2 - Loop Engine)
-4. `LoopSession` state machine class (binary oscillation, oracleRetryCount, cleanup)
-5. `LoopEngine` event-driven orchestration (cancellation lifecycle, cleanup routine)
-6. `writeHistoryFile()` and `compactHistory()` for `.loop-history.md`
-7. `SuccessCriterion` routing - test/build/lint/command/fileExists evaluated directly; oracle/observer dispatched
-8. Structured verification tool for Oracle (with retry-wrapper)
-9. `onArtifactWrite` callback for orchestrator-owned artifact lifecycle
-10. `src/skills/loop-engineering/SKILL.md` (Grill interview + loop monitor)
-11. `/loop` command registration
-12. Tests: state transitions, retry logic, cancellation lifecycle, cleanup, SuccessCriterion routing
-
----
-
-## Out of Scope (for MVP)
-- **Worktree isolation** - deferred to Future Extensions. MVP uses in-process execution.
-- **Cross-loop memory** - deferred to Future Extensions. MVP uses per-session history only.
-- **Trigger automation** - deferred to Future Extensions. Only 'manual' (`/loop` command) in MVP.
-- **Fuzzy verification** - SuccessCriterion only supports binary outcomes. No engagement metrics or content quality scoring.
-- **Token budget / cost controls** - `maxAttempts` limits iterations but not token spend per iteration. Deferred to post-MVP.
-- **MCP connectors** - no GitHub Issues, Slack, Sentry integration
-- Persistence layer (in-memory only for session; file-based for `.loop-history.md`)
-- New hooks or infrastructure beyond orchestration wiring
-- Visualization/monitoring beyond skill prompts
-- Layer 1 always enforces constraints - no "signals not constraints" philosophy in the engine layer
-
-**Full theory compliance** would require all 6 building blocks:
-1. Trigger (cron, webhooks, events) - deferred
-2. Worktree isolation - deferred
-3. Execution (covered) - done
-4. Verification (fuzzy path) - deferred
-5. Memory (cross-loop) - deferred
-6. Connectors (MCPs) - deferred
-
-MVP = items 3 + 4 (binary verification) + skill harness + orchestration wiring.
-
----
-
-## Real-World Validation
-
-### autoresearch (Karpathy, March 2026)
-
-A minimal autonomous research loop that validates our architecture:
-
-| autoresearch | Our Spec |
-|---|---|
-| `program.md` (skill/instructions) | `src/skills/loop-engineering/SKILL.md` |
-| `train.py` (while True loop) | `LoopEngine` (event-driven state machine) |
-| `prepare.py` (fixed, never edited) | Infrastructure (BackgroundJobBoard, agents) |
-| git history | `.loop-history.md` context compaction |
-| manual inspection | `@oracle` / `@observer` verification |
-| 5-min experiments | `maxAttempts` with circuit breaker |
-
-**Key takeaway:** Karpathy's loop is the simplest possible: skill + executor + git history + manual verification. No cross-loop memory, no triggers, no MCP connectors. Our MVP (execute + binary verification + skill harness + orchestration wiring) matches this proven pattern.
-
-**Divergence:** autoresearch has no verification agent - Karpathy manually inspects results. Our spec adds `@oracle`/`@observer` as automated verifiers, which is the right next step beyond manual inspection but still within the "binary oscillation" pattern.
-
-### Comparison with Claude Code and Codex
-
-| Feature | Claude Code | Codex (OpenAI) | OpenCode (our target) |
-|---|---|---|---|
-| Loop mechanism | `while True` in CLAUDE.md | Agent loop (background tasks) | Background Job Board + LoopEngine |
-| Verification | Manual / `claude-mem` | Task completion signal | `@oracle`/`@observer` structured JSON |
-| Context persistence | `CLAUDE.md` edits | Cloud session state | `.loop-history.md` + future `.loop-memory.md` |
-| Worktree isolation | Manual (`git worktrees`) | N/A (cloud) | Planned (using-git-worktrees skill) |
-| Trigger automation | None | Scheduled background agents | Planned (cron/webhook/event) |
-
-Our architecture is ahead of both on the verification and trigger fronts, but behind Claude Code on real-world adoption. The spec is sound.
-
----
-
-## PR Scope
-
-**Phased roadmap:**
-- **Phase 1**: Runtime loop engine (this PR)
-- **Phase 2**: Loop skill (Grill + Monitor)
-- **Phase 3**: Routine integration - loop engine plugs into existing oh-my-opencode-slim workflow routines
-- **Phase 4**: Triggers (cron, webhooks)
-- **Phase 5**: Persistent memory (cross-loop)
-
-This progression mirrors how users adopt loop engineering and reduces implementation risk.
-
-### PR 1 - Convergence Signals (BackgroundJobBoard extension)
-- Extends `BackgroundJobRecord` with `totalErrors`, `timeoutCount`, `lastErrorAt`
-- Adds convergence helper methods to `BackgroundJobBoard`
-- Upgrades event plumbing to callback array
-- **Ready to open now**
-
-### PR 2 - Loop Engine (full runtime orchestration)
-- `LoopSession` + `LoopEngine` event-driven state machine
-- `SuccessCriterion` routing (test/build/lint/command/fileExists + oracle/observer)
-- Skill + `/loop` command
-- Tests
-- **Depends on PR 1 merging first**
-
-### Deferred (Architected in Future Extensions)
-- Worktree isolation
-- Cross-loop memory
-- Trigger automation (schedule, webhook, event)
-
-### Deferred (Not yet architected)
-- Fuzzy verification
-- MCP connectors
-
----
-
-## Future Extensions (Deferred - Not in MVP)
-
-These features are deferred. Interfaces will be defined when implementation begins.
-
-- **Worktree isolation** - opt-in per LoopDefinition, uses `using-git-worktrees` skill. Prevents parallel loop file collisions.
-- **Cross-loop memory** - `.loop-memory.md` file store. Learns from prior loops: successful strategies, failure patterns, tuned convergence thresholds.
-- **Trigger automation** - cron, webhook, event-driven invocation. `LoopTrigger` interface defined in Phase 4.
-
----
-
-## Example Usage
-
-### Implementation Loop (Fixer → Oracle)
-
-```
-User: /loop
-
-Orchestrator follows skill's Grill instructions:
-  "What are you trying to accomplish?"
-User: "Fix the auth bug in src/auth/"
-  "What does success look like?"
-User: "All tests pass and no regressions"
-  "Max attempts?"
-User: "3"
-  "Execute agent?"
-User: "fixer"
-  "Verify agent?"
-User: "oracle"
-
-Orchestrator calls loopEngine.startLoop(definition)
-
-Loop Engine (event-driven):
-  Attempt 1:
-    executing  → @fixer executes plan → job completes
-    verifying  → @oracle returns JSON verification → FAIL (reason: "token mismatch in auth handler")
-      → parse failed → retry once → parse failed again → fail closed
-  Attempt 2:
-    writeHistoryFile() → .loop-history.md
-    executing  → @fixer reads .loop-history.md + failure reason → self-corrects → job completes
-    verifying  → @oracle returns JSON verification → PASS
-  → done
-
-Orchestrator receives onLoopComplete → reports to human
-```
-
-### UI Loop (Designer → Observer)
-
-```
-User: /loop
-
-Orchestrator collects definition:
-  goal: "Improve the dashboard header"
-  successCriteria: "Header is responsive, centered, no overflow on mobile"
-  executeAgent: "designer"
-  verifyAgent: "observer"
-  maxAttempts: 2
-
-Loop Engine:
-  Attempt 1:
-    executing  → @designer implements changes → writes screenshot to /tmp/loop-xyz/artifact-1.png
-    verifying  → @observer reads artifact-1.png → JSON: passed: false, reason: "overflow on 375px viewport"
-  Attempt 2:
-    writeHistoryFile() → .loop-history.md
-    executing  → @designer reads .loop-history.md + reason → self-corrects → writes artifact-2.png
-    verifying  → @observer reads artifact-2.png → JSON: passed: true
-  → done
-```
-
-### Escalation to Council
-
-```
-Loop Engine:
-  Attempt 1..3: all FAIL (verification failed each time)
-  → attempts >= maxAttempts → phase = 'escalated' → fires onEscalated
-
-Orchestrator receives onEscalated:
-  "Loop reached max attempts. Dispatching @council to analyze failures..."
-  → calls council for macro-strategy synthesis
-  → human reviews Council output, decides next action
-```

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

+ 8 - 1
oh-my-opencode-slim.schema.json

@@ -9,7 +9,7 @@
       "type": "boolean"
     },
     "compactSidebar": {
-      "description": "Use the compact TUI sidebar layout when enabled.",
+      "description": "Use the compact TUI sidebar layout. Defaults to true; set false to use the expanded layout.",
       "type": "boolean"
     },
     "autoUpdate": {
@@ -366,6 +366,13 @@
           "type": "number",
           "minimum": 0
         },
+        "maxRetries": {
+          "default": 3,
+          "description": "Number of consecutive 429/rate-limit responses tolerated on the same model before aborting (or swapping to the next fallback model when a chain is configured).",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 9007199254740991
+        },
         "retry_on_empty": {
           "default": true,
           "description": "When true (default), empty provider responses are treated as failures, triggering fallback/retry. Set to false to treat them as successes.",

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

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

+ 18 - 4
src/agents/index.ts

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

+ 158 - 1
src/companion/manager.test.ts

@@ -1,5 +1,12 @@
 import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import {
@@ -54,6 +61,10 @@ function attachFakeChild(manager: CompanionManager): { killed: () => boolean } {
   return { killed: () => killed };
 }
 
+function companionPidFile(): string {
+  return path.join(path.dirname(stateFilePath()), 'companion.pid');
+}
+
 describe('CompanionManager', () => {
   it('writes an intro entry on load', () => {
     const m = make();
@@ -248,6 +259,10 @@ describe('CompanionManager', () => {
     const first = make('reload-session');
     first.onLoad();
     const firstChild = attachFakeChild(first);
+    writeFileSync(companionPidFile(), String(process.pid));
+    (first as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (first as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid =
+      process.pid;
 
     const second = make('reload-session');
     second.onLoad();
@@ -263,6 +278,11 @@ describe('CompanionManager', () => {
     const enabled = make('disable-session');
     enabled.onLoad();
     const child = attachFakeChild(enabled);
+    writeFileSync(companionPidFile(), String(process.pid));
+    (enabled as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (
+      enabled as unknown as { spawnedCompanionPid: number }
+    ).spawnedCompanionPid = process.pid;
 
     const disabled = new CompanionManager('disable-session', '/path', {
       enabled: false,
@@ -430,6 +450,143 @@ describe('CompanionManager', () => {
     expect(state.config.enabled).toBe(true);
   });
 
+  it('skips spawn when PID file points to a live process', () => {
+    // Write a PID file with our own PID (which is alive)
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, String(process.pid));
+
+    const m = make('test-pid-guard');
+    m.onLoad();
+
+    // Should not have spawned — PID file guard prevented it
+    // The session should still be written to state
+    const state = readState();
+    expect(state.sessions[0].session_id).toBe('test-pid-guard');
+  });
+
+  it('spawns when PID file contains a dead process', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    // Use an impossibly high PID that no kernel will ever assign
+    writeFileSync(pidFile, '999999999');
+
+    const m = make('test-stale-pid');
+    m.onLoad();
+
+    // Stale PID file should have been cleaned up
+    expect(existsSync(pidFile)).toBe(false);
+    const state = readState();
+    expect(state.sessions[0].session_id).toBe('test-stale-pid');
+  });
+
+  it('skips spawn while another process holds the PID file lock', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    const lock = `${pidFile}.lock`;
+    mkdirSync(lock);
+    writeFileSync(path.join(lock, 'owner'), String(process.pid));
+
+    const m = make('test-pending-pid');
+    m.onLoad();
+    m.onExit();
+
+    expect(existsSync(lock)).toBe(true);
+    expect(existsSync(pidFile)).toBe(false);
+  });
+
+  it('stores the spawned child PID in the PID file', () => {
+    const bin = path.join(TEST_DIR, 'fake-companion');
+    writeFileSync(bin, '#!/bin/sh\nexec sleep 30\n');
+    chmodSync(bin, 0o755);
+
+    const m = make('test-child-pid', '/path', {
+      enabled: true,
+      position: 'bottom-right',
+      size: 'medium',
+      binaryPath: bin,
+    });
+    m.onLoad();
+
+    const pid = Number(readFileSync(companionPidFile(), 'utf8'));
+    expect(Number.isInteger(pid)).toBe(true);
+    expect(pid).not.toBe(process.pid);
+    expect(pid).toBe(
+      (m as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid,
+    );
+  });
+
+  it('spawns when no PID file exists', () => {
+    const m = make('test-no-pid');
+    m.onLoad();
+    const state = readState();
+    expect(state.sessions[0].session_id).toBe('test-no-pid');
+  });
+
+  it('cleans up PID file on exit when this manager was the spawner', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, '999999999'); // stale PID so spawn proceeds
+
+    const m = make('test-pid-cleanup');
+    // Simulate a spawner by writing a PID file as if spawn succeeded.
+    // In reality the binary doesn't exist so spawn fails before writing,
+    // but the cleanup logic only fires when wasSpawner is true.
+    writeFileSync(pidFile, String(process.pid));
+    (m as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (m as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid =
+      process.pid;
+    m.onLoad();
+    m.onExit();
+
+    expect(existsSync(pidFile)).toBe(false);
+  });
+
+  it('does not delete PID file on exit when this manager was not the spawner', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, String(process.pid));
+
+    const m = make('test-pid-no-cleanup');
+    m.onLoad(); // skips spawn because PID is alive, wasSpawner stays false
+    m.onExit();
+
+    // Non-spawner must not delete the guard file
+    expect(existsSync(pidFile)).toBe(true);
+  });
+
+  it('does not delete a PID file owned by a different spawned child', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, '222222222');
+
+    const m = make('test-pid-different-child');
+    (m as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (m as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid =
+      111111111;
+    m.onExit();
+
+    expect(readFileSync(pidFile, 'utf8')).toBe('222222222');
+  });
+
+  it('does not kill the singleton when another session remains in state', () => {
+    const first = make('first-session');
+    const second = make('second-session');
+    first.onLoad();
+    second.onLoad();
+    const child = attachFakeChild(first);
+    const pidFile = companionPidFile();
+    writeFileSync(pidFile, String(process.pid));
+    (first as unknown as { wasSpawner: boolean }).wasSpawner = true;
+    (first as unknown as { spawnedCompanionPid: number }).spawnedCompanionPid =
+      process.pid;
+
+    first.onExit();
+
+    expect(child.killed()).toBe(false);
+    expect(readFileSync(pidFile, 'utf8')).toBe(String(process.pid));
+  });
+
   it('removes disabled session entries on load', () => {
     mkdirSync(path.dirname(stateFilePath()), { recursive: true });
     writeFileSync(

+ 159 - 13
src/companion/manager.ts

@@ -5,6 +5,7 @@ import {
   readFileSync,
   renameSync,
   rmSync,
+  statSync,
   writeFileSync,
 } from 'node:fs';
 import * as os from 'node:os';
@@ -61,6 +62,84 @@ export function stateFilePath(): string {
   );
 }
 
+function pidFilePath(): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg)
+      ? xdg
+      : path.join(os.homedir(), '.local', 'share');
+  return path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'companion.pid',
+  );
+}
+
+function isProcessAlive(pid: number): boolean {
+  if (!Number.isInteger(pid) || pid <= 0) return false;
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch (err) {
+    return (err as NodeJS.ErrnoException).code === 'EPERM';
+  }
+}
+
+function parsePidFile(raw: string): number | null {
+  const pid = Number(raw.trim());
+  if (!Number.isInteger(pid) || pid <= 0) return null;
+  return pid;
+}
+
+function acquirePidFileLock(file: string): (() => void) | null {
+  const lock = `${file}.lock`;
+  mkdirSync(path.dirname(lock), { recursive: true });
+  for (let attempt = 0; attempt < 2; attempt++) {
+    try {
+      mkdirSync(lock);
+      writeFileSync(path.join(lock, 'owner'), String(process.pid));
+      return () => {
+        try {
+          rmSync(lock, { recursive: true, force: true });
+        } catch {}
+      };
+    } catch (err) {
+      const code = (err as NodeJS.ErrnoException).code;
+      if (code !== 'EEXIST') throw err;
+      if (pidFileLockHasLiveOwner(lock)) return null;
+      log('[companion] removing stale PID file lock for dead process');
+      rmSync(lock, { recursive: true, force: true });
+    }
+  }
+  return null;
+}
+
+function acquirePidFileLockWithRetry(
+  file: string,
+  attempts: number,
+): (() => void) | null {
+  for (let attempt = 0; attempt < attempts; attempt++) {
+    const release = acquirePidFileLock(file);
+    if (release) return release;
+    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
+  }
+  return null;
+}
+
+function pidFileLockHasLiveOwner(lock: string): boolean {
+  try {
+    const owner = parsePidFile(readFileSync(path.join(lock, 'owner'), 'utf8'));
+    if (owner !== null) return isProcessAlive(owner);
+  } catch {
+    try {
+      return Date.now() - statSync(lock).mtimeMs < 5000;
+    } catch {}
+  }
+  return false;
+}
+
 function defaultBinaryPath(): string {
   const xdg = process.env.XDG_DATA_HOME?.trim();
   const base =
@@ -154,6 +233,8 @@ export class CompanionManager {
   private readonly busyAgentSessions = new Map<string, string>();
   private readonly config?: CompanionConfig;
   private companionProcess: ChildProcess | null = null;
+  private wasSpawner = false;
+  private spawnedCompanionPid: number | null = null;
 
   constructor(sessionId: string, cwd: string, config?: CompanionConfig) {
     this.id = sessionId;
@@ -261,12 +342,6 @@ export class CompanionManager {
 
   onExit(): void {
     activeManagers.delete(this);
-    if (this.companionProcess) {
-      try {
-        this.companionProcess.kill();
-      } catch {}
-      this.companionProcess = null;
-    }
     if (activeManagers.size === 0 && activeExitListener) {
       try {
         process.removeListener('exit', activeExitListener);
@@ -277,6 +352,37 @@ export class CompanionManager {
     writeState((state) => {
       state.sessions = state.sessions.filter((s) => s.session_id !== this.id);
     });
+    if (this.wasSpawner && this.removeOwnedPidFileIfNoSessionsRemain()) {
+      if (this.companionProcess) {
+        try {
+          this.companionProcess.kill();
+        } catch {}
+      }
+    }
+    this.companionProcess = null;
+  }
+
+  private removeOwnedPidFileIfNoSessionsRemain(): boolean {
+    if (this.spawnedCompanionPid == null) return true;
+    const file = pidFilePath();
+    const release = acquirePidFileLockWithRetry(file, 80);
+    if (!release) {
+      log('[companion] PID file lock busy during exit; leaving guard intact');
+      return false;
+    }
+    try {
+      if (readState().sessions.length > 0) return false;
+      if (!existsSync(file)) return true;
+      const parsed = parsePidFile(readFileSync(file, 'utf8'));
+      if (parsed === this.spawnedCompanionPid) {
+        rmSync(file, { force: true });
+      }
+      return true;
+    } catch {
+      return false;
+    } finally {
+      release();
+    }
   }
 
   /** One entry per running agent instance (two fixers → two cells). */
@@ -335,15 +441,37 @@ export class CompanionManager {
 
   private spawnIfAvailable(): void {
     if (this.config?.enabled !== true) return;
-    const bin = resolveCompanionBinaryPath(this.config);
-    if (!bin) {
-      const expected = this.config.binaryPath?.trim() || defaultBinaryPath();
-      log(
-        `[companion] enabled but companion binary not found at expected path: ${expected}. Please install/download the companion binary separately.`,
-      );
+    const pidFile = pidFilePath();
+    let releasePidFileLock: (() => void) | null = null;
+    try {
+      releasePidFileLock = acquirePidFileLockWithRetry(pidFile, 80);
+      if (releasePidFileLock === null) {
+        log('[companion] another instance already running, skipping spawn');
+        return;
+      }
+    } catch (err) {
+      log('[companion] PID file lock failed', String(err));
       return;
     }
+    let spawnedChild: ChildProcess | null = null;
     try {
+      if (existsSync(pidFile)) {
+        const existingPid = parsePidFile(readFileSync(pidFile, 'utf8'));
+        if (existingPid !== null && isProcessAlive(existingPid)) {
+          log('[companion] another instance already running, skipping spawn');
+          return;
+        }
+        log('[companion] removing stale PID file for dead process');
+        rmSync(pidFile, { force: true });
+      }
+      const bin = resolveCompanionBinaryPath(this.config);
+      if (!bin) {
+        const expected = this.config.binaryPath?.trim() || defaultBinaryPath();
+        log(
+          `[companion] enabled but companion binary not found at expected path: ${expected}. Please install/download the companion binary separately.`,
+        );
+        return;
+      }
       const child = spawn(bin, [], {
         detached: true,
         env: {
@@ -355,8 +483,19 @@ export class CompanionManager {
         },
         stdio: 'ignore',
       });
+      spawnedChild = child;
+      child.once('error', (err) => {
+        log('[companion] spawn failed', String(err));
+      });
       this.companionProcess = child;
       child.unref();
+      if (child.pid == null) {
+        log('[companion] spawn returned without a child PID, skipping guard');
+        return;
+      }
+      writeFileSync(pidFile, String(child.pid));
+      this.wasSpawner = true;
+      this.spawnedCompanionPid = child.pid;
       log(
         '[companion] spawned',
         JSON.stringify({
@@ -366,7 +505,14 @@ export class CompanionManager {
         }),
       );
     } catch (err) {
-      log('[companion] spawn failed', String(err));
+      if (spawnedChild && !this.wasSpawner) {
+        try {
+          spawnedChild.kill();
+        } catch {}
+      }
+      log('[companion] spawn guard failed', String(err));
+    } finally {
+      releasePidFileLock?.();
     }
   }
 }

+ 24 - 1
src/config/schema.ts

@@ -180,6 +180,16 @@ export const FailoverConfigSchema = z
     enabled: z.boolean().default(true),
     timeoutMs: z.number().min(0).default(15000),
     retryDelayMs: z.number().min(0).default(500),
+    maxRetries: z
+      .number()
+      .int()
+      .min(0)
+      .default(3)
+      .describe(
+        'Number of consecutive 429/rate-limit responses tolerated on the ' +
+          'same model before aborting (or swapping to the next fallback ' +
+          'model when a chain is configured).',
+      ),
     retry_on_empty: z
       .boolean()
       .default(true)
@@ -187,6 +197,17 @@ export const FailoverConfigSchema = z
         'When true (default), empty provider responses are treated as failures, ' +
           'triggering fallback/retry. Set to false to treat them as successes.',
       ),
+    runtimeOverride: z
+      .boolean()
+      .default(true)
+      .describe(
+        'When true (default), a runtime model selected via /model that is ' +
+          'outside the configured fallback chain will still trigger the chain ' +
+          'on rate-limit errors. When false, out-of-chain runtime picks are ' +
+          'respected and the error surfaces instead of silently falling back ' +
+          'to the chain. Models that are members of the chain always fall back ' +
+          'regardless of this setting.',
+      ),
   })
   .strict();
 
@@ -286,7 +307,9 @@ export const PluginConfigSchema = z
     compactSidebar: z
       .boolean()
       .optional()
-      .describe('Use the compact TUI sidebar layout when enabled.'),
+      .describe(
+        'Use the compact TUI sidebar layout. Defaults to true; set false to use the expanded layout.',
+      ),
     autoUpdate: z
       .boolean()
       .optional()

+ 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

+ 359 - 25
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<
@@ -187,7 +188,8 @@ describe('ForegroundFallbackManager session.error', () => {
       },
     });
 
-    expect(mocks.abort).toHaveBeenCalledTimes(1);
+    // promptAsync is called directly (no abort needed when it succeeds)
+    expect(mocks.abort).toHaveBeenCalledTimes(0);
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
 
     const call = mocks.promptAsync.mock.calls[0] as [
@@ -268,6 +270,7 @@ describe('ForegroundFallbackManager session.error', () => {
       },
     });
 
+    expect(mocks.abort).not.toHaveBeenCalled();
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 
@@ -287,10 +290,13 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 
-  test('continues fallback when abort rejects', async () => {
+  test('falls back to abort+retry when promptAsync fails on busy session', async () => {
     const { client, mocks } = createMockClient({
+      promptAsyncImpl: async () => {
+        throw new Error('session busy');
+      },
       abortImpl: async () => {
-        throw new Error('abort failed');
+        // abort succeeds on first call
       },
     });
     const mgr = new ForegroundFallbackManager(client, makeChains(), true);
@@ -298,13 +304,14 @@ describe('ForegroundFallbackManager session.error', () => {
     await mgr.handleEvent({
       type: 'session.error',
       properties: {
-        sessionID: 'sess-abort-rejects',
+        sessionID: 'sess-busy',
         error: { message: 'Rate limit exceeded' },
       },
     });
 
+    // First promptAsync attempt failed → abort called, then promptAsync retried
     expect(mocks.abort).toHaveBeenCalledTimes(1);
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
   });
 });
 
@@ -377,9 +384,8 @@ describe('ForegroundFallbackManager message.updated', () => {
 describe('ForegroundFallbackManager session.status', () => {
   test('triggers fallback on retry status with rate limit message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
 
-    // Pre-seed model
     await mgr.handleEvent({
       type: 'message.updated',
       properties: {
@@ -404,7 +410,7 @@ describe('ForegroundFallbackManager session.status', () => {
 
   test('triggers fallback on retry status with insufficient balance message', async () => {
     const { client, mocks } = createMockClient();
-    const mgr = new ForegroundFallbackManager(client, makeChains(), true);
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 1);
 
     await mgr.handleEvent({
       type: 'message.updated',
@@ -442,6 +448,62 @@ describe('ForegroundFallbackManager session.status', () => {
 
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
+
+  test('tracks retries and only intervenes after maxRetries', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+
+    // Pre-seed model
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-retry',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    // First two retries should be absorbed (maxRetries - 1 = 2)
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-retry',
+        status: {
+          type: 'retry',
+          attempt: 1,
+          message: 'rate limit, retrying...',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-retry',
+        status: {
+          type: 'retry',
+          attempt: 2,
+          message: 'rate limit, retrying...',
+        },
+      },
+    });
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
+
+    // Third retry exhausts the budget → tryFallback intervenes
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-retry',
+        status: {
+          type: 'retry',
+          attempt: 3,
+          message: 'rate limit, retrying...',
+        },
+      },
+    });
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -480,7 +542,7 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
     expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 
-  test('does not call promptAsync when all chain models have been tried', async () => {
+  test('aborts when all chain models have been tried', async () => {
     // Scenario: chain = ['anthropic/claude-a', 'openai/gpt-b'].
     // Current model is 'openai/gpt-b' (the last fallback already in use).
     // tried will contain: 'openai/gpt-b' (current) → chain.find() → 'anthropic/claude-a'
@@ -513,6 +575,7 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
 
     // Session B (fresh session, different ID): only model-y is in chain and it IS
     // the current model → tried gets model-y → chain.find() = undefined → exhausted
+    // → abort called to stop the freeze
     const { client: client2, mocks: mocks2 } = createMockClient();
     const mgr2 = new ForegroundFallbackManager(
       client2,
@@ -531,6 +594,7 @@ describe('ForegroundFallbackManager chain exhaustion', () => {
         },
       },
     });
+    expect(mocks2.abort).toHaveBeenCalledTimes(1);
     expect(mocks2.promptAsync).not.toHaveBeenCalled();
   });
 });
@@ -672,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({
@@ -689,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)
@@ -726,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({
@@ -745,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({
@@ -864,3 +934,267 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     expect(call[0].body.model.modelID).toBe('glm-5.2');
   });
 });
+
+// ---------------------------------------------------------------------------
+// runtimeOverride config
+// ---------------------------------------------------------------------------
+
+describe('ForegroundFallbackManager runtimeOverride', () => {
+  test('falls back for out-of-chain model when runtimeOverride=true (default)', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      true, // runtimeOverride
+    );
+
+    // Simulate session using a model NOT in any chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // runtimeOverride=true → should fall back even for out-of-chain model
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[0] = anthropic/claude-opus-4-5
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
+  });
+
+  test('skips fallback for out-of-chain model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using a model NOT in any chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-2',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // runtimeOverride=false + model not in chain → abort session, no fallback
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('always falls back for in-chain model regardless of runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using a model that IS in the chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-3',
+          agent: 'orchestrator',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain → should fall back regardless of runtimeOverride
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[1] = openai/gpt-4o (chain[0] is the current model)
+    expect(call[0].body.model.providerID).toBe('openai');
+    expect(call[0].body.model.modelID).toBe('gpt-4o');
+  });
+
+  test('falls back for in-chain secondary model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate session using chain[1] — still in chain
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-4',
+          agent: 'orchestrator',
+          providerID: 'openai',
+          modelID: 'gpt-4o',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain → should fall back
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    // Falls back to chain[0] = anthropic/claude-opus-4-5 (chain[1] is tried)
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
+  });
+
+  test('falls back for unknown agent with in-chain model when runtimeOverride=false', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Simulate unknown agent (e.g. "compaction") using a model that IS in
+    // the orchestrator chain — resolveChain infers the chain from the model.
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-5',
+          agent: 'compaction',
+          providerID: 'openai',
+          modelID: 'gpt-4o',
+          error: { message: 'rate limit exceeded' },
+        },
+      },
+    });
+
+    // Model IS in chain (resolved via model matching) → should fall back
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('session.error with runtimeOverride=false and out-of-chain model aborts session', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3,
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Seed session with out-of-chain model
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-err-override',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+        },
+      },
+    });
+
+    // Trigger session.error with rate limit
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-err-override',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+
+  test('session.status with runtimeOverride=false and out-of-chain model aborts after retry budget exhausted', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      client,
+      makeChains(),
+      true,
+      3, // maxRetries
+      undefined,
+      false, // runtimeOverride
+    );
+
+    // Seed session with out-of-chain model
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-status-override',
+          agent: 'orchestrator',
+          providerID: 'custom',
+          modelID: 'expensive-model',
+        },
+      },
+    });
+
+    // First retry (attempt 1) — absorbed by retry budget
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.abort).toHaveBeenCalledTimes(0);
+
+    // Second retry (attempt 2) — absorbed
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.abort).toHaveBeenCalledTimes(0);
+
+    // Third retry (attempt 3) — budget exhausted, tryFallback runs, guard aborts
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-override',
+        status: { type: 'retry', message: 'rate limit, retrying...' },
+      },
+    });
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(0);
+    expect(mocks.abort).toHaveBeenCalledTimes(1);
+  });
+});

+ 133 - 37
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'];
@@ -99,6 +100,15 @@ export class ForegroundFallbackManager {
    *  when the model has changed, allowing the cascade to continue when a
    *  new fallback model also fails within the dedup window. */
   private readonly lastTriggerModel = new Map<string, string>();
+  /** sessionID → consecutive 429 count for the current model.
+   *  Reset on model swap or session deletion. */
+  private readonly sessionRetries = new Map<string, number>();
+
+  /** Exposed for task-session-manager: prevents idle reconciliation
+   *  while a fallback abort/re-prompt is in flight for this session. */
+  isFallbackInProgress(sessionID: string): boolean {
+    return this.inProgress.has(sessionID);
+  }
 
   constructor(
     private readonly client: OpencodeClient,
@@ -109,7 +119,29 @@ export class ForegroundFallbackManager {
      */
     private readonly chains: Record<string, string[]>,
     private readonly enabled: boolean,
-  ) {}
+    /** Consecutive 429s tolerated on the same model before swap/abort. */
+    private readonly maxRetries: number = 3,
+    coordinator?: SessionLifecycle,
+    /**
+     * When true (default), a runtime model outside the configured chain
+     * still triggers fallback on rate-limit errors. When false, out-of-chain
+     * runtime picks are respected and the error surfaces instead. Models
+     * that are members of the chain always fall back regardless.
+     */
+    private readonly runtimeOverride: boolean = true,
+  ) {
+    if (coordinator) {
+      coordinator.onSessionDeleted((id) => {
+        this.sessionModel.delete(id);
+        this.sessionAgent.delete(id);
+        this.sessionTried.delete(id);
+        this.inProgress.delete(id);
+        this.lastTrigger.delete(id);
+        this.lastTriggerModel.delete(id);
+        this.sessionRetries.delete(id);
+      });
+    }
+  }
 
   /**
    * Process an OpenCode plugin event.
@@ -144,7 +176,12 @@ export class ForegroundFallbackManager {
         }
         // Rate-limit on an individual message
         if (info.error && isRateLimitError(info.error)) {
-          await this.tryFallback(sessionID);
+          if (this.shouldIntervene(sessionID)) {
+            await this.tryFallback(sessionID);
+          }
+        } else {
+          // Successful response: clear retry count so recovery is not forgotten.
+          this.sessionRetries.delete(sessionID);
         }
         break;
       }
@@ -153,7 +190,12 @@ export class ForegroundFallbackManager {
         const props = event.properties as
           | { sessionID?: string; error?: unknown }
           | undefined;
-        if (props?.sessionID && props.error && isRateLimitError(props.error)) {
+        if (
+          props?.sessionID &&
+          props.error &&
+          isRateLimitError(props.error) &&
+          this.shouldIntervene(props.sessionID)
+        ) {
           await this.tryFallback(props.sessionID);
         }
         break;
@@ -163,15 +205,11 @@ export class ForegroundFallbackManager {
         const props = event.properties as
           | {
               sessionID?: string;
-              status?: { type?: string; message?: string };
+              status?: { type?: string; message?: string; attempt?: number };
             }
           | undefined;
         if (!props?.sessionID || !props.status?.message) break;
         const msg = props.status.message.toLowerCase();
-        // Check for rate-limit signals in the status message regardless of
-        // status type. OpenCode proxies may emit monthly/weekly/5-hour usage
-        // limit errors with type 'error' instead of 'retry' on fresh sessions
-        // where no retry is attempted - the retry-type guard would miss them.
         if (
           msg.includes('rate limit') ||
           msg.includes('usage limit') ||
@@ -183,7 +221,14 @@ export class ForegroundFallbackManager {
           msg.includes('high concurrency') ||
           msg.includes('reduce concurrency')
         ) {
-          await this.tryFallback(props.sessionID);
+          // session.status retry path always counts toward the budget
+          // — even the first retry is absorbed before intervening.
+          if (this.checkRetryBudget(props.sessionID)) {
+            await this.tryFallback(props.sessionID);
+          }
+        } else {
+          // Non-rate-limit status: clear retry count (recovery).
+          this.sessionRetries.delete(props.sessionID);
         }
         break;
       }
@@ -200,29 +245,52 @@ 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);
+          log('[foreground-fallback] session.deleted observed', {
+            sessionID: id,
+          });
         }
         break;
       }
     }
   }
 
+  // ---------------------------------------------------------------------------
+  // Retry budget
+  // ---------------------------------------------------------------------------
+
+  /** Increment retry counter and return true when the budget is exhausted.
+   *  Used by the session.status retry path — each retry counts toward the
+   *  budget and only triggers fallback after maxRetries - 1 absorptions.
+   *  Non-retry paths (session.error / message.updated) use shouldIntervene(),
+   *  which bypasses the counter on first occurrence. */
+  private checkRetryBudget(sessionID: string): boolean {
+    const tried = this.sessionRetries.get(sessionID) ?? 0;
+    if (tried < this.maxRetries - 1) {
+      this.sessionRetries.set(sessionID, tried + 1);
+      log('[foreground-fallback] rate-limit retry', {
+        sessionID,
+        attempt: tried + 1,
+        remaining: this.maxRetries - tried - 1,
+      });
+      return false;
+    }
+    this.sessionRetries.delete(sessionID);
+    return true;
+  }
+
+  /** For non-retry paths (session.error, message.updated): intervene immediately
+   *  unless the session is already in a retry window (has prior retries). */
+  private shouldIntervene(sessionID: string): boolean {
+    const tried = this.sessionRetries.get(sessionID) ?? 0;
+    if (tried === 0) return true;
+    return this.checkRetryBudget(sessionID);
+  }
+
   // ---------------------------------------------------------------------------
   // Core fallback logic
   // ---------------------------------------------------------------------------
@@ -271,6 +339,29 @@ export class ForegroundFallbackManager {
         currentModel = chain[0];
       }
 
+      // Guard: when runtimeOverride is false, skip fallback for models
+      // that are not members of the configured chain. This respects a
+      // deliberate runtime `/model` pick (e.g. an expensive model outside
+      // the chain) and lets the error surface instead of silently swapping
+      // to the chain's default. Models that ARE in the chain always fall
+      // back normally regardless of this setting.
+      if (
+        !this.runtimeOverride &&
+        currentModel &&
+        !chain.includes(currentModel)
+      ) {
+        log('[foreground-fallback] current model not in chain, skipping fallback (runtimeOverride=false)', {
+          sessionID,
+          agentName,
+          currentModel,
+          chain,
+        });
+        // Abort the session so the rate-limit error surfaces to the user
+        // instead of leaving the session in a silent retry loop.
+        await abortSessionWithTimeout(this.client, sessionID);
+        return;
+      }
+
       if (!this.sessionTried.has(sessionID)) {
         this.sessionTried.set(sessionID, new Set());
       }
@@ -299,15 +390,18 @@ export class ForegroundFallbackManager {
           this.sessionTried.set(sessionID, tried);
           nextModel = stickyFallback;
         } else {
-          log('[foreground-fallback] fallback chain exhausted', {
+          log('[foreground-fallback] fallback chain exhausted, aborting', {
             sessionID,
             agentName,
             tried: [...tried],
           });
+          await abortSessionWithTimeout(this.client, sessionID);
           return;
         }
       }
       tried.add(nextModel);
+      // Reset retry count on model switch — the new model starts fresh.
+      this.sessionRetries.delete(sessionID);
 
       const ref = parseModelReference(nextModel);
       if (!ref) {
@@ -351,25 +445,27 @@ export class ForegroundFallbackManager {
         return;
       }
 
-      // Abort the currently rate-limited prompt so the session becomes idle.
+      // Try queuing the fallback prompt without aborting first. If OpenCode
+      // accepts it (204), the fallback model replaces the retry loop
+      // transparently — no dialog, no session error shown to the user.
+      // If promptAsync throws (e.g. session busy), fall back to abort+retry.
       try {
-        await abortSessionWithTimeout(this.client, sessionID);
-      } catch (error) {
-        // Session may already be idle or abort may be slow; keep fallback best-effort.
-        log('[foreground-fallback] abort did not complete cleanly', {
+        await sessionClient.promptAsync({
+          path: { id: sessionID },
+          body: { parts: lastUser.parts, model: ref },
+        });
+      } catch (_promptErr) {
+        log('[foreground-fallback] promptAsync on busy session, aborting', {
           sessionID,
-          error: error instanceof Error ? error.message : String(error),
+        });
+        await abortSessionWithTimeout(this.client, sessionID);
+        await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
+        await sessionClient.promptAsync({
+          path: { id: sessionID },
+          body: { parts: lastUser.parts, model: ref },
         });
       }
 
-      // Give the server a moment to finalise the abort before re-prompting.
-      await new Promise((r) => setTimeout(r, REPROMPT_DELAY_MS));
-
-      await sessionClient.promptAsync({
-        path: { id: sessionID },
-        body: { parts: lastUser.parts, model: ref },
-      });
-
       this.sessionModel.set(sessionID, nextModel);
       log('[foreground-fallback] switched to fallback model', {
         sessionID,

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

+ 215 - 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';
 
@@ -13,6 +14,8 @@ function createHook(options?: {
   readContextMaxFiles?: number;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
+  isFallbackInProgress?: (sessionID: string) => boolean;
+  coordinator?: SessionLifecycle;
 }) {
   const hook = createTaskSessionManagerHook(
     {
@@ -30,6 +33,8 @@ function createHook(options?: {
       readContextMaxFiles: options?.readContextMaxFiles,
       backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
+      isFallbackInProgress: options?.isFallbackInProgress,
+      coordinator: options?.coordinator,
     },
   );
 
@@ -1808,7 +1813,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'](
       {
@@ -1835,12 +1841,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);
@@ -1849,7 +1850,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'](
       {
@@ -1865,12 +1867,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'](
       {
@@ -1891,9 +1888,211 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
 
-  test('parent deletion clears jobs and pending calls', async () => {
+  test('reconciles running child session job from session.idle event', async () => {
     const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: (id) => id === 'parent-1',
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('ignores session.idle for already reconciled job', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+    board.markReconciled('child-1');
+
     const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('does not reconcile from idle when fallback is in progress', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: (id) => id === 'parent-1',
+      isFallbackInProgress: (id) => id === 'child-1',
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    // Job should still be running — not reconciled
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+  });
+
+  test('reconciles from idle when fallback guard passes', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: (id) => id === 'parent-1',
+      // isFallbackInProgress returns false for child-1
+      isFallbackInProgress: () => false,
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('busy-after-idle from fallback re-prompt leaves job running', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      timedOut: false,
+    });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+      isFallbackInProgress: (id) => id === 'child-1',
+    });
+
+    // First idle (abort from fallback) — guarded, no reconciliation
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    // Busy signal (fallback re-prompt) — updates lastLiveBusyAt
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'child-1', status: { type: 'busy' } },
+      },
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    // Second idle (real completion) — fallback no longer in progress
+    const hook2 = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+      isFallbackInProgress: () => false,
+    });
+    await hook2.hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('cancelled job is not reconciled from idle', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    board.markCancelled('child-1', 'explicit cancel');
+    expect(board.get('child-1')).toMatchObject({ state: 'cancelled' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: () => false,
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
+    });
+
+    // Should remain cancelled — idle does not override terminal state
+    const job = board.get('child-1');
+    expect(job?.state).toBe('cancelled');
+  });
+
+  test('idle via session.status idle path triggers reconciliation', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'fix bug',
+    });
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+
+    const { hook } = createHook({
+      backgroundJobBoard: board,
+      shouldManageSession: (id) => id === 'parent-1',
+    });
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'child-1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalState: 'completed',
+    });
+  });
+
+  test('parent deletion clears jobs and pending calls', async () => {
+    const coordinator = new SessionLifecycle(() => {});
+    const board = new BackgroundJobBoard();
+    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' } },
@@ -1905,9 +2104,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') },

+ 62 - 30
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,
@@ -91,8 +93,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-
+     *  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 =
@@ -110,6 +119,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 {
@@ -582,7 +602,8 @@ 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,
           managesSession: sessionId
@@ -591,6 +612,7 @@ export function createTaskSessionManagerHook(
           terminalJobsPending: sessionId
             ? (terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
             : 0,
+          runningJobForSession: job?.state === 'running' || false,
         });
         if (sessionId && options.shouldManageSession(sessionId)) {
           setTimeout(
@@ -598,12 +620,44 @@ export function createTaskSessionManagerHook(
             IDLE_RECONCILE_DELAY_MS,
           ).unref?.();
         }
+
+        // Fallback: for background child sessions that go idle without
+        // an injected completion, reconcile the board entry since the
+        // session being idle is itself the completion signal.
+        // Guard: skip when a foreground-fallback abort/re-prompt is in
+        // flight for this session — the idle is transient, not a real
+        // completion.
+        if (
+          job &&
+          sessionId &&
+          job.state === 'running' &&
+          !options.isFallbackInProgress?.(sessionId)
+        ) {
+          log('[task-session-manager] reconciled running job from idle', {
+            sessionID: sessionId,
+            alias: job.alias,
+            parentSessionID: job.parentSessionID,
+          });
+          backgroundJobBoard.updateStatus({
+            taskID: sessionId,
+            state: 'completed',
+            resultSummary:
+              'Background task completed (reconciled from idle event)',
+          });
+          backgroundJobBoard.markReconciled(sessionId);
+          taskContextTracker.pendingManagedTaskIds.delete(sessionId);
+          backgroundJobBoard.addContext(
+            sessionId,
+            taskContextTracker.contextFilesForPrompt(sessionId),
+          );
+          taskContextTracker.prune(backgroundJobBoard);
+        }
         return;
       }
 
       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
@@ -627,7 +681,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;
@@ -661,34 +715,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,
+      });
     },
   };
 

+ 119 - 156
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,53 +262,45 @@ 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
+    // 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.
     foregroundFallback = new ForegroundFallbackManager(
       ctx.client,
       runtimeChains,
-      config.fallback?.enabled !== false &&
-        Object.keys(runtimeChains).length > 0,
+      config.fallback?.enabled !== false,
+      config.fallback?.maxRetries ?? 3,
+      sessionLifecycle,
+      config.fallback?.runtimeOverride ?? true,
     );
 
     deepworkCommandHook = createDeepworkCommandHook();
@@ -312,10 +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(
@@ -325,7 +385,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     );
     cancelTaskTools = createCancelTaskTool({
       client: ctx.client,
-      backgroundJobBoard,
+      backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
@@ -842,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'
@@ -882,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);
         }
@@ -901,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) => {
@@ -1048,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.
@@ -1093,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);
     },
   };
 };

+ 6 - 2
src/multiplexer/codemap.md

@@ -12,6 +12,8 @@ Provides a unified abstraction layer for terminal multiplexers (tmux and zellij)
 - **Concrete Implementations**:
   - `TmuxMultiplexer`: tmux-specific implementation using `tmux` CLI commands
   - `ZellijMultiplexer`: zellij-specific implementation using zellij plugin API
+  - `HerdrMultiplexer`: herdr-specific implementation using `herdr` CLI commands
+- **Shared Utilities** (`shared.ts`): `quoteShellArg`, `buildOpencodeAttachCommand`, and `findBinary` — extracted from the three adapters to eliminate copy-paste duplication.
 - **Session Manager** (`session-manager.ts`): Tracks child session lifecycle and coordinates pane operations via event-driven architecture.
 - **Factory** (`factory.ts`): Creates appropriate multiplexer instance based on configuration and environment detection.
 
@@ -164,7 +166,9 @@ interface MultiplexerConfig {
 |------|---------|
 | `index.ts` | Public API exports |
 | `types.ts` | Core interfaces and shared utilities |
+| `shared.ts` | Shared infrastructure (quoteShellArg, buildOpencodeAttachCommand, findBinary) |
 | `factory.ts` | Multiplexer instance creation |
 | `session-manager.ts` | Session lifecycle management |
-| `tmux.ts` | tmux-specific implementation |
-| `zellij.ts` | zellij-specific implementation |
+| `tmux/index.ts` | tmux-specific implementation |
+| `zellij/index.ts` | zellij-specific implementation |
+| `herdr/index.ts` | herdr-specific implementation |

+ 19 - 96
src/multiplexer/herdr/index.ts

@@ -15,6 +15,12 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+  normalizePathForShell,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 type HerdrPaneDirection = 'right' | 'down';
@@ -47,7 +53,7 @@ export class HerdrMultiplexer implements Multiplexer {
       return this.binaryPath !== null;
     }
 
-    this.binaryPath = await this.findBinary();
+    this.binaryPath = await findBinary('herdr');
     this.hasChecked = true;
     return this.binaryPath !== null;
   }
@@ -69,6 +75,10 @@ export class HerdrMultiplexer implements Multiplexer {
     }
 
     try {
+      // Normalize Windows backslashes→/ so sh -lc (MSYS2) doesn't
+      // corrupt --cwd (issue #568).
+      const attachDir = normalizePathForShell(directory);
+
       // 1. Split the parent pane to create a new one
       const splitArgs = [
         herdr,
@@ -78,7 +88,7 @@ export class HerdrMultiplexer implements Multiplexer {
         '--direction',
         this.paneDirection,
         '--cwd',
-        directory,
+        attachDir,
         '--no-focus',
       ];
 
@@ -120,7 +130,7 @@ export class HerdrMultiplexer implements Multiplexer {
       const opencodeCmd = buildOpencodeAttachCommand(
         sessionId,
         serverUrl,
-        directory,
+        attachDir,
       );
 
       log('[herdr] spawnPane: running attach command', {
@@ -152,50 +162,13 @@ export class HerdrMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const herdr = await this.getBinary();
-    if (!herdr) {
-      log('[herdr] closePane: herdr binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[herdr] closePane: sending Ctrl+C', { paneId });
-      await crossSpawn([herdr, 'pane', 'send-keys', paneId, 'ctrl+c'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      log('[herdr] closePane: closing pane', { paneId });
-      const proc = crossSpawn([herdr, 'pane', 'close', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[herdr] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0 || exitCode === 1) {
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[herdr] closePane: failed (pane may already be closed)', {
-        paneId,
-      });
-      return false;
-    } catch (err) {
-      log('[herdr] closePane: exception', { error: String(err) });
-      return false;
-    }
+    return gracefulClosePane(herdr, paneId, {
+      ctrlC: ['pane', 'send-keys', paneId, 'ctrl+c'],
+      close: ['pane', 'close', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(
@@ -215,36 +188,6 @@ export class HerdrMultiplexer implements Multiplexer {
     await this.isAvailable();
     return this.binaryPath;
   }
-
-  private async findBinary(): Promise<string | null> {
-    const cmd = process.platform === 'win32' ? 'where' : 'which';
-
-    try {
-      const proc = crossSpawn([cmd, 'herdr'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) {
-        log("[herdr] findBinary: 'which herdr' failed", { exitCode });
-        return null;
-      }
-
-      const stdout = await proc.stdout();
-      const path = stdout.trim().split('\n')[0];
-      if (!path) {
-        log('[herdr] findBinary: no path in output');
-        return null;
-      }
-
-      log('[herdr] findBinary: found', { path });
-      return path;
-    } catch (err) {
-      log('[herdr] findBinary: exception', { error: String(err) });
-      return null;
-    }
-  }
 }
 
 /**
@@ -284,23 +227,3 @@ function getPaneDirection(layout: MultiplexerLayout): HerdrPaneDirection {
       return 'right';
   }
 }
-
-function buildOpencodeAttachCommand(
-  sessionId: string,
-  serverUrl: string,
-  directory: string,
-): string {
-  return [
-    'opencode',
-    'attach',
-    quoteShellArg(serverUrl),
-    '--session',
-    quoteShellArg(sessionId),
-    '--dir',
-    quoteShellArg(directory),
-  ].join(' ');
-}
-
-function quoteShellArg(value: string): string {
-  return `'${value.replace(/'/g, `'\\''`)}'`;
-}

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

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

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

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

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

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

+ 145 - 0
src/multiplexer/shared.ts

@@ -0,0 +1,145 @@
+/**
+ * Shared multiplexer infrastructure
+ *
+ * Functions used across tmux, zellij, and herdr backend adapters.
+ * Extracted to eliminate copy-paste duplication and prevent drift.
+ */
+
+import { crossSpawn } from '../utils/compat';
+import { log } from '../utils/logger';
+
+export function quoteShellArg(value: string): string {
+  return `'${value.replace(/'/g, `'\\''`)}'`;
+}
+
+/** Normalize Windows backslashes to / so sh -lc (MSYS2/Git Bash) doesn't treat them as escape chars. */
+export function normalizePathForShell(directory: string): string {
+  return process.platform === 'win32'
+    ? directory.replace(/\\/g, '/')
+    : directory;
+}
+
+export function buildOpencodeAttachCommand(
+  sessionId: string,
+  serverUrl: string,
+  directory: string,
+): string {
+  const attachDir = normalizePathForShell(directory);
+  return [
+    'opencode',
+    'attach',
+    quoteShellArg(serverUrl),
+    '--session',
+    quoteShellArg(sessionId),
+    '--dir',
+    quoteShellArg(attachDir),
+  ].join(' ');
+}
+
+export async function findBinary(
+  binaryName: string,
+  options: { verify?: boolean } = {},
+): Promise<string | null> {
+  const isWindows = process.platform === 'win32';
+  const cmd = isWindows ? 'where' : 'which';
+  const logPrefix = `[${binaryName}]`;
+
+  try {
+    const proc = crossSpawn([cmd, binaryName], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+
+    const exitCode = await proc.exited;
+    if (exitCode !== 0) {
+      log(`${logPrefix} findBinary: '${cmd} ${binaryName}' failed`, {
+        exitCode,
+      });
+      return null;
+    }
+
+    const stdout = await proc.stdout();
+    const path = stdout.trim().split('\n')[0];
+    if (!path) {
+      log(`${logPrefix} findBinary: no path in output`);
+      return null;
+    }
+
+    log(`${logPrefix} findBinary: found`, { path });
+
+    // Verify the binary works if requested
+    if (options.verify) {
+      try {
+        const verifyProc = crossSpawn([path, '-V'], {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        });
+        const verifyExitCode = await verifyProc.exited;
+        if (verifyExitCode !== 0) {
+          log(`${logPrefix} findBinary: verification failed for ${path}`);
+          return null;
+        }
+        const verifyStdout = await verifyProc.stdout();
+        log(`${logPrefix} findBinary: verified`, {
+          version: verifyStdout.trim(),
+        });
+      } catch (verifyErr) {
+        log(`${logPrefix} findBinary: verification exception`, {
+          error: String(verifyErr),
+        });
+        return null;
+      }
+    }
+
+    return path;
+  } catch (err) {
+    log(`${logPrefix} findBinary: exception`, { error: String(err) });
+    return null;
+  }
+}
+
+const GRACEFUL_SHUTDOWN_DELAY_MS = 250;
+
+export interface GracefulClosePaneOptions {
+  /** Backend-specific Ctrl+C command args (binary prepended by caller). */
+  ctrlC: string[];
+  /** Backend-specific close/kill command args (binary prepended by caller). */
+  close: string[];
+  /** Accept exit code 1 as success (zellij/herdr treat "already closed" as 1). */
+  acceptExitCode1?: boolean;
+  /** Return true for empty/unknown paneId instead of false (zellij/herdr behavior). */
+  emptyPaneReturnsTrue?: boolean;
+}
+
+export async function gracefulClosePane(
+  binary: string | null,
+  paneId: string,
+  options: GracefulClosePaneOptions,
+): Promise<boolean> {
+  if (!binary) return false;
+
+  const isEmpty = !paneId || paneId === 'unknown';
+  if (isEmpty) return options.emptyPaneReturnsTrue ?? false;
+
+  try {
+    const ctrlCProc = crossSpawn([binary, ...options.ctrlC], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    await ctrlCProc.exited;
+
+    await new Promise((r) => setTimeout(r, GRACEFUL_SHUTDOWN_DELAY_MS));
+
+    const proc = crossSpawn([binary, ...options.close], {
+      stdout: 'ignore',
+      stderr: 'ignore',
+    });
+    const exitCode = await proc.exited;
+
+    if (exitCode === 0) return true;
+    if (options.acceptExitCode1 && exitCode === 1) return true;
+    return false;
+  } catch {
+    return false;
+  }
+}

+ 17 - 107
src/multiplexer/tmux/index.ts

@@ -5,6 +5,11 @@
 import type { MultiplexerLayout } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 const TMUX_LAYOUT_DEBOUNCE_MS = 150;
@@ -30,7 +35,7 @@ export class TmuxMultiplexer implements Multiplexer {
       return this.binaryPath !== null;
     }
 
-    this.binaryPath = await this.findBinary();
+    this.binaryPath = await findBinary('tmux', { verify: true });
     this.hasChecked = true;
     return this.binaryPath !== null;
   }
@@ -53,19 +58,11 @@ export class TmuxMultiplexer implements Multiplexer {
 
     try {
       // Build the attach command
-      const quotedDirectory = quoteShellArg(directory);
-      const quotedUrl = quoteShellArg(serverUrl);
-      const quotedSessionId = quoteShellArg(sessionId);
-
-      const opencodeCmd = [
-        'opencode',
-        'attach',
-        quotedUrl,
-        '--session',
-        quotedSessionId,
-        '--dir',
-        quotedDirectory,
-      ].join(' ');
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+      );
 
       // tmux split-window -h -d -P -F '#{pane_id}' <cmd>
       const args = [
@@ -120,54 +117,13 @@ export class TmuxMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId) {
-      log('[tmux] closePane: no paneId provided');
-      return false;
-    }
-
     const tmux = await this.getBinary();
-    if (!tmux) {
-      log('[tmux] closePane: tmux binary not found');
-      return false;
-    }
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      log('[tmux] closePane: sending Ctrl+C', { paneId });
-      const ctrlCProc = crossSpawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      await ctrlCProc.exited;
-
-      // Wait for graceful shutdown
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Kill the pane
-      log('[tmux] closePane: killing pane', { paneId });
-      const proc = crossSpawn([tmux, 'kill-pane', '-t', paneId], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      const stderr = await proc.stderr();
-
-      log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
-
-      if (exitCode === 0) {
-        // Rebalance panes after bursts of child sessions settle.
-        this.scheduleLayout();
-        return true;
-      }
-
-      // Pane might already be closed
-      log('[tmux] closePane: failed (pane may already be closed)', { paneId });
-      return false;
-    } catch (err) {
-      log('[tmux] closePane: exception', { error: String(err) });
-      return false;
-    }
+    const closed = await gracefulClosePane(tmux, paneId, {
+      ctrlC: ['send-keys', '-t', paneId, 'C-c'],
+      close: ['kill-pane', '-t', paneId],
+    });
+    if (closed) this.scheduleLayout();
+    return closed;
   }
 
   async applyLayout(
@@ -275,50 +231,4 @@ export class TmuxMultiplexer implements Multiplexer {
   private targetArgs(): string[] {
     return this.targetPane ? ['-t', this.targetPane] : [];
   }
-
-  private async findBinary(): Promise<string | null> {
-    const isWindows = process.platform === 'win32';
-    const cmd = isWindows ? 'where' : 'which';
-
-    try {
-      const proc = crossSpawn([cmd, 'tmux'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) {
-        log("[tmux] findBinary: 'which tmux' failed", { exitCode });
-        return null;
-      }
-
-      const stdout = await proc.stdout();
-      const path = stdout.trim().split('\n')[0];
-      if (!path) {
-        log('[tmux] findBinary: no path in output');
-        return null;
-      }
-
-      // Verify it works
-      const verifyProc = crossSpawn([path, '-V'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      const verifyExit = await verifyProc.exited;
-      if (verifyExit !== 0) {
-        log('[tmux] findBinary: tmux -V failed', { path, verifyExit });
-        return null;
-      }
-
-      log('[tmux] findBinary: found', { path });
-      return path;
-    } catch (err) {
-      log('[tmux] findBinary: exception', { error: String(err) });
-      return null;
-    }
-  }
-}
-
-function quoteShellArg(value: string): string {
-  return `'${value.replace(/'/g, `'\\''`)}'`;
 }

+ 13 - 63
src/multiplexer/zellij/index.ts

@@ -14,6 +14,12 @@
 
 import type { MultiplexerLayout, ZellijPaneMode } from '../../config/schema';
 import { crossSpawn } from '../../utils/compat';
+import {
+  buildOpencodeAttachCommand,
+  findBinary,
+  gracefulClosePane,
+  quoteShellArg,
+} from '../shared';
 import type { Multiplexer, PaneResult } from '../types';
 
 interface ZellijTabInfo {
@@ -58,7 +64,7 @@ export class ZellijMultiplexer implements Multiplexer {
     if (this.hasChecked) {
       return this.binaryPath !== null;
     }
-    this.binaryPath = await this.findBinary();
+    this.binaryPath = await findBinary('zellij');
     this.hasChecked = true;
     return this.binaryPath !== null;
   }
@@ -489,34 +495,13 @@ export class ZellijMultiplexer implements Multiplexer {
   }
 
   async closePane(paneId: string): Promise<boolean> {
-    if (!paneId || paneId === 'unknown') return true;
-
     const zellij = await this.getBinary();
-    if (!zellij) return false;
-
-    try {
-      // Send Ctrl+C for graceful shutdown
-      await crossSpawn(
-        [zellij, 'action', 'write', '--pane-id', paneId, '\u0003'],
-        {
-          stdout: 'ignore',
-          stderr: 'ignore',
-        },
-      ).exited;
-
-      await new Promise((r) => setTimeout(r, 250));
-
-      // Close the pane
-      const proc = crossSpawn(
-        [zellij, 'action', 'close-pane', '--pane-id', paneId],
-        { stdout: 'pipe', stderr: 'pipe' },
-      );
-
-      const exitCode = await proc.exited;
-      return exitCode === 0 || exitCode === 1;
-    } catch {
-      return false;
-    }
+    return gracefulClosePane(zellij, paneId, {
+      ctrlC: ['action', 'write', '--pane-id', paneId, '\u0003'],
+      close: ['action', 'close-pane', '--pane-id', paneId],
+      acceptExitCode1: true,
+      emptyPaneReturnsTrue: true,
+    });
   }
 
   async applyLayout(
@@ -584,21 +569,6 @@ export class ZellijMultiplexer implements Multiplexer {
     await this.isAvailable();
     return this.binaryPath;
   }
-
-  private async findBinary(): Promise<string | null> {
-    const cmd = process.platform === 'win32' ? 'where' : 'which';
-    try {
-      const proc = crossSpawn([cmd, 'zellij'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
-      if ((await proc.exited) !== 0) return null;
-      const stdout = await proc.stdout();
-      return stdout.trim().split('\n')[0] || null;
-    } catch {
-      return null;
-    }
-  }
 }
 
 function normalizePaneId(paneId: string): string {
@@ -620,26 +590,6 @@ function getPaneDirection(
   }
 }
 
-function buildOpencodeAttachCommand(
-  sessionId: string,
-  serverUrl: string,
-  directory: string,
-): string {
-  return [
-    'opencode',
-    'attach',
-    quoteShellArg(serverUrl),
-    '--session',
-    quoteShellArg(sessionId),
-    '--dir',
-    quoteShellArg(directory),
-  ].join(' ');
-}
-
 function buildShellLaunchCommand(command: string): string {
   return ['sh', '-lc', quoteShellArg(command)].join(' ');
 }
-
-function quoteShellArg(value: string): string {
-  return `'${value.replace(/'/g, `'\\''`)}'`;
-}

+ 2 - 2
src/tools/acp-run.ts

@@ -306,7 +306,7 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
       if (!cwd) throw new Error('acp_run requires a working directory');
 
       await ctx.ask({
-        permission: 'bash',
+        permission: 'acp_run',
         patterns: [`${config.command} ${config.args.join(' ')}`.trim()],
         always: [],
         metadata: {
@@ -324,7 +324,7 @@ export function createAcpRunTool(agents: AcpAgentsConfig = {}): ToolDefinition {
         async (title, metadata) => {
           if (config.permissionMode === 'reject') return;
           await ctx.ask({
-            permission: 'bash',
+            permission: 'acp_run',
             patterns: [`acp:${args.agent}:${title}`],
             always: [],
             metadata,

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

+ 30 - 0
src/tui.test.ts

@@ -4,6 +4,7 @@ import * as os from 'node:os';
 import * as path from 'node:path';
 import {
   getSidebarAgentNames,
+  readCompactSidebar,
   readConfigInvalid,
   splitSidebarModelId,
   default as tuiPlugin,
@@ -119,6 +120,35 @@ describe('readConfigInvalid', () => {
       fs.rmSync(tempDir, { recursive: true, force: true });
     }
   });
+
+  test('uses compact sidebar by default', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      fs.mkdirSync(projectDir, { recursive: true });
+
+      expect(readCompactSidebar(projectDir)).toBe(true);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('allows expanded sidebar config', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      const configDir = path.join(projectDir, '.opencode');
+      fs.mkdirSync(configDir, { recursive: true });
+      fs.writeFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({ compactSidebar: false }),
+      );
+
+      expect(readCompactSidebar(projectDir)).toBe(false);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
 });
 
 describe('tui plugin env disable', () => {

+ 12 - 5
src/tui.ts

@@ -124,10 +124,10 @@ function agentRow(
 function compactAgentRow(
   label: string,
   model: string,
-  variant: string | undefined,
+  _variant: string | undefined,
   theme: { textMuted: unknown },
 ): JSX.Element {
-  const value = variant ? `${model} (${variant})` : model;
+  const modelName = splitSidebarModelId(model).model;
   return box(
     {
       width: '100%',
@@ -136,7 +136,7 @@ function compactAgentRow(
     },
     [
       text({ fg: theme.textMuted, width: 14 }, [label]),
-      text({ fg: theme.textMuted }, [value]),
+      text({ fg: theme.textMuted }, [modelName]),
     ],
   );
 }
@@ -177,7 +177,10 @@ function renderSidebar(
         [
           box(
             { paddingLeft: 1, paddingRight: 1, backgroundColor: theme.accent },
-            [text({ fg: theme.background }, ['OMO-Slim'])],
+            // Use theme.text, not theme.background: when the theme background is
+            // "none" (transparent) the foreground becomes RGBA(0,0,0,0) and the
+            // badge text vanishes. See #582.
+            [text({ fg: theme.text }, ['OMO-Slim'])],
           ),
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],
@@ -229,7 +232,7 @@ function readConfigState(directory: string): {
       configInvalid = true;
     },
   });
-  const compactSidebar = config.compactSidebar ?? false;
+  const compactSidebar = config.compactSidebar ?? true;
   return { configInvalid, compactSidebar };
 }
 
@@ -237,6 +240,10 @@ export function readConfigInvalid(directory: string): boolean {
   return readConfigState(directory).configInvalid;
 }
 
+export function readCompactSidebar(directory: string): boolean {
+  return readConfigState(directory).compactSidebar;
+}
+
 const plugin: TuiPluginModule & { id: string } = {
   id: `${PLUGIN_NAME}:tui`,
   tui: async (api, _options, meta) => {

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