Просмотр исходного кода

Merge pull request #624 from mhenke/fix/service-unavailable-fallback

Alvin Unreal 4 недель назад
Родитель
Сommit
47b240d260
71 измененных файлов с 5115 добавлено и 211 удалено
  1. 94 0
      .agents/skills/cli-review/SKILL.md
  2. 27 0
      .all-contributorsrc
  3. 95 0
      .github/ISSUE_TEMPLATE/preset_submission.yml
  4. 2 1
      .gitignore
  5. 3 3
      README.ja-JP.md
  6. 3 3
      README.ko-KR.md
  7. 13 5
      README.md
  8. 3 3
      README.zh-CN.md
  9. 1 0
      codemap.md
  10. 4 0
      docs/acp-agents.md
  11. 191 0
      docs/adr/001-session-reflection-mode.md
  12. 4 3
      docs/companion.md
  13. 6 6
      docs/configuration.md
  14. 1 0
      docs/installation.md
  15. 313 0
      docs/loop-engineering-research.md
  16. 36 8
      docs/multiplexer-integration.md
  17. 131 0
      docs/project-local-customization.md
  18. 27 0
      docs/skills.md
  19. 715 0
      docs/superpowers/plans/2026-06-25-loop-engineering-runtime.md
  20. 625 0
      docs/superpowers/specs/2026-06-25-loop-engineering-runtime.md
  21. 5 4
      oh-my-opencode-slim.schema.json
  22. 1 1
      package.json
  23. 1 0
      scripts/verify-release-artifact.ts
  24. 11 0
      skills-lock.json
  25. 5 4
      src/agents/councillor.test.ts
  26. 90 0
      src/agents/custom.test.ts
  27. 20 7
      src/agents/index.test.ts
  28. 76 27
      src/agents/index.ts
  29. 5 4
      src/agents/orchestrator.ts
  30. 7 0
      src/cli/custom-skills.ts
  31. 1 1
      src/cli/index.ts
  32. 6 6
      src/cli/install.test.ts
  33. 4 4
      src/cli/install.ts
  34. 1 0
      src/cli/skills.test.ts
  35. 18 2
      src/config/loader.test.ts
  36. 40 10
      src/config/loader.ts
  37. 410 0
      src/config/project-local-customization.test.ts
  38. 20 29
      src/config/schema.ts
  39. 18 0
      src/hooks/filter-available-skills/index.test.ts
  40. 10 4
      src/hooks/filter-available-skills/index.ts
  41. 74 5
      src/hooks/foreground-fallback/index.test.ts
  42. 19 8
      src/hooks/foreground-fallback/index.ts
  43. 2 2
      src/hooks/image-hook.ts
  44. 1 0
      src/hooks/index.ts
  45. 80 0
      src/hooks/loop-command/index.test.ts
  46. 78 0
      src/hooks/loop-command/index.ts
  47. 34 0
      src/hooks/phase-reminder/index.test.ts
  48. 8 4
      src/hooks/phase-reminder/index.ts
  49. 102 0
      src/hooks/reflect/index.test.ts
  50. 34 3
      src/hooks/reflect/index.ts
  51. 38 0
      src/hooks/task-session-manager/index.test.ts
  52. 13 7
      src/hooks/task-session-manager/index.ts
  53. 22 0
      src/hooks/types.ts
  54. 25 6
      src/index.ts
  55. 120 0
      src/loop/loop-session.test.ts
  56. 117 0
      src/loop/loop-session.ts
  57. 11 3
      src/multiplexer/codemap.md
  58. 59 0
      src/multiplexer/factory.test.ts
  59. 19 5
      src/multiplexer/factory.ts
  60. 41 0
      src/multiplexer/herdr/codemap.md
  61. 444 0
      src/multiplexer/herdr/index.test.ts
  62. 306 0
      src/multiplexer/herdr/index.ts
  63. 1 0
      src/multiplexer/index.ts
  64. 4 4
      src/multiplexer/types.ts
  65. 3 1
      src/skills/codemap.md
  66. 30 0
      src/skills/loop-engineering/SKILL.md
  67. 133 0
      src/skills/reflect/SKILL.md
  68. 159 0
      src/skills/release-smoke-test/SKILL.md
  69. 15 24
      src/tui.ts
  70. 34 0
      src/utils/background-job-board.test.ts
  71. 46 4
      src/utils/background-job-board.ts

+ 94 - 0
.agents/skills/cli-review/SKILL.md

@@ -0,0 +1,94 @@
+---
+name: cli-review
+description: >
+  Runs a Greptile CLI review for the current local branch, installing or authenticating the CLI
+  when needed, then summarizes JSON findings for the user. Use when the user wants Greptile
+  feedback before opening a PR, outside a hosted PR review flow, or directly from a local checkout.
+license: MIT
+metadata:
+  author: greptileai
+  version: "1.0"
+allowed-tools: Bash(git:*) Bash(greptile:*) Bash(command:*) Bash(curl:*) Bash(npm:*)
+---
+
+# CLI Review
+
+Run a Greptile review from the local checkout and summarize the findings.
+
+## Instructions
+
+### 1. Confirm repository context
+
+Start from the current repository root:
+
+```bash
+git rev-parse --show-toplevel
+```
+
+If the command fails, tell the user that the Greptile CLI review must be run from a git repository.
+
+### 2. Check for the Greptile CLI
+
+Check whether `greptile` is installed:
+
+```bash
+command -v greptile
+```
+
+If it is missing, do not install it automatically. Ask the user for permission, then show the recommended install command:
+
+```bash
+npm i -g greptile
+```
+
+If npm is unavailable, offer the shell installer fallback:
+
+```bash
+curl -fsSL "https://greptile.com/cli/install" | sh
+```
+
+After installation, re-run `command -v greptile`.
+
+### 3. Ensure authentication
+
+Check the signed-in account:
+
+```bash
+greptile whoami
+```
+
+If the CLI reports that authentication is missing, run:
+
+```bash
+greptile login
+```
+
+Wait for the user to complete the login flow before continuing.
+
+### 4. Run the review
+
+Prefer JSON output:
+
+```bash
+greptile review --json
+```
+
+If JSON output is unsupported or fails with a usage error, fall back to:
+
+```bash
+greptile review --agent
+```
+
+Do not hide the raw command failure if both commands fail. Summarize the failing command and the next action the user needs to take.
+
+### 5. Summarize results
+
+Parse JSON output when available and report:
+
+- Review status
+- Number of findings
+- Highest severity findings first
+- Files that need edits
+- Suggested next command or fix path
+
+When output is plain text, preserve the same structure as much as possible. Keep the summary concise and focused on actionable findings.

+ 27 - 0
.all-contributorsrc

@@ -632,6 +632,33 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "s-shank",
+      "name": "Shank",
+      "avatar_url": "https://avatars.githubusercontent.com/u/241541918?v=4",
+      "profile": "https://github.com/s-shank",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "rgutzen",
+      "name": "Robin Gutzen",
+      "avatar_url": "https://avatars.githubusercontent.com/u/16289604?v=4",
+      "profile": "https://rgutzen.github.io/",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "dragon-Elec",
+      "name": "Yash",
+      "avatar_url": "https://avatars.githubusercontent.com/u/197374270?v=4",
+      "profile": "https://github.com/dragon-Elec",
+      "contributions": [
+        "code"
+      ]
     }
   ],
   "commitConvention": "angular"

+ 95 - 0
.github/ISSUE_TEMPLATE/preset_submission.yml

@@ -0,0 +1,95 @@
+name: Community Preset Submission
+description: Submit a tested preset for review and inclusion in the Community Presets gallery.
+title: "[Preset] <preset-name>"
+labels:
+  - community-preset
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for sharing your preset. We review submissions manually for clarity, validity, and usefulness.
+
+        To keep the gallery high quality, include all fields below and keep JSON code blocks valid.
+
+  - type: input
+    id: preset_name
+    attributes:
+      label: Preset name
+      description: A short, descriptive name for the preset.
+      placeholder: "high-recall-coding"
+    validations:
+      required: true
+
+  - type: input
+    id: github_username
+    attributes:
+      label: GitHub username
+      description: Submitter GitHub handle (without @).
+      placeholder: your-handle
+    validations:
+      required: true
+
+  - type: textarea
+    id: config_code
+    attributes:
+      label: Preset config (JSON/code)
+      description: Paste the preset config block exactly as you want it displayed.
+      render: json
+      placeholder: |
+        {
+          "presets": {
+            "your-preset-name": {
+              "orchestrator": { "model": "openai:gpt-4o" }
+            }
+          }
+        }
+    validations:
+      required: true
+
+  - type: textarea
+    id: providers_models
+    attributes:
+      label: Supported providers and models
+      description: List provider IDs and models used (or recommended).
+      placeholder: |
+        - OpenAI: gpt-4o, gpt-4.1
+        - DeepSeek: deepseek-reasoner
+    validations:
+      required: true
+
+  - type: textarea
+    id: use_case
+    attributes:
+      label: Intended use case
+      description: Explain the primary goal and when to use this preset.
+      placeholder: "Code review, long-context reasoning, low-cost brainstorming, etc."
+    validations:
+      required: true
+
+  - type: textarea
+    id: cost_performance_notes
+    attributes:
+      label: Cost/performance notes
+      description: Share expectations, token cost behavior, latency, and quality tradeoffs.
+      placeholder: "Low cost at 8k context, good completion speed, higher latency under load."
+    validations:
+      required: false
+
+  - type: textarea
+    id: screenshots_notes
+    attributes:
+      label: Screenshots / notes
+      description: Add screenshots, sample outputs, or extra notes to explain behavior.
+      placeholder: "Optional screenshot links, terminal traces, example prompts, caveats."
+      render: markdown
+    validations:
+      required: false
+
+  - type: checkboxes
+    id: display_consent
+    attributes:
+      label: Display permission
+      description: Confirm you agree to have this preset displayed on the Community Presets page.
+      options:
+        - label: I confirm this preset can be shown publicly on oh-my-opencode-slim.com/community-presets.
+          required: true

+ 2 - 1
.gitignore

@@ -37,6 +37,8 @@ coverage/
 tmp/
 temp/
 local
+.loop-history-*.md
+.opencode/loop-history/
 
 .sisyphus/
 .hive/
@@ -58,7 +60,6 @@ PR-NOTES.md
 REVIEW.md
 !docs/goal.md
 docs/plans
-docs/superpowers
 
 # Python
 __pycache__/

+ 3 - 3
README.ja-JP.md

@@ -114,7 +114,7 @@ bunx oh-my-opencode-slim@latest install
 > バックグラウンドオーケストレーションの仕組みを理解しておくことを**推奨**します。**[Orchestrator のプロンプト](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** には、スケジューラーのルール、専門エージェントへのルーティングロジック、作業をバックグラウンドエージェントへ割り当てるしきい値が記述されています。`@agentName <task>` のようにサブエージェントを呼び出すことで、いつでも手動で委譲できます。
 
 > [!TIP]
-> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux または Zellij ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
+> バックグラウンドエージェントが現在のデフォルトワークフローになっているため、**[Multiplexer Integration](docs/multiplexer-integration.md)** を有効化して設定することを**強く推奨**します。各エージェントが専用の Tmux、Zellij または Herdr ペインで自動的に開かれるため、Orchestrator がセッションを調整し続けている間も、専門エージェントの作業をリアルタイムで追えます。
 
 デフォルトで生成される設定には `openai` と `opencode-go` の両方のプリセットが含まれます。
 
@@ -197,7 +197,7 @@ V2 では、バックグラウンド専門家が基本の考え方になりま
   <p><i>左下のビジュアル Companion。</i></p>
 </div>
 
-対話式インストールでは、インストーラーが Companion を有効にするか尋ね、デフォルトは `yes` です。自動化では明示的に有効化できます。
+対話式インストールでは、インストーラーが Companion を有効にするか尋ね、デフォルトは `no` です。自動化では明示的に有効化できます。
 
 ```bash
 bunx oh-my-opencode-slim@latest install --companion=yes
@@ -610,7 +610,7 @@ Worktrees は、Git worktree を `.slim/worktrees/<slug>/` 配下の安全で隔
 | **[Council](docs/council.md)** | 複数のモデルを並列実行し、`@council` で 1 つの回答に統合します |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | カスタムプロンプト、モデル、MCP アクセス、Orchestrator の委譲ルールを備えた独自の専門エージェントを定義します |
 | **[ACP Agents](docs/acp-agents.md)** | Claude Code ACP や Gemini ACP などの外部 ACP 互換エージェントを委譲可能なサブエージェントとして接続します |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux や Zellij のペインでライブ表示します |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux、Zellij や Herdr のペインでライブ表示します |
 | **[Codemap](docs/codemap.md)** | 階層的なコードマップを生成し、大規模コードベースを迅速に理解します |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane を使い、隔離された並列または高リスクなコーディング作業を行います |

+ 3 - 3
README.ko-KR.md

@@ -112,7 +112,7 @@ bunx oh-my-opencode-slim@latest install
 > 자동 위임이 어떻게 동작하는지 이해하는 것을 **권장**합니다. **[Orchestrator 프롬프트](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 에는 위임 규칙, 전문 에이전트 라우팅 로직, 메인 에이전트가 언제 서브에이전트로 작업을 넘겨야 하는지에 대한 임계값이 포함되어 있습니다. 수동으로 위임하려면 `@agentName <task>`로 서브에이전트를 호출하면 됩니다.
 
 > [!TIP]
-> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux 또는 Zellij 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
+> 이제 백그라운드 에이전트가 기본 워크플로이므로 **[Multiplexer Integration](docs/multiplexer-integration.md)** 을 활성화하고 설정하는 것을 **강력히 권장**합니다. 각 에이전트를 전용 Tmux, Zellij, 또는 Herdr 창에서 자동으로 열어 주기 때문에, Orchestrator가 세션을 계속 조율하는 동안 전문 에이전트들의 작업을 실시간으로 따라볼 수 있습니다.
 
 기본 생성 설정에는 `openai`와 `opencode-go` 프리셋이 모두 포함되어 있습니다.
 
@@ -195,7 +195,7 @@ V2에서는 백그라운드 전문가가 기본 동작 모델입니다. Orchestr
   <p><i>왼쪽 아래의 시각적 companion.</i></p>
 </div>
 
-대화형 설치 중 인스톨러는 Companion 활성화 여부를 묻고 기본값은 `yes`입니다. 자동화에서는 명시적으로 활성화할 수 있습니다.
+대화형 설치 중 인스톨러는 Companion 활성화 여부를 묻고 기본값은 `no`입니다. 자동화에서는 명시적으로 활성화할 수 있습니다.
 
 ```bash
 bunx oh-my-opencode-slim@latest install --companion=yes
@@ -608,7 +608,7 @@ Worktrees는 Git worktree를 `.slim/worktrees/<slug>/` 아래의 안전하고 
 | **[Council](docs/council.md)** | `@council`로 여러 모델을 병렬 실행하고 하나의 답변으로 종합 |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | 커스텀 프롬프트, 모델, MCP 접근, Orchestrator 위임 규칙으로 커스텀 전문 에이전트 정의 |
 | **[ACP Agents](docs/acp-agents.md)** | Claude Code ACP 또는 Gemini ACP 같은 외부 ACP 호환 에이전트를 위임 가능한 서브에이전트로 연결 |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux 또는 Zellij 페인에서 에이전트 작업을 실시간으로 확인 |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux, Zellij, 또는 Herdr 페인에서 에이전트 작업을 실시간으로 확인 |
 | **[Codemap](docs/codemap.md)** | 계층형 코드맵을 생성하여 대규모 코드베이스를 빠르게 파악 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Worktrees](docs/worktrees.md)** | `.slim/worktrees/` lane을 사용해 격리된 병렬 또는 고위험 코딩 작업 수행 |

+ 13 - 5
README.md

@@ -30,6 +30,10 @@ The main idea is simple: instead of forcing one model to do everything, the plug
 
 To explore the agents themselves, see **[Meet the Pantheon](#meet-the-pantheon)**. For the full feature set, see **[Features & Workflows](#features-and-workflows)** below.
 
+### Submit Your Preset
+
+Using a model mix that works well? Share it with the community through the **[preset submission form](https://github.com/alvinunreal/oh-my-opencode-slim/issues/new?template=preset_submission.yml)**. Accepted presets are reviewed and displayed in the **[Community Presets gallery](https://ohmyopencodeslim.com/community-presets)** with attribution.
+
 ### Manage Agent Skills with LazySkills
 
 <p align="center">
@@ -109,7 +113,7 @@ Then:
 > It's **recommended** to understand how background orchestration works. The **[Orchestrator prompt](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** contains the scheduler rules, specialist routing logic, and thresholds for when work should be assigned to background agents. You can always delegate manually by calling a subagent via: `@agentName <task>`
 
 > [!TIP]
-> Because background agents are now the default workflow, it is **highly recommended** to enable and configure **[Multiplexer Integration](docs/multiplexer-integration.md)**. It automatically opens each agent in a dedicated Tmux or Zellij pane, so you can watch specialists work live while the Orchestrator continues coordinating the session.
+> Because background agents are now the default workflow, it is **highly recommended** to enable and configure **[Multiplexer Integration](docs/multiplexer-integration.md)**. It automatically opens each agent in a dedicated Tmux, Zellij, or Herdr pane, so you can watch specialists work live while the Orchestrator continues coordinating the session.
 
 The default generated configuration includes both `openai` and `opencode-go` presets.
 
@@ -205,7 +209,7 @@ V2 makes background specialists the default mental model: the Orchestrator plans
 the work graph, launches the right agents, avoids overlapping write ownership,
 and waits for terminal task results before acting on them.
 
-See **[Background Orchestration](docs/background-orchestration.md)** for the
+See **[Background Orchestration](docs/v2-background-orchestration.md)** for the
 full scheduler model.
 
 #### Companion
@@ -220,7 +224,7 @@ background work is easier to follow at a glance.
 </div>
 
 During interactive install, the installer asks whether to enable Companion and
-defaults to `yes`. For automation, enable it explicitly with:
+defaults to `no`. For automation, enable it explicitly with:
 
 ```bash
 bunx oh-my-opencode-slim@latest install --companion=yes
@@ -646,7 +650,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | Define your own specialists with custom prompts, models, MCP access, and Orchestrator delegation rules |
 | **[ACP Agents](docs/acp-agents.md)** | Connect external ACP-compatible agents such as Claude Code ACP or Gemini ACP as delegatable subagents |
-| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
+| **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux, Zellij, or Herdr panes |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |
 | **[Clonedeps](docs/clonedeps.md)** | Clone selected dependency source into an ignored local workspace for inspection |
 | **[Worktrees](docs/worktrees.md)** | Use `.slim/worktrees/` lanes for isolated parallel or risky coding work |
@@ -660,6 +664,7 @@ Use this section as a map: start with installation, then jump to features, confi
 |-----|----------------|
 | **[Installation Guide](docs/installation.md)** | Install the plugin, use CLI flags, reset config, and troubleshoot setup |
 | **[Configuration](docs/configuration.md)** | Config file locations, JSONC support, prompt overrides, and full option reference |
+| **[Project Customization](docs/project-local-customization.md)** | Repository-specific custom agents, prompt overrides, per-agent skills, and precedence |
 | **[Background Orchestration](docs/background-orchestration.md)** | Scheduler-first orchestrator model built around native background subagents |
 | **[Maintainer Guide](docs/maintainers.md)** | Issue triage rules, label meanings, support routing, and repo maintenance workflow |
 | **[Skills](docs/skills.md)** | Bundled skills such as `simplify`, `codemap`, `clonedeps`, `deepwork`, `reflect`, `worktrees`, and `oh-my-opencode-slim` |
@@ -683,7 +688,7 @@ Use this section as a map: start with installation, then jump to features, confi
   <p><sub>Every merged contribution leaves a mark on the realm.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-69-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-72-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -786,6 +791,9 @@ Use this section as a map: start with installation, then jump to features, confi
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/824156793"><img src="https://avatars.githubusercontent.com/u/19755784?v=4?s=100" width="100px;" alt="lilili"/><br /><sub><b>lilili</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=824156793" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="http://mikehenke.com/"><img src="https://avatars.githubusercontent.com/u/119844?v=4?s=100" width="100px;" alt="Mike Henke"/><br /><sub><b>Mike Henke</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=mhenke" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/imVinayPandya"><img src="https://avatars.githubusercontent.com/u/5011197?v=4?s=100" width="100px;" alt="Vinay Pandya"/><br /><sub><b>Vinay Pandya</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=imVinayPandya" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/s-shank"><img src="https://avatars.githubusercontent.com/u/241541918?v=4?s=100" width="100px;" alt="Shank"/><br /><sub><b>Shank</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=s-shank" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://rgutzen.github.io/"><img src="https://avatars.githubusercontent.com/u/16289604?v=4?s=100" width="100px;" alt="Robin Gutzen"/><br /><sub><b>Robin Gutzen</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=rgutzen" title="Code">💻</a></td>
+      <td align="center" valign="top" width="16.66%"><a href="https://github.com/dragon-Elec"><img src="https://avatars.githubusercontent.com/u/197374270?v=4?s=100" width="100px;" alt="Yash"/><br /><sub><b>Yash</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=dragon-Elec" title="Code">💻</a></td>
     </tr>
   </tbody>
 </table>

+ 3 - 3
README.zh-CN.md

@@ -109,7 +109,7 @@ bunx oh-my-opencode-slim@latest install
 > **建议**了解后台编排的工作原理。**[编排者提示词 (Orchestrator prompt)](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 包含调度规则、专家路由逻辑,以及何时应把工作分配给后台智能体的阈值。您始终可以通过以下方式手动委派任务:`@智能体名称 <任务内容>`
 
 > [!TIP]
-> 由于后台智能体现在是默认工作流,**强烈建议**启用并配置 **[Multiplexer Integration](docs/multiplexer-integration.md)**。它会自动在专用的 Tmux 或 Zellij 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
+> 由于后台智能体现在是默认工作流,**强烈建议**启用并配置 **[Multiplexer Integration](docs/multiplexer-integration.md)**。它会自动在专用的 Tmux、Zellij 或 Herdr 窗格中打开每个智能体,让您在 Orchestrator 继续协调会话时,实时跟进各个专家智能体的工作。
 
 默认生成的配置包含 `openai` 和 `opencode-go` 两个预设:
 
@@ -192,7 +192,7 @@ V2 将后台专家作为默认心智模型:Orchestrator 规划工作图,启
   <p><i>左下角视觉伴侣。</i></p>
 </div>
 
-交互式安装期间,安装器会询问是否启用 Companion,并默认选择 `yes`。自动化安装可显式启用:
+交互式安装期间,安装器会询问是否启用 Companion,并默认选择 `no`。自动化安装可显式启用:
 
 ```bash
 bunx oh-my-opencode-slim@latest install --companion=yes
@@ -605,7 +605,7 @@ Worktrees 将 Git worktree 作为安全、隔离的编码通道管理,默认
 | **[Council](docs/council.md)** | 使用 `@council` 并行运行多个模型并合成单一答案 |
 | **[自定义智能体](docs/configuration.md#custom-agents)** | 使用自定义提示词、模型、MCP 访问和 Orchestrator 委派规则定义自己的专家 |
 | **[ACP Agents](docs/acp-agents.md)** | 将 Claude Code ACP 或 Gemini ACP 等外部 ACP 兼容智能体连接为可委派子智能体 |
-| **[多路复用器集成](docs/multiplexer-integration.md)** | 在 Tmux 或 Zellij 窗格中实时观看智能体工作 |
+| **[多路复用器集成](docs/multiplexer-integration.md)** | 在 Tmux、Zellij 或 Herdr 窗格中实时观看智能体工作 |
 | **[Codemap](docs/codemap.md)** | 生成层级代码地图,更快理解大型代码库 |
 | **[Clonedeps](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[Worktrees](docs/worktrees.md)** | 使用 `.slim/worktrees/` 通道进行隔离的并行或高风险编码工作 |

+ 1 - 0
codemap.md

@@ -47,6 +47,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
 | `src/multiplexer/tmux/` | tmux backend implementation for pane lifecycle and layout management. | [View Map](src/multiplexer/tmux/codemap.md) |
 | `src/multiplexer/zellij/` | zellij backend implementation for tab/pane lifecycle. | [View Map](src/multiplexer/zellij/codemap.md) |
+| `src/multiplexer/herdr/` | herdr backend implementation for pane lifecycle. | [View Map](src/multiplexer/herdr/codemap.md) |
 | `src/skills/` | Bundled install-time OpenCode skills shipped as static payloads. | [View Map](src/skills/codemap.md) |
 | `src/skills/codemap/` | Repository-mapping skill package and codemap state-management script. | [View Map](src/skills/codemap/codemap.md) |
 | `src/skills/clonedeps/` | Workflow-only dependency source mirroring skill that routes discovery/ref resolution through librarian and direct orchestrator git operations. | [View Map](src/skills/clonedeps/codemap.md) |

+ 4 - 0
docs/acp-agents.md

@@ -69,6 +69,10 @@ Or let the orchestrator delegate to it when its routing prompt matches the task.
 | `permissionMode` | `ask` \| `allow` \| `reject` | `ask` | How ACP permission requests are answered. |
 | `timeoutMs` | integer | `300000` | Timeout for one ACP run. |
 
+> **`permission` vs `permissionMode`:** These are separate concepts.
+> - **`permission`** (on normal custom, built-in, and preset agents) provides SDK-enforced, expressive per-tool rules with pattern support, accepting `ask`/`allow`/`deny`. See [Agent Permissions](configuration.md#agent-permissions).
+> - **`permissionMode`** (ACP agents only) controls how the plugin answers the external ACP subprocess's permission requests, with simpler `ask`/`allow`/`reject` options.
+
 ## Authentication
 
 ACP agents may advertise `authMethods` during initialization and may require

+ 191 - 0
docs/adr/001-session-reflection-mode.md

@@ -0,0 +1,191 @@
+# ADR-001: Session Reflection Mode for /reflect
+
+**Date:** 2026-06-29
+**Status:** Accepted
+**Deciders:** User + Orchestrator
+
+## Context
+
+We are adding a `--sessions` mode to the `/reflect` command in oh-my-opencode-slim. This mode enables cross-session reflection by analyzing past OpenCode sessions to find repeated patterns, friction, and improvement opportunities.
+
+Current `/reflect` only looks at the current conversation and project files. The new mode analyzes historical sessions across all repos to find patterns like:
+- "Which workflows succeed most often?"
+- "Which agents get stuck?"
+- "Which models cause retries?"
+- "What precedes successful completions?"
+
+## Decisions
+
+### 1. Implementation Approach: Prompt-only
+
+**Decision:** Extend the reflect skill (SKILL.md) with instructions for session reflection. No new code tools or command hooks.
+
+**Rationale:**
+- Consistent with how `/reflect` currently works (prompt-based guidance)
+- The LLM already has tools (Read, Write, Bash) to do everything needed
+- Smallest useful form — no code changes required
+- YAGNI: Start simple, add code if prompt-only proves insufficient
+
+**Alternatives considered:**
+- Hybrid (skill + command hook): More reliable enumeration, but adds code complexity
+- Full code (new tool): Fastest execution, but most complex
+
+### 2. Command Syntax
+
+**Decision:** Remove `--global` (never shipped), add `--sessions` flag.
+
+```
+/reflect                          # Local mode (current repo)
+/reflect release workflow         # Local mode, focused theme
+/reflect --sessions               # Session archaeology (last 50 sessions)
+/reflect --sessions --last 20     # Session archaeology, last 20
+/reflect --sessions release workflow  # Session archaeology, focused theme
+```
+
+**Rationale:**
+- `--global` was never rolled out, so no migration needed
+- `--sessions` clearly describes the mode's purpose
+- `--last N` provides user control over scope
+
+### 3. Session Discovery
+
+**Decision:** LLM reads `~/.local/share/opencode/log/opencode.log` and greps for `session.id=ses_[a-f0-9]+` to extract session IDs.
+
+**Rationale:**
+- Session IDs are reliably present in the main OpenCode log
+- Pattern is stable and parseable with simple grep
+- No new API dependencies needed
+
+**Log format:**
+```
+timestamp=2026-06-10T15:08:45.427Z level=INFO run=9bd29194 message=loop session.id=ses_14de9c68effegtZtlATm42wnz7 step=0
+```
+
+### 4. Session Scope
+
+**Decision:** Analyze last N sessions total (regardless of project), not per-project.
+
+**Rationale:**
+- Simpler to implement
+- Patterns emerge naturally across repos
+- User controls scope with `--last N`
+- Per-project caps can be added later if needed
+
+**Default:** 50 sessions
+**Maximum:** 100 sessions (cap to avoid context explosion)
+
+### 5. Storage Location
+
+**Decision:** Store reflection summaries in `~/.config/opencode/oh-my-opencode-slim/reflections/`.
+
+**Rationale:**
+- Existing OMOS directory already contains presets, prompts, orchestrator_append.md
+- Pragmatic: keeps all OMOS data in one place
+- Easy discovery and cleanup
+- Global across projects (sessions are not project-specific)
+
+**Structure:**
+```
+~/.config/opencode/oh-my-opencode-slim/reflections/
+  sessions/
+    ses_14de9c68effegtZtlATm42wnz7.json
+  weekly/
+    week-26.json
+  monthly/
+    month-06.json
+```
+
+**Alternatives considered:**
+| Option | Pros | Cons | Decision |
+|--------|------|------|----------|
+| `~/.config/opencode/oh-my-opencode-slim/reflections/` | Existing directory, single location | Mixes config and data | **Accepted** |
+| `~/.local/share/oh-my-opencode-slim/reflections/` | XDG-compliant, data separation | New directory, splits OMOS data | Rejected |
+| `.slim/reflections/` (project-local) | Tied to codebase | Sessions are global, not project-specific | Rejected |
+| Ephemeral (no storage) | No disk overhead | Re-analyzes expensive sessions, no trend tracking | Rejected |
+
+### 6. Two-Phase Architecture
+
+**Decision:** Per-session reflection first, then aggregation.
+
+**Rationale:**
+- Scalable: processes one session at a time (not hundreds in context)
+- Aggregation works on concise summaries (20-30k tokens) not raw sessions (millions)
+- Enables hierarchical aggregation: session → weekly → monthly
+
+**Flow:**
+```
+OpenCode logs
+  → Extract session IDs
+  → For each session:
+      → Load via client.session.messages()
+      → Analyze and produce structured summary
+      → Store in reflections/sessions/<id>.json
+  → Aggregate all summaries
+  → Produce final recommendations
+```
+
+### 7. Cache Pattern
+
+**Decision:** LLM manages its own cache using Read/Write tools.
+
+**Rationale:**
+- No new code needed — LLM already has file tools
+- Avoids re-analyzing expensive sessions
+- Enables incremental updates (only analyze new sessions)
+
+**Logic:**
+1. Check if `reflections/sessions/<id>.json` exists
+2. If yes, load it (saves tokens)
+3. If no, analyze session and save summary
+4. Aggregate across all summaries for final report
+
+### 8. Per-Session Analysis
+
+**Decision:** Each session produces a structured JSON summary with metadata, frictions, and recommendations.
+
+**Schema:**
+```json
+{
+  "session": "ses_14de9c68effegtZtlATm42wnz7",
+  "project": "/home/user/Projects/oh-my-opencode-slim",
+  "timestamp": "2026-06-10T15:08:45.427Z",
+  "goal": "Fix CI failure",
+  "success": true,
+  "frictions": [
+    "Repeated grep to find test file",
+    "Three failed test runs before passing"
+  ],
+  "recommendations": [
+    "Create /test-ci command"
+  ],
+  "duration_minutes": 18,
+  "models_used": ["opencode/mimo-v2.5-free"],
+  "agents_used": ["orchestrator", "fixer", "explorer"],
+  "tools_used": ["Read", "Edit", "Bash"],
+  "confidence": 0.85
+}
+```
+
+**Confidence scoring:**
+- 0.9-1.0: Clear success/failure, obvious patterns
+- 0.7-0.9: Likely outcome, patterns inferred from tool usage
+- 0.5-0.7: Uncertain outcome, limited evidence
+- <0.5: Skip or mark as "needs more evidence"
+
+## Implementation Notes
+
+- The LLM manages its own cache using Read/Write tools
+- Reflection files are JSON with session metadata, frictions, and recommendations
+- Hierarchical aggregation: session → weekly → monthly summaries
+- Old reflections can be pruned by age or count (configurable)
+- Skill instructions guide the LLM through the full workflow
+
+## Consequences
+
+- No code changes needed — purely skill instruction updates
+- All OMOS persistent data lives in one directory tree
+- Reflections are available across all projects (global)
+- LLM manages file I/O, cache, and aggregation
+- Users can inspect or delete reflections manually
+- Hierarchical aggregation (weekly/monthly) is possible via stored summaries
+- Per-project session caps can be added later if needed

+ 4 - 3
docs/companion.md

@@ -71,10 +71,11 @@ after monitor or resolution changes.
 ## Installer Flag
 
 During interactive installation, the installer asks whether to download and
-enable the native Companion binary. The prompt defaults to `yes`, so pressing
-Enter installs it.
+enable the native Companion binary. The prompt defaults to `no`, so pressing
+Enter skips it.
 
-On niri, the prompt installs normally now that the native companion is fixed.
+On niri, Companion can install normally when enabled now that the native binary
+is fixed.
 
 Companion installation is best-effort. If the binary cannot be downloaded or
 installed, the installer prints a warning and continues installing the core

+ 6 - 6
docs/configuration.md

@@ -1,6 +1,6 @@
 # Configuration Reference
 
-Complete reference for all configuration files and options in oh-my-opencode-slim.
+Complete reference for all configuration files and options in oh-my-opencode-slim. For repository-specific configurations, custom agents, and prompt directory lookups, see the [Project-local Customization Guide](project-local-customization.md).
 
 ---
 
@@ -11,7 +11,7 @@ Complete reference for all configuration files and options in oh-my-opencode-sli
 | `~/.config/opencode/opencode.json` | OpenCode core settings (plugin registration, providers) |
 | `~/.config/opencode/oh-my-opencode-slim.json` | Plugin settings — agents, multiplexer, MCPs, council |
 | `~/.config/opencode/oh-my-opencode-slim.jsonc` | Same, but with JSONC (comments + trailing commas). Takes precedence over `.json` if both exist |
-| `.opencode/oh-my-opencode-slim.json` | Project-local overrides (optional, checked first) |
+| `.opencode/oh-my-opencode-slim.json` | Project-local overrides (optional, higher precedence than user config) |
 
 > **💡 JSONC recommended:** Use the `.jsonc` extension to add comments and trailing commas. If both `.jsonc` and `.json` exist, `.jsonc` takes precedence.
 
@@ -39,7 +39,7 @@ Customize agent prompts without modifying source code. Create markdown files in
 | `{agent}.md` | Replaces the agent's default prompt entirely |
 | `{agent}_append.md` | Appends custom instructions to the default prompt |
 
-When a `preset` is active, the plugin checks `~/.config/opencode/oh-my-opencode-slim/{preset}/` first, then falls back to the root directory.
+When a `preset` is active, the plugin checks preset directories before falling back to root directories. Both global user prompt directories and project-local prompt directories are searched. For the complete lookup precedence order, see [Project-local Customization](project-local-customization.md).
 
 **Example directory structure:**
 
@@ -125,9 +125,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `acpAgents.<name>.timeoutMs` | integer | `0` | Timeout for a single ACP run in milliseconds. `0` disables the timeout so external agents can run indefinitely. Finite values can be up to `2147483647`ms (~24.8 days) |
 | `disabled_agents` | string[] | `["observer"]` | Agent names to disable globally. Set to `[]` to enable Observer; this is global, not per-preset |
 | `autoUpdate` | boolean | `true` | Automatically install plugin updates in the background; set to `false` for notification-only mode |
-| `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
-| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij maps `main-vertical` to right and `main-horizontal` to down |
-| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij |
+| `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, `herdr`, or `none` |
+| `multiplexer.layout` | string | `"main-vertical"` | Layout preset: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical`. Tmux applies full layouts; Zellij and Herdr map `main-vertical` to right and `main-horizontal` to down |
+| `multiplexer.main_pane_size` | number | `60` | Main pane size as percentage (20–80) for tmux main layouts; ignored by Zellij and Herdr |
 | `multiplexer.zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `agent-tab` creates/reuses a dedicated `opencode-agents` tab; `current-tab` opens subagents as panes in the tab containing the parent OpenCode pane, falling back to the focused tab if the parent pane cannot be resolved |
 | `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |

+ 1 - 0
docs/installation.md

@@ -34,6 +34,7 @@ The installer supports the following options:
 | Option | Description |
 |--------|-------------|
 | `--skills=yes|no` | Install bundled skills (default: yes) |
+| `--companion=ask\|yes\|no` | Install and enable the desktop Companion (`ask` by default; prompt defaults to no) |
 | `--preset=<name>` | Active generated config preset: `openai` or `opencode-go` (default: `openai`) |
 | `--background-subagents=ask\|yes\|no` | Configure the required background-subagents environment export (`ask` by default; prompt defaults to yes) |
 | `--background-subagents-target=<path>` | Write the background-subagents export to a specific shell/profile file |

+ 313 - 0
docs/loop-engineering-research.md

@@ -0,0 +1,313 @@
+# Loop Engineering: Research, Building Blocks & Tool Comparison
+
+> Research compiled from 112 sources across 9 platforms (Reddit, X, YouTube, TikTok, Instagram, Hacker News, GitHub, Digg, Web) on 2026-06-27.
+
+---
+
+## What Is Loop Engineering?
+
+Loop engineering is the practice of replacing yourself as the person who prompts a coding agent. Instead of typing instructions turn-by-turn, you design a system that finds work, hands it to agents, checks results, records progress, and decides the next action - on a schedule or until a goal is met.
+
+The term crystallized in June 2026 around two viral moments:
+
+- **Peter Steinberger** (creator of OpenClaw): "You shouldn't be prompting coding agents anymore. You should be designing loops that prompt your agents." (June 7, 2026 - 6.5M views)
+- **Boris Cherny** (head of Claude Code at Anthropic): "I don't prompt Claude anymore. I have loops running. They're the ones prompting Claude and figuring out what to do. My job is to write loops." (June 5, 2026)
+
+Google engineer **Addy Osmani** then published the essay that gave the practice its name and anatomy: five building blocks plus memory.
+
+---
+
+## The Five Building Blocks
+
+Osmani's framework identifies five primitives that compose a loop, plus durable state:
+
+### 1. Automations (Trigger + Schedule)
+
+**What it does:** Discovers work on a timer or event, triages it, and feeds it to the agent loop.
+
+**Why it matters:** Without automation, you're still manually kicking off each run. Automation is what makes the loop autonomous.
+
+**Examples:** Cron jobs, GitHub Actions, event-driven triggers, scheduled scans.
+
+### 2. Worktrees (Isolation)
+
+**What it does:** Gives each parallel agent its own isolated git checkout so multiple agents don't collide on the same files.
+
+**Why it matters:** Parallel sub-agents editing the same branch create merge conflicts and corrupted state. Worktrees prevent this.
+
+**Examples:** `git worktree`, per-thread isolation, per-agent directories.
+
+### 3. Skills (Project Knowledge)
+
+**What it does:** Codifies project conventions, build steps, and domain knowledge into files the agent reads every run. Prevents "intent debt" - the agent guessing wrong about how your project works.
+
+**Why it matters:** An agent starts every session cold. Without written knowledge, it fills gaps with confident guesses. Skills are intent written down once, read every time.
+
+**Examples:** `SKILL.md` files, `CLAUDE.md`, `AGENTS.md`, project-specific instructions.
+
+### 4. Connectors (Tool Integration)
+
+**What it does:** Wires the agent into real tools - databases, APIs, file systems, CI/CD, issue trackers.
+
+**Why it matters:** A loop that can't act on the real world is just a thought experiment. Connectors give it hands.
+
+**Examples:** MCP servers, CLI tools, API integrations, database connections.
+
+### 5. Sub-agents (Maker/Checker Split)
+
+**What it does:** Separates the agent that does the work (maker) from the agent that verifies it (checker). One agent proposes, a different one validates.
+
+**Why it matters:** A single agent grading its own work is like a student marking their own exam. The maker/checker split is what makes unattended loops trustworthy.
+
+**Examples:** Dedicated reviewer agents, test runners, lint checkers, oracle reviewers.
+
+### Plus: Durable State (Memory)
+
+**What it does:** Persists what was tried, what passed, and what's still open between loop iterations. Lives on disk, not in the context window.
+
+**Why it matters:** The model forgets between sessions. The repo doesn't. State files, git history, and progress logs are the loop's long-term memory.
+
+---
+
+## Tool Comparison: Claude Code vs Codex vs OpenCode
+
+### Automations / Scheduling
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| `/goal` command | Yes | Yes | No (spec only) |
+| `/loop` command | Yes | No | No (spec only) |
+| `/batch` command | Yes | No | No |
+| Scheduled automations | Yes (hooks, GitHub Actions) | Yes (Automations tab) | No (Phase 4) |
+| Event triggers | Yes (hooks) | Yes (triage inbox) | No |
+| Cron-like scheduling | Yes | Yes | No |
+
+**Verdict:** Claude Code and Codex both ship full automation primitives. OpenCode has the loop engine fully designed in spec but not yet implemented.
+
+### Worktrees (Isolation)
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| Git worktree support | Yes (`--worktree`) | Yes (built-in per thread) | Yes (orchestrator skill) |
+| Sub-agent isolation | Yes (`isolation: worktree`) | Yes (built-in) | Yes (apply-patch hook) |
+| Programmatic creation | Yes | Yes | No (skill-only, prompt-driven) |
+| Loop integration | Yes | Yes | No (spec only) |
+
+**Verdict:** Claude Code and Codex have worktrees as runtime primitives integrated with their loop engines. OpenCode has worktrees as an orchestrator skill with apply-patch support, but no programmatic runtime integration yet.
+
+### Skills (Project Knowledge)
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| `SKILL.md` format | Yes | Yes | Yes |
+| Per-agent assignment | Yes | Yes | Yes (with permissions) |
+| Bundled skills | No (user-created) | Yes (Agent Skills) | Yes (7 bundled) |
+| Skill marketplace | Yes (plugins) | Yes | No |
+| Intent debt prevention | Yes | Yes | Yes |
+
+**Verdict:** All three have mature skill systems. OpenCode's is notably rich with 7 bundled skills, per-agent permission control, and automatic sync on plugin updates.
+
+### Connectors / MCP
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| MCP server support | Yes | Yes (Connectors) | Yes |
+| Built-in MCPs | No (user-configured) | Yes | Yes (3 built-in) |
+| Per-agent MCP permissions | No | No | Yes (wildcard, exclusion, explicit) |
+| Remote MCP servers | Yes | Yes | Yes |
+| Local MCP servers | Yes | Yes | Yes |
+
+**Verdict:** All three support MCP. OpenCode stands out with 3 built-in MCPs (websearch, context7, gh_grep) and a sophisticated per-agent permission system.
+
+### Sub-agents (Maker/Checker Split)
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| Agent spawning | Yes | Yes | Yes (9 agents) |
+| Background execution | Yes | Yes | Yes (background jobs) |
+| Maker/checker split | Yes | Yes | Yes (orchestrator dispatches, specialists execute) |
+| Depth tracking | Yes | Yes | Yes (max 3 levels) |
+| Session reuse | Yes | Yes | Yes (BackgroundJobBoard) |
+| Job tracking | Limited | Limited | Yes (Background Job Board with aliases) |
+| Cancellation | Yes | Yes | Yes (cancel_task tool) |
+| Parallel dispatch | Yes | Yes | Yes (explicit in orchestrator prompt) |
+
+**Verdict:** All three have sub-agent support. OpenCode's is the most structured with 9 specialized agents, a formal Background Job Board, session reuse, depth tracking, and a dedicated orchestrator that never implements directly.
+
+### Loop Engine (Iteration Primitive)
+
+| Feature | Claude Code | Codex | OpenCode |
+|---------|-------------|-------|----------|
+| Built-in loop command | Yes (`/goal`, `/loop`) | Yes (`/goal`) | No (spec only) |
+| Success criteria | Yes (test, build, lint) | Yes | Yes (designed: test, build, lint, fileExists, command, oracle, observer) |
+| Iteration cap | Yes | Yes | Yes (designed) |
+| No-progress detection | Yes | Yes | Yes (designed: totalErrors, timeoutCount) |
+| Escalation to human | Yes | Yes | Yes (designed: @council at Layer 0) |
+| Cost budgeting | Limited | Limited | Yes (designed) |
+
+**Verdict:** Claude Code and Codex have working loop primitives. OpenCode's loop engine is fully designed with a 3-layer architecture (Orchestrator -> LoopEngine -> Specialists) but not yet implemented.
+
+---
+
+## The Ralph Technique
+
+### What Is Ralph?
+
+The Ralph Wiggum loop is a specific implementation pattern for loop engineering. Named by Geoffrey Huntley in July 2025 after The Simpsons character Ralph Wiggum ("I'm in danger!"), it's the simplest possible loop:
+
+```bash
+while :; do cat PROMPT.md | claude; done
+```
+
+In its purest form, Ralph is a Bash loop. That's it.
+
+### How It Works
+
+1. **Define a PRD** (Product Requirements Document) with small, atomic tasks
+2. **Write a PROMPT.md** that instructs the agent to read the PRD and work on one task
+3. **Run the loop** - each iteration spawns a fresh agent instance with clean context
+4. **Memory persists via disk** - git history, `progress.txt`, and `prd.json` survive between iterations
+5. **Stop when done** - the agent signals completion, or a max iteration cap is hit
+
+### The Four Principles
+
+1. **Each iteration = fresh context.** The agent doesn't remember the previous iteration. Only git history, progress.txt, and prd.json persist.
+2. **Keep tasks small.** Each PRD item must fit in a single context window. "Build the entire dashboard" is too large. "Add a filter dropdown to a list" is right.
+3. **Update AGENTS.md.** Write discovered patterns and precautions at the end of each iteration. The next iteration reads them.
+4. **Failures are data.** Deterministically bad means failures are predictable and informative. The loop learns from them.
+
+### Ralph vs Full Loop Engineering
+
+| Aspect | Ralph Technique | Full Loop Engineering |
+|--------|----------------|----------------------|
+| **Complexity** | Single Bash loop | Multi-block system (automations, worktrees, skills, connectors, sub-agents) |
+| **Context management** | Fresh context each iteration (via disk memory) | Persistent context with durable state |
+| **Verification** | Manual (human reviews diffs) | Automated maker/checker split |
+| **Parallelism** | Sequential (one agent at a time) | Parallel sub-agents with worktree isolation |
+| **Scheduling** | Manual trigger | Automated (cron, events, webhooks) |
+| **Tool integration** | Minimal (git + agent) | Full MCP connectors |
+| **Cost control** | Manual (max iterations) | Automatic (token budgets, no-progress detection) |
+| **Best for** | Well-defined, test-driven tasks | Complex, multi-step, long-running work |
+
+### When to Use Ralph
+
+- Tasks are well-defined and test-driven
+- You want maximum simplicity
+- You're comfortable reviewing diffs manually
+- The codebase is small enough for fresh context each iteration
+- You want to start loop engineering today with zero setup
+
+### When to Use Full Loop Engineering
+
+- Tasks require parallel work across multiple files
+- You need automated verification (maker/checker)
+- The work spans multiple days or sessions
+- You need cost controls and budget management
+- The codebase is large and context-heavy
+
+---
+
+## OpenCode's Loop Engineering Status
+
+### What Exists Today
+
+| Building Block | Status | Implementation |
+|----------------|--------|----------------|
+| **Skills** | Mature | 7 bundled skills, per-agent permissions, auto-sync |
+| **Connectors/MCP** | Mature | 3 built-in MCPs, per-agent permission system |
+| **Sub-agents** | Mature | 9 agents, Background Job Board, depth tracking, session reuse |
+| **Worktrees** | Skill-only | Orchestrator skill, apply-patch hook support |
+| **Automations** | Not implemented | Deferred to Phase 4 |
+| **Loop engine** | Spec only | Fully designed, not implemented |
+
+### What's Designed But Not Built
+
+The loop engineering spec (`docs/superpowers/specs/2026-06-25-loop-engineering-runtime.md`) defines:
+
+- **LoopEngine** class with event-driven orchestration
+- **LoopSession** state machine (executing <-> verifying binary oscillation)
+- **SuccessCriterion** routing (test, build, lint, fileExists, command, oracle, observer)
+- **Convergence signals** (totalErrors, timeoutCount, lastErrorAt)
+- **.loop-history.md** context compaction
+- **Escalation via @council** at Layer 0 only
+- **Phased rollout:** Phase 1 (runtime engine) -> Phase 2 (loop skill) -> Phase 3 (routine integration) -> Phase 4 (triggers) -> Phase 5 (persistent memory)
+
+### The Gap
+
+OpenCode has 3 of 5 building blocks fully wired (skills, connectors, sub-agents), worktrees as a skill, and automations + loop engine as unimplemented specs. The sub-agent infrastructure is the strongest piece - the Background Job Board, session reuse, and depth tracking are more structured than what Claude Code or Codex expose.
+
+The missing piece is the outer loop itself: the scheduler that runs on a timer, spawns work, and keeps going without human intervention. Once that lands (Phase 1-2 of the spec), OpenCode will have a complete loop engineering stack.
+
+---
+
+## Case Study: Andrej Karpathy's autoresearch
+
+[autoresearch](https://github.com/karpathy/autoresearch) (March 2026) is the purest real-world implementation of loop engineering. It's a minimalist autonomous AI research framework: one Python file to edit (`train.py`), one fixed evaluation harness (`prepare.py`), and one Markdown "skill" file (`program.md`) that drives an AI agent to run an infinite experiment loop.
+
+### How It Works
+
+1. The agent reads `program.md` which defines a `LOOP FOREVER` construct
+2. It modifies `train.py` with an experimental idea
+3. Runs a 5-minute training experiment on a single GPU
+4. Evaluates validation bits-per-byte (BPB)
+5. If improved: keeps the commit, advances the branch
+6. If same or worse: git resets to previous state
+7. Repeats indefinitely (~12 experiments/hour, ~100 per night)
+
+### Mapping to the 5 Building Blocks
+
+| Building Block | autoresearch | Notes |
+|---|---|---|
+| **Automations** | The agent IS the scheduler | `LOOP FOREVER` in program.md, no external cron |
+| **Worktrees** | Not used | Single-agent, single-file system |
+| **Skills** | `program.md` IS the skill | Explicitly called "a super lightweight skill" |
+| **Connectors** | Not used | Agent's native tools only (git, python) |
+| **Sub-agents** | Not used | Single-agent system |
+
+### Comparison to Ralph and Full Loop Engineering
+
+| Aspect | autoresearch | Ralph | Full Loop Engineering |
+|---|---|---|---|
+| Agent lifetime | Continuous (one session) | Fresh per iteration | Continuous with sub-agents |
+| Context | Grows within session | Reset each iteration | Persistent + fresh sub-agent contexts |
+| Memory | Git + results.tsv | Git + progress.txt + prd.json | Durable state files + skills |
+| Verification | Automated (val_bpb check) | Manual (human reviews diffs) | Maker/checker split |
+| Scheduling | Agent self-schedules | Manual trigger | Cron/events |
+| Parallelism | None | None | Parallel sub-agents |
+| Complexity | ~100 lines (program.md) | ~50 lines (Bash loop) | Hundreds of lines across 5 blocks |
+
+### Key Lessons
+
+1. **The skill file is the most important piece.** `program.md` defines the loop, success criteria, and failure handling. Without it, the agent has no idea what to do.
+
+2. **Git is sufficient memory for many loops.** No need for complex state management. The commit history IS the state.
+
+3. **A fixed time budget makes experiments comparable.** Every experiment runs for exactly 5 minutes, regardless of what the agent changes. This is critical for fair evaluation.
+
+4. **Fast-fail prevents wasted compute.** NaN or exploding loss (>100) causes immediate exit. No point running 5 minutes of garbage.
+
+5. **The agent can be its own scheduler.** For simple loops, no external cron is needed. The agent follows the loop instructions and keeps going.
+
+6. **Minimum viable loop = skill + git + evaluation.** You don't need the full 5-block stack to get value from loop engineering. Start simple, add complexity when the task demands it.
+
+---
+
+## Key Takeaways
+
+1. **The shift is real.** The people building the most-used coding agents have stopped prompting by hand. The leverage moved from the model to the loop around it.
+
+2. **The five blocks are converging.** Claude Code and Codex ship nearly identical primitives. The pattern is becoming tool-agnostic.
+
+3. **Ralph is the simplest on-ramp.** If you want to start loop engineering today, a Bash loop + PRD + git history is enough. Scale up to full loop engineering when the complexity demands it.
+
+4. **The skill is the asset.** A loop with no reusable skills inside it is just a while-true around a stranger. Build skills worth calling.
+
+5. **Cost is the immediate bottleneck.** Token consumption scales linearly with loop iterations. Budget caps are mandatory, not optional.
+
+6. **OpenCode is 60% there.** Skills, connectors, and sub-agents are mature. The loop engine and automations are designed but unimplemented. The sub-agent infrastructure is the strongest piece of the stack.
+
+7. **autoresearch proves the minimum viable loop.** You don't need the full 5-block stack. A skill file + git + evaluation metric is enough to run autonomous experiments overnight. Start simple, add complexity when the task demands it.
+
+---
+
+*Sources: Addy Osmani (addyosmani.com), Peter Steinberger (@steipete), Boris Cherny (Anthropic), Matt Van Horn (techtwitter.com), Geoffrey Huntley (ghuntley.com/ralph), snarktank/ralph (GitHub), Andrej Karpathy (autoresearch), awesomeclaude.ai, lushbinary.com, StationX, The Register, The New Stack, Business Insider, and 100+ Reddit/X/YouTube/TikTok threads.*

+ 36 - 8
docs/multiplexer-integration.md

@@ -1,6 +1,6 @@
 # Multiplexer Integration Guide
 
-Use tmux or Zellij to watch subagents work in live panes while OpenCode keeps running in your main session.
+Use tmux, Zellij, or Herdr to watch subagents work in live panes while OpenCode keeps running in your main session.
 
 ## Table of Contents
 
@@ -83,7 +83,17 @@ Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 }
 ```
 
-### 2. Start OpenCode inside tmux or Zellij
+**Herdr only:**
+
+```jsonc
+{
+  "multiplexer": {
+    "type": "herdr"
+  }
+}
+```
+
+### 2. Start OpenCode inside tmux, Zellij, or Herdr
 
 **Tmux:**
 
@@ -99,6 +109,13 @@ zellij
 opencode --port 4096
 ```
 
+**Herdr:**
+
+```bash
+herdr
+opencode --port 4096
+```
+
 ### 3. Trigger delegated work
 
 Ask OpenCode to do something that launches subagents. New panes should appear automatically.
@@ -127,9 +144,9 @@ Please analyze this codebase and create a documentation structure.
 
 | Setting | Type | Default | Description |
 |---------|------|---------|-------------|
-| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, or `"none"` |
-| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij pane directions where possible |
-| `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`) |
+| `type` | string | `"none"` | `"auto"`, `"tmux"`, `"zellij"`, `"herdr"`, or `"none"` |
+| `layout` | string | `"main-vertical"` | Layout preset for tmux; mapped to Zellij/Herdr pane directions where possible |
+| `main_pane_size` | number | `60` | Main pane size percentage for tmux only (`20`-`80`); ignored by Zellij and Herdr |
 | `zellij_pane_mode` | string | `"agent-tab"` | Zellij pane placement: `"agent-tab"` creates/reuses a dedicated tab; `"current-tab"` opens panes in the tab containing the parent OpenCode pane |
 
 ### Supported Multiplexers
@@ -138,6 +155,7 @@ Please analyze this codebase and create a documentation structure.
 |-------------|--------|-------|
 | **Tmux** | ✅ Supported | Full layout control with `main-vertical`, `main-horizontal`, `tiled`, and more |
 | **Zellij** | ✅ Supported | Creates a dedicated `opencode-agents` tab by default; can open panes in the parent OpenCode tab with `zellij_pane_mode: "current-tab"`; maps `main-*` layouts to pane directions |
+| **Herdr** | ✅ Supported | Splits panes in the current Herdr workspace; maps `main-vertical`/`even-horizontal`/`tiled` layouts to right splits and `main-horizontal`/`even-vertical` to down splits; no layout rebalancing (like Zellij) |
 
 **Example: open Zellij subagents in the parent OpenCode tab**
 
@@ -175,9 +193,9 @@ This is converted automatically to `multiplexer.type: "tmux"`.
 
 ## Layouts
 
-Tmux supports full layout control and main pane sizing. Zellij maps only the
-`main-*` layout settings to pane creation directions; exact `main_pane_size`
-rebalancing is tmux-only.
+Tmux supports full layout control and main pane sizing. Zellij and Herdr map
+only the `main-*` layout settings to pane creation directions; exact
+`main_pane_size` rebalancing is tmux-only.
 
 | Layout | Description |
 |--------|-------------|
@@ -197,6 +215,16 @@ For Zellij:
 | `even-vertical` | Uses Zellij's native pane placement |
 | `tiled` | Uses Zellij's native pane placement |
 
+For Herdr:
+
+| Layout | Herdr behavior |
+|--------|-----------------|
+| `main-vertical` | Opens new subagent panes to the right |
+| `main-horizontal` | Opens new subagent panes down |
+| `even-horizontal` | Opens new subagent panes to the right |
+| `even-vertical` | Opens new subagent panes down |
+| `tiled` | Opens new subagent panes to the right |
+
 **Example: wide-screen layout**
 
 ```jsonc

+ 131 - 0
docs/project-local-customization.md

@@ -0,0 +1,131 @@
+# Project-local Customization
+
+This document describes how to configure and customize oh-my-opencode-slim on a per-project (repository-specific) basis. Project-local customization allows teams and repositories to define custom agents, override systemic prompts, restrict skills, and orchestrate MCP configurations without polluting global user configurations.
+
+## Security & Trust Boundary Warning
+
+> ⚠️ **IMPORTANT SECURITY NOTICE**
+> Because project-local configuration files (`.opencode/oh-my-opencode-slim.jsonc`) and prompt templates (`.opencode/oh-my-opencode-slim/`) are loaded automatically when you open and work in a project directory, they can modify agent behaviors, enable/disable tools, and grant extra model access permissions.
+> **Only work in and run OpenCode within repositories you explicitly trust.**
+
+---
+
+## Feature Comparison
+
+| Feature | Scope / Location | Description |
+|---|---|---|
+| **Configuration file** | `.opencode/oh-my-opencode-slim.json[c]` | Project-level configuration file that overrides global user settings, merging presets, agent profiles, and multiplexer integration. |
+| **Custom agents** | `agents` configuration block | Define new specialized agents by keying them under `agents.<custom-name>` with required `model`, custom system `prompt`, and optional routing guidance. |
+| **Built-in prompt overrides** | `.opencode/oh-my-opencode-slim/<agent>.md` | Completely override the built-in system prompt for any agent (e.g. `oracle.md`, `explorer.md`, `orchestrator.md`, or custom agents). |
+| **Append prompts** | `.opencode/oh-my-opencode-slim/<agent>_append.md` | Append additional rules or guidelines to the existing base (inline or default built-in) prompt without overriding it completely. |
+| **Per-agent skills** | `agents.<agent>.skills` | Explicitly restrict or authorize specific local codebase skills/scripts that this agent is allowed to execute. |
+| **Per-agent MCPs** | `agents.<agent>.mcps` | Assign, restrict, or authorize specific Model Context Protocol (MCP) servers (like `websearch` or `context7`) to specific agents. |
+| **Presets** | `presets` configuration block | Bundle named agent environments. User and project preset definitions deep-merge; the active preset then merges into `agents`. |
+| **Precedence** | User config, project config, presets, prompt files | Project-local settings take precedence over user-global settings, while root `agents.*` entries beat active preset entries. |
+
+---
+
+## Configuration Precedence
+
+When oh-my-opencode-slim loads, it resolves configuration properties and prompt templates across multiple layers. The inheritance precedence operates strictly as follows:
+
+```
+[Built-in Defaults]
+       ↓ (overridden by)
+[User Config] (global)
+       ↓ (overridden by)
+[Project Config] (local repository)
+       ↓ (overridden by)
+[Environment Preset Override] (via OH_MY_OPENCODE_SLIM_PRESET env var)
+       ↓ (merged into agents)
+[Active Preset] (merges preset-specific agent options)
+       ↓ (overridden by)
+[Root Config agents.*] (individual agent configs beat preset configurations)
+```
+
+### Note on Root Overrides vs Presets
+The root `agents.*` configuration (defined at the top level of user or project config) always takes precedence over the active preset configurations. To override a root agent choice globally, you must specify the override in the project-level root `agents.*` rather than inside a local preset configuration alone.
+
+---
+
+## Prompt Lookup Precedence
+
+When looking up markdown prompt template files (such as `<agent>.md` or `<agent>_append.md`), oh-my-opencode-slim searches directories in a strict hierarchical order. Precedence is evaluated for the replacement prompt file and the append prompt file **independently** in the following sequence:
+
+1. **Project Preset Directory**
+   `<project>/.opencode/oh-my-opencode-slim/<preset>/<agent>.md` (if preset is active and safe)
+2. **Project Root Directory**
+   `<project>/.opencode/oh-my-opencode-slim/<agent>.md`
+3. **User Preset Directory (Global)**
+   `<user-config-dir>/oh-my-opencode-slim/<preset>/<agent>.md`
+4. **User Root Directory (Global)**
+   `<user-config-dir>/oh-my-opencode-slim/<agent>.md`
+
+---
+
+## Prompt Composition Rules
+
+For any agent, the final system prompt is computed dynamically using the following formula:
+
+1. **Calculate Base Prompt:**
+   ```
+   base = inlinePrompt ?? defaultBuiltInPrompt
+   ```
+   - For built-in agents, `defaultBuiltInPrompt` is their factory template.
+   - For custom agents, `defaultBuiltInPrompt` defaults to `"You are the <name> specialist."`.
+   - `inlinePrompt` is the inline `prompt` string configured directly inside the `agents.<agent>.prompt` object.
+
+2. **Calculate Effective Base:**
+   ```
+   effectiveBase = filePrompt ?? base
+   ```
+   - `filePrompt` is the content of the resolved `<agent>.md` replacement file (located according to the Prompt Lookup Precedence).
+
+3. **Append Append Prompt:**
+   - If an append file `<agent>_append.md` is resolved, it is appended to the `effectiveBase` separated by two newlines:
+     ```
+     finalPrompt = effectiveBase + "\n\n" + appendPrompt
+     ```
+   - Otherwise:
+     ```
+     finalPrompt = effectiveBase
+     ```
+
+---
+
+## Custom Routing Guidance (`orchestratorPrompt`)
+
+Every non-orchestrator agent (both built-in and custom) can define an `orchestratorPrompt`. This snippet is automatically injected into the central **Orchestrator** prompt to instruct it on when and how to delegate tasks to this agent.
+
+- **Additive Routing Guidance:** The local config snippet is grouped under a clear markdown header (`# Project-specific routing guidance`) at the end of the orchestrator prompt. It does not replace the default routing blocks.
+- **Display name rewriting:** Any mentions of `@<internalName>` within the `orchestratorPrompt` are automatically mapped to the agent's custom `displayName` if one was defined.
+- **Disabled agents:** If an agent is disabled via the `disabled_agents` config option, its `orchestratorPrompt` is **not** injected.
+- **Orchestrator agent constraint:** The orchestrator agent itself cannot define an `orchestratorPrompt`. Setting `agents.orchestrator.orchestratorPrompt` will be rejected by the schema.
+
+---
+
+## Examples
+
+### Overriding Oracle prompt in active preset
+
+Your project has the config `.opencode/oh-my-opencode-slim.jsonc`:
+
+```json
+{
+  "preset": "backend-preset",
+  "presets": {
+    "backend-preset": {
+      "oracle": {
+        "model": "anthropic/claude-3-5-sonnet",
+        "prompt": "You are the project senior backend oracle. Focus strictly on NestJS."
+      }
+    }
+  }
+}
+```
+
+If you also place a file under `.opencode/oh-my-opencode-slim/backend-preset/oracle.md` containing:
+```
+Your primary focus is auditing backend security and performance.
+```
+According to prompt composition rules, the markdown file prompt overrides the inline preset prompt, so the effective base prompt becomes `"Your primary focus is auditing backend security and performance."`.

+ 27 - 0
docs/skills.md

@@ -18,6 +18,7 @@ Bundled skills are installed by the `oh-my-opencode-slim` installer.
 | [`deepwork`](#deepwork) | Heavy/complex coding sessions workflow | `orchestrator` |
 | [`reflect`](#reflect) | Review repeated work and suggest reusable workflow improvements | `orchestrator` |
 | [`worktrees`](#worktrees) | Safe Git worktree lane management | `orchestrator` |
+| [`release-smoke-test`](#release-smoke-test) | Packed release-candidate and bugfix smoke validation | `orchestrator` |
 | [`oh-my-opencode-slim`](#oh-my-opencode-slim) | Plugin configuration and self-improvement guidance | `orchestrator` |
 
 ---
@@ -210,6 +211,32 @@ This should apply on the next OpenCode run; restart OpenCode if you need it imme
 
 ---
 
+## release-smoke-test
+
+**Validate packed release candidates and bugfixes before public publish.**
+
+`release-smoke-test` is an orchestrator-only skill for proving a release branch
+works as an installed package artifact. It builds and packs the candidate,
+installs the tarball into a throwaway app, runs OpenCode with a sanitized
+temporary config, verifies the active `plugin_origins`, and searches isolated
+logs for the crash signature being fixed.
+
+Use it for release hardening, runtime compatibility checks, and model-specific
+smokes such as OpenCode 1.17.11 malformed message transform regressions.
+
+Typical request:
+
+```text
+Use release-smoke-test to validate this release candidate before npm publish.
+```
+
+The skill distinguishes fully isolated smokes from host-provider smokes. If a
+model such as GPT-5.5 Fast needs provider aliases from the current machine, the
+skill records that limitation instead of treating it as equivalent to a clean
+`env -i` smoke.
+
+---
+
 ## Skills Assignment
 
 Control which skills each agent can use in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):

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

@@ -0,0 +1,715 @@
+# 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.

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

@@ -0,0 +1,625 @@
+# 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
+```

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

@@ -8,12 +8,12 @@
     "setDefaultAgent": {
       "type": "boolean"
     },
-    "autoUpdate": {
-      "description": "Disable automatic installation of plugin updates when false. Defaults to true.",
+    "compactSidebar": {
+      "description": "Use the compact TUI sidebar layout when enabled.",
       "type": "boolean"
     },
-    "compactSidebar": {
-      "description": "Render each agent on a single line in the TUI sidebar instead of the default multi-line layout. Default false.",
+    "autoUpdate": {
+      "description": "Disable automatic installation of plugin updates when false. Defaults to true.",
       "type": "boolean"
     },
     "presets": {
@@ -225,6 +225,7 @@
             "auto",
             "tmux",
             "zellij",
+            "herdr",
             "none"
           ]
         },

+ 1 - 1
package.json

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

+ 1 - 0
scripts/verify-release-artifact.ts

@@ -37,6 +37,7 @@ const packagedRequiredFiles = [
   'src/skills/deepwork/SKILL.md',
   'src/skills/reflect/SKILL.md',
   'src/skills/oh-my-opencode-slim/SKILL.md',
+  'src/skills/release-smoke-test/SKILL.md',
   'src/skills/worktrees/SKILL.md',
 ];
 

+ 11 - 0
skills-lock.json

@@ -0,0 +1,11 @@
+{
+  "version": 1,
+  "skills": {
+    "cli-review": {
+      "source": "greptileai/skills",
+      "sourceType": "github",
+      "skillPath": "cli-review/SKILL.md",
+      "computedHash": "1b1fe57c18746de2a85068770ec01fa7ac26471f5c9cb8047553ca433fea1acd"
+    }
+  }
+}

+ 5 - 4
src/agents/councillor.test.ts

@@ -48,16 +48,17 @@ describe('createCouncillorAgent', () => {
     expect(agent.config.prompt).toContain('Additional instructions here.');
   });
 
-  test('custom prompt takes priority over append prompt', () => {
+  test('custom prompt and append prompt compose together', () => {
     const customPrompt = 'Custom prompt only.';
-    const customAppendPrompt = 'Should be ignored.';
+    const customAppendPrompt = 'Additional routing context.';
     const agent = createCouncillorAgent(
       'test-model',
       customPrompt,
       customAppendPrompt,
     );
-    expect(agent.config.prompt).toBe(customPrompt);
-    expect(agent.config.prompt).not.toContain(customAppendPrompt);
+    expect(agent.config.prompt).toBe(
+      `${customPrompt}\n\n${customAppendPrompt}`,
+    );
   });
 });
 

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

@@ -252,4 +252,94 @@ describe('custom-agent creation', () => {
       "ACP agent 'fixer' conflicts with a built-in agent name or alias",
     );
   });
+
+  test('appends ACP routing prompts separately without heading, and preserves rewriting', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: {
+          model: 'openai/gpt-5.4-mini',
+          displayName: 'fancy-explorer',
+        },
+        janitor: {
+          model: 'openai/gpt-5.5',
+          orchestratorPrompt:
+            'Please use @janitor to clean up after @explorer has completed.',
+        },
+      },
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+          orchestratorPrompt:
+            'Please delegate research tasks to @claude-research or @explorer.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    // Verify Project-specific routing guidance exists and has the custom agent override prompt rewritten
+    expect(prompt).toContain('# Project-specific routing guidance');
+    expect(prompt).toContain(
+      'Please use @janitor to clean up after @fancy-explorer has completed.',
+    );
+
+    // Verify ACP routing prompt is appended but NOT under the Project-specific routing guidance section
+    // (i.e. it comes after or is separate, let's verify exact substring sequence or that the ACP test isn't inside the heading section)
+    const pieces = prompt.split('# Project-specific routing guidance');
+    expect(pieces.length).toBe(2);
+
+    const headingContent = pieces[1];
+    // The headingContent should contain the custom prompt but not contain the ACP prompt if ACP prompt is appended after/before.
+    // Wait, in our implementation, order of appending is:
+    // 1) overridden/custom prompts under # Project-specific routing guidance.
+    // 2) ACP routing prompts (without the header).
+    // So headingContent (everything after the header) will contain the custom prompt, and then at the very end (or separated), the ACP prompt.
+    // But the ACP prompt is not under that header's specific block if it's appended separately.
+    // Wait, is there a way to verify they are separated? Yes, we can verify that the custom prompt block and ACP prompt block are two separate parts,
+    // and that ACP prompt is appended at the very end of the string, outside of the heading's contiguous text block or that if only ACP is present, the heading doesn't show up.
+    expect(headingContent).toContain(
+      'Please use @janitor to clean up after @fancy-explorer has completed.',
+    );
+    expect(headingContent).toContain(
+      'Please delegate research tasks to @claude-research or @fancy-explorer.',
+    );
+
+    // Let's also check a scenario where only ACP routing prompt is present. There should be NO heading at all!
+    const configOnlyAcp: PluginConfig = {
+      agents: {
+        explorer: {
+          model: 'openai/gpt-5.4-mini',
+          displayName: 'fancy-explorer',
+        },
+      },
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+          orchestratorPrompt:
+            'Please delegate research tasks to @claude-research or @explorer.',
+        },
+      },
+    };
+
+    const agentsOnlyAcp = createAgents(configOnlyAcp);
+    const orchestratorOnlyAcp = agentsOnlyAcp.find(
+      (agent) => agent.name === 'orchestrator',
+    );
+    const promptOnlyAcp = orchestratorOnlyAcp?.config.prompt ?? '';
+
+    expect(promptOnlyAcp).not.toContain('# Project-specific routing guidance');
+    expect(promptOnlyAcp).toContain(
+      'Please delegate research tasks to @claude-research or @fancy-explorer.',
+    );
+  });
 });

+ 20 - 7
src/agents/index.test.ts

@@ -236,7 +236,7 @@ describe('orchestrator agent', () => {
       { id: 'github-copilot/claude-3.5-haiku' },
       { id: 'openai/gpt-4' },
     ]);
-    expect(orchestrator?.config.model).toBeUndefined();
+    expect(orchestrator?.config.model).toBe('google/gemini-3-pro');
   });
 });
 
@@ -258,7 +258,7 @@ describe('per-model variant in array config', () => {
       { id: 'google/gemini-3-flash', variant: 'low' },
       { id: 'openai/gpt-4o-mini' },
     ]);
-    expect(explorer?.config.model).toBeUndefined();
+    expect(explorer?.config.model).toBe('google/gemini-3-flash');
   });
 
   test('top-level variant preserved alongside per-model variants', () => {
@@ -795,7 +795,7 @@ describe('AgentOverrideConfigSchema options validation', () => {
 });
 
 describe('PluginConfigSchema custom-agent-only prompt fields', () => {
-  test('rejects prompt on built-in top-level agent overrides', () => {
+  test('allows prompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         oracle: {
@@ -805,10 +805,10 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
-    expect(result.success).toBe(false);
+    expect(result.success).toBe(true);
   });
 
-  test('rejects orchestratorPrompt on built-in top-level agent overrides', () => {
+  test('allows orchestratorPrompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         explorer: {
@@ -818,10 +818,10 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
-    expect(result.success).toBe(false);
+    expect(result.success).toBe(true);
   });
 
-  test('rejects custom-only prompt fields on built-in preset agents', () => {
+  test('allows custom-only prompt fields on built-in preset agents', () => {
     const result = PluginConfigSchema.safeParse({
       presets: {
         openai: {
@@ -833,6 +833,19 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects orchestratorPrompt on orchestrator agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        orchestrator: {
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@orchestrator\n- Role: should be invalid here',
+        },
+      },
+    });
+
     expect(result.success).toBe(false);
   });
 

+ 76 - 27
src/agents/index.ts

@@ -160,7 +160,10 @@ function applyOverrides(
       agent._modelArray = override.model.map((m) =>
         typeof m === 'string' ? { id: m } : m,
       );
-      agent.config.model = undefined; // cleared; runtime hook resolves from _modelArray
+      // 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;
     } else {
       agent.config.model = override.model;
     }
@@ -317,7 +320,10 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
  * @param config - Optional plugin configuration with agent overrides
  * @returns Array of agent definitions (orchestrator first, then subagents)
  */
-export function createAgents(config?: PluginConfig): AgentDefinition[] {
+export function createAgents(
+  config?: PluginConfig,
+  options?: { projectDirectory?: string },
+): AgentDefinition[] {
   const disabled = getDisabledAgents(config);
   if (!config?.council) {
     disabled.add('council');
@@ -350,12 +356,27 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   )
     .filter(([name]) => !disabled.has(name))
     .map(([name, factory]) => {
-      const customPrompts = loadAgentPrompt(name, config?.preset);
-      return factory(
-        getModelForAgent(name),
+      // Get base agent definition using the subagent factory with undefined prompts
+      const agent = factory(getModelForAgent(name), undefined, undefined);
+
+      const customPrompts = loadAgentPrompt(name, {
+        preset: config?.preset,
+        projectDirectory: options?.projectDirectory,
+      });
+
+      const override = getAgentOverride(config, name);
+      const inlinePrompt = override?.prompt;
+      const defaultPrompt = agent.config.prompt ?? '';
+
+      const basePrompt =
+        inlinePrompt !== undefined ? inlinePrompt : defaultPrompt;
+      agent.config.prompt = resolvePrompt(
+        basePrompt,
         customPrompts.prompt,
         customPrompts.appendPrompt,
       );
+
+      return agent;
     });
 
   // 1b. Discover unknown keys in config.agents as custom subagents.
@@ -381,7 +402,10 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
       return [];
     }
 
-    const customPrompts = loadAgentPrompt(name, config?.preset);
+    const customPrompts = loadAgentPrompt(name, {
+      preset: config?.preset,
+      projectDirectory: options?.projectDirectory,
+    });
 
     return [
       buildCustomAgentDefinition(
@@ -472,13 +496,30 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   const orchestratorOverride = getAgentOverride(config, 'orchestrator');
   const orchestratorModel =
     orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
-  const orchestratorPrompts = loadAgentPrompt('orchestrator', config?.preset);
+  const orchestratorPrompts = loadAgentPrompt('orchestrator', {
+    preset: config?.preset,
+    projectDirectory: options?.projectDirectory,
+  });
   const orchestrator = createOrchestratorAgent(
     orchestratorModel,
+    undefined,
+    undefined,
+    disabled,
+  );
+
+  const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
+  const defaultOrchestratorPrompt = orchestrator.config.prompt ?? '';
+
+  const baseOrchestratorPrompt =
+    inlineOrchestratorPrompt !== undefined
+      ? inlineOrchestratorPrompt
+      : defaultOrchestratorPrompt;
+  orchestrator.config.prompt = resolvePrompt(
+    baseOrchestratorPrompt,
     orchestratorPrompts.prompt,
     orchestratorPrompts.appendPrompt,
-    disabled,
   );
+
   applyDefaultPermissions(
     orchestrator,
     orchestratorOverride?.skills,
@@ -499,8 +540,8 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     }
   }
 
-  // 3b. Append custom orchestrator hints from custom agent overrides.
-  const customOrchestratorPrompts = customSubAgents
+  // 3b. Append custom orchestrator hints from built-in and custom agent overrides.
+  const extraOrchestratorPromptsList = [...builtInSubAgents, ...customSubAgents]
     .map((agent) => {
       const override = getAgentOverride(config, agent.name);
       return override?.orchestratorPrompt;
@@ -551,28 +592,34 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // Inject display names into orchestrator prompt (complete map)
   injectDisplayNames(orchestrator, displayNameMap);
 
-  const extraOrchestratorPrompts = [
-    ...customOrchestratorPrompts,
-    ...acpOrchestratorPrompts,
-  ];
+  const rewritePrompt = (promptText: string) => {
+    let text = promptText;
+    for (const [internalName, displayName] of displayNameMap) {
+      text = text.replace(
+        new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
+        `@${normalizeDisplayName(displayName)}`,
+      );
+    }
+    return text;
+  };
 
-  if (extraOrchestratorPrompts.length > 0) {
-    const rewrittenPrompts = extraOrchestratorPrompts.map((promptText) => {
-      let text = promptText;
-      for (const [internalName, displayName] of displayNameMap) {
-        text = text.replace(
-          new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
-          `@${normalizeDisplayName(displayName)}`,
-        );
-      }
-      return text;
-    });
+  const rewrittenOverrides = extraOrchestratorPromptsList.map(rewritePrompt);
+  const rewrittenAcps = acpOrchestratorPrompts.map(rewritePrompt);
+
+  let updatedPrompt = orchestrator.config.prompt ?? '';
 
-    orchestrator.config.prompt = `${orchestrator.config.prompt}\n\n${rewrittenPrompts.join(
+  if (rewrittenOverrides.length > 0) {
+    updatedPrompt = `${updatedPrompt}\n\n# Project-specific routing guidance\n\n${rewrittenOverrides.join(
       '\n\n',
     )}`;
   }
 
+  if (rewrittenAcps.length > 0) {
+    updatedPrompt = `${updatedPrompt}\n\n${rewrittenAcps.join('\n\n')}`;
+  }
+
+  orchestrator.config.prompt = updatedPrompt;
+
   return [orchestrator, ...allSubAgents];
 }
 
@@ -581,12 +628,14 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
  * Converts agent definitions to SDK config format and applies classification metadata.
  *
  * @param config - Optional plugin configuration with agent overrides
+ * @param options - Optional options including projectDirectory
  * @returns Record mapping agent names to their SDK configurations
  */
 export function getAgentConfigs(
   config?: PluginConfig,
+  options?: { projectDirectory?: string },
 ): Record<string, SDKAgentConfig> {
-  const agents = createAgents(config);
+  const agents = createAgents(config, options);
 
   const applyClassification = (
     name: string,

+ 5 - 4
src/agents/orchestrator.ts

@@ -13,16 +13,17 @@ export interface AgentDefinition {
 /**
  * Resolve agent prompt from base/custom/append inputs.
  * If customPrompt is provided, it replaces the base entirely.
- * Otherwise, customAppendPrompt is appended to the base.
+ * If customAppendPrompt is provided, it appends after whichever base won.
  */
 export function resolvePrompt(
   base: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): string {
-  if (customPrompt) return customPrompt;
-  if (customAppendPrompt) return `${base}\n\n${customAppendPrompt}`;
-  return base;
+  const effectiveBase = customPrompt !== undefined ? customPrompt : base;
+  return customAppendPrompt !== undefined
+    ? `${effectiveBase}\n\n${customAppendPrompt}`
+    : effectiveBase;
 }
 
 // Agent descriptions for the orchestrator prompt

+ 7 - 0
src/cli/custom-skills.ts

@@ -67,6 +67,13 @@ export const CUSTOM_SKILLS: CustomSkill[] = [
     allowedAgents: ['orchestrator'],
     sourcePath: 'src/skills/oh-my-opencode-slim',
   },
+  {
+    name: 'release-smoke-test',
+    description:
+      'Validate packed release candidates and bugfixes before public publish',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/release-smoke-test',
+  },
   {
     name: 'worktrees',
     description:

+ 1 - 1
src/cli/index.ts

@@ -74,7 +74,7 @@ Usage:
 Options:
   --skills=yes|no        Install bundled skills (default: yes)
   --companion=ask|yes|no Install desktop companion binary and enable config
-                         (default: ask; prompt defaults to yes)
+                         (default: ask; prompt defaults to no)
   --preset=<name>        Active generated config preset (default: openai)
   --background-subagents=ask|yes|no
                           Persist required OpenCode background subagent env

+ 6 - 6
src/cli/install.test.ts

@@ -24,12 +24,12 @@ describe('shouldInstallCompanion', () => {
     });
   });
 
-  test('dry-run defaults to install on niri', async () => {
+  test('dry-run defaults to skip on niri', async () => {
     process.env.NIRI_SOCKET = '/run/user/1000/niri.sock';
     const config = { ...baseConfig(), dryRun: true };
 
-    await expect(shouldInstallCompanion(config)).resolves.toBe(true);
-    expect(config.companion).toBe('yes');
+    await expect(shouldInstallCompanion(config)).resolves.toBe(false);
+    expect(config.companion).toBe('no');
   });
 
   test('explicit companion yes still enables companion on niri', async () => {
@@ -39,13 +39,13 @@ describe('shouldInstallCompanion', () => {
     await expect(shouldInstallCompanion(config)).resolves.toBe(true);
   });
 
-  test('dry-run still defaults to install outside niri', async () => {
+  test('dry-run defaults to skip outside niri', async () => {
     delete process.env.NIRI_SOCKET;
     delete process.env.XDG_CURRENT_DESKTOP;
     delete process.env.DESKTOP_SESSION;
     const config = { ...baseConfig(), dryRun: true };
 
-    await expect(shouldInstallCompanion(config)).resolves.toBe(true);
-    expect(config.companion).toBe('yes');
+    await expect(shouldInstallCompanion(config)).resolves.toBe(false);
+    expect(config.companion).toBe('no');
   });
 });

+ 4 - 4
src/cli/install.ts

@@ -240,10 +240,10 @@ export async function shouldInstallCompanion(
 
   if (config.dryRun) {
     printInfo(
-      'Dry run mode - would ask to install the desktop companion (default: yes).',
+      'Dry run mode - would ask to install the desktop companion (default: no).',
     );
-    config.companion = 'yes';
-    return true;
+    config.companion = 'no';
+    return false;
   }
 
   if (!process.stdin.isTTY) {
@@ -258,7 +258,7 @@ export async function shouldInstallCompanion(
   printInfo('The optional desktop companion shows live agent activity.');
   const shouldInstall = await confirm(
     'Install and enable the desktop companion?',
-    true,
+    false,
   );
   config.companion = shouldInstall ? 'yes' : 'no';
 

+ 1 - 0
src/cli/skills.test.ts

@@ -25,6 +25,7 @@ describe('skills permissions', () => {
     expect(orchestratorPerms.clonedeps).toBe('allow');
     expect(orchestratorPerms.deepwork).toBe('allow');
     expect(orchestratorPerms.reflect).toBe('allow');
+    expect(orchestratorPerms['release-smoke-test']).toBe('allow');
     expect(orchestratorPerms.worktrees).toBe('allow');
     expect(orchestratorPerms['oh-my-opencode-slim']).toBe('allow');
   });

+ 18 - 2
src/config/loader.test.ts

@@ -86,7 +86,7 @@ describe('loadPluginConfig', () => {
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 
-  test('rejects custom-only prompt fields on built-in agents in config files', () => {
+  test('accepts prompt on built-in agents and rejects orchestratorPrompt on orchestrator in config files', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
     fs.mkdirSync(projectConfigDir, { recursive: true });
@@ -97,12 +97,28 @@ describe('loadPluginConfig', () => {
         agents: {
           oracle: {
             model: 'openai/gpt-5.5',
-            prompt: 'This should be rejected for built-in agents.',
+            prompt: 'This is now allowed for built-in agents.',
           },
         },
       }),
     );
 
+    const loaded = loadPluginConfig(projectDir);
+    expect(loaded.agents?.oracle?.prompt).toBe(
+      'This is now allowed for built-in agents.',
+    );
+
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: {
+          orchestrator: {
+            model: 'openai/gpt-5.5',
+            orchestratorPrompt: 'This must be rejected.',
+          },
+        },
+      }),
+    );
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 

+ 40 - 10
src/config/loader.ts

@@ -197,6 +197,7 @@ export function mergePluginConfigs(
     ...base,
     ...override,
     agents: deepMerge(base.agents, override.agents),
+    presets: deepMerge(base.presets, override.presets),
     tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
@@ -342,31 +343,58 @@ export function loadPluginConfig(
  * then falls back to the root prompts directory.
  *
  * @param agentName - Name of the agent (e.g., "orchestrator", "explorer")
- * @param preset - Optional preset name for preset-scoped prompt lookup
+ * @param optionsOrPreset - Optional preset name or options configuration
  * @returns Object with prompt and/or appendPrompt if files exist
  */
 export function loadAgentPrompt(
   agentName: string,
-  preset?: string,
+  optionsOrPreset?: string | { preset?: string; projectDirectory?: string },
 ): {
   prompt?: string;
   appendPrompt?: string;
 } {
+  let preset: string | undefined;
+  let projectDirectory: string | undefined;
+
+  if (typeof optionsOrPreset === 'string') {
+    preset = optionsOrPreset;
+  } else if (optionsOrPreset && typeof optionsOrPreset === 'object') {
+    preset = optionsOrPreset.preset;
+    projectDirectory = optionsOrPreset.projectDirectory;
+  }
+
   const presetDirName =
     preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
-  const promptSearchDirs = getConfigSearchDirs().flatMap((configDir) => {
-    const promptsDir = path.join(configDir, PROMPTS_DIR_NAME);
-    return presetDirName
-      ? [path.join(promptsDir, presetDirName), promptsDir]
-      : [promptsDir];
-  });
-  const result: { prompt?: string; appendPrompt?: string } = {};
+
+  const searchDirs: string[] = [];
+
+  // Lookup order preference:
+  // 1. Project preset dir
+  if (projectDirectory && presetDirName) {
+    searchDirs.push(
+      path.join(projectDirectory, '.opencode', PROMPTS_DIR_NAME, presetDirName),
+    );
+  }
+  // 2. Project root dir
+  if (projectDirectory) {
+    searchDirs.push(path.join(projectDirectory, '.opencode', PROMPTS_DIR_NAME));
+  }
+  // 3. User preset dirs
+  if (presetDirName) {
+    for (const userDir of getConfigSearchDirs()) {
+      searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME, presetDirName));
+    }
+  }
+  // 4. User root dirs
+  for (const userDir of getConfigSearchDirs()) {
+    searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME));
+  }
 
   const readFirstPrompt = (
     fileName: string,
     errorPrefix: string,
   ): string | undefined => {
-    for (const dir of promptSearchDirs) {
+    for (const dir of searchDirs) {
       const promptPath = path.join(dir, fileName);
       if (!fs.existsSync(promptPath)) {
         continue;
@@ -385,6 +413,8 @@ export function loadAgentPrompt(
     return undefined;
   };
 
+  const result: { prompt?: string; appendPrompt?: string } = {};
+
   // Check for replacement prompt
   result.prompt = readFirstPrompt(
     `${agentName}.md`,

+ 410 - 0
src/config/project-local-customization.test.ts

@@ -0,0 +1,410 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { createAgents } from '../agents';
+import { loadAgentPrompt, mergePluginConfigs } from './loader';
+import { PluginConfigSchema } from './schema';
+
+describe('Project-local customization - 15 core cases', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proj-custom-test-'));
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    process.env.XDG_CONFIG_HOME = tempDir;
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  // Test Case 1: Project prompt root beats user prompt root
+  test('1. Project prompt root beats user prompt root', () => {
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(path.join(userDir, 'oracle.md'), 'user-oracle');
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    fs.mkdirSync(projectPromptDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'project-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', { projectDirectory: projectDir });
+    expect(loaded.prompt).toBe('project-oracle');
+  });
+
+  // Test Case 2: Project preset prompt beats project root prompt
+  test('2. Project preset prompt beats project root prompt', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    const projectPresetDir = path.join(projectPromptDir, 'test-preset');
+    fs.mkdirSync(projectPresetDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'project-root-oracle',
+    );
+    fs.writeFileSync(
+      path.join(projectPresetDir, 'oracle.md'),
+      'project-preset-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', {
+      preset: 'test-preset',
+      projectDirectory: projectDir,
+    });
+    expect(loaded.prompt).toBe('project-preset-oracle');
+  });
+
+  // Test Case 3: User preset prompt beats user root prompt when no project prompt exists
+  test('3. User preset prompt beats user root prompt when no project prompt exists', () => {
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const userPresetDir = path.join(userDir, 'test-preset');
+    fs.mkdirSync(userPresetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(userDir, 'oracle.md'), 'user-root-oracle');
+    fs.writeFileSync(
+      path.join(userPresetDir, 'oracle.md'),
+      'user-preset-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', { preset: 'test-preset' });
+    expect(loaded.prompt).toBe('user-preset-oracle');
+  });
+
+  // Test Case 4: Replacement and append both apply together
+  test('4. Replacement and append both apply together', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    fs.mkdirSync(projectPromptDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'replacement prompt',
+    );
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle_append.md'),
+      'append prompt',
+    );
+
+    const agents = createAgents(undefined, { projectDirectory: projectDir });
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe('replacement prompt\n\nappend prompt');
+  });
+
+  // Test Case 5: Inline built-in prompt is accepted and used
+  test('5. Inline built-in prompt is accepted and used', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe(
+      'You are the inline oracle prompt override.',
+    );
+  });
+
+  // Test Case 6: File prompt overrides inline built-in prompt
+  test('6. File prompt overrides inline built-in prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    // User prompt file mock
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userDir, 'oracle.md'),
+      'File prompt override content',
+    );
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe('File prompt override content');
+  });
+
+  // Test Case 7: Append file appends to inline built-in prompt
+  test('7. Append file appends to inline built-in prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    // User append file mock
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(path.join(userDir, 'oracle_append.md'), 'append content');
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe(
+      'You are the inline oracle prompt override.\n\nappend content',
+    );
+  });
+
+  // Test Case 8: Built-in orchestratorPrompt is injected into orchestrator prompt
+  test('8. Built-in orchestratorPrompt is injected into orchestrator prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt:
+            'Please routing to @oracle when architecture is queried.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).toContain(
+      '# Project-specific routing guidance',
+    );
+    expect(orchestrator?.config.prompt).toContain(
+      'Please routing to @oracle when architecture is queried.',
+    );
+  });
+
+  // Test Case 9: Disabled built-in agent\'s orchestratorPrompt is not injected
+  test("9. Disabled built-in agent's orchestratorPrompt is not injected", () => {
+    const config = {
+      disabled_agents: ['oracle'],
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt:
+            'Please routing to @oracle when architecture is queried.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).not.toContain(
+      'Please routing to @oracle',
+    );
+  });
+
+  // Test Case 10: agents.orchestrator.orchestratorPrompt is rejected or explicitly ignored
+  test('10. agents.orchestrator.orchestratorPrompt is rejected', () => {
+    const invalidConfig = {
+      agents: {
+        orchestrator: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt: 'Self guidance',
+        },
+      },
+    };
+
+    const parsed = PluginConfigSchema.safeParse(invalidConfig);
+    expect(parsed.success).toBe(false);
+  });
+
+  // Test Case 11: presets deep-merge preserves unrelated user presets
+  test('11. presets deep-merge preserves unrelated user presets', () => {
+    const userConfig = {
+      presets: {
+        presetA: {
+          oracle: { model: 'model-a' },
+        },
+      },
+    };
+
+    const projectConfig = {
+      presets: {
+        presetB: {
+          explorer: { model: 'model-b' },
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    expect(merged.presets?.presetA).toBeDefined();
+    expect(merged.presets?.presetB).toBeDefined();
+    expect(merged.presets?.presetA.oracle?.model).toBe('model-a');
+    expect(merged.presets?.presetB.explorer?.model).toBe('model-b');
+  });
+
+  // Test Case 12: Same-name preset deep-merges by agent and nested options
+  test('12. Same-name preset deep-merges by agent and nested options', () => {
+    const userConfig = {
+      presets: {
+        myPreset: {
+          oracle: {
+            model: 'model-a',
+            options: { tokenLimit: 1000, debug: true },
+          },
+        },
+      },
+    };
+
+    const projectConfig = {
+      presets: {
+        myPreset: {
+          oracle: {
+            temperature: 0.7,
+            options: { debug: false, maxSearch: 5 },
+          },
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    const oraclePreset = merged.presets?.myPreset?.oracle;
+    expect(oraclePreset?.model).toBe('model-a');
+    expect(oraclePreset?.temperature).toBe(0.7);
+    expect(oraclePreset?.options).toEqual({
+      tokenLimit: 1000,
+      debug: false,
+      maxSearch: 5,
+    });
+  });
+
+  // Test Case 13: Project config still overrides user config for root agents
+  test('13. Project config still overrides user config for root agents', () => {
+    const userConfig = {
+      agents: {
+        oracle: {
+          model: 'user-model',
+          temperature: 0.1,
+        },
+      },
+    };
+
+    const projectConfig = {
+      agents: {
+        oracle: {
+          model: 'project-model',
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    expect(merged.agents?.oracle?.model).toBe('project-model');
+    expect(merged.agents?.oracle?.temperature).toBe(0.1);
+  });
+
+  // Test Case 14: Schema accepts built-in prompt/orchestratorPrompt in root and presets, but rejects empty strings
+  test('14. Schema accepts built-in prompt/orchestratorPrompt in root and presets, but rejects empty strings', () => {
+    // Non-empty is allowed
+    const valid = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          prompt: 'non-empty prompt',
+          orchestratorPrompt: 'non-empty guidance',
+        },
+      },
+    });
+    expect(valid.success).toBe(true);
+
+    // Empty prompt is rejected
+    const invalidPrompt = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          prompt: '',
+        },
+      },
+    });
+    expect(invalidPrompt.success).toBe(false);
+
+    // Empty orchestratorPrompt is rejected
+    const invalidOrchestrator = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          orchestratorPrompt: '',
+        },
+      },
+    });
+    expect(invalidOrchestrator.success).toBe(false);
+  });
+
+  // Test Case 15: Docs examples match actual precedence
+  test('15. Precedence chain: project preset -> project root -> user preset -> user root', () => {
+    const userDir = path.join(tempDir, 'opencode');
+    const userPromptDir = path.join(userDir, 'oh-my-opencode-slim');
+    const userPresetDir = path.join(userPromptDir, 'my-preset');
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    const projectPresetDir = path.join(projectPromptDir, 'my-preset');
+
+    fs.mkdirSync(userPresetDir, { recursive: true });
+    fs.mkdirSync(projectPresetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(userPromptDir, 'oracle.md'), 'user-root');
+    fs.writeFileSync(path.join(userPresetDir, 'oracle.md'), 'user-preset');
+    fs.writeFileSync(path.join(projectPromptDir, 'oracle.md'), 'project-root');
+    fs.writeFileSync(
+      path.join(projectPresetDir, 'oracle.md'),
+      'project-preset',
+    );
+
+    // 1. All exist -> project preset won
+    let result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('project-preset');
+
+    // 2. Remove project preset -> project root won
+    fs.unlinkSync(path.join(projectPresetDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('project-root');
+
+    // 3. Remove project root -> user preset won
+    fs.unlinkSync(path.join(projectPromptDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('user-preset');
+
+    // 4. Remove user preset -> user root won
+    fs.unlinkSync(path.join(userPresetDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('user-root');
+  });
+});

+ 20 - 29
src/config/schema.ts

@@ -1,5 +1,4 @@
 import { z } from 'zod';
-import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
 const MANUAL_AGENT_NAMES = [
@@ -86,7 +85,13 @@ export const AgentOverrideConfigSchema = z
   .strict();
 
 // Multiplexer type options
-export const MultiplexerTypeSchema = z.enum(['auto', 'tmux', 'zellij', 'none']);
+export const MultiplexerTypeSchema = z.enum([
+  'auto',
+  'tmux',
+  'zellij',
+  'herdr',
+  'none',
+]);
 export type MultiplexerType = z.infer<typeof MultiplexerTypeSchema>;
 
 // Layout options (shared across multiplexers)
@@ -257,33 +262,18 @@ export type AcpAgentPermissionMode = z.infer<
 export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
 export type AcpAgentsConfig = z.infer<typeof AcpAgentsConfigSchema>;
 
-function validateCustomOnlyPromptFields(
+function rejectOrchestratorPromptOnOrchestrator(
   overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
   ctx: z.RefinementCtx,
   pathPrefix: Array<string | number>,
 ): void {
   for (const [name, override] of Object.entries(overrides)) {
-    const isBuiltInOrAlias =
-      (ALL_AGENT_NAMES as readonly string[]).includes(name) ||
-      AGENT_ALIASES[name] !== undefined;
-
-    if (!isBuiltInOrAlias) {
-      continue;
-    }
-
-    if (override.prompt !== undefined) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        path: [...pathPrefix, name, 'prompt'],
-        message: 'prompt is only supported for custom agents',
-      });
-    }
-
-    if (override.orchestratorPrompt !== undefined) {
+    if (name === 'orchestrator' && override.orchestratorPrompt !== undefined) {
       ctx.addIssue({
         code: z.ZodIssueCode.custom,
         path: [...pathPrefix, name, 'orchestratorPrompt'],
-        message: 'orchestratorPrompt is only supported for custom agents',
+        message:
+          'orchestratorPrompt is not supported for the orchestrator agent',
       });
     }
   }
@@ -293,17 +283,15 @@ export const PluginConfigSchema = z
   .object({
     preset: z.string().optional(),
     setDefaultAgent: z.boolean().optional(),
-    autoUpdate: z
+    compactSidebar: z
       .boolean()
       .optional()
-      .describe(
-        'Disable automatic installation of plugin updates when false. Defaults to true.',
-      ),
-    compactSidebar: z
+      .describe('Use the compact TUI sidebar layout when enabled.'),
+    autoUpdate: z
       .boolean()
       .optional()
       .describe(
-        'Render each agent on a single line in the TUI sidebar instead of the default multi-line layout. Default false.',
+        'Disable automatic installation of plugin updates when false. Defaults to true.',
       ),
     presets: z.record(z.string(), PresetSchema).optional(),
     agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
@@ -344,12 +332,15 @@ export const PluginConfigSchema = z
   })
   .superRefine((value, ctx) => {
     if (value.agents) {
-      validateCustomOnlyPromptFields(value.agents, ctx, ['agents']);
+      rejectOrchestratorPromptOnOrchestrator(value.agents, ctx, ['agents']);
     }
 
     if (value.presets) {
       for (const [presetName, preset] of Object.entries(value.presets)) {
-        validateCustomOnlyPromptFields(preset, ctx, ['presets', presetName]);
+        rejectOrchestratorPromptOnOrchestrator(preset, ctx, [
+          'presets',
+          presetName,
+        ]);
       }
     }
   });

+ 18 - 0
src/hooks/filter-available-skills/index.test.ts

@@ -47,6 +47,24 @@ describe('filterAvailableSkillsText', () => {
 });
 
 describe('createFilterAvailableSkillsHook', () => {
+  test('ignores messages without OpenCode info or parts', async () => {
+    const hook = createFilterAvailableSkillsHook(mockCtx, {});
+    const output = {
+      messages: [
+        {},
+        { info: { role: 'assistant' } },
+        {
+          info: { role: 'system' },
+          parts: [{ type: 'text', text: availableSkillsBlock('skill1') }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output as never);
+
+    expect(output.messages[2].parts[0].text).toContain('<name>skill1</name>');
+  });
+
   test('filters system prompt skill blocks for explicit agent skills', async () => {
     const config: PluginConfig = {
       agents: {

+ 10 - 4
src/hooks/filter-available-skills/index.ts

@@ -6,7 +6,11 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { getSkillPermissionsForAgent } from '../../cli/skills';
 import { getAgentOverride, type PluginConfig } from '../../config';
-import type { MessageWithParts } from '../types';
+import {
+  isMessageWithParts,
+  isUserMessageWithParts,
+  type MessageWithParts,
+} from '../types';
 
 const AVAILABLE_SKILLS_BLOCK_REGEX =
   /<available_skills>\s*([\s\S]*?)\s*<\/available_skills>/g;
@@ -22,7 +26,7 @@ interface SkillEntry {
 function getCurrentAgent(messages: MessageWithParts[]): string {
   for (let index = messages.length - 1; index >= 0; index -= 1) {
     const message = messages[index];
-    if (message.info.role === 'user') {
+    if (isUserMessageWithParts(message)) {
       return message.info.agent ?? 'orchestrator';
     }
   }
@@ -113,9 +117,11 @@ export function createFilterAvailableSkillsHook(
   return {
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
-      output: { messages: MessageWithParts[] },
+      output: { messages?: unknown },
     ): Promise<void> => {
-      const { messages } = output;
+      const messages = (
+        Array.isArray(output.messages) ? output.messages : []
+      ).filter(isMessageWithParts);
       if (messages.length === 0) {
         return;
       }

+ 74 - 5
src/hooks/foreground-fallback/index.test.ts

@@ -13,7 +13,7 @@ function createMockClient(overrides?: {
   promptAsyncImpl?: (args: unknown) => Promise<unknown>;
   abortImpl?: () => Promise<unknown>;
   includePromptAsync?: boolean;
-  messagesData?: Array<{ info: { role: string }; parts: unknown[] }>;
+  messagesData?: unknown[];
 }) {
   const promptAsync = mock(async (args: unknown) => {
     if (overrides?.promptAsyncImpl) return overrides.promptAsyncImpl(args);
@@ -93,6 +93,30 @@ describe('isRateLimitError', () => {
     expect(isRateLimitError({ message: 'Service Unavailable' })).toBe(true);
   });
 
+  test('returns true for "Monthly usage limit reached"', () => {
+    expect(
+      isRateLimitError({
+        message: 'Monthly usage limit reached. Resets in X days.',
+      }),
+    ).toBe(true);
+  });
+
+  test('returns true for "5-hour usage limit reached"', () => {
+    expect(
+      isRateLimitError({
+        message: '5-hour usage limit reached. Resets in 36min.',
+      }),
+    ).toBe(true);
+  });
+
+  test('returns true for "Weekly usage limit reached"', () => {
+    expect(
+      isRateLimitError({
+        message: 'Weekly usage limit reached. Resets in 2 days.',
+      }),
+    ).toBe(true);
+  });
+
   test('returns false for non-rate-limit error', () => {
     expect(isRateLimitError({ message: 'invalid API key' })).toBe(false);
   });
@@ -178,6 +202,50 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(call[0].body.model.modelID).toBe('gpt-4o');
   });
 
+  test('skips malformed messages without info when locating the last user message', async () => {
+    // OpenCode may return partial/streaming messages whose `info` is undefined;
+    // the fallback must ignore those rather than crash, and still re-submit the
+    // real last user message.
+    ({ client, mocks } = createMockClient({
+      messagesData: [
+        {},
+        { info: { role: 'assistant' }, parts: [] },
+        { parts: [{ type: 'text', text: 'no info' }] },
+        {
+          info: { role: 'user' },
+          parts: [{ type: 'text', text: 'real prompt' }],
+        },
+      ],
+    }));
+    mgr = new ForegroundFallbackManager(client, makeChains(), true);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { parts: Array<{ text?: string }> } },
+    ];
+    expect(call[0].body.parts[0]?.text).toBe('real prompt');
+  });
+
   test('does nothing when error is not a rate limit', async () => {
     await mgr.handleEvent({
       type: 'session.error',
@@ -512,7 +580,7 @@ describe('ForegroundFallbackManager deduplication', () => {
 // ---------------------------------------------------------------------------
 
 describe('ForegroundFallbackManager subagent.session.created', () => {
-  test('records agent name from subagent.session.created when agentName provided', async () => {
+  test('records agent name from subagent.session.created and falls back correctly', async () => {
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(client, makeChains(), true);
 
@@ -535,9 +603,10 @@ describe('ForegroundFallbackManager subagent.session.created', () => {
       },
     ];
     // explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
-    // no current model tracked → first untried = openai/gpt-4o-mini
-    expect(call[0].body.model.providerID).toBe('openai');
-    expect(call[0].body.model.modelID).toBe('gpt-4o-mini');
+    // agentName known → currentModel inferred as chain[0] (primary)
+    // primary is tried → fallback picks claude-haiku
+    expect(call[0].body.model.providerID).toBe('anthropic');
+    expect(call[0].body.model.modelID).toBe('claude-haiku');
   });
 });
 

+ 19 - 8
src/hooks/foreground-fallback/index.ts

@@ -21,6 +21,7 @@ import {
   abortSessionWithTimeout,
   parseModelReference,
 } from '../../utils/session';
+import { isUserMessageWithParts } from '../types';
 
 type OpencodeClient = PluginInput['client'];
 
@@ -45,6 +46,9 @@ const RATE_LIMIT_PATTERNS = [
   // ponytail: transient server errors mixed in; rename to isRetryableError
   // and split from rate-limit detection when this list grows further
   /service unavailable/i,
+  /monthly usage limit/i,
+  /5-hour usage limit/i,
+  /weekly usage limit/i,
 ];
 
 export function isRateLimitError(error: unknown): boolean {
@@ -225,7 +229,7 @@ export class ForegroundFallbackManager {
 
     this.inProgress.add(sessionID);
     try {
-      const currentModel = this.sessionModel.get(sessionID);
+      let currentModel = this.sessionModel.get(sessionID);
       const agentName = this.sessionAgent.get(sessionID);
       const chain = this.resolveChain(agentName, currentModel);
       if (!chain.length) {
@@ -236,6 +240,15 @@ export class ForegroundFallbackManager {
         return;
       }
 
+      // When the agent is known but no model was captured (common for
+      // subagent error events that fire before message.updated), infer
+      // the current model as the chain's first entry. Without this, the
+      // fallback would incorrectly re-select the primary model as the
+      // "next" fallback target.
+      if (!currentModel && agentName && chain.length > 0) {
+        currentModel = chain[0];
+      }
+
       if (!this.sessionTried.has(sessionID)) {
         this.sessionTried.set(sessionID, new Set());
       }
@@ -287,13 +300,11 @@ export class ForegroundFallbackManager {
       const result = await this.client.session.messages({
         path: { id: sessionID },
       });
-      const messages = (result.data ?? []) as Array<{
-        info: { role: string };
-        parts: unknown[];
-      }>;
-      const lastUser = [...messages]
-        .reverse()
-        .find((m) => m.info.role === 'user');
+      // result.data may contain partial/streaming messages whose `info` is
+      // undefined at runtime (OpenCode violates its own declared type), so
+      // guard each entry instead of dereferencing `info` directly.
+      const messages = (result.data ?? []) as unknown[];
+      const lastUser = [...messages].reverse().find(isUserMessageWithParts);
       if (!lastUser) {
         log('[foreground-fallback] no user message found', { sessionID });
         return;

+ 2 - 2
src/hooks/image-hook.ts

@@ -9,7 +9,7 @@ import {
   writeFileSync,
 } from 'node:fs';
 import { basename, extname, join } from 'node:path';
-import type { MessageWithParts } from './types';
+import { isUserMessageWithParts, type MessageWithParts } from './types';
 
 // Debounce: only run cleanup every 10 minutes per directory
 const lastCleanupByDir = new Map<string, number>();
@@ -170,7 +170,7 @@ export function processImageAttachments(args: {
   }> = [];
 
   for (const msg of messages) {
-    if (msg.info.role !== 'user') continue;
+    if (!isUserMessageWithParts(msg)) continue;
     const imageParts = msg.parts.filter(isImagePart);
     if (imageParts.length > 0) {
       messagesWithImages.push({ msg, imageParts });

+ 1 - 0
src/hooks/index.ts

@@ -11,6 +11,7 @@ export {
 } from './foreground-fallback';
 export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery/hook';
+export { createLoopCommandHook } from './loop-command';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
 export { createReflectCommandHook } from './reflect';

+ 80 - 0
src/hooks/loop-command/index.test.ts

@@ -0,0 +1,80 @@
+import { describe, expect, test } from 'bun:test';
+import { createLoopCommandHook } from './index';
+
+describe('loop command hook', () => {
+  test('registers /loop command when absent', () => {
+    const hook = createLoopCommandHook();
+    const config: Record<string, unknown> = {};
+    hook.registerCommand(config);
+
+    const command = (config.command as Record<string, unknown>).loop as {
+      template: string;
+      description: string;
+    };
+
+    expect(command).toBeDefined();
+    expect(command.template).toContain('loop');
+    expect(command.description).toBeDefined();
+  });
+
+  test('does not overwrite existing /loop command', () => {
+    const hook = createLoopCommandHook();
+    const existing = { template: 'custom', description: 'custom loop' };
+    const config: Record<string, unknown> = { command: { loop: existing } };
+    hook.registerCommand(config);
+    expect((config.command as Record<string, unknown>).loop).toBe(existing);
+  });
+
+  test('shows help when no arguments provided', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'loop', sessionID: 's1', arguments: '  ' },
+      output,
+    );
+
+    expect(output.parts.length).toBe(1);
+    expect(output.parts[0].text).toContain('Usage');
+  });
+
+  test('generates activation prompt with user text', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'loop',
+        sessionID: 's1',
+        arguments: 'fix typescript errors until typecheck passes, max 3 tries',
+      },
+      output,
+    );
+
+    const text = output.parts[0].text;
+    expect(output.parts.length).toBe(1);
+    expect(text).toContain('The user ran `/loop`');
+    expect(text).toContain(
+      'fix typescript errors until typecheck passes, max 3 tries',
+    );
+    expect(text).toContain('goal, successCriteria, maxAttempts');
+    expect(text).toContain('missing or unclear');
+    expect(text).toContain('.opencode/loop-history/');
+    expect(text).toContain('history-{NNN}.md');
+    expect(text).not.toContain('attempt-{N}.md');
+    expect(text).toContain('Dispatch @fixer');
+  });
+
+  test('ignores other commands', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [{ type: 'text' as const, text: 'original' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'deepwork', sessionID: 's1', arguments: 'x' },
+      output,
+    );
+
+    expect(output.parts.length).toBe(1);
+    expect(output.parts[0].text).toBe('original');
+  });
+});

+ 78 - 0
src/hooks/loop-command/index.ts

@@ -0,0 +1,78 @@
+import { createInternalAgentTextPart } from '../../utils';
+
+const COMMAND_NAME = 'loop';
+
+function historyDir(): string {
+  const shortID = Math.random().toString(36).slice(2, 8);
+  const timestamp = Date.now().toString(36);
+  return `.opencode/loop-history/loop-${timestamp}-${shortID}`;
+}
+
+function activationPrompt(text: string): string {
+  const dir = historyDir();
+
+  return [
+    'The user ran `/loop`. From the text below, extract: goal, successCriteria, maxAttempts.',
+    '',
+    'If ANY are missing or unclear — push back and ask the user to clarify.',
+    'Do not assume or guess. All three must be explicit.',
+    '',
+    'Once all three are clear, run the loop:',
+    '',
+    text,
+    '',
+    'For each attempt:',
+    `1. Read \`${dir}/\` for prior results`,
+    '2. Dispatch @fixer with the goal',
+    '3. Verify per the successCriteria',
+    `4. Write result to \`${dir}/history-{NNN}.md\` (PASS/FAIL + reason)`,
+    '5. PASS -> stop. FAIL under maxAttempts -> retry. FAIL at max -> escalate.',
+  ].join('\n');
+}
+
+function helpPrompt(): string {
+  return [
+    'Usage: `/loop <description>`',
+    '',
+    'Describe what to accomplish, what success looks like, and how many tries.',
+    '',
+    'Examples:',
+    '  `/loop fix typescript errors until typecheck passes, max 3 tries`',
+    '  `/loop improve api performance until response under 500ms, try 5 times`',
+    '  `/loop refactor auth module, tests must pass, 4 attempts max`',
+  ].join('\n');
+}
+
+export function createLoopCommandHook(): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+} {
+  return {
+    registerCommand: (opencodeConfig) => {
+      const cfg = opencodeConfig.command as Record<string, unknown> | undefined;
+      if (cfg?.[COMMAND_NAME]) return;
+      if (!opencodeConfig.command) opencodeConfig.command = {};
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Run an automated execute-verify loop',
+        description:
+          'Dispatch fixer, verify, iterate with file-based history on disk.',
+      };
+    },
+
+    handleCommandExecuteBefore: async (input, output) => {
+      if (input.command !== COMMAND_NAME) return;
+
+      output.parts.length = 0;
+      const args = input.arguments.trim();
+      if (!args) {
+        output.parts.push(createInternalAgentTextPart(helpPrompt()));
+        return;
+      }
+
+      output.parts.push({ type: 'text', text: activationPrompt(args) });
+    },
+  };
+}

+ 34 - 0
src/hooks/phase-reminder/index.test.ts

@@ -121,6 +121,17 @@ describe('createPhaseReminderHook', () => {
     expect(output.messages).toEqual([]);
   });
 
+  test('handles missing or non-array messages', async () => {
+    const hook = createPhaseReminderHook();
+
+    await expect(
+      hook['experimental.chat.messages.transform']({}, {}),
+    ).resolves.toBeUndefined();
+    await expect(
+      hook['experimental.chat.messages.transform']({}, { messages: {} }),
+    ).resolves.toBeUndefined();
+  });
+
   test('handles no user messages', async () => {
     const hook = createPhaseReminderHook();
     const output = {
@@ -136,4 +147,27 @@ describe('createPhaseReminderHook', () => {
 
     expect(output.messages[0].parts[0].text).toBe('Hi');
   });
+
+  test('skips malformed messages while still appending to latest valid user message', async () => {
+    const hook = createPhaseReminderHook();
+    const output = {
+      messages: [
+        {},
+        { info: { role: 'assistant' } },
+        { parts: [{ type: 'text', text: 'missing info' }] },
+        {
+          info: { role: 'user', agent: 'orchestrator' },
+          parts: [{ type: 'text', text: 'hello' }],
+        },
+      ],
+    };
+
+    await expect(
+      hook['experimental.chat.messages.transform']({}, output as never),
+    ).resolves.toBeUndefined();
+
+    expect(output.messages[3].parts.length).toBe(2);
+    expect(output.messages[3].parts[0].text).toBe('hello');
+    expect(output.messages[3].parts[1].text).toBe(PHASE_REMINDER);
+  });
 });

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

@@ -7,7 +7,7 @@
  */
 import { PHASE_REMINDER } from '../../config/constants';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
-import type { MessageWithParts } from '../types';
+import { isUserMessageWithParts } from '../types';
 
 export { PHASE_REMINDER };
 
@@ -20,9 +20,9 @@ export function createPhaseReminderHook() {
   return {
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
-      output: { messages: MessageWithParts[] },
+      output: { messages?: unknown },
     ): Promise<void> => {
-      const { messages } = output;
+      const messages = Array.isArray(output.messages) ? output.messages : [];
 
       if (messages.length === 0) {
         return;
@@ -30,7 +30,7 @@ export function createPhaseReminderHook() {
 
       let lastUserMessageIndex = -1;
       for (let i = messages.length - 1; i >= 0; i--) {
-        if (messages[i].info.role === 'user') {
+        if (isUserMessageWithParts(messages[i])) {
           lastUserMessageIndex = i;
           break;
         }
@@ -41,6 +41,10 @@ export function createPhaseReminderHook() {
       }
 
       const lastUserMessage = messages[lastUserMessageIndex];
+      if (!isUserMessageWithParts(lastUserMessage)) {
+        return;
+      }
+
       const agent = lastUserMessage.info.agent;
       if (agent && agent !== 'orchestrator') {
         return;

+ 102 - 0
src/hooks/reflect/index.test.ts

@@ -81,6 +81,108 @@ describe('reflect command hook', () => {
     expect(output.parts[0].text).toContain('MCP/tool permission change');
   });
 
+  test('detects --sessions flag and activates session mode', async () => {
+    const hook = createReflectCommandHook();
+    hook.registerCommand({});
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'reflect', sessionID: 's1', arguments: '--sessions' },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('Session Reflection Mode:');
+    expect(output.parts[0].text).toContain('Analyze the last 50 sessions');
+    expect(output.parts[0].text).toContain(
+      '- Extract session IDs from OpenCode logs',
+    );
+    expect(output.parts[0].text).toContain(
+      '- Load session content from SQLite database',
+    );
+    expect(output.parts[0].text).toContain(
+      '- Analyze each session for patterns and friction',
+    );
+    expect(output.parts[0].text).toContain(
+      '- Aggregate findings across all sessions',
+    );
+    expect(output.parts[0].text).toContain(
+      '- Report with scope (global/cross-repo/project-specific), confidence, and impact',
+    );
+    // Default focus for session mode
+    expect(output.parts[0].text).toContain(
+      'Analyze recent sessions to find repeated patterns, friction, and improvement opportunities.',
+    );
+  });
+
+  test('parses --last N flag in session mode', async () => {
+    const hook = createReflectCommandHook();
+    hook.registerCommand({});
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'reflect',
+        sessionID: 's1',
+        arguments: '--sessions --last 20',
+      },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('Analyze the last 20 sessions');
+  });
+
+  test('caps --last at 100 in session mode', async () => {
+    const hook = createReflectCommandHook();
+    hook.registerCommand({});
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'reflect',
+        sessionID: 's1',
+        arguments: '--sessions --last 999',
+      },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('Analyze the last 100 sessions');
+  });
+
+  test('--sessions with focus text includes both session mode and custom focus', async () => {
+    const hook = createReflectCommandHook();
+    hook.registerCommand({});
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'reflect',
+        sessionID: 's1',
+        arguments: '--sessions feedback on PR reviews',
+      },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('Session Reflection Mode:');
+    expect(output.parts[0].text).toContain('Focus:\nfeedback on PR reviews');
+  });
+
+  test('defaults to 50 sessions when --last is not provided', async () => {
+    const hook = createReflectCommandHook();
+    hook.registerCommand({});
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'reflect', sessionID: 's1', arguments: '--sessions' },
+      output,
+    );
+
+    expect(output.parts[0].text).toContain('Analyze the last 50 sessions');
+  });
+
   test('ignores other commands', async () => {
     const hook = createReflectCommandHook();
     hook.registerCommand({});

+ 34 - 3
src/hooks/reflect/index.ts

@@ -1,13 +1,32 @@
 const COMMAND_NAME = 'reflect';
 
-function activationPrompt(focus: string): string {
+function activationPrompt(
+  focus: string,
+  isSessionMode = false,
+  lastN = 50,
+): string {
   const focusBlock = focus
     ? ['Focus:', focus]
     : [
         'Focus:',
-        'Review recent work broadly and identify repeated workflow friction worth improving.',
+        isSessionMode
+          ? 'Analyze recent sessions to find repeated patterns, friction, and improvement opportunities.'
+          : 'Review recent work broadly and identify repeated workflow friction worth improving.',
       ];
 
+  const modeBlock = isSessionMode
+    ? [
+        '',
+        'Session Reflection Mode:',
+        `- Analyze the last ${lastN} sessions (use --last N to adjust)`,
+        '- Extract session IDs from OpenCode logs',
+        '- Load session content from SQLite database',
+        '- Analyze each session for patterns and friction',
+        '- Aggregate findings across all sessions',
+        '- Report with scope (global/cross-repo/project-specific), confidence, and impact',
+      ]
+    : [];
+
   return [
     'Use the reflect skill for this request.',
     '',
@@ -19,6 +38,7 @@ function activationPrompt(focus: string): string {
     '- treat creating nothing as a valid result when evidence is weak;',
     '- ask before changing prompts, skills, commands, agents, MCP access, or config unless the user explicitly requested the exact edit;',
     '- return a compact report with findings, recommended changes, skipped candidates, and items needing more evidence.',
+    ...modeBlock,
     '',
     ...focusBlock,
   ].join('\n');
@@ -54,10 +74,21 @@ export function createReflectCommandHook(): {
     handleCommandExecuteBefore: async (input, output) => {
       if (input.command !== COMMAND_NAME || !shouldHandleCommand) return;
 
+      const args = input.arguments.trim();
+      const isSessionMode = args.includes('--sessions');
+      const lastMatch = args.match(/--last\s+(\d+)/);
+      const last = lastMatch ? Math.min(parseInt(lastMatch[1], 10), 100) : 50;
+
+      // Remove flags from focus text
+      const focus = args
+        .replace(/--sessions/g, '')
+        .replace(/--last\s+\d+/g, '')
+        .trim();
+
       output.parts.length = 0;
       output.parts.push({
         type: 'text',
-        text: activationPrompt(input.arguments.trim()),
+        text: activationPrompt(focus, isSessionMode, last),
       });
     },
   };

+ 38 - 0
src/hooks/task-session-manager/index.test.ts

@@ -43,6 +43,44 @@ function createMessages(sessionID: string, text = 'user message') {
 }
 
 describe('task-session-manager hook', () => {
+  test('ignores messages without OpenCode info or parts', async () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map scheduler hooks',
+    });
+    const { hook } = createHook({ backgroundJobBoard: board });
+    const messages = {
+      messages: [
+        {},
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+        },
+        { parts: [{ type: 'text', text: 'missing info' }] },
+        {
+          info: { role: 'assistant' },
+          parts: [{ type: 'text', text: 'assistant response' }],
+        },
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [{ type: 'text', text: 'valid user message' }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+
+    expect(messages.messages).toHaveLength(5);
+    expect(messages.messages[4].parts[0].text).toContain(
+      '### Background Job Board',
+    );
+    expect(messages.messages[4].parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / running',
+    );
+  });
+
   test('stores background task launches in job board prompt context', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

+ 13 - 7
src/hooks/task-session-manager/index.ts

@@ -13,7 +13,11 @@ import {
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import { isRateLimitError } from '../foreground-fallback/index';
-import type { MessagePart, MessageWithParts } from '../types';
+import {
+  isUserMessageWithParts,
+  type MessagePart,
+  type MessageWithParts,
+} from '../types';
 
 interface TaskArgs {
   description?: unknown;
@@ -639,10 +643,12 @@ export function createTaskSessionManagerHook(
 
     'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
-      output: { messages: MessageWithParts[] },
+      output: { messages?: unknown },
     ): Promise<void> => {
-      for (const [messageIndex, message] of output.messages.entries()) {
-        if (message.info.role !== 'user') continue;
+      const messages = Array.isArray(output.messages) ? output.messages : [];
+
+      for (const [messageIndex, message] of messages.entries()) {
+        if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') {
           continue;
         }
@@ -658,9 +664,9 @@ export function createTaskSessionManagerHook(
         }
       }
 
-      for (let i = output.messages.length - 1; i >= 0; i -= 1) {
-        const message = output.messages[i];
-        if (message.info.role !== 'user') continue;
+      for (let i = messages.length - 1; i >= 0; i -= 1) {
+        const message = messages[i];
+        if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') return;
         if (
           !message.info.sessionID ||

+ 22 - 0
src/hooks/types.ts

@@ -24,3 +24,25 @@ export type MessageWithParts = {
   info: MessageInfo;
   parts: MessagePart[];
 };
+
+export function isMessageWithParts(
+  message: unknown,
+): message is MessageWithParts {
+  if (!message || typeof message !== 'object') {
+    return false;
+  }
+
+  const candidate = message as Partial<MessageWithParts>;
+  return (
+    !!candidate.info &&
+    typeof candidate.info === 'object' &&
+    typeof candidate.info.role === 'string' &&
+    Array.isArray(candidate.parts)
+  );
+}
+
+export function isUserMessageWithParts(
+  message: unknown,
+): message is MessageWithParts {
+  return isMessageWithParts(message) && message.info.role === 'user';
+}

+ 25 - 6
src/index.ts

@@ -25,6 +25,7 @@ import {
   createDelegateTaskRetryHook,
   createFilterAvailableSkillsHook,
   createJsonErrorRecoveryHook,
+  createLoopCommandHook,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
   createReflectCommandHook,
@@ -148,6 +149,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let foregroundFallback: ForegroundFallbackManager;
   let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
   let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
+  let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
@@ -188,8 +190,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     disabledAgents = getDisabledAgents(config);
     rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
-    agentDefs = createAgents(config);
-    agents = getAgentConfigs(config);
+    agentDefs = createAgents(config, { projectDirectory: ctx.directory });
+    agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
 
     // Build model array map and runtime fallback chains from _modelArray
     // entries (when the user configures model as an array in
@@ -261,7 +263,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       multiplexerConfig,
       backgroundJobBoard,
     );
-    backgroundJobBoard.setTerminalStateListener((taskID) => {
+    backgroundJobBoard.addTerminalStateListener((taskID) => {
       void multiplexerSessionManager.retryDeferredIdleClose(taskID);
     });
 
@@ -305,6 +307,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     deepworkCommandHook = createDeepworkCommandHook();
     reflectCommandHook = createReflectCommandHook();
+    loopCommandHook = createLoopCommandHook();
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
       maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
@@ -514,9 +517,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             | Record<string, unknown>
             | undefined;
           if (entry) {
-            entry.model = chosen.id;
-            if (chosen.variant) {
-              entry.variant = chosen.variant;
+            // Only apply model array resolution if no user-selected model
+            // exists. A user-selected model (via /model command) takes
+            // precedence over the config's fallback chain to preserve
+            // runtime selections and avoid breaking provider cache.
+            if (entry.model === undefined) {
+              entry.model = chosen.id;
+              if (chosen.variant) {
+                entry.variant = chosen.variant;
+              }
             }
           } else {
             // Agent exists in slim but not in opencodeConfig.agent —
@@ -740,6 +749,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       interviewManager.registerCommand(opencodeConfig);
       deepworkCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
+      loopCommandHook.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
     },
 
@@ -943,6 +953,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await loopCommandHook.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],

+ 120 - 0
src/loop/loop-session.test.ts

@@ -0,0 +1,120 @@
+import { describe, expect, spyOn, test } from 'bun:test';
+import * as fs from 'node:fs';
+import {
+  compactAttempt,
+  createLoopSession,
+  type LoopDefinition,
+  loopDirname,
+  writeHistoryFile,
+} from './loop-session';
+
+function testDef(overrides?: Partial<LoopDefinition>): LoopDefinition {
+  return {
+    goal: 'test goal',
+    successCriteria: 'it works',
+    success: { type: 'test', command: 'bun test' },
+    maxAttempts: 3,
+    executeAgent: 'fixer',
+    verifyAgent: 'oracle',
+    ...overrides,
+  };
+}
+
+describe('loopDirname', () => {
+  test('creates human-readable dir name with short ID', () => {
+    const name = loopDirname('loop-mqwo5ddt', 'Fix typescript errors');
+    expect(name).toBe('fix-typescript-errors-mqwo5ddt');
+  });
+
+  test('slugifies the goal text', () => {
+    const name = loopDirname('loop-abc-123', 'Fix TypeScript & ESLint errors!');
+    expect(name).toBe('fix-typescript-eslint-errors-123');
+  });
+
+  test('truncates long goals', () => {
+    const longGoal = 'a'.repeat(50);
+    const name = loopDirname('xyz-999', longGoal);
+    expect(name.length).toBeLessThan(60);
+  });
+});
+
+describe('createLoopSession', () => {
+  test('creates a session with executing phase and attempt 1', () => {
+    const def = testDef();
+    const session = createLoopSession(def, 'loop-test-1');
+
+    expect(session.loopID).toBe('loop-test-1');
+    expect(session.definition).toBe(def);
+    expect(session.currentPhase).toBe('executing');
+    expect(session.attempts).toBe(1);
+    expect(session.history).toEqual([]);
+    expect(session.activeJobID).toBeUndefined();
+    expect(session.manualReviewPending).toBe(false);
+    expect(session.historyDir).toContain('test-goal');
+  });
+});
+
+describe('compactAttempt', () => {
+  test('formats a passed attempt', () => {
+    const result = compactAttempt({
+      attemptNumber: 1,
+      executionResult: 'bun test',
+      verificationResult: { passed: true, reason: 'all green' },
+    });
+    expect(result).toContain('## Attempt 1');
+    expect(result).toContain('**Outcome:** PASS');
+    expect(result).toContain('### Execution Result');
+  });
+
+  test('formats a failed attempt with reason', () => {
+    const result = compactAttempt({
+      attemptNumber: 2,
+      executionResult: 'bun test',
+      verificationResult: { passed: false, reason: 'tests failed' },
+    });
+    expect(result).toContain('## Attempt 2');
+    expect(result).toContain('FAIL: tests failed');
+  });
+
+  test('includes artifacts when present', () => {
+    const result = compactAttempt({
+      attemptNumber: 1,
+      executionResult: 'built',
+      verificationResult: { passed: true, reason: 'ok' },
+      artifactPaths: ['src/output.ts', 'src/output.test.ts'],
+    });
+    expect(result).toContain('artifacts: src/output.ts, src/output.test.ts');
+  });
+});
+
+describe('writeHistoryFile', () => {
+  test('uses the attempt number for the history filename', () => {
+    const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
+    const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(
+      () => undefined,
+    );
+    const session = createLoopSession(testDef(), 'loop-test-1');
+    session.attempts = 99;
+    session.history.push({
+      attemptNumber: 4,
+      executionResult: 'bun test',
+      verificationResult: { passed: true, reason: 'ok' },
+    });
+
+    try {
+      writeHistoryFile(session);
+
+      expect(mkdirSpy).toHaveBeenCalledWith(session.historyDir, {
+        recursive: true,
+      });
+      expect(writeSpy).toHaveBeenCalledWith(
+        expect.stringContaining('history-004.md'),
+        expect.stringContaining('## Attempt 4'),
+        { encoding: 'utf-8' },
+      );
+    } finally {
+      mkdirSpy.mockRestore();
+      writeSpy.mockRestore();
+    }
+  });
+});

+ 117 - 0
src/loop/loop-session.ts

@@ -0,0 +1,117 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+const HISTORY_DIR = join(process.cwd(), '.opencode', 'loop-history');
+
+function slugify(text: string): string {
+  return text
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/(^-|-$)/g, '')
+    .slice(0, 40);
+}
+
+export function loopDirname(loopID: string, goal: string): string {
+  const parts = loopID.split('-');
+  const shortID = parts[parts.length - 1] ?? loopID;
+  return `${slugify(goal)}-${shortID}`;
+}
+
+export type LoopPhase =
+  | 'executing'
+  | 'verifying'
+  | 'done'
+  | 'escalated'
+  | 'cancelled';
+
+export type ExecuteAgent = 'fixer' | 'designer' | 'explorer' | 'librarian';
+export type VerifyAgent = 'oracle' | 'observer' | 'test';
+
+export type SuccessCriterion =
+  | { type: 'test'; command: string }
+  | { type: 'build'; command: string }
+  | { type: 'lint'; command: string }
+  | { type: 'fileExists'; path: string }
+  | { type: 'command'; command: string; expectExitCode?: number }
+  | { type: 'oracle' }
+  | { type: 'observer' }
+  | { type: 'manual' };
+
+export interface LoopDefinition {
+  goal: string;
+  successCriteria: string;
+  success: SuccessCriterion;
+  maxAttempts: number;
+  executeAgent: ExecuteAgent;
+  verifyAgent: VerifyAgent;
+  contextFiles?: string[];
+  parentSessionID?: string;
+}
+
+export type VerificationResult =
+  | { passed: true; reason: string }
+  | { passed: false; reason: string; suggestedFix?: string };
+
+export interface AttemptRecord {
+  attemptNumber: number;
+  executionResult: string;
+  verificationResult: VerificationResult;
+  artifactPaths?: string[];
+}
+
+export interface LoopSession {
+  loopID: string;
+  definition: LoopDefinition;
+  currentPhase: LoopPhase;
+  attempts: number;
+  activeJobID?: string;
+  history: AttemptRecord[];
+  historyDir: string;
+  manualReviewPending: boolean;
+}
+
+export function createLoopSession(
+  definition: LoopDefinition,
+  loopID: string,
+): LoopSession {
+  const dir = join(HISTORY_DIR, loopDirname(loopID, definition.goal));
+  return {
+    loopID,
+    definition,
+    currentPhase: 'executing',
+    attempts: 1,
+    history: [],
+    manualReviewPending: false,
+    historyDir: dir,
+  };
+}
+
+export function compactAttempt(attempt: AttemptRecord): string {
+  const outcome = attempt.verificationResult.passed
+    ? 'PASS'
+    : `FAIL: ${attempt.verificationResult.reason}`;
+  const artifacts = attempt.artifactPaths?.length
+    ? `\n  → artifacts: ${attempt.artifactPaths.join(', ')}`
+    : '';
+  return `## Attempt ${attempt.attemptNumber}
+
+**Outcome:** ${outcome}${artifacts}
+
+### Execution Result
+\`\`\`
+${attempt.executionResult}
+\`\`\`
+`;
+}
+
+export function writeHistoryFile(session: LoopSession): void {
+  const lastAttempt = session.history.at(-1);
+  if (!lastAttempt) return;
+  const attemptFile = join(
+    session.historyDir,
+    `history-${String(lastAttempt.attemptNumber).padStart(3, '0')}.md`,
+  );
+  mkdirSync(session.historyDir, { recursive: true });
+  const content = compactAttempt(lastAttempt);
+  writeFileSync(attemptFile, content, { encoding: 'utf-8' });
+}

+ 11 - 3
src/multiplexer/codemap.md

@@ -4,7 +4,7 @@
 
 - Provide multiplexer-backed visualization for spawned subagent sessions.
 - Select and instantiate terminal backend based on config/env:
-  `auto`, `tmux`, `zellij`, or `none`.
+  `auto`, `tmux`, `zellij`, `herdr`, or `none`.
 - Manage lifecycle of child session panes with lifecycle hooks from OpenCode
   events plus health/polling fallback.
 - Keep pane cleanup safe and graceful (best-effort interrupt + kill).
@@ -43,6 +43,14 @@
     down; `tiled`/`even-horizontal`/`even-vertical` use Zellij native placement
     and `main_pane_size` remains a no-op.
 
+- `herdr/index.ts` (`HerdrMultiplexer`)
+  - Detects binary via `which`. `spawnPane` splits the parent pane (using
+    `HERDR_PANE_ID` or `--current`), renames it, and runs `opencode attach`
+    via `herdr pane run`. Parses JSON CLI output to extract pane IDs.
+  - `closePane` sends `ctrl+c` then `herdr pane close`.
+  - `applyLayout` is a no-op (like Zellij).
+  - Auto-detects via `HERDR_ENV`/`HERDR_PANE_ID` env vars.
+
 - `session-manager.ts` (`MultiplexerSessionManager`)
   - Initialized once from plugin context and config.
   - Subscribes to lifecycle events:
@@ -84,8 +92,8 @@
 - Integrates with OpenCode session events and server URL from plugin input.
 - Uses helper endpoints defined by `src/config` multiplexer settings:
   `type`, `layout`, `main_pane_size`.
-- Implementations in `src/multiplexer/tmux` and `src/multiplexer/zellij` are used
-  through the shared abstraction.
+- Implementations in `src/multiplexer/tmux`, `src/multiplexer/zellij`, and
+  `src/multiplexer/herdr` are used through the shared abstraction.
 - Validation coverage:
   - `src/multiplexer/factory.test.ts`
   - `src/multiplexer/session-manager.test.ts`

+ 59 - 0
src/multiplexer/factory.test.ts

@@ -7,10 +7,14 @@ async function importFreshFactory(suffix: string) {
 describe('multiplexer factory', () => {
   const originalTmux = process.env.TMUX;
   const originalTmuxPane = process.env.TMUX_PANE;
+  const originalHerdrEnv = process.env.HERDR_ENV;
+  const originalHerdrPaneId = process.env.HERDR_PANE_ID;
 
   afterEach(() => {
     process.env.TMUX = originalTmux;
     process.env.TMUX_PANE = originalTmuxPane;
+    process.env.HERDR_ENV = originalHerdrEnv;
+    process.env.HERDR_PANE_ID = originalHerdrPaneId;
   });
 
   test('returns a fresh tmux instance per call', async () => {
@@ -72,4 +76,59 @@ describe('multiplexer factory', () => {
     expect(second).not.toBeNull();
     expect(Object.is(first, second)).toBe(false);
   });
+
+  test('returns a herdr instance when type is herdr', async () => {
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('herdr-explicit');
+
+    const multiplexer = getMultiplexer({
+      type: 'herdr',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
+
+  test('auto-detects herdr when HERDR_ENV is set', async () => {
+    delete process.env.TMUX;
+    delete process.env.TMUX_PANE;
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('auto-herdr');
+
+    const multiplexer = getMultiplexer({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
+
+  test('auto-detects herdr when only HERDR_PANE_ID is set', async () => {
+    delete process.env.TMUX;
+    delete process.env.TMUX_PANE;
+    delete process.env.HERDR_ENV;
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    const { getMultiplexer } = await importFreshFactory('auto-herdr-pane');
+
+    const multiplexer = getMultiplexer({
+      type: 'auto',
+      layout: 'main-vertical',
+      main_pane_size: 60,
+      zellij_pane_mode: 'agent-tab',
+    });
+
+    expect(multiplexer).not.toBeNull();
+    expect(multiplexer?.type).toBe('herdr');
+  });
 });

+ 19 - 5
src/multiplexer/factory.ts

@@ -4,6 +4,7 @@
 
 import type { MultiplexerConfig, MultiplexerType } from '../config/schema';
 import { log } from '../utils/logger';
+import { HerdrMultiplexer } from './herdr';
 import { TmuxMultiplexer } from './tmux';
 import type { Multiplexer } from './types';
 import { ZellijMultiplexer } from './zellij';
@@ -11,9 +12,9 @@ import { ZellijMultiplexer } from './zellij';
 /**
  * Create a multiplexer instance based on config.
  *
- * Do not cache instances: tmux/zellij integrations may depend on
- * per-process environment like TMUX_PANE/ZELLIJ, which should be captured
- * fresh for each plugin context.
+ * Do not cache instances: tmux/zellij/herdr integrations may depend on
+ * per-process environment like TMUX_PANE/ZELLIJ/HERDR_PANE_ID, which should
+ * be captured fresh for each plugin context.
  */
 export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
   const { type } = config;
@@ -39,6 +40,10 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
       );
       actualType = 'zellij';
       break;
+    case 'herdr':
+      multiplexer = new HerdrMultiplexer(config.layout, config.main_pane_size);
+      actualType = 'herdr';
+      break;
     case 'auto': {
       // Auto-detect based on environment variables only
       // Note: Does NOT fall back to binary availability checks
@@ -52,6 +57,12 @@ export function getMultiplexer(config: MultiplexerConfig): Multiplexer | null {
           config.zellij_pane_mode,
         );
         actualType = 'zellij';
+      } else if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
+        multiplexer = new HerdrMultiplexer(
+          config.layout,
+          config.main_pane_size,
+        );
+        actualType = 'herdr';
       } else {
         // Not inside any session, disable multiplexer
         log('[multiplexer] auto: not inside any session, disabling');
@@ -78,15 +89,18 @@ export function clearMultiplexerCache(): void {
 
 /**
  * Get the effective multiplexer type for auto mode
- * Returns the actual type that would be used (tmux/zellij/none)
+ * Returns the actual type that would be used (tmux/zellij/herdr/none)
  */
-export function getAutoMultiplexerType(): 'tmux' | 'zellij' | 'none' {
+export function getAutoMultiplexerType(): 'tmux' | 'zellij' | 'herdr' | 'none' {
   if (process.env.TMUX) {
     return 'tmux';
   }
   if (process.env.ZELLIJ) {
     return 'zellij';
   }
+  if (process.env.HERDR_ENV || process.env.HERDR_PANE_ID) {
+    return 'herdr';
+  }
   return 'none';
 }
 

+ 41 - 0
src/multiplexer/herdr/codemap.md

@@ -0,0 +1,41 @@
+# src/multiplexer/herdr/
+
+## Responsibility
+
+- Implement Herdr-backed pane orchestration for delegated sessions as an alternative to tmux and zellij.
+- Manage pane lifecycle (split, rename, run, close) within the current Herdr workspace.
+- Keep process cleanup safe and graceful (interrupt + close).
+
+## Design
+
+- `HerdrMultiplexer` in `index.ts` implements `Multiplexer`.
+- `findBinary` is a `which/where herdr` probe with cached path.
+- `isInsideSession` checks `process.env.HERDR_ENV` or `process.env.HERDR_PANE_ID`; `isAvailable` uses cached `binaryPath`.
+- `spawnPane` resolves the parent pane via `HERDR_PANE_ID` or `--current`, splits it in the configured direction, renames the new pane to the agent description, and runs `opencode attach` via `herdr pane run`. Pane IDs are extracted by parsing JSON CLI output from `herdr pane split`.
+- `closePane` sends `ctrl+c` via `herdr pane send_keys`, waits briefly, then runs `herdr pane close`.
+- `applyLayout` is intentionally a no-op (Herdr does not expose equivalent layout rebalancing APIs).
+- Layout direction mapping is done by `getPaneDirection`:
+  - `main-vertical`, `even-horizontal`, `tiled` → `right`
+  - `main-horizontal`, `even-vertical` → `down`
+
+## Flow
+
+- `spawnPane(sessionId, description, serverUrl, directory)`:
+  - resolve herdr binary via `getBinary()`
+  - determine parent pane from `HERDR_PANE_ID` env var or `--current`
+  - split parent pane via `herdr pane split <parent> --direction <dir>`
+  - parse JSON output to extract new pane ID (`new_pane_id`)
+  - rename the pane via `herdr pane send_text <pane> \x1b]0;<desc>\x07`
+  - run `opencode attach <url> --session <sessionId> --dir <directory>` via `herdr pane run <pane>`
+  - return `{ success, paneId }`
+- `closePane(paneId)`:
+  - `herdr pane send_keys <pane> ctrl+c`
+  - wait 250ms
+  - `herdr pane close <pane>`; treats exit codes `0` and `1` as successful closure.
+- `applyLayout` is a no-op retained for interface compatibility.
+
+## Integration
+
+- Selected when `multiplexerConfig.type === 'herdr'` or auto mode resolves to herdr (`process.env.HERDR_ENV` or `process.env.HERDR_PANE_ID` present).
+- Consumed by `MultiplexerSessionManager` as the pane backend in Herdr environments.
+- UI attach command semantics are identical to tmux in argument shape: `opencode attach <url> --session <sessionId> --dir <directory>`, so delegated sessions remain config-agnostic across backends.

+ 444 - 0
src/multiplexer/herdr/index.test.ts

@@ -0,0 +1,444 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+
+type SpawnResult = {
+  exited: Promise<number>;
+  stdout: () => Promise<string>;
+  stderr: () => Promise<string>;
+  kill: () => boolean;
+  exitCode: number | null;
+  proc: never;
+};
+
+const crossSpawnMock = mock((_command: string[]) => createSpawnResult());
+
+mock.module('../../utils/logger', () => ({
+  log: mock(() => {}),
+}));
+
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+}));
+
+let importCounter = 0;
+
+function createSpawnResult(
+  exitCode = 0,
+  stdout = '',
+  stderr = '',
+): SpawnResult {
+  return {
+    exited: Promise.resolve(exitCode),
+    stdout: () => Promise.resolve(stdout),
+    stderr: () => Promise.resolve(stderr),
+    kill: () => true,
+    exitCode,
+    proc: {} as never,
+  };
+}
+
+function createSplitResponse(paneId: string): string {
+  return JSON.stringify({
+    id: 'cli:pane:split',
+    result: {
+      type: 'pane_info',
+      pane: {
+        pane_id: paneId,
+        tab_id: 'w1:t1',
+        workspace_id: 'w1',
+      },
+    },
+  });
+}
+
+async function importFreshHerdr() {
+  return import(`./index?test=${importCounter++}`);
+}
+
+function commands(): string[][] {
+  return crossSpawnMock.mock.calls.map((call) => call[0] as string[]);
+}
+
+describe('HerdrMultiplexer', () => {
+  const originalHerdrEnv = process.env.HERDR_ENV;
+  const originalHerdrPaneId = process.env.HERDR_PANE_ID;
+
+  beforeEach(() => {
+    process.env.HERDR_ENV = '1';
+    process.env.HERDR_PANE_ID = 'w1:p1';
+
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p2')}\n`);
+      }
+      return createSpawnResult();
+    });
+  });
+
+  afterEach(() => {
+    process.env.HERDR_ENV = originalHerdrEnv;
+    process.env.HERDR_PANE_ID = originalHerdrPaneId;
+  });
+
+  test('spawns an opencode attach process in a herdr split pane', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'w1:p2' });
+
+    const allCommands = commands();
+
+    // 1. which herdr
+    expect(allCommands[0]).toEqual(['which', 'herdr']);
+
+    // 2. pane split
+    expect(allCommands[1]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'split',
+      'w1:p1',
+      '--direction',
+      'right',
+      '--cwd',
+      '/repo',
+      '--no-focus',
+    ]);
+
+    // 3. pane rename
+    expect(allCommands[2]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'rename',
+      'w1:p2',
+      'Herdr worker',
+    ]);
+
+    // 4. pane run
+    expect(allCommands[3]).toEqual([
+      '/usr/bin/herdr',
+      'pane',
+      'run',
+      'w1:p2',
+      "opencode attach 'http://localhost:4096' --session 'session-1' --dir '/repo'",
+    ]);
+  });
+
+  test('uses --current when HERDR_PANE_ID is not set', async () => {
+    delete process.env.HERDR_PANE_ID;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+
+    expect(splitCommand).toContain('--current');
+    expect(splitCommand).not.toContain('w1:p1');
+  });
+
+  test('closes herdr panes gracefully', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const success = await herdr.closePane('w1:p2');
+
+    expect(success).toBe(true);
+    expect(commands()).toEqual([
+      ['which', 'herdr'],
+      ['/usr/bin/herdr', 'pane', 'send-keys', 'w1:p2', 'ctrl+c'],
+      ['/usr/bin/herdr', 'pane', 'close', 'w1:p2'],
+    ]);
+  });
+
+  test('returns true when closing unknown pane id', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const success = await herdr.closePane('unknown');
+    expect(success).toBe(true);
+  });
+
+  test('returns true when pane close exits with code 1 (already closed)', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('close')) {
+        return createSpawnResult(1, '', 'pane not found');
+      }
+      return createSpawnResult();
+    });
+
+    const success = await herdr.closePane('w1:p2');
+    expect(success).toBe(true);
+  });
+
+  test('returns false when pane close exits with code 2 (real failure)', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('close')) {
+        return createSpawnResult(2, '', 'fatal error');
+      }
+      return createSpawnResult();
+    });
+
+    const success = await herdr.closePane('w1:p2');
+    expect(success).toBe(false);
+  });
+
+  test('parses pane_id when split output has extra NDJSON lines', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const extraLine = JSON.stringify({
+      id: 'cli:event',
+      result: { type: 'progress', message: 'splitting...' },
+    });
+    const paneLine = createSplitResponse('w1:p9');
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${extraLine}\n${paneLine}\n`);
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: true, paneId: 'w1:p9' });
+  });
+
+  test('reports failure when split output has only non-pane JSON lines', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    const progressOnly = JSON.stringify({
+      id: 'cli:event',
+      result: { type: 'progress', message: 'working...' },
+    });
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${progressOnly}\n`);
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('reports failure when split returns non-zero exit code', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(1, '', 'split failed');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('reports failure when split output has no pane_id', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, 'not valid json\n');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('reports failure when pane run returns non-zero exit code', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    crossSpawnMock.mockImplementation((command: string[]) => {
+      if (command[0] === 'which') {
+        return createSpawnResult(0, '/usr/bin/herdr\n');
+      }
+      if (command.includes('split')) {
+        return createSpawnResult(0, `${createSplitResponse('w1:p3')}\n`);
+      }
+      if (command.includes('run')) {
+        return createSpawnResult(1, '', 'run failed');
+      }
+      return createSpawnResult();
+    });
+
+    const result = await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    expect(result).toEqual({ success: false });
+  });
+
+  test('main-horizontal layout opens panes down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-horizontal', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('even-vertical layout opens panes down', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('even-vertical', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('down');
+  });
+
+  test('tiled layout opens panes right', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('tiled', 60);
+
+    await herdr.spawnPane(
+      'session-1',
+      'Herdr worker',
+      'http://localhost:4096',
+      '/repo',
+    );
+
+    const splitCommand = commands().find((command) =>
+      command.includes('split'),
+    );
+    const directionArgIndex = splitCommand?.indexOf('--direction') ?? -1;
+
+    expect(directionArgIndex).toBeGreaterThanOrEqual(0);
+    expect(splitCommand?.[directionArgIndex + 1]).toBe('right');
+  });
+
+  test('isInsideSession returns true when HERDR_ENV is set', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(true);
+  });
+
+  test('isInsideSession returns true when HERDR_PANE_ID is set', async () => {
+    delete process.env.HERDR_ENV;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(true);
+  });
+
+  test('isInsideSession returns false when no herdr env vars are set', async () => {
+    delete process.env.HERDR_ENV;
+    delete process.env.HERDR_PANE_ID;
+
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    expect(herdr.isInsideSession()).toBe(false);
+  });
+
+  test('applyLayout is a no-op', async () => {
+    const { HerdrMultiplexer } = await importFreshHerdr();
+    const herdr = new HerdrMultiplexer('main-vertical', 60);
+
+    await herdr.applyLayout('tiled', 50);
+
+    // Only the binary check command should have been issued
+    expect(commands()).toHaveLength(0);
+  });
+});

+ 306 - 0
src/multiplexer/herdr/index.ts

@@ -0,0 +1,306 @@
+/**
+ * Herdr multiplexer implementation
+ *
+ * Splits panes for sub-agent sessions in Herdr.
+ *
+ * Herdr is an agent-aware terminal multiplexer (workspaces → tabs → panes).
+ * Pane IDs use the format `w<workspace>:p<pane>`. The CLI outputs
+ * newline-delimited JSON; `pane split` returns a `pane_info` result whose
+ * `pane.pane_id` field is the new pane's ID.
+ *
+ * Environment detection: Herdr injects `HERDR_ENV=1` and `HERDR_PANE_ID`
+ * into every pane it manages.
+ */
+
+import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
+import { log } from '../../utils/logger';
+import type { Multiplexer, PaneResult } from '../types';
+
+type HerdrPaneDirection = 'right' | 'down';
+
+interface HerdrCliResponse {
+  result?: {
+    type?: string;
+    pane?: { pane_id?: string };
+  };
+  error?: { code?: string; message?: string };
+}
+
+export class HerdrMultiplexer implements Multiplexer {
+  readonly type = 'herdr' as const;
+
+  private binaryPath: string | null = null;
+  private hasChecked = false;
+  private readonly parentPaneId = process.env.HERDR_PANE_ID;
+  private readonly paneDirection: HerdrPaneDirection;
+
+  constructor(layout: MultiplexerLayout = 'main-vertical', mainPaneSize = 60) {
+    // Herdr does not support exact main pane sizing like tmux.
+    // Layout config is mapped to pane split direction.
+    void mainPaneSize;
+    this.paneDirection = getPaneDirection(layout);
+  }
+
+  async isAvailable(): Promise<boolean> {
+    if (this.hasChecked) {
+      return this.binaryPath !== null;
+    }
+
+    this.binaryPath = await this.findBinary();
+    this.hasChecked = true;
+    return this.binaryPath !== null;
+  }
+
+  isInsideSession(): boolean {
+    return !!(process.env.HERDR_ENV || process.env.HERDR_PANE_ID);
+  }
+
+  async spawnPane(
+    sessionId: string,
+    description: string,
+    serverUrl: string,
+    directory: string,
+  ): Promise<PaneResult> {
+    const herdr = await this.getBinary();
+    if (!herdr) {
+      log('[herdr] spawnPane: herdr binary not found');
+      return { success: false };
+    }
+
+    try {
+      // 1. Split the parent pane to create a new one
+      const splitArgs = [
+        herdr,
+        'pane',
+        'split',
+        ...this.targetPaneArg(),
+        '--direction',
+        this.paneDirection,
+        '--cwd',
+        directory,
+        '--no-focus',
+      ];
+
+      log('[herdr] spawnPane: splitting pane', { args: splitArgs });
+
+      const splitProc = crossSpawn(splitArgs, {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const splitExitCode = await splitProc.exited;
+      const splitStdout = await splitProc.stdout();
+      const splitStderr = await splitProc.stderr();
+
+      if (splitExitCode !== 0) {
+        log('[herdr] spawnPane: split failed', {
+          exitCode: splitExitCode,
+          stderr: splitStderr.trim(),
+        });
+        return { success: false };
+      }
+
+      // Parse JSON response to extract pane_id
+      const paneId = parsePaneId(splitStdout);
+      if (!paneId) {
+        log('[herdr] spawnPane: could not parse pane_id from output', {
+          stdout: splitStdout.trim(),
+        });
+        return { success: false };
+      }
+
+      // 2. Rename the pane for visibility
+      await crossSpawn(
+        [herdr, 'pane', 'rename', paneId, description.slice(0, 30)],
+        { stdout: 'ignore', stderr: 'ignore' },
+      ).exited;
+
+      // 3. Run opencode attach in the new pane
+      const opencodeCmd = buildOpencodeAttachCommand(
+        sessionId,
+        serverUrl,
+        directory,
+      );
+
+      log('[herdr] spawnPane: running attach command', {
+        paneId,
+        command: opencodeCmd,
+      });
+
+      const runProc = crossSpawn([herdr, 'pane', 'run', paneId, opencodeCmd], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+
+      const runExitCode = await runProc.exited;
+      if (runExitCode !== 0) {
+        const runStderr = await runProc.stderr();
+        log('[herdr] spawnPane: run failed', {
+          exitCode: runExitCode,
+          stderr: runStderr.trim(),
+        });
+        return { success: false };
+      }
+
+      log('[herdr] spawnPane: SUCCESS', { paneId });
+      return { success: true, paneId };
+    } catch (err) {
+      log('[herdr] spawnPane: exception', { error: String(err) });
+      return { success: false };
+    }
+  }
+
+  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;
+    }
+  }
+
+  async applyLayout(
+    _layout: MultiplexerLayout,
+    _mainPaneSize: number,
+  ): Promise<void> {
+    // No-op for herdr. Herdr does not support tmux-like exact main pane
+    // sizing/rebalancing; layout is applied to future pane creation by
+    // mapping configured layouts to pane split directions.
+  }
+
+  private targetPaneArg(): string[] {
+    return this.parentPaneId ? [this.parentPaneId] : ['--current'];
+  }
+
+  private async getBinary(): Promise<string | null> {
+    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;
+    }
+  }
+}
+
+/**
+ * Parse the pane_id from a herdr CLI JSON response.
+ *
+ * Herdr outputs newline-delimited JSON like:
+ * {"id":"cli:pane:split","result":{"type":"pane_info","pane":{"pane_id":"w1:p2",...}}}
+ */
+function parsePaneId(stdout: string): string | null {
+  const trimmed = stdout.trim();
+  if (!trimmed) return null;
+
+  for (const line of trimmed.split('\n')) {
+    const candidate = line.trim();
+    if (!candidate) continue;
+    try {
+      const response = JSON.parse(candidate) as HerdrCliResponse;
+      const paneId = response.result?.pane?.pane_id;
+      if (paneId) return paneId;
+    } catch {
+      // Not a JSON line (e.g. progress/diagnostic); skip and keep scanning.
+    }
+  }
+
+  log('[herdr] parsePaneId: no pane_id found in output', { stdout: trimmed });
+  return null;
+}
+
+function getPaneDirection(layout: MultiplexerLayout): HerdrPaneDirection {
+  switch (layout) {
+    case 'main-horizontal':
+    case 'even-vertical':
+      return 'down';
+    case 'main-vertical':
+    case 'even-horizontal':
+    case 'tiled':
+      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, `'\\''`)}'`;
+}

+ 1 - 0
src/multiplexer/index.ts

@@ -7,6 +7,7 @@ export {
   getMultiplexer,
   startAvailabilityCheck,
 } from './factory';
+export { HerdrMultiplexer } from './herdr';
 export {
   MultiplexerSessionManager,
   TmuxSessionManager,

+ 4 - 4
src/multiplexer/types.ts

@@ -1,8 +1,8 @@
 /**
  * Multiplexer abstraction layer
  *
- * Provides a unified interface for terminal multiplexers (tmux, zellij, etc.)
- * to spawn and manage panes for child agent sessions.
+ * Provides a unified interface for terminal multiplexers (tmux, zellij,
+ * herdr, etc.) to spawn and manage panes for child agent sessions.
  */
 
 import type { MultiplexerConfig, MultiplexerLayout } from '../config/schema';
@@ -14,10 +14,10 @@ export interface PaneResult {
 
 /**
  * Core multiplexer interface
- * Implementations: TmuxMultiplexer, ZellijMultiplexer
+ * Implementations: TmuxMultiplexer, ZellijMultiplexer, HerdrMultiplexer
  */
 export interface Multiplexer {
-  readonly type: 'tmux' | 'zellij';
+  readonly type: 'tmux' | 'zellij' | 'herdr';
 
   /**
    * Check if the multiplexer binary is available on the system

+ 3 - 1
src/skills/codemap.md

@@ -22,6 +22,7 @@
   - `src/skills/reflect/` (orchestrator-only workflow for learning from repeated work and suggesting reusable improvements)
   - `src/skills/worktrees/` (orchestrator-only workflow for safe Git worktree lanes)
   - `src/skills/oh-my-opencode-slim/` (orchestrator-only plugin configuration and self-improvement guidance)
+  - `src/skills/release-smoke-test/` (orchestrator-only workflow for packed release-candidate runtime validation)
 - Files are considered static runtime payload. No plugin TS module in `src/` imports these files directly; they
   are loaded by OpenCode via filesystem installation.
 
@@ -44,6 +45,7 @@
   bundled skill payloads such as `src/skills/simplify/SKILL.md`,
   `src/skills/codemap/SKILL.md`, `src/skills/clonedeps/SKILL.md`, and
   `src/skills/deepwork/SKILL.md`, `src/skills/reflect/SKILL.md`,
-  `src/skills/worktrees/SKILL.md`, plus `src/skills/oh-my-opencode-slim/SKILL.md`,
+  `src/skills/worktrees/SKILL.md`, `src/skills/release-smoke-test/SKILL.md`,
+  plus `src/skills/oh-my-opencode-slim/SKILL.md`,
   are present in the tarball.
 - `package.json` scripts (`verify:release`, `build`) rely on these assets to ensure install-time skill availability.

+ 30 - 0
src/skills/loop-engineering/SKILL.md

@@ -0,0 +1,30 @@
+---
+name: loop-engineering
+description: Loop engineering runtime Grill + Monitor
+---
+
+# Loop Engineering Skill
+
+## Grill (orchestrator interview)
+
+1. Goal: "What are you trying to accomplish?"
+2. Success criteria: "Describe how we know the loop succeeded."
+3. Success type: choose from `test`, `build`, `lint`, `command`, `fileExists`, `oracle`, `observer`, `manual`. For CLI steps provide `successCommand`; for file detection provide `successPath`.
+4. Execute agent: fixer / designer / explorer / librarian
+5. Verify agent: oracle / observer / test
+6. Max attempts (default 3)
+7. Optional context files: which files or directories should be read before execution?
+
+## Loop Monitor
+
+- Listen to callbacks:
+  - `onLoopComplete(loopID, success)` → report final outcome
+  - `onEscalated(loopID, reason)` → escalate to human
+  - `onManualReview(loopID, reason)` → prompt human to approve/fail and call `resolveManualReview(loopID, passed, reason)`
+- Show current state and attempt count on each callback
+- For manual verification, present the failure reason before asking for pass/fail
+- If human forces cancellation, call `cancel(loopID)` through the orchestrator
+
+## Notes
+- Manual verification is the minimal on-ramp (autoresearch pattern). It pauses the loop until `resolveManualReview` is called. Do not auto-resolve.
+- BackgroundJobBoard signals (totalErrors, timeoutCount) are already baked into the runtime.

+ 133 - 0
src/skills/reflect/SKILL.md

@@ -17,6 +17,7 @@ The goal is to identify real repeated friction and suggest practical improvement
 Use Reflect when the user asks to:
 
 - run `/reflect` or `/reflect <focus>`;
+- run `/reflect --sessions` for session archaeology;
 - learn from recent sessions or repeated workflows;
 - find work they keep doing manually;
 - improve their oh-my-opencode-slim setup based on actual usage using oh-my-opencode-slim skill;
@@ -26,6 +27,136 @@ Use Reflect when the user asks to:
 Do not use Reflect for ordinary implementation work, one-off debugging, broad
 architecture review, or speculative agent creation without workflow evidence.
 
+## Session Mode
+
+When the user includes `--sessions` in their reflect command, shift to session
+archaeology: analyze historical OpenCode sessions across all repos to find
+repeated patterns, friction, and improvement opportunities.
+
+### Session Discovery
+
+1. **Load recent sessions** — Query the SQLite database directly:
+   ```bash
+   bun -e "import Database from 'bun:sqlite'; const db = new Database('/home/mhenke/.local/share/opencode/opencode.db'); console.log(db.query('SELECT id, directory, title, agent, model, time_created, cost, tokens_input, tokens_output FROM session ORDER BY time_created DESC LIMIT 50').all())"
+   ```
+   Adjust `LIMIT 50` to `--last N` if specified.
+
+   **Session table columns:** `id, directory, title, agent, model, time_created, cost, tokens_input, tokens_output`
+
+2. **Load session messages** — For each session ID, query the message table:
+   ```bash
+   bun -e "import Database from 'bun:sqlite'; const db = new Database('/home/mhenke/.local/share/opencode/opencode.db'); console.log(db.query('SELECT data FROM message WHERE session_id = ?').all('ses_14de9c68effegtZtlATm42wnz7'))"
+   ```
+
+   **Message table columns:** `id, session_id, time_created, time_updated, data` (data is JSON with role, agent, model, summary, etc.)
+
+### Per-Session Analysis
+
+For each session, analyze and produce a structured summary:
+
+```json
+{
+  "session": "ses_14de9c68effegtZtlATm42wnz7",
+  "project": "/home/user/Projects/oh-my-opencode-slim",
+  "timestamp": "2026-06-10T15:08:45.427Z",
+  "goal": "Fix CI failure",
+  "success": true,
+  "frictions": [
+    "Repeated grep to find test file",
+    "Three failed test runs before passing"
+  ],
+  "recommendations": [
+    "Create /test-ci command"
+  ],
+  "duration_minutes": 18,
+  "models_used": ["opencode/mimo-v2.5-free"],
+  "agents_used": ["orchestrator", "fixer", "explorer"],
+  "tools_used": ["Read", "Edit", "Bash"],
+  "confidence": 0.85
+}
+```
+
+**Confidence scoring:**
+- 0.9-1.0: Clear success/failure, obvious patterns
+- 0.7-0.9: Likely outcome, patterns inferred from tool usage
+- 0.5-0.7: Uncertain outcome, limited evidence
+- <0.5: Skip or mark as "needs more evidence"
+
+### Storage and Caching
+
+Store session summaries in `~/.config/opencode/oh-my-opencode-slim/reflections/sessions/`.
+
+**Cache logic:**
+1. Check if `<session-id>.json` exists in reflections directory
+2. If yes, load it (saves tokens)
+3. If no, analyze session and save summary
+4. Aggregate across all summaries for final report
+
+### Aggregation
+
+After analyzing all sessions, aggregate findings:
+
+1. **Group by theme** — sessions with similar frictions cluster together
+2. **Count frequency** — "42/50 sessions had repeated grep before editing"
+3. **Rank by impact** — prioritize recommendations that appear most often
+4. **Filter noise** — skip one-off issues, focus on repeated patterns
+5. **Cross-reference** — see if patterns correlate with specific models, agents, or repos
+
+**Scope categories:**
+- **Global** — applies to all repos (pattern seen in >50% of repos)
+- **Cross-repo** — applies to specific repos where pattern appears
+- **Project-specific** — only relevant to one repo
+
+### Output Format
+
+Return a compact report with scope and confidence:
+
+```text
+Session Reflection Report
+Analyzing 50 most recent sessions across 8 repos.
+
+Repos analyzed:
+- <repo> (<N> sessions)
+- ... (M more)
+
+Findings
+- <pattern>: N/50 sessions across M repos.
+  - Scope: global | cross-repo (<repos>) | project-specific (<repo>)
+  - Confidence: 0.95
+  - Impact: High | Medium | Low
+
+Recommended changes
+- <asset>: <purpose>
+  - Scope: global | cross-repo (<repos>) | project-specific (<repo>)
+  - Confidence: 0.97
+  - Estimated time saved: High | Medium | Low
+
+Skipped
+- <candidate>: why not worth packaging now.
+  - Scope: <reason>
+  - Confidence: <score>
+
+Needs more evidence
+- <candidate>: what would make it actionable.
+  - Current scope: <what we've seen>
+  - Required scope: <what would confirm>
+```
+
+### Error Handling
+
+**Log file issues:**
+- Log doesn't exist → "No OpenCode log found at <path>. Run OpenCode in at least one repo first."
+- Log is empty → "OpenCode log is empty. No sessions to analyze."
+
+**Session loading issues:**
+- Session ID not loadable → Skip with warning: "Session <id> could not be loaded, skipping."
+- Session has no messages → Skip: "Session <id> has no messages."
+
+**Recovery pattern:**
+- Log the failure
+- Continue with remaining sessions
+- Report failures at end: "3 sessions skipped due to load errors"
+
 ## Core Contract
 
 Reflect must be conservative and evidence-driven.
@@ -66,6 +197,8 @@ Reflect can be triggered directly:
 ```text
 /reflect
 /reflect release workflow and checks
+/reflect --sessions
+/reflect --sessions --last 100
 ```
 
 With no arguments, review recent work broadly. With arguments, focus the review

+ 159 - 0
src/skills/release-smoke-test/SKILL.md

@@ -0,0 +1,159 @@
+---
+name: release-smoke-test
+description: Test an oh-my-opencode-slim release candidate or bugfix before publishing. Use when validating a packed plugin artifact, release branch, crash fix, OpenCode runtime compatibility, or model-specific smoke test such as OpenCode 1.17.11 message transform regressions.
+---
+
+# Release Smoke Test
+
+Use this skill to validate an `oh-my-opencode-slim` release candidate before
+public npm publish. Test the packed artifact, not `@latest` and not the source
+tree.
+
+## Core Workflow
+
+1. Start from the release-prep branch or commit.
+2. Build and pack the candidate.
+3. Install the tarball into a throwaway app.
+4. Create an isolated OpenCode config pointing at the installed
+   `node_modules/oh-my-opencode-slim/dist/index.js`.
+5. Run `opencode debug config` and verify `plugin_origins` contains only the
+   intended plugin when doing an isolation smoke.
+6. Run non-pure `opencode run --print-logs --log-level DEBUG`.
+7. Search isolated logs for the original crash signature.
+8. Record exact artifact, model, OpenCode version, command shape, result, and
+   limitations on the release issue or PR.
+
+## Pack Candidate
+
+Use a temp directory so release validation never depends on the local package
+cache.
+
+```bash
+SMOKE=/tmp/oh-my-opencode-slim-release-smoke
+rm -rf "$SMOKE"
+mkdir -p "$SMOKE/pkg" "$SMOKE/app" "$SMOKE/home" "$SMOKE/xdg/opencode" "$SMOKE/run"
+
+bun run build
+npm pack --pack-destination "$SMOKE/pkg"
+```
+
+Install the tarball:
+
+```bash
+cd "$SMOKE/app"
+bun init -y
+bun add "$SMOKE/pkg"/oh-my-opencode-slim-*.tgz
+node -p "require('./node_modules/oh-my-opencode-slim/package.json').version"
+```
+
+## Isolated Config
+
+Write the minimal OpenCode config:
+
+```bash
+cat > "$SMOKE/xdg/opencode/opencode.json" <<EOF
+{
+  "model": "opencode/deepseek-v4-flash-free",
+  "plugin": [
+    "file://$SMOKE/app/node_modules/oh-my-opencode-slim/dist/index.js"
+  ],
+  "agent": {
+    "orchestrator": {
+      "model": "opencode/deepseek-v4-flash-free"
+    }
+  }
+}
+EOF
+```
+
+Use `env -i` for the cleanest smoke. This strips host `OPENCODE_*`, `ORCA_*`,
+and project overlay variables that can silently add plugins or provider aliases.
+
+```bash
+env -i PATH="$PATH" HOME="$SMOKE/home" XDG_CONFIG_HOME="$SMOKE/xdg" \
+  opencode debug config
+```
+
+Confirm:
+
+- `plugin_origins` has exactly one entry.
+- That entry points to the temp app's packed `dist/index.js`.
+- The model is the one intended for the smoke.
+
+If OpenCode needs provider aliases from the host environment, run a second
+non-isolated model-specific smoke and clearly label it as weaker isolation.
+
+## Runtime Smoke
+
+Run the actual prompt with timeout:
+
+```bash
+env -i PATH="$PATH" HOME="$SMOKE/home" XDG_CONFIG_HOME="$SMOKE/xdg" \
+  timeout 120 \
+  opencode run --print-logs --log-level DEBUG "Say OK only."
+```
+
+Expected result:
+
+```text
+OK
+```
+
+Search logs for the bug signature. For the OpenCode 1.17.11 malformed-message
+crash, use:
+
+```bash
+rg "message\\.info\\.role|undefined is not an object|Cannot read properties of undefined|TypeError" \
+  "$SMOKE/home/.local/share/opencode/log" -n 2>/dev/null || true
+```
+
+No matches should appear.
+
+## OpenAI / Host-Provider Smoke
+
+If the fully isolated environment cannot resolve OpenAI provider aliases, run a
+separate host-provider smoke while keeping the plugin path pointed at the
+tarball install.
+
+```bash
+mkdir -p "$SMOKE/config"
+cat > "$SMOKE/config/opencode.json" <<EOF
+{
+  "model": "openai/gpt-5.5-fast",
+  "plugin": [
+    "file://$SMOKE/app/node_modules/oh-my-opencode-slim/dist/index.js"
+  ],
+  "agent": {
+    "orchestrator": {
+      "model": "openai/gpt-5.5-fast"
+    }
+  }
+}
+EOF
+
+OPENCODE_CONFIG_DIR="$SMOKE/config" \
+  timeout 120 \
+  opencode run --print-logs --log-level DEBUG "Say OK only."
+```
+
+Report this as a host-provider smoke because existing project, user, or Orca
+OpenCode config may still merge in. Use `opencode debug config` to disclose
+what else loaded.
+
+## Reporting Template
+
+```markdown
+## Release-candidate smoke validation
+
+- Commit under test:
+- Tarball:
+- Installed package version:
+- OpenCode version:
+- Config isolation: sanitized `env -i` / host-provider
+- Plugin origin:
+- Model:
+- Command:
+- Result:
+- Crash signature search:
+- Limitations:
+```

+ 15 - 24
src/tui.ts

@@ -91,10 +91,6 @@ export function getSidebarAgentNames(snapshot: TuiSnapshot): string[] {
     : FALLBACK_SIDEBAR_AGENTS;
 }
 
-function truncate(str: string, maxLen: number): string {
-  return str.length > maxLen ? `${str.slice(0, maxLen - 1)}…` : str;
-}
-
 function agentRow(
   label: string,
   model: string,
@@ -104,12 +100,19 @@ function agentRow(
   const modelParts = splitSidebarModelId(model);
   const detailRows: JSX.Element[] = [];
 
+  function detailRow(fieldLabel: string, value: string) {
+    return box({ width: '100%', flexDirection: 'row', paddingLeft: 2 }, [
+      text({ fg: theme.textMuted, width: 9 }, [fieldLabel]),
+      text({ fg: theme.textMuted }, [value]),
+    ]);
+  }
+
   if (modelParts.provider) {
-    detailRows.push(agentDetailRow('provider', modelParts.provider, theme));
+    detailRows.push(detailRow('provider', modelParts.provider));
   }
-  detailRows.push(agentDetailRow('model', modelParts.model, theme));
+  detailRows.push(detailRow('model', modelParts.model));
   if (variant) {
-    detailRows.push(agentDetailRow('variant', variant, theme));
+    detailRows.push(detailRow('variant', variant));
   }
 
   return box({ width: '100%', flexDirection: 'column', marginBottom: 1 }, [
@@ -124,32 +127,20 @@ function compactAgentRow(
   variant: string | undefined,
   theme: { textMuted: unknown },
 ): JSX.Element {
-  const value = variant ? `${model}  ${variant}` : model;
+  const value = variant ? `${model} (${variant})` : model;
   return box(
     {
       width: '100%',
       flexDirection: 'row',
       justifyContent: 'space-between',
-      marginBottom: 0,
     },
     [
-      text({ fg: theme.textMuted }, [label]),
-      text({ fg: theme.textMuted }, [truncate(value, 40)]),
+      text({ fg: theme.textMuted, width: 14 }, [label]),
+      text({ fg: theme.textMuted }, [value]),
     ],
   );
 }
 
-function agentDetailRow(
-  label: string,
-  value: string,
-  theme: { textMuted: unknown },
-): JSX.Element {
-  return box({ width: '100%', flexDirection: 'row', paddingLeft: 2 }, [
-    text({ fg: theme.textMuted, width: 9 }, [label]),
-    text({ fg: theme.textMuted }, [value]),
-  ]);
-}
-
 function renderSidebar(
   snapshot: TuiSnapshot,
   version: string,
@@ -164,7 +155,6 @@ function renderSidebar(
   compactSidebar: boolean,
 ): JSX.Element {
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
-
   return box(
     {
       width: '100%',
@@ -239,7 +229,8 @@ function readConfigState(directory: string): {
       configInvalid = true;
     },
   });
-  return { configInvalid, compactSidebar: config.compactSidebar ?? false };
+  const compactSidebar = config.compactSidebar ?? false;
+  return { configInvalid, compactSidebar };
 }
 
 export function readConfigInvalid(directory: string): boolean {

+ 34 - 0
src/utils/background-job-board.test.ts

@@ -73,6 +73,40 @@ describe('BackgroundJobBoard', () => {
     });
   });
 
+  test('resets timeout convergence when a timed out job completes', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement parser',
+    });
+
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'running',
+      timedOut: true,
+    });
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'running',
+      timedOut: true,
+    });
+
+    const completed = board.updateStatus({
+      taskID: 'ses_1',
+      state: 'completed',
+      timedOut: true,
+    });
+
+    expect(completed).toMatchObject({
+      state: 'completed',
+      timedOut: true,
+      timeoutCount: 0,
+    });
+    expect(board.hasConvergenceSignals('ses_1')).toBe(false);
+  });
+
   test('formats running and terminal unreconciled jobs for prompt', () => {
     const board = new BackgroundJobBoard();
     board.registerLaunch({

+ 46 - 4
src/utils/background-job-board.ts

@@ -31,6 +31,9 @@ export interface BackgroundJobRecord {
   lastUsedAt: number;
   terminalState?: TaskOutputState;
   contextFiles: ContextFile[];
+  totalErrors?: number;
+  timeoutCount?: number;
+  lastErrorAt?: number;
 }
 
 export interface BackgroundJobBoardOptions {
@@ -79,7 +82,7 @@ const AGENT_PREFIX: Record<string, string> = {
 export class BackgroundJobBoard {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
-  private terminalStateListener?: TerminalStateListener;
+  private terminalStateListeners: TerminalStateListener[] = [];
 
   private readonly maxReusablePerAgent: number;
   private readonly readContextMinLines: number;
@@ -91,8 +94,24 @@ export class BackgroundJobBoard {
     this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
   }
 
+  addTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners.push(listener);
+  }
+
+  removeTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners = this.terminalStateListeners.filter(
+      (entry) => entry !== listener,
+    );
+  }
+
   setTerminalStateListener(listener?: TerminalStateListener): void {
-    this.terminalStateListener = listener;
+    this.terminalStateListeners = listener ? [listener] : [];
+  }
+
+  private notifyTerminalStateListeners(taskID: string): void {
+    for (const listener of this.terminalStateListeners) {
+      listener(taskID);
+    }
   }
 
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
@@ -118,6 +137,8 @@ export class BackgroundJobBoard {
         lastLiveBusyAt: now,
         lastUsedAt: now,
         updatedAt: now,
+        totalErrors: existing.totalErrors ?? 0,
+        timeoutCount: existing.timeoutCount ?? 0,
       } satisfies BackgroundJobRecord;
       this.jobs.set(input.taskID, updated);
       return updated;
@@ -141,6 +162,8 @@ export class BackgroundJobBoard {
       updatedAt: now,
       alias: this.nextAlias(input.parentSessionID, input.agent),
       contextFiles: [],
+      totalErrors: 0,
+      timeoutCount: 0,
     };
 
     this.jobs.set(input.taskID, record);
@@ -180,9 +203,20 @@ export class BackgroundJobBoard {
       lastStatusError: input.lastStatusError,
     };
 
+    if (input.state === 'completed') {
+      updated.timeoutCount = 0;
+    }
+    if (input.state === 'error') {
+      updated.totalErrors = (existing.totalErrors ?? 0) + 1;
+      updated.lastErrorAt = updated.updatedAt;
+    }
+    if (input.timedOut && input.state !== 'completed') {
+      updated.timeoutCount = (existing.timeoutCount ?? 0) + 1;
+    }
+
     this.jobs.set(input.taskID, updated);
     this.trimReusable(input.taskID);
-    if (notifyTerminal) this.terminalStateListener?.(input.taskID);
+    if (notifyTerminal) this.notifyTerminalStateListeners(input.taskID);
     return updated;
   }
 
@@ -285,7 +319,7 @@ export class BackgroundJobBoard {
     };
 
     this.jobs.set(taskID, updated);
-    if (notifyTerminal) this.terminalStateListener?.(taskID);
+    if (notifyTerminal) this.notifyTerminalStateListeners(taskID);
     return updated;
   }
 
@@ -367,6 +401,14 @@ export class BackgroundJobBoard {
     return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
   }
 
+  hasConvergenceSignals(taskID: string, threshold = 3): boolean {
+    const job = this.jobs.get(taskID);
+    if (!job) return false;
+    const errors = job.totalErrors ?? 0;
+    const timeouts = job.timeoutCount ?? 0;
+    return errors >= threshold || timeouts >= threshold;
+  }
+
   formatForPrompt(
     parentSessionID: string,
     now = Date.now(),