Browse Source

Merge pull request #481 from alvinunreal/v2-beta

[BETA] - Version 2
Alvin 1 month ago
parent
commit
f7590d4722
100 changed files with 10801 additions and 6163 deletions
  1. 160 0
      .github/workflows/companion-release.yml
  2. 12 0
      .gitignore
  3. 1 1
      .slim/clonedeps.json
  4. 0 3
      .slim/codemap.json
  5. 47 0
      AGENTS.md
  6. 17 21
      README.ja-JP.md
  7. 5 7
      README.ko-KR.md
  8. 35 23
      README.md
  9. 17 20
      README.zh-CN.md
  10. 5 6
      codemap.md
  11. 4549 0
      companion/Cargo.lock
  12. 27 0
      companion/Cargo.toml
  13. BIN
      companion/gifs/council.gif
  14. BIN
      companion/gifs/designer.gif
  15. BIN
      companion/gifs/explorer.gif
  16. BIN
      companion/gifs/fixer.gif
  17. BIN
      companion/gifs/intro.gif
  18. BIN
      companion/gifs/librarian.gif
  19. BIN
      companion/gifs/oracle.gif
  20. BIN
      companion/gifs/orchestrator.gif
  21. BIN
      companion/gifs/question.gif
  22. 498 0
      companion/src/app.rs
  23. 35 0
      companion/src/gifs.rs
  24. 45 0
      companion/src/main.rs
  25. 20 0
      companion/src/screen.rs
  26. 46 0
      companion/src/singleton.rs
  27. 103 0
      companion/src/state.rs
  28. 2 8
      docs/authors-preset.md
  29. 150 0
      docs/background-job-board-lessons.md
  30. 189 0
      docs/companion.md
  31. 28 13
      docs/configuration.md
  32. 82 4
      docs/installation.md
  33. 0 9
      docs/interview.md
  34. 3 3
      docs/mcps.md
  35. 2 2
      docs/quick-reference.md
  36. 0 51
      docs/session-goal.md
  37. 32 22
      docs/session-management.md
  38. 70 0
      docs/skills.md
  39. 0 135
      docs/subtask.md
  40. 1 1
      docs/thirty-dollars-preset.md
  41. 0 49
      docs/todo-continuation.md
  42. 10 37
      docs/tools.md
  43. 373 0
      docs/v2-background-orchestration.md
  44. 32 0
      docs/v2-workstreams.md
  45. 621 0
      docs/v2_core.md
  46. BIN
      img/subtask.png
  47. BIN
      img/v2beta.webp
  48. 26 43
      oh-my-opencode-slim.schema.json
  49. 6 3
      package.json
  50. 2 0
      scripts/verify-release-artifact.ts
  51. 3 0
      src/agents/council.ts
  52. 3 0
      src/agents/councillor.ts
  53. 4 0
      src/agents/designer.ts
  54. 3 0
      src/agents/explorer.ts
  55. 4 1
      src/agents/fixer.ts
  56. 18 2
      src/agents/index.test.ts
  57. 5 0
      src/agents/index.ts
  58. 4 1
      src/agents/librarian.ts
  59. 3 0
      src/agents/observer.ts
  60. 3 0
      src/agents/oracle.ts
  61. 75 75
      src/agents/orchestrator.ts
  62. 235 0
      src/cli/background-subagents.test.ts
  63. 109 0
      src/cli/background-subagents.ts
  64. 232 0
      src/cli/companion.ts
  65. 14 0
      src/cli/custom-skills.ts
  66. 38 6
      src/cli/index.ts
  67. 124 2
      src/cli/install.ts
  68. 32 1
      src/cli/providers.test.ts
  69. 8 0
      src/cli/providers.ts
  70. 2 0
      src/cli/skills.test.ts
  71. 7 0
      src/cli/types.ts
  72. 6 6
      src/codemap.md
  73. 301 0
      src/companion/manager.test.ts
  74. 260 0
      src/companion/manager.ts
  75. 2 2
      src/config/agent-mcps.test.ts
  76. 1 1
      src/config/agent-mcps.ts
  77. 1 1
      src/config/codemap.md
  78. 19 3
      src/config/constants.ts
  79. 0 20
      src/config/loader.test.ts
  80. 14 2
      src/config/loader.ts
  81. 15 62
      src/config/schema.ts
  82. 5 9
      src/hooks/codemap.md
  83. 78 0
      src/hooks/deepwork/index.test.ts
  84. 62 0
      src/hooks/deepwork/index.ts
  85. 1 2
      src/hooks/index.ts
  86. 1 1
      src/hooks/json-error-recovery/hook.ts
  87. 0 231
      src/hooks/session-goal/index.test.ts
  88. 0 243
      src/hooks/session-goal/index.ts
  89. 28 42
      src/hooks/task-session-manager/codemap.md
  90. 1229 401
      src/hooks/task-session-manager/index.test.ts
  91. 527 52
      src/hooks/task-session-manager/index.ts
  92. 0 77
      src/hooks/todo-continuation/codemap.md
  93. 0 3043
      src/hooks/todo-continuation/index.test.ts
  94. 0 879
      src/hooks/todo-continuation/index.ts
  95. 0 204
      src/hooks/todo-continuation/todo-hygiene.test.ts
  96. 0 207
      src/hooks/todo-continuation/todo-hygiene.ts
  97. 60 112
      src/index.ts
  98. 1 1
      src/mcp/grep-app.ts
  99. 11 11
      src/mcp/index.test.ts
  100. 2 2
      src/mcp/index.ts

+ 160 - 0
.github/workflows/companion-release.yml

@@ -0,0 +1,160 @@
+name: Companion Release
+
+on:
+  workflow_dispatch:
+    inputs:
+      version:
+        description: 'Companion version, for example 0.1.0'
+        required: true
+        type: string
+      targets:
+        description: >-
+          Comma-separated targets to build: macos-arm64,macos-x64,linux-x64,windows-x64
+        required: true
+        default: macos-arm64
+        type: string
+
+permissions:
+  contents: write
+
+jobs:
+  macos-arm64:
+    name: macOS arm64
+    if: contains(github.event.inputs.targets, 'macos-arm64')
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: aarch64-apple-darwin
+      - name: Build companion
+        working-directory: companion
+        run: cargo build --release --target aarch64-apple-darwin
+      - name: Package companion
+        run: |
+          set -euo pipefail
+          version='${{ github.event.inputs.version }}'
+          target='aarch64-apple-darwin'
+          name="oh-my-opencode-slim-companion-v${version}-${target}"
+          mkdir -p dist
+          cp "companion/target/${target}/release/oh-my-opencode-slim-companion" dist/oh-my-opencode-slim-companion
+          tar -C dist -czf "${name}.tar.gz" oh-my-opencode-slim-companion
+      - uses: actions/upload-artifact@v4
+        with:
+          name: companion-macos-arm64
+          path: '*.tar.gz'
+
+  macos-x64:
+    name: macOS x64
+    if: contains(github.event.inputs.targets, 'macos-x64')
+    runs-on: macos-13
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: x86_64-apple-darwin
+      - name: Build companion
+        working-directory: companion
+        run: cargo build --release --target x86_64-apple-darwin
+      - name: Package companion
+        run: |
+          set -euo pipefail
+          version='${{ github.event.inputs.version }}'
+          target='x86_64-apple-darwin'
+          name="oh-my-opencode-slim-companion-v${version}-${target}"
+          mkdir -p dist
+          cp "companion/target/${target}/release/oh-my-opencode-slim-companion" dist/oh-my-opencode-slim-companion
+          tar -C dist -czf "${name}.tar.gz" oh-my-opencode-slim-companion
+      - uses: actions/upload-artifact@v4
+        with:
+          name: companion-macos-x64
+          path: '*.tar.gz'
+
+  linux-x64:
+    name: Linux x64
+    if: contains(github.event.inputs.targets, 'linux-x64')
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: x86_64-unknown-linux-gnu
+      - name: Install system dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev
+      - name: Build companion
+        working-directory: companion
+        run: cargo build --release --target x86_64-unknown-linux-gnu
+      - name: Package companion
+        run: |
+          set -euo pipefail
+          version='${{ github.event.inputs.version }}'
+          target='x86_64-unknown-linux-gnu'
+          name="oh-my-opencode-slim-companion-v${version}-${target}"
+          mkdir -p dist
+          cp "companion/target/${target}/release/oh-my-opencode-slim-companion" dist/oh-my-opencode-slim-companion
+          tar -C dist -czf "${name}.tar.gz" oh-my-opencode-slim-companion
+      - uses: actions/upload-artifact@v4
+        with:
+          name: companion-linux-x64
+          path: '*.tar.gz'
+
+  windows-x64:
+    name: Windows x64
+    if: contains(github.event.inputs.targets, 'windows-x64')
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: x86_64-pc-windows-msvc
+      - name: Build companion
+        working-directory: companion
+        run: cargo build --release --target x86_64-pc-windows-msvc
+      - name: Package companion
+        shell: pwsh
+        run: |
+          $version = '${{ github.event.inputs.version }}'
+          $target = 'x86_64-pc-windows-msvc'
+          $name = "oh-my-opencode-slim-companion-v$version-$target"
+          New-Item -ItemType Directory -Force -Path dist | Out-Null
+          Copy-Item "companion/target/$target/release/oh-my-opencode-slim-companion.exe" "dist/oh-my-opencode-slim-companion.exe"
+          Compress-Archive -Path "dist/oh-my-opencode-slim-companion.exe" -DestinationPath "$name.zip" -Force
+      - uses: actions/upload-artifact@v4
+        with:
+          name: companion-windows-x64
+          path: '*.zip'
+
+  release:
+    name: Publish companion release
+    needs:
+      - macos-arm64
+      - macos-x64
+      - linux-x64
+      - windows-x64
+    if: always() && !cancelled() && !failure()
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/download-artifact@v4
+        with:
+          path: artifacts
+          merge-multiple: true
+      - name: Generate checksums
+        run: |
+          set -euo pipefail
+          cd artifacts
+          sha256sum * > SHA256SUMS
+      - name: Create or update GitHub release
+        env:
+          GH_TOKEN: ${{ github.token }}
+          VERSION: ${{ github.event.inputs.version }}
+        run: |
+          set -euo pipefail
+          tag="companion-v${VERSION}"
+          if ! gh release view "$tag" >/dev/null 2>&1; then
+            gh release create "$tag" \
+              --title "Companion v${VERSION}" \
+              --notes "Manual companion binary release for oh-my-opencode-slim."
+          fi
+          gh release upload "$tag" artifacts/* --clobber

+ 12 - 0
.gitignore

@@ -56,6 +56,7 @@ GOAL.md
 GOALS.md
 PR-NOTES.md
 REVIEW.md
+!docs/goal.md
 docs/plans
 docs/superpowers
 
@@ -83,6 +84,17 @@ wheels/
 *.egg
 captures/
 
+# Rust companion build artifacts
+companion/target/
+
 # BEGIN oh-my-opencode-slim clonedeps
 .slim/clonedeps/repos/
 # END oh-my-opencode-slim clonedeps
+
+# Local git worktrees for parallel feature work
+.slim/worktrees/
+
+# Deepwork session artifacts
+.slim/deepwork/
+.codegraph/
+companion/VIDEOS/

+ 1 - 1
.slim/clonedeps.json

@@ -1,6 +1,6 @@
 {
   "version": "1.0.0",
-  "updatedAt": "2026-05-12T00:00:00.000Z",
+  "updatedAt": "2026-05-25T00:00:00.000Z",
   "dependencies": [
     {
       "name": "@opencode-ai/plugin",

+ 0 - 3
.slim/codemap.json

@@ -95,8 +95,6 @@
     "src/hooks/phase-reminder/index.ts": "55c78ab86f3b26a071c8e2f639831b8b",
     "src/hooks/post-file-tool-nudge/index.ts": "7a23d01b3396c4018015e0e90629c45d",
     "src/hooks/task-session-manager/index.ts": "a9d701588ceefa4b40454e27c1ed2ea1",
-    "src/hooks/todo-continuation/index.ts": "4bc29a79ce7d85acc120c87a02cd09d2",
-    "src/hooks/todo-continuation/todo-hygiene.ts": "08374710c80e24ca55b78a1ff5ed7a81",
     "src/index.ts": "fa92b5f3491ff9a2ba8a1ccf3e631ba2",
     "src/interview/dashboard.ts": "dbe6703d036ff16952c98f5cc0066e5c",
     "src/interview/document.ts": "c8c35c9042fdef497925c89ce1dba1b4",
@@ -174,7 +172,6 @@
     "src/hooks/phase-reminder": "80f01bd7edd895fcd3950a44b21a4d3a",
     "src/hooks/post-file-tool-nudge": "e01c0aa6e649ec049c068d6a1b2006f9",
     "src/hooks/task-session-manager": "0c73238a14d84eb1d204f9cc0044180d",
-    "src/hooks/todo-continuation": "f3622b6cc650fcd74043cde96b56b9f3",
     "src/interview": "3920f8d94c932173803d6fdd8506b9b3",
     "src/mcp": "5f5fc5fbb54bf9944063483cee8be88f",
     "src/multiplexer": "f543dda4ba0043e6c5e4ea0c07e11a77",

+ 47 - 0
AGENTS.md

@@ -100,6 +100,53 @@ oh-my-opencode-slim/
 5. Run `bun test` to verify tests pass
 6. Commit changes
 
+## V2 Branch and Worktree Workflow
+
+Keep `master` stable for the npm `latest` release. Use `v2-beta` as the central
+V2 integration branch: merge focused V2 feature branches there, test the combined
+V2 experience there, and publish npm `beta` releases from there. Do not develop
+unrelated features directly on `v2-beta`; keep it as the shared integration point.
+
+Recommended branch flow:
+
+```text
+master              stable latest users
+v2-beta            central V2 integration/test branch; publish @beta here
+v2/<feature-name>  focused V2 feature branches merged into v2-beta
+```
+
+For new V2 work, branch from `v2-beta`:
+
+```bash
+git checkout v2-beta
+git pull --ff-only origin v2-beta
+git checkout -b v2/<feature-name>
+```
+
+For parallel feature work, prefer a worktree so `v2-beta` stays clean:
+
+```bash
+git worktree add ../oh-my-opencode-slim-v2-<feature-name> \
+  -b v2/<feature-name> v2-beta
+```
+
+Create PRs from `v2/<feature-name>` into `v2-beta`. Merge into `master` only
+when V2 is ready to become the stable `latest` release.
+
+To test all V2 work together, use `v2-beta` after feature branches are merged:
+
+```bash
+git checkout v2-beta
+git pull --ff-only origin v2-beta
+bun run check:ci
+bun run typecheck
+bun test
+```
+
+If a merged feature needs more work, continue on its focused branch or create a
+new `v2/<feature-name>` follow-up branch from the latest `v2-beta`, then merge it
+back into `v2-beta` again when ready.
+
 ## Tmux Session Lifecycle Management
 
 When working with tmux integration, understanding the session lifecycle is crucial for preventing orphaned processes and ghost panes.

+ 17 - 21
README.ja-JP.md

@@ -1,9 +1,6 @@
 <div align="center">
-  <a href="https://github.com/alvinunreal/oh-my-opencode-slim/stargazers">
-    <img src="img/v2beta.webp" alt="V2 Beta Release" style="border-radius: 10px;">
-  </a>
-  <h3>✨ V2 ベータリリース:バックグラウンドオーケストレーションが登場 ✨</h3>
-  <p><i>オーケストレーターがバックグラウンドで専門エージェントをスケジューリングし、<br><code>/deepwork</code> が大きなゴールをファイルに紐づいた計画へと変換します。<br>ベータテスターの皆様:フィードバックは Telegram でお寄せください。</i></p>
+  <h3>✨ デフォルトのバックグラウンドオーケストレーションが登場 ✨</h3>
+  <p><i>オーケストレーターはワークフローマネージャーとしてバックグラウンドで専門エージェントをスケジューリングし、<br><code>/deepwork</code> が大きなゴールをファイルに紐づいた計画へと変換します。<br>フィードバックは Telegram でお寄せください。</i></p>
 
   <p><b>オープン・マルチエージェント・スイート</b> · あらゆるモデルを組み合わせ · タスクを自動委譲</p>
 
@@ -45,21 +42,21 @@ Install and configure oh-my-opencode-slim: https://raw.githubusercontent.com/alv
 bunx oh-my-opencode-slim@latest install
 ```
 
-### V2 バックグラウンドオーケストレーション・ベータ
+> **翻訳ステータス:** 英語版 README が最新です。この日本語訳には古い表現が一部残っている可能性があります。
 
-V2 では、オーケストレーターがデフォルトの実行ワーカーからスケジューラーへと役割を変えます。
+### デフォルトのバックグラウンドオーケストレーション
+
+現在のデフォルトでは、オーケストレーターが実行ワーカーではなくスケジューラーとして動作します。
 作業を計画し、専門エージェントをバックグラウンドタスクとしてディスパッチし、ステータスをポーリングし、
 結果を整合させてから処理を続行します。これには OpenCode のネイティブな
-バックグラウンドサブエージェントサポートが必要であり、ベータユーザーは実験的なフラグを
-有効にして OpenCode を起動する必要があります。
+バックグラウンドサブエージェントサポートが必要です。下記の環境変数を有効にして OpenCode を起動してください。
 
 ```bash
-# 既存ユーザー: 先に OpenCode のキャッシュ済みパッケージを削除し、beta を取得し直します。
-rm -rf ~/.cache/opencode/packages/oh-my-opencode-slim
-bunx oh-my-opencode-slim@beta install
-OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
+bunx oh-my-opencode-slim@beta install --background-subagents=yes
 ```
 
+インストール後はターミナルを再起動するか、更新された shell ファイルを source してから `opencode` を実行してください。一回限りなら `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode` でも起動できます。
+
 ### はじめに
 
 インストーラーは OpenAI と OpenCode Go の両方のプリセットを生成し、デフォルトで OpenAI が有効になっています。OpenAI プリセットでは、判断力の高いエージェントに `openai/gpt-5.5` を、より高速でスコープの限定されたエージェントに `openai/gpt-5.4-mini` を使用します。インストール時に OpenCode Go をアクティブにするには `bunx oh-my-opencode-slim@latest install --preset=opencode-go` を実行するか、インストール後に `~/.config/opencode/oh-my-opencode-slim.json` のデフォルトプリセット名を変更してください。
@@ -81,7 +78,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
 4. **各エージェントに使用したいモデルを更新します**
 
 > [!TIP]
-> 自動委譲の仕組みを理解しておくことを**推奨**します。**[Orchestrator のプロンプト](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** には、委譲ルール、専門エージェントへのルーティングロジック、メインエージェントがサブエージェントに作業を引き継ぐべきしきい値が記述されています。`@agentName <task>` のようにサブエージェントを呼び出すことで、いつでも手動で委譲できます。
+> バックグラウンドオーケストレーションの仕組みを理解しておくことを**推奨**します。**[Orchestrator のプロンプト](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** には、スケジューラーのルール、専門エージェントへのルーティングロジック、作業をバックグラウンドエージェントへ割り当てるしきい値が記述されています。`@agentName <task>` のようにサブエージェントを呼び出すことで、いつでも手動で委譲できます。
 
 デフォルトで生成される設定には `openai` と `opencode-go` の両方のプリセットが含まれます。
 
@@ -93,7 +90,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.5", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.5", "variant": "high", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "grep_app"] },
+      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
       "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
       "designer": { "model": "openai/gpt-5.4-mini", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }
@@ -102,7 +99,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
       "orchestrator": { "model": "opencode-go/glm-5.1", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
       "oracle": { "model": "opencode-go/deepseek-v4-pro", "variant": "max", "skills": ["simplify"], "mcps": [] },
       "council": { "model": "opencode-go/deepseek-v4-pro", "variant": "high", "skills": [], "mcps": [] },
-      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
       "explorer": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [] },
       "designer": { "model": "opencode-go/kimi-k2.6", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] }
@@ -124,8 +121,10 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
 
 インストールと認証を済ませた後、すべてのエージェントが設定済みで応答することを確認してください:
 
+先にターミナルを再起動するか、更新された shell ファイルを source してください。環境変数が有効なら `opencode` を実行できます。現在の shell で未設定の場合は次を使います:
+
 ```bash
-opencode
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
 ```
 
 次に以下を実行します:
@@ -503,11 +502,8 @@ ping all agents
 | **[Council](docs/council.md)** | 複数のモデルを並列実行し、`@council` で 1 つの回答に統合します |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | エージェントの動作を Tmux や Zellij のペインでライブ表示します |
 | **[Session Management](docs/session-management.md)** | 短いエイリアスで最近の子エージェントセッションを再利用し、最初からやり直さずに済みます |
-| **[Session Goal](docs/session-goal.md)** | `/goal` でセッションの目標をピン留めし、TODO・委譲・検証の整合性を保ちます |
-| **[Todo Continuation](docs/todo-continuation.md)** | クールダウンと安全チェック付きで Orchestrator セッションを自動継続します |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset` で実行時にエージェントモデルのプリセットを切り替えます |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | カスタムプロンプト、モデル、MCP アクセス、Orchestrator の委譲ルールを備えた独自の専門エージェントを定義します |
-| **[Subtask](docs/subtask.md)** | `/subtask` で境界が明確な子ワーカーを実行し、構造化された要約をメインセッションに返します |
 | **[Codemap](docs/codemap.md)** | 階層的なコードマップを生成し、大規模コードベースを迅速に理解します |
 | **[Clonedeps](docs/clonedeps.md)** | 選択した依存関係のソースを ignore 済みのローカルワークスペースにクローンし、調査できるようにします |
 | **[Interview](docs/interview.md)** | ブラウザベースの Q&A フローで、ざっくりとしたアイデアを構造化された Markdown 仕様に変換します |
@@ -520,7 +516,7 @@ ping all agents
 | **[Configuration](docs/configuration.md)** | 設定ファイルの配置場所、JSONC サポート、プロンプトの上書き、全オプションのリファレンス |
 | **[Maintainer Guide](docs/maintainers.md)** | Issue のトリアージルール、ラベルの意味、サポートの振り分け、リポジトリ運用ワークフロー |
 | **[Skills](docs/skills.md)** | `simplify`、`codemap`、`clonedeps` などの同梱スキル |
-| **[MCPs](docs/mcps.md)** | `websearch`、`context7`、`grep_app`、およびエージェントごとの MCP 権限の仕組み |
+| **[MCPs](docs/mcps.md)** | `websearch`、`context7`、`gh_grep`、およびエージェントごとの MCP 権限の仕組み |
 | **[Tools](docs/tools.md)** | `webfetch`、LSP ツール、コード検索、フォーマッターなどの組み込みツール機能 |
 
 ### 💡 プリセット

+ 5 - 7
README.ko-KR.md

@@ -89,7 +89,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.5", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.5", "variant": "high", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "grep_app"] },
+      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
       "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
       "designer": { "model": "openai/gpt-5.4-mini", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }
@@ -98,7 +98,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
       "orchestrator": { "model": "opencode-go/glm-5.1", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
       "oracle": { "model": "opencode-go/deepseek-v4-pro", "variant": "max", "skills": ["simplify"], "mcps": [] },
       "council": { "model": "opencode-go/deepseek-v4-pro", "variant": "high", "skills": [], "mcps": [] },
-      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
       "explorer": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [] },
       "designer": { "model": "opencode-go/kimi-k2.6", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] }
@@ -496,13 +496,11 @@ ping all agents
 | 문서 | 내용 |
 |-----|------|
 | **[Council](docs/council.md)** | `@council`로 여러 모델을 병렬 실행하고 하나의 답변으로 종합 |
+| **[Background Orchestration](docs/v2-background-orchestration.md)** | OpenCode 네이티브 백그라운드 서브에이전트를 기반으로 한 스케줄러 우선 오케스트레이터 모델 |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Tmux 또는 Zellij 페인에서 에이전트 작업을 실시간으로 확인 |
 | **[Session Management](docs/session-management.md)** | 단축 별칭으로 최근 자식 에이전트 세션을 재사용하여 처음부터 다시 시작하지 않기 |
-| **[Session Goal](docs/session-goal.md)** | `/goal`로 세션 목표를 고정하여 투두, 위임, 검증이 정렬되게 유지 |
-| **[Todo Continuation](docs/todo-continuation.md)** | 쿨다운과 안전 검사를 통해 오케스트레이터 세션 자동 이어서 진행 |
 | **[Preset Switching](docs/preset-switching.md)** | `/preset`으로 런타임에 에이전트 모델 프리셋 전환 |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | 커스텀 프롬프트, 모델, MCP 접근, Orchestrator 위임 규칙으로 커스텀 전문 에이전트 정의 |
-| **[Subtask](docs/subtask.md)** | `/subtask`로 제한된 자식 워커를 실행하고 메인 세션에 구조화된 요약 반환 |
 | **[Codemap](docs/codemap.md)** | 계층형 코드맵을 생성하여 대규모 코드베이스를 빠르게 파악 |
 | **[Clonedeps](docs/clonedeps.md)** | 선택한 의존성 소스를 무시된 로컬 워크스페이스에 복제하여 검사 |
 | **[Interview](docs/interview.md)** | 브라우저 기반 Q&A 흐름을 통해 거친 아이디어를 구조화된 마크다운 명세로 변환 |
@@ -514,8 +512,8 @@ ping all agents
 |-----|------|
 | **[Configuration](docs/configuration.md)** | 설정 파일 위치, JSONC 지원, 프롬프트 오버라이드, 전체 옵션 레퍼런스 |
 | **[Maintainer Guide](docs/maintainers.md)** | 이슈 트리아지 규칙, 라벨 의미, 지원 라우팅, 저장소 유지보수 워크플로우 |
-| **[Skills](docs/skills.md)** | `simplify`, `codemap`, `clonedeps` 등 번들된 스킬 |
-| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `grep_app` 및 에이전트별 MCP 권한 동작 방식 |
+| **[Skills](docs/skills.md)** | `simplify`, `codemap`, `clonedeps`, `deepwork`, `oh-my-opencode-slim` 등 번들된 스킬 |
+| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `gh_grep` 및 에이전트별 MCP 권한 동작 방식 |
 | **[Tools](docs/tools.md)** | `webfetch`, LSP 도구, 코드 검색, 포매터 등 내장 도구 기능 |
 
 ### 💡 프리셋

+ 35 - 23
README.md

@@ -1,9 +1,9 @@
 <div align="center">
   <a href="https://github.com/alvinunreal/oh-my-opencode-slim/stargazers">
-    <img src="img/v2beta.webp" alt="V2 Beta Release" style="border-radius: 10px;">
+    <img src="img/4k.png" alt="4K GitHub Stars Milestone" style="border-radius: 10px;">
   </a>
-  <h3>✨ V2 Beta Release: Background Orchestration Has Arrived ✨</h3>
-  <p><i>The orchestrator now schedules specialist agents in the background,<br>while <code>/deepwork</code> turns big goals into file-backed plans.<br>Beta testers: share your feedback with us on Telegram.</i></p>
+  <h3>✨ Default Background Orchestration Has Arrived ✨</h3>
+  <p><i>The orchestrator now manages the workflow and schedules specialist agents in the background,<br>while <code>/deepwork</code> turns big goals into file-backed plans.<br>Share feedback and questions with us on Telegram.</i></p>
 
   <p><b>Open Multi Agent Suite</b> · Mix any models · Auto delegate tasks</p>
 
@@ -58,24 +58,31 @@ Install and configure oh-my-opencode-slim: https://raw.githubusercontent.com/alv
 bunx oh-my-opencode-slim@latest install
 ```
 
-### V2 Background-Orchestration Beta
+### Default Background Orchestration
 
-V2 changes the orchestrator from the default execution worker into a scheduler:
-it plans work, dispatches specialists as background tasks, polls their status,
-then reconciles results before continuing. This requires OpenCode's native
-background subagent support, so beta users must start OpenCode with the
-experimental flag enabled.
+The orchestrator is now a workflow manager and scheduler, not the main coding
+worker: it plans work, dispatches specialists as background tasks, receives
+completion events from OpenCode or checks status only when needed, then
+reconciles results before continuing. This uses OpenCode's native background
+subagent support, so OpenCode must run with the background-subagents environment
+variable enabled.
 
 ```bash
-# Existing users: clear OpenCode's cached package first so beta is fetched fresh.
-rm -rf ~/.cache/opencode/packages/oh-my-opencode-slim
-bunx oh-my-opencode-slim@beta install
-OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
+bunx oh-my-opencode-slim@beta install --background-subagents=yes
 ```
 
+The installer can set this up for you with
+`--background-subagents=ask|yes|no`. In an interactive TTY, the default is
+`ask`; in non-interactive mode, the default is `no`. Use
+`--background-subagents=yes` to opt in immediately or `--background-subagents=no`
+to skip. If you want the installer to write to a specific shell/profile file,
+add `--background-subagents-target=<path>`. After shell setup, restart your
+terminal or source the updated file before starting `opencode`; for a one-shot
+launch, run `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode`.
+
 ### Getting Started
 
-The installer generates both OpenAI and OpenCode Go presets, with OpenAI active by default. OpenAI uses `openai/gpt-5.5` for the higher-judgment agents and `openai/gpt-5.4-mini` for the faster scoped agents. To make OpenCode Go active during install, run `bunx oh-my-opencode-slim@latest install --preset=opencode-go` or change the default preset name in `~/.config/opencode/oh-my-opencode-slim.json` after installation.
+The installer generates both OpenAI and OpenCode Go presets, with OpenAI active by default. OpenAI uses `openai/gpt-5.5` for the workflow manager/scheduler and higher-judgment agents, and `openai/gpt-5.4-mini` for faster scoped specialists. To make OpenCode Go active during install, run `bunx oh-my-opencode-slim@latest install --preset=opencode-go` or change the default preset name in `~/.config/opencode/oh-my-opencode-slim.json` after installation.
 
 Then:
 
@@ -96,7 +103,13 @@ Then:
 4. **Update the models you want for each agent**
 
 > [!TIP]
-> It's **recommended** to understand how automatic delegation works. The **[Orchestrator prompt](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** contains the delegation rules, specialist routing logic, and the thresholds for when the main agent should hand work off to subagents. You can alway delegate manually by calling a subagent via: `@agentName <task>`
+> 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>`
+
+### Legacy V1 note
+
+The current `@latest` package is the background-orchestration release. If a
+maintained V1 branch or tag is created later, it will be documented separately as
+historical compatibility guidance rather than part of the default install path.
 
 The default generated configuration includes both `openai` and `opencode-go` presets.
 
@@ -108,7 +121,7 @@ The default generated configuration includes both `openai` and `opencode-go` pre
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.5", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.5", "variant": "high", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "grep_app"] },
+      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
       "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
       "designer": { "model": "openai/gpt-5.4-mini", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }
@@ -117,7 +130,7 @@ The default generated configuration includes both `openai` and `opencode-go` pre
       "orchestrator": { "model": "opencode-go/glm-5.1", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
       "oracle": { "model": "opencode-go/deepseek-v4-pro", "variant": "max", "skills": ["simplify"], "mcps": [] },
       "council": { "model": "opencode-go/deepseek-v4-pro", "variant": "high", "skills": [], "mcps": [] },
-      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
       "explorer": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [] },
       "designer": { "model": "opencode-go/kimi-k2.6", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] }
@@ -197,7 +210,7 @@ If any agent fails to respond, check your provider authentication and config fil
   </tr>
   <tr>
     <td colspan="2">
-      <b>Model Guidance:</b> Choose your default, strongest all-around coding model. Orchestrator is both the main coding agent and the delegator, so it needs strong implementation ability, good judgment, and reliable instruction-following.
+      <b>Model Guidance:</b> Choose your strongest planning and judgment model. Orchestrator is the workflow manager: it plans, schedules background specialists, reconciles results, and verifies outcomes, so it needs reliable instruction-following and high-level technical judgment more than raw worker throughput.
     </td>
   </tr>
 </table>
@@ -517,17 +530,16 @@ Use this section as a map: start with installation, then jump to features, confi
 | Doc | What it covers |
 |-----|----------------|
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
+| **[Background Orchestration](docs/v2-background-orchestration.md)** | Scheduler-first orchestrator model built around native background subagents |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
-| **[Session Goal](docs/session-goal.md)** | Pin a session objective with `/goal` so todos, delegation, and verification stay aligned |
-| **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
 | **[Custom Agents](docs/configuration.md#custom-agents)** | Define your own specialists with custom prompts, models, MCP access, and Orchestrator delegation rules |
-| **[Subtask](docs/subtask.md)** | Run a bounded child worker with `/subtask` and return a structured summary to the main session |
 | **[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 |
 | **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
 | **[Divoom Display](docs/divoom.md)** | Mirror orchestrator and specialist-agent activity to a Divoom MiniToo Bluetooth display |
+| **[Companion](docs/companion.md)** | Floating window companion for parsing, help, and types |
 
 ### ⚙️ Config & Reference
 
@@ -535,8 +547,8 @@ Use this section as a map: start with installation, then jump to features, confi
 |-----|----------------|
 | **[Configuration](docs/configuration.md)** | Config file locations, JSONC support, prompt overrides, and full option reference |
 | **[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`, and `clonedeps` |
-| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `grep_app`, and how MCP permissions work per agent |
+| **[Skills](docs/skills.md)** | Bundled skills such as `simplify`, `codemap`, `clonedeps`, `deepwork`, and `oh-my-opencode-slim` |
+| **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `gh_grep`, and how MCP permissions work per agent |
 | **[Tools](docs/tools.md)** | Built-in tool capabilities like `webfetch`, LSP tools, code search, and formatters |
 
 ### 💡 Presets

+ 17 - 20
README.zh-CN.md

@@ -1,9 +1,6 @@
 <div align="center">
-  <a href="https://github.com/alvinunreal/oh-my-opencode-slim/stargazers">
-    <img src="img/v2beta.webp" alt="V2 Beta Release" style="border-radius: 10px;">
-  </a>
-  <h3>✨ V2 Beta 版本:后台编排已上线 ✨</h3>
-  <p><i>编排者(Orchestrator)现在可在后台调度专家智能体,<br>同时 <code>/deepwork</code> 可以将宏大目标转化为基于文件的具体计划。<br>Beta 测试人员:请在 Telegram 上与我们分享您的反馈。</i></p>
+  <h3>✨ 默认后台编排已上线 ✨</h3>
+  <p><i>编排者(Orchestrator)现在作为工作流管理器在后台调度专家智能体,<br>同时 <code>/deepwork</code> 可以将宏大目标转化为基于文件的具体计划。<br>欢迎在 Telegram 上与我们分享反馈。</i></p>
 
   <p><b>开放式多智能体套件</b> · 混合任意模型 · 自动委派任务</p>
 
@@ -43,18 +40,19 @@ Install and configure oh-my-opencode-slim: https://raw.githubusercontent.com/alv
 bunx oh-my-opencode-slim@latest install
 ```
 
-### V2 后台编排 Beta 版
+> **翻译状态说明:** 英文 README 是最新版本;此中文翻译可能仍有少量旧表述。
 
-V2 将编排者(Orchestrator)从默认的执行工作器转变为调度器:
-它规划工作、将专家作为后台任务分发、轮询其状态,并在继续执行之前核对结果。这需要 OpenCode 原生的后台子智能体支持,因此 Beta 版用户必须在启用实验性标志的情况下启动 OpenCode。
+### 默认后台编排
+
+当前版本将编排者(Orchestrator)从默认的执行工作器转变为调度器:
+它规划工作、将专家作为后台任务分发、按需检查状态,并在继续执行之前核对结果。这需要 OpenCode 原生的后台子智能体支持,因此请使用下方环境变量启动 OpenCode。
 
 ```bash
-# 现有用户:先清除 OpenCode 缓存的插件包,确保重新拉取 beta 版本。
-rm -rf ~/.cache/opencode/packages/oh-my-opencode-slim
-bunx oh-my-opencode-slim@beta install
-OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
+bunx oh-my-opencode-slim@beta install --background-subagents=yes
 ```
 
+安装后请重启终端或 source 更新过的 shell 文件,然后再运行 `opencode`;也可以一次性使用 `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode` 启动。
+
 ### 入门指南
 
 安装程序会同时生成 OpenAI 和 OpenCode Go 的预设(Preset),默认启用 OpenAI 预设。OpenAI 使用 `openai/gpt-5.5` 作为具备高级判断力智能体的模型,并使用 `openai/gpt-5.4-mini` 作为响应更快速、针对具体任务智能体的模型。若要在安装过程中激活 OpenCode Go 预设,请运行 `bunx oh-my-opencode-slim@latest install --preset=opencode-go` 或在安装后修改 `~/.config/opencode/oh-my-opencode-slim.json` 文件中的默认预设名称。
@@ -76,7 +74,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
 4. **为您要分配的每个智能体更新模型配置**
 
 > [!TIP]
-> **强烈建议**了解自动委派(Automatic Delegation)的工作原理。**[编排者提示词 (Orchestrator prompt)](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 包含了委派规则、专家路由逻辑,以及主智能体何时将工作转交给子智能体的阈值。您始终可以通过以下方式手动委派任务:`@智能体名称 <任务内容>`
+> **建议**了解后台编排的工作原理。**[编排者提示词 (Orchestrator prompt)](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/src/agents/orchestrator.ts#L28)** 包含调度规则、专家路由逻辑,以及何时应把工作分配给后台智能体的阈值。您始终可以通过以下方式手动委派任务:`@智能体名称 <任务内容>`
 
 默认生成的配置包含 `openai` 和 `opencode-go` 两个预设:
 
@@ -88,7 +86,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
     "openai": {
       "orchestrator": { "model": "openai/gpt-5.5", "skills": ["*"], "mcps": ["*", "!context7"] },
       "oracle": { "model": "openai/gpt-5.5", "variant": "high", "skills": ["simplify"], "mcps": [] },
-      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "grep_app"] },
+      "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": ["websearch", "context7", "gh_grep"] },
       "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
       "designer": { "model": "openai/gpt-5.4-mini", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }
@@ -97,7 +95,7 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
       "orchestrator": { "model": "opencode-go/glm-5.1", "skills": [ "*" ], "mcps": [ "*", "!context7" ] },
       "oracle": { "model": "opencode-go/deepseek-v4-pro", "variant": "max", "skills": ["simplify"], "mcps": [] },
       "council": { "model": "opencode-go/deepseek-v4-pro", "variant": "high", "skills": [], "mcps": [] },
-      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+      "librarian": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
       "explorer": { "model": "opencode-go/minimax-m2.7", "skills": [], "mcps": [] },
       "designer": { "model": "opencode-go/kimi-k2.6", "variant": "medium", "skills": [], "mcps": [] },
       "fixer": { "model": "opencode-go/deepseek-v4-flash", "variant": "high", "skills": [], "mcps": [] }
@@ -118,8 +116,10 @@ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=1 opencode
 
 在完成安装与认证后,请验证所有智能体是否已正确配置并能够响应:
 
+请先确保已重启终端或 source 更新过的 shell 文件。环境变量生效后可运行 `opencode`;如果尚未更新当前 shell,请使用:
+
 ```bash
-opencode
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
 ```
 
 然后运行:
@@ -497,11 +497,8 @@ ping all agents
 | **[Council (议会) (docs/council.md)](docs/council.md)** | 使用 `@council` 并行运行多个模型并合成单一答案 |
 | **[多路复用器集成 (docs/multiplexer-integration.md)](docs/multiplexer-integration.md)** | 在 Tmux 或 Zellij 窗格中实时观看智能体的工作过程 |
 | **[会话管理 (docs/session-management.md)](docs/session-management.md)** | 使用短别名复用最近的子智能体会话,而不是重新开始 |
-| **[会话目标 (docs/session-goal.md)](docs/session-goal.md)** | 用 `/goal` 固定会话目标,以确保待办事项、委派和验证保持一致 |
-| **[待办事项持续执行 (docs/todo-continuation.md)](docs/todo-continuation.md)** | 具备冷却时间和安全检查的编排者会话自动持续执行 |
 | **[运行时预设切换 (docs/preset-switching.md)](docs/preset-switching.md)** | 在运行时使用 `/preset` 切换智能体模型预设 |
 | **[自定义智能体 (docs/configuration.md#custom-agents)](docs/configuration.md#custom-agents)** | 自定义专家智能体:配置独特的提示词、模型、MCP 权限和编排者委派规则 |
-| **[子任务 (docs/subtask.md)](docs/subtask.md)** | 使用 `/subtask` 运行受限的子工作器,并将结构化总结返回到主会话 |
 | **[代码地图 (Codemap) (docs/codemap.md)](docs/codemap.md)** | 生成层级代码地图,快速理解大型代码库 |
 | **[克隆依赖 (Clonedeps) (docs/clonedeps.md)](docs/clonedeps.md)** | 将选定的依赖源码克隆到被忽略的本地工作区中以供检查 |
 | **[访谈式生成 (Interview) (docs/interview.md)](docs/interview.md)** | 通过基于浏览器的问答流,将粗糙的想法转变为结构化的 Markdown 规范文档 |
@@ -514,7 +511,7 @@ ping all agents
 | **[配置指南 (docs/configuration.md)](docs/configuration.md)** | 配置文件位置、JSONC 支持、提示词覆盖以及完整的选项参考 |
 | **[维护者指南 (docs/maintainers.md)](docs/maintainers.md)** | 问题分流规则、标签含义、支持路由以及仓库维护工作流 |
 | **[技能列表 (Skills) (docs/skills.md)](docs/skills.md)** | 捆绑的技能,如 `simplify`、`codemap` 和 `clonedeps` |
-| **[MCP 服务 (docs/mcps.md)](docs/mcps.md)** | `websearch`、`context7`、`grep_app` 以及每个智能体的 MCP 权限工作机制 |
+| **[MCP 服务 (docs/mcps.md)](docs/mcps.md)** | `websearch`、`context7`、`gh_grep` 以及每个智能体的 MCP 权限工作机制 |
 | **[工具说明 (docs/tools.md)](docs/tools.md)** | 内置工具能力,如 `webfetch`、LSP 工具、代码搜索和格式化工具 |
 
 ### 💡 预设配置

+ 5 - 6
codemap.md

@@ -7,7 +7,7 @@
 - define orchestrator and specialist agents,
 - load layered plugin configuration and per-agent permissions,
 - expose additional tools and MCP integrations,
-- manage delegated/resumable session orchestration and terminal multiplexer visualization,
+- manage background job-board orchestration and terminal multiplexer visualization,
 - inject workflow-enforcement hooks plus runtime command handlers,
 - ship install-time skills and a bootstrap CLI.
 
@@ -18,7 +18,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | Path | Role |
 |---|---|
 | `package.json` | Package manifest, dependency graph, release scripts, published file list. |
-| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council/session managers, multiplexer session mirroring, interview/preset managers, task-session tracking, and config merge behavior. |
+| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council managers, shared background job board, multiplexer session mirroring, interview/preset managers, task-session tracking, and config merge behavior. |
 | `src/cli/index.ts` | CLI entrypoint for installation/bootstrap workflows. |
 | `src/config/schema.ts` | Source-of-truth runtime config schema used by validation and schema generation. |
 | `scripts/generate-schema.ts` | Generates `oh-my-opencode-slim.schema.json` from the Zod config schema. |
@@ -42,7 +42,6 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | `src/hooks/phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases. | [View Map](src/hooks/phase-reminder/codemap.md) |
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
 | `src/hooks/task-session-manager/` | Resumable `task` session tracking, short alias resolution, prompt injection, and stale-session cleanup. | [View Map](src/hooks/task-session-manager/codemap.md) |
-| `src/hooks/todo-continuation/` | Auto-continue behavior for outstanding todo execution. | [View Map](src/hooks/todo-continuation/codemap.md) |
 | `src/interview/` | `/interview` feature: per-session and dashboard prompt/state orchestration, persistence, local UI, and cross-process coordination. | [View Map](src/interview/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
 | `src/multiplexer/` | Terminal multiplexer abstraction layer with backend selection, session mirroring, polling fallback, and shutdown lifecycle orchestration. | [View Map](src/multiplexer/codemap.md) |
@@ -74,8 +73,8 @@ This codemap intentionally covers the plugin repository itself and excludes the
    - Hooks can transform prompts/messages, normalize system message arrays, repair tool failures, or intercept runtime commands before/after execution.
 
 3. **Delegated execution**
-   - OpenCode child sessions are created by delegation/council flows and tracked by plugin utilities.
-   - `src/hooks/task-session-manager/` remembers reusable child sessions and injects short aliases into the orchestrator prompt.
+   - Native OpenCode background tasks are parsed from `task` output and injected completion messages and tracked in the shared background job board.
+   - `src/hooks/task-session-manager/` updates job-board state, resolves short aliases, and injects background/reusable job context into the orchestrator prompt.
    - `src/multiplexer/` optionally mirrors those sessions into tmux/zellij panes.
    - Results flow back into the parent session through notifications/output polling.
 
@@ -92,7 +91,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 - Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/` for depth tracking, result extraction, task output parsing, and alias state.
 - `src/tools/council.ts` delegates into `src/council/`.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
-- `src/hooks/task-session-manager/` depends on `src/utils/session-manager.ts` and `src/utils/task.ts` to support child-session reuse.
+- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.
 

+ 4549 - 0
companion/Cargo.lock

@@ -0,0 +1,4549 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "accesskit"
+version = "0.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45"
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5393c75d4666f580f4cac0a968bc97c36076bb536a129f28210dac54ee127ed"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "atspi-common",
+ "serde",
+ "thiserror 1.0.69",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a12dc159d52233c43d9fe5415969433cbdd52c3d6e0df51bda7d447427b9986"
+dependencies = [
+ "accesskit",
+ "immutable-chunkmap",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.17.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfc6c1ecd82053d127961ad80a8beaa6004fb851a3a5b96506d7a6bd462403f6"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "once_cell",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be7f5cf6165be10a54b2655fa2e0e12b2509f38ed6fc43e11c31fdb7ee6230bb"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "974e96c347384d9133427167fb8a58c340cb0496988dacceebdc1ed27071023b"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "paste",
+ "static_assertions",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aea3522719f1c44564d03e9469a8e2f3a98b3a8a880bd66d0789c6b9c4a669dd"
+dependencies = [
+ "accesskit",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.13.0",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys 0.6.0+11769913",
+ "num_enum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.4",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.4",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atspi"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf"
+dependencies = [
+ "atspi-common",
+ "atspi-connection",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-connection"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+ "futures-lite",
+ "zbus",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+ "zvariant",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bit-set"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.13.0",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
+dependencies = [
+ "bitflags 2.13.0",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop 0.13.0",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop 0.14.4",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "cgl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "com"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6"
+dependencies = [
+ "com_macros",
+]
+
+[[package]]
+name = "com_macros"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5"
+dependencies = [
+ "com_macros_support",
+ "proc-macro2",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "com_macros_support"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "ecolor"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "eframe"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "egui-wgpu",
+ "egui-winit",
+ "egui_glow",
+ "glow 0.14.2",
+ "glutin",
+ "glutin-winit",
+ "image",
+ "js-sys",
+ "log",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "web-time",
+ "winapi",
+ "windows-sys 0.52.0",
+ "winit",
+]
+
+[[package]]
+name = "egui"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974"
+dependencies = [
+ "accesskit",
+ "ahash",
+ "emath",
+ "epaint",
+ "log",
+ "nohash-hasher",
+]
+
+[[package]]
+name = "egui-wgpu"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "epaint",
+ "log",
+ "thiserror 1.0.69",
+ "type-map",
+ "web-time",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "egui-winit"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6"
+dependencies = [
+ "accesskit_winit",
+ "ahash",
+ "arboard",
+ "egui",
+ "log",
+ "raw-window-handle",
+ "smithay-clipboard",
+ "web-time",
+ "webbrowser",
+ "winit",
+]
+
+[[package]]
+name = "egui_extras"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1"
+dependencies = [
+ "ahash",
+ "egui",
+ "enum-map",
+ "image",
+ "log",
+ "mime_guess2",
+]
+
+[[package]]
+name = "egui_glow"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "egui",
+ "glow 0.14.2",
+ "log",
+ "memoffset",
+ "wasm-bindgen",
+ "web-sys",
+ "winit",
+]
+
+[[package]]
+name = "emath"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enum-map"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+dependencies = [
+ "enum-map-derive",
+ "serde",
+]
+
+[[package]]
+name = "enum-map-derive"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "epaint"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f"
+dependencies = [
+ "ab_glyph",
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gif"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glow"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glow"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg_aliases 0.2.1",
+ "cgl",
+ "dispatch2",
+ "glutin_egl_sys",
+ "glutin_glx_sys",
+ "glutin_wgl_sys",
+ "libloading",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "raw-window-handle",
+ "wayland-sys",
+ "windows-sys 0.52.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin-winit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
+dependencies = [
+ "cfg_aliases 0.2.1",
+ "glutin",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "glutin_egl_sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
+dependencies = [
+ "gl_generator",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "glutin_glx_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
+dependencies = [
+ "gl_generator",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+dependencies = [
+ "bitflags 2.13.0",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7"
+dependencies = [
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+ "winapi",
+ "windows 0.52.0",
+]
+
+[[package]]
+name = "gpu-descriptor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
+dependencies = [
+ "bitflags 2.13.0",
+ "gpu-descriptor-types",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "gpu-descriptor-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hassle-rs"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890"
+dependencies = [
+ "bitflags 2.13.0",
+ "com",
+ "libc",
+ "libloading",
+ "thiserror 1.0.69",
+ "widestring",
+ "winapi",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hexf-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "color_quant",
+ "gif",
+ "moxcms",
+ "num-traits",
+ "png",
+]
+
+[[package]]
+name = "immutable-chunkmap"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "bitflags 2.13.0",
+ "libc",
+ "plain",
+ "redox_syscall 0.8.1",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "metal"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21"
+dependencies = [
+ "bitflags 2.13.0",
+ "block",
+ "core-graphics-types",
+ "foreign-types",
+ "log",
+ "objc",
+ "paste",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess2"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
+dependencies = [
+ "mime",
+ "phf",
+ "phf_shared",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "naga"
+version = "22.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.13.0",
+ "cfg_aliases 0.1.1",
+ "codespan-reporting",
+ "hexf-parse",
+ "indexmap",
+ "log",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "termcolor",
+ "thiserror 1.0.69",
+ "unicode-xid",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.0",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys 0.6.0+11769913",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases 0.2.1",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "oh-my-opencode-slim-companion"
+version = "0.1.0"
+dependencies = [
+ "dirs",
+ "eframe",
+ "egui",
+ "egui_extras",
+ "libc",
+ "serde",
+ "serde_json",
+ "winit",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "orbclient"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "unicase",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+ "unicase",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
+[[package]]
+name = "pxfm"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
+
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.13.0",
+ "calloop 0.13.0",
+ "calloop-wayland-source 0.3.0",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.13.0",
+ "calloop 0.14.4",
+ "calloop-wayland-source 0.4.1",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 1.1.4",
+ "thiserror 2.0.18",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit 0.20.0",
+ "wayland-backend",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "type-map"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
+dependencies = [
+ "rustc-hash 2.1.2",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.13.0",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.4",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.13.0",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.13.0",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.4",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
+dependencies = [
+ "bitflags 2.13.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.13.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.13.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.13.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.13.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.39.4",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
+dependencies = [
+ "core-foundation 0.10.1",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+ "url",
+ "web-sys",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "wgpu"
+version = "22.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433"
+dependencies = [
+ "arrayvec",
+ "cfg_aliases 0.1.1",
+ "document-features",
+ "js-sys",
+ "log",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "22.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a"
+dependencies = [
+ "arrayvec",
+ "bit-vec",
+ "bitflags 2.13.0",
+ "cfg_aliases 0.1.1",
+ "document-features",
+ "indexmap",
+ "log",
+ "naga",
+ "once_cell",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 1.0.69",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "22.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bitflags 2.13.0",
+ "cfg_aliases 0.1.1",
+ "core-graphics-types",
+ "glow 0.13.1",
+ "glutin_wgl_sys",
+ "gpu-alloc",
+ "gpu-allocator",
+ "gpu-descriptor",
+ "hassle-rs",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "metal",
+ "naga",
+ "ndk-sys 0.5.0+25.2.9519653",
+ "objc",
+ "once_cell",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "renderdoc-sys",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "web-sys",
+ "wgpu-types",
+ "winapi",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "22.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d"
+dependencies = [
+ "bitflags 2.13.0",
+ "js-sys",
+ "web-sys",
+]
+
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
+dependencies = [
+ "windows-core 0.52.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-result",
+ "windows-strings",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.13.0",
+ "block2",
+ "bytemuck",
+ "calloop 0.13.0",
+ "cfg_aliases 0.2.1",
+ "concurrent-queue",
+ "core-foundation 0.9.4",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "sctk-adwaita",
+ "smithay-client-toolkit 0.19.2",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.13.0",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.4",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xcursor"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
+
+[[package]]
+name = "xdg-home"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "xkbcommon-dl"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
+dependencies = [
+ "bitflags 2.13.0",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-fs",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "hex",
+ "nix",
+ "ordered-stream",
+ "rand",
+ "serde",
+ "serde_repr",
+ "sha1",
+ "static_assertions",
+ "tracing",
+ "uds_windows",
+ "windows-sys 0.52.0",
+ "xdg-home",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d"
+dependencies = [
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus-lockstep-macros"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "4.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
+dependencies = [
+ "serde",
+ "static_assertions",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_xml"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233"
+dependencies = [
+ "quick-xml 0.30.0",
+ "serde",
+ "static_assertions",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zvariant_derive",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]

+ 27 - 0
companion/Cargo.toml

@@ -0,0 +1,27 @@
+[package]
+name = "oh-my-opencode-slim-companion"
+version = "0.1.0"
+edition = "2021"
+description = "Desktop companion for oh-my-opencode-slim — shows active agent GIFs per session"
+
+[[bin]]
+name = "oh-my-opencode-slim-companion"
+path = "src/main.rs"
+
+[dependencies]
+eframe = { version = "0.29", default-features = true }
+egui = "0.29"
+egui_extras = { version = "0.29", features = ["image", "gif"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+dirs = "5"
+winit = "0.30"
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+[profile.release]
+opt-level = 3
+lto = true
+codegen-units = 1
+strip = true

BIN
companion/gifs/council.gif


BIN
companion/gifs/designer.gif


BIN
companion/gifs/explorer.gif


BIN
companion/gifs/fixer.gif


BIN
companion/gifs/intro.gif


BIN
companion/gifs/librarian.gif


BIN
companion/gifs/oracle.gif


BIN
companion/gifs/orchestrator.gif


BIN
companion/gifs/question.gif


+ 498 - 0
companion/src/app.rs

@@ -0,0 +1,498 @@
+use std::collections::HashSet;
+use std::sync::mpsc::Receiver;
+use std::time::Duration;
+
+use eframe::egui;
+
+use crate::gifs::Gifs;
+use crate::screen::primary_size;
+use crate::state::{read_state, start_watcher, SessionInfo};
+
+const DEFAULT_SIZE: f32 = 120.0;
+const GAP: f32 = 10.0;
+
+const SIZE_PRESETS: &[(&str, f32)] = &[
+    ("S  ·  80px", 80.0),
+    ("M  ·  120px", 120.0),
+    ("L  ·  160px", 160.0),
+    ("XL  ·  200px", 200.0),
+];
+
+const SESSIONS_KEY: &str = "companion_sessions";
+const SIZE_KEY: &str = "companion_size";
+const MENU_OPEN_KEY: &str = "companion_menu_open";
+const MENU_POS_KEY: &str = "companion_menu_pos";
+
+// Per session: (id, cwd, agents, status)
+type SessionSnapshot = Vec<(String, String, Vec<String>, String)>;
+
+/// Grid columns for N agents.
+fn grid_cols(n: usize) -> usize {
+    match n {
+        0 | 1 => 1,
+        2 | 3 | 4 => 2,
+        _ => 3,
+    }
+}
+
+/// Returns (cols, rows) for N agents.
+fn grid_dims(n: usize) -> (usize, usize) {
+    let n = n.max(1);
+    let cols = grid_cols(n);
+    let rows = (n + cols - 1) / cols;
+    (cols, rows)
+}
+
+/// Cell rects for each agent, with orphan cells centered in the last row.
+fn cell_rects(agents: usize, cols: usize, rows: usize, cell: f32) -> Vec<egui::Rect> {
+    let mut rects = Vec::with_capacity(agents);
+    let full_rows = agents / cols;
+    let remainder = agents % cols;
+
+    for row in 0..full_rows {
+        for col in 0..cols {
+            rects.push(egui::Rect::from_min_size(
+                egui::pos2(col as f32 * cell, row as f32 * cell),
+                egui::vec2(cell, cell),
+            ));
+        }
+    }
+
+    if remainder > 0 {
+        let x_offset = (cols - remainder) as f32 * cell / 2.0;
+        for col in 0..remainder {
+            rects.push(egui::Rect::from_min_size(
+                egui::pos2(x_offset + col as f32 * cell, full_rows as f32 * cell),
+                egui::vec2(cell, cell),
+            ));
+        }
+    }
+
+    let _ = rows; // used by caller for window sizing
+    rects
+}
+
+pub struct CompanionApp {
+    state_path: std::path::PathBuf,
+    sessions: Vec<SessionInfo>,
+    gifs: Gifs,
+    rx: Receiver<()>,
+    registered: bool,
+    positioned: HashSet<String>,
+    size: f32,
+    screen: [f32; 2],
+    position: String,
+    has_modern_config: bool,
+}
+
+impl CompanionApp {
+    pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
+        let state_path = crate::state::state_file_path();
+        let state = read_state(&state_path);
+        let sessions = state.sessions;
+
+        let mut initial_size = DEFAULT_SIZE;
+        let mut position = "bottom-right".to_string();
+        let has_modern_config = state.config.is_some();
+        if let Some(ref cfg) = state.config {
+            initial_size = match cfg.size.as_str() {
+                "small" => 80.0,
+                "medium" => 120.0,
+                "large" => 160.0,
+                _ => 120.0,
+            };
+            position = cfg.position.clone();
+        }
+
+        let rx = start_watcher(state_path.clone());
+
+        Self {
+            state_path,
+            sessions,
+            gifs: Gifs::new(),
+            rx,
+            registered: false,
+            positioned: HashSet::new(),
+            size: initial_size,
+            screen: primary_size(),
+            position,
+            has_modern_config,
+        }
+    }
+
+    fn poll(&mut self) {
+        if self.rx.try_recv().is_ok() {
+            while self.rx.try_recv().is_ok() {}
+            let state = read_state(&self.state_path);
+            self.sessions = state.sessions;
+            self.has_modern_config = state.config.is_some();
+            if let Some(ref cfg) = state.config {
+                self.position = cfg.position.clone();
+            } else {
+                self.position = "bottom-right".to_string();
+            }
+        }
+
+        // Liveness check runs every tick, not just on file changes: when an
+        // opencode process is killed it never rewrites the state file, so
+        // waiting on the watcher would leave its window open forever.
+        let has_modern = self.has_modern_config;
+        self.sessions
+            .retain(|s| s.pid.map(is_pid_alive).unwrap_or(!has_modern));
+        let live: HashSet<_> = self.sessions.iter().map(|s| s.session_id.clone()).collect();
+        self.positioned.retain(|id| live.contains(id));
+    }
+
+    fn initial_pos(&self, index: usize, win_w: f32, win_h: f32) -> [f32; 2] {
+        let slot = index as f32;
+        let (x, y) = match self.position.as_str() {
+            "bottom-left" => {
+                let x = GAP + (win_w + GAP) * slot;
+                let y = self.screen[1] - win_h - GAP;
+                (x, y)
+            }
+            "top-right" => {
+                let x = self.screen[0] - (win_w + GAP) * (slot + 1.0);
+                let y = GAP;
+                (x, y)
+            }
+            "top-left" => {
+                let x = GAP + (win_w + GAP) * slot;
+                let y = GAP;
+                (x, y)
+            }
+            _ => { // "bottom-right"
+                let x = self.screen[0] - (win_w + GAP) * (slot + 1.0);
+                let y = self.screen[1] - win_h - GAP;
+                (x, y)
+            }
+        };
+        let x_max = (self.screen[0] - win_w - GAP).max(GAP);
+        let y_max = (self.screen[1] - win_h - GAP).max(GAP);
+        [x.clamp(GAP, x_max), y.clamp(GAP, y_max)]
+    }
+}
+
+impl eframe::App for CompanionApp {
+    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+        self.poll();
+
+        let quit = ctx.data(|d| {
+            d.get_temp::<bool>(egui::Id::new("companion_quit"))
+                .unwrap_or(false)
+        });
+        if quit || (self.registered && self.sessions.is_empty()) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+            return;
+        }
+
+        if !self.registered {
+            self.gifs.register(ctx);
+            ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
+            self.registered = true;
+        }
+
+        self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
+
+        // Store registered GIF URIs (with unknown agents falling back to the
+        // orchestrator GIF) so cells never point at an unregistered image.
+        let snapshot: SessionSnapshot = self
+            .sessions
+            .iter()
+            .map(|s| {
+                let uris: Vec<String> = if s.active_agents.is_empty() {
+                    vec![self.gifs.uri("intro")]
+                } else {
+                    s.active_agents.iter().map(|a| self.gifs.uri(a)).collect()
+                };
+                (s.session_id.clone(), s.cwd.clone(), uris, s.status.clone())
+            })
+            .collect();
+        ctx.data_mut(|d| d.insert_temp(egui::Id::new(SESSIONS_KEY), snapshot));
+
+        egui::CentralPanel::default()
+            .frame(egui::Frame::none().fill(egui::Color32::TRANSPARENT))
+            .show(ctx, |_| {});
+
+        for (i, session) in self.sessions.iter().enumerate() {
+            let vid = egui::ViewportId::from_hash_of(&session.session_id);
+            let sid = session.session_id.clone();
+            let size = self.size;
+
+            let agents: Vec<String> = if session.active_agents.is_empty() {
+                vec!["intro".to_string()]
+            } else {
+                session.active_agents.clone()
+            };
+            let n = agents.len().max(1);
+            let (cols, rows) = grid_dims(n);
+            let win_w = size * cols as f32;
+            let win_h = size * rows as f32;
+
+            let is_first = !self.positioned.contains(&session.session_id);
+            if is_first {
+                self.positioned.insert(session.session_id.clone());
+            }
+
+            let mut builder = egui::ViewportBuilder::default()
+                .with_title(&session.project_name())
+                .with_decorations(false)
+                .with_transparent(true)
+                .with_always_on_top()
+                .with_active(false)
+                .with_inner_size([win_w, win_h]);
+
+            if is_first {
+                builder = builder.with_position(self.initial_pos(i, win_w, win_h));
+            }
+
+            ctx.show_viewport_deferred(vid, builder, move |ctx, _class| {
+                render_session_window(ctx, &sid, size);
+            });
+        }
+
+        // Size-picker popup
+        let menu_open: bool =
+            ctx.data(|d| d.get_temp(egui::Id::new(MENU_OPEN_KEY)).unwrap_or(false));
+        if menu_open {
+            let pos: [f32; 2] =
+                ctx.data(|d| d.get_temp(egui::Id::new(MENU_POS_KEY)).unwrap_or([200.0, 200.0]));
+
+            ctx.show_viewport_deferred(
+                egui::ViewportId::from_hash_of("size_picker"),
+                egui::ViewportBuilder::default()
+                    .with_title("size")
+                    .with_decorations(false)
+                    .with_always_on_top()
+                    .with_inner_size([160.0, 190.0])
+                    .with_position(pos),
+                |ctx, _| render_size_picker(ctx),
+            );
+        }
+
+        ctx.request_repaint_after(Duration::from_millis(150));
+    }
+}
+
+fn render_session_window(ctx: &egui::Context, session_id: &str, _size: f32) {
+    let sessions: SessionSnapshot =
+        ctx.data(|d| d.get_temp(egui::Id::new(SESSIONS_KEY)).unwrap_or_default());
+    let (_, cwd, agents, _) = sessions
+        .iter()
+        .find(|(id, _, _, _)| id == session_id)
+        .cloned()
+        .unwrap_or_default();
+
+    // Snapshot holds ready-to-use GIF URIs.
+    let uris: Vec<String> = if agents.is_empty() {
+        vec!["bytes://intro.gif".to_string()]
+    } else {
+        agents
+    };
+
+    let project = std::path::Path::new(&cwd)
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("unknown")
+        .to_string();
+
+    let current_size: f32 =
+        ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
+
+    let n = uris.len().max(1);
+    let (cols, rows) = grid_dims(n);
+    let win_w = current_size * cols as f32;
+    let win_h = current_size * rows as f32;
+
+    // Resize viewport when size or agent count changes.
+    let layout_key = egui::Id::new(session_id).with("layout");
+    let applied: (u32, u32, u32) = ctx.data(|d| d.get_temp(layout_key).unwrap_or((0, 0, 0)));
+    let current = (current_size as u32, cols as u32, rows as u32);
+    if applied != current {
+        ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
+        ctx.data_mut(|d| d.insert_temp(layout_key, current));
+    }
+
+    // Right-click → open size picker
+    if ctx.input(|i| i.pointer.secondary_released()) {
+        let win_origin = ctx
+            .input(|i| i.viewport().outer_rect.map(|r| r.min))
+            .unwrap_or_default();
+        let cursor = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
+        ctx.data_mut(|d| {
+            d.insert_temp(
+                egui::Id::new(MENU_POS_KEY),
+                [win_origin.x + cursor.x, win_origin.y + cursor.y],
+            );
+            d.insert_temp(egui::Id::new(MENU_OPEN_KEY), true);
+        });
+    }
+
+    // Drag
+    if ctx.input(|i| i.pointer.primary_down()) {
+        ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
+    }
+
+    egui::CentralPanel::default()
+        .frame(
+            egui::Frame::none()
+                .fill(egui::Color32::TRANSPARENT)
+                .inner_margin(egui::Margin::ZERO),
+        )
+        .show(ctx, |ui| {
+            ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
+            let rects = cell_rects(n, cols, rows, current_size);
+
+            for (i, uri) in uris.iter().enumerate() {
+                if let Some(&cell) = rects.get(i) {
+                    ui.put(cell, egui::Image::new(uri).fit_to_exact_size(egui::vec2(current_size, current_size)));
+                }
+            }
+
+            // Project label — overlaid on bottom strip of the window
+            let label_h = (current_size * 0.15).clamp(13.0, 30.0);
+            let font_size = (current_size * 0.09).clamp(9.0, 13.0);
+            let full_rect = egui::Rect::from_min_size(
+                egui::Pos2::ZERO,
+                egui::vec2(win_w, win_h),
+            );
+            let strip = egui::Rect::from_min_size(
+                egui::pos2(0.0, win_h - label_h),
+                egui::vec2(win_w, label_h),
+            );
+            ui.painter()
+                .rect_filled(strip, 0.0, egui::Color32::from_black_alpha(185));
+
+            let fid = egui::FontId::proportional(font_size);
+            let label = fit_text(ctx, &project, &fid, win_w - 10.0);
+            ui.painter().text(
+                egui::pos2(full_rect.center().x, strip.center().y),
+                egui::Align2::CENTER_CENTER,
+                &label,
+                fid,
+                egui::Color32::WHITE,
+            );
+        });
+
+    ctx.request_repaint_after(Duration::from_millis(50));
+}
+
+fn render_size_picker(ctx: &egui::Context) {
+    let size: f32 = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
+    let frames_key = egui::Id::new("menu_frames");
+    let frames: u32 = ctx.data(|d| d.get_temp(frames_key).unwrap_or(0));
+    ctx.data_mut(|d| d.insert_temp(frames_key, frames + 1));
+
+    let close = ctx.input(|i| i.key_pressed(egui::Key::Escape))
+        || (frames > 1 && !ctx.input(|i| i.focused));
+
+    if close {
+        ctx.data_mut(|d| {
+            d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
+            d.insert_temp(frames_key, 0u32);
+        });
+        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+        return;
+    }
+
+    egui::CentralPanel::default()
+        .frame(
+            egui::Frame::none()
+                .fill(egui::Color32::from_rgb(28, 28, 28))
+                .inner_margin(egui::Margin::same(8.0)),
+        )
+        .show(ctx, |ui| {
+            ui.label(
+                egui::RichText::new("Window size")
+                    .size(11.0)
+                    .color(egui::Color32::from_rgb(160, 160, 160)),
+            );
+            ui.add_space(4.0);
+
+            for (label, preset) in SIZE_PRESETS {
+                let active = (size - preset).abs() < 0.5;
+                let text = if active {
+                    egui::RichText::new(*label).size(12.0).strong().color(egui::Color32::WHITE)
+                } else {
+                    egui::RichText::new(*label)
+                        .size(12.0)
+                        .color(egui::Color32::from_rgb(200, 200, 200))
+                };
+                if ui
+                    .add_sized([144.0, 20.0], egui::Button::new(text).frame(false))
+                    .clicked()
+                {
+                    ctx.data_mut(|d| {
+                        d.insert_temp(egui::Id::new(SIZE_KEY), *preset);
+                        d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
+                        d.insert_temp(frames_key, 0u32);
+                    });
+                    ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+                }
+            }
+
+            ui.add_space(4.0);
+            ui.separator();
+            ui.add_space(2.0);
+
+            if ui
+                .add_sized(
+                    [144.0, 20.0],
+                    egui::Button::new(
+                        egui::RichText::new("Close companion")
+                            .size(12.0)
+                            .color(egui::Color32::from_rgb(220, 100, 100)),
+                    )
+                    .frame(false),
+                )
+                .clicked()
+            {
+                ctx.data_mut(|d| {
+                    d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
+                    d.insert_temp(frames_key, 0u32);
+                    d.insert_temp(egui::Id::new("companion_quit"), true);
+                });
+                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+            }
+        });
+}
+
+fn fit_text(ctx: &egui::Context, text: &str, font_id: &egui::FontId, max_width: f32) -> String {
+    let measure = |s: &str| -> f32 {
+        ctx.fonts(|f| f.layout_no_wrap(s.to_string(), font_id.clone(), egui::Color32::WHITE))
+            .rect
+            .width()
+    };
+    if measure(text) <= max_width {
+        return text.to_string();
+    }
+    let ellipsis = "…";
+    let budget = (max_width - measure(ellipsis)).max(0.0);
+    let chars: Vec<(usize, char)> = text.char_indices().collect();
+    let mut lo = 0usize;
+    let mut hi = chars.len();
+    while lo < hi {
+        let mid = (lo + hi + 1) / 2;
+        let end = chars[mid - 1].0 + chars[mid - 1].1.len_utf8();
+        if measure(&text[..end]) <= budget {
+            lo = mid;
+        } else {
+            hi = mid - 1;
+        }
+    }
+    if lo == 0 {
+        return ellipsis.to_string();
+    }
+    let end = chars[lo - 1].0 + chars[lo - 1].1.len_utf8();
+    format!("{}{ellipsis}", &text[..end])
+}
+
+#[cfg(unix)]
+fn is_pid_alive(pid: u32) -> bool {
+    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
+}
+
+#[cfg(not(unix))]
+fn is_pid_alive(_pid: u32) -> bool {
+    true
+}

+ 35 - 0
companion/src/gifs.rs

@@ -0,0 +1,35 @@
+use egui::Context;
+use std::collections::HashMap;
+
+pub struct Gifs {
+    map: HashMap<&'static str, &'static [u8]>,
+}
+
+impl Gifs {
+    pub fn new() -> Self {
+        let mut map: HashMap<&'static str, &'static [u8]> = HashMap::new();
+        map.insert("council",      include_bytes!("../gifs/council.gif"));
+        map.insert("councillor",   include_bytes!("../gifs/council.gif"));
+        map.insert("designer",     include_bytes!("../gifs/designer.gif"));
+        map.insert("explorer",     include_bytes!("../gifs/explorer.gif"));
+        map.insert("fixer",        include_bytes!("../gifs/fixer.gif"));
+        map.insert("input",        include_bytes!("../gifs/question.gif"));
+        map.insert("intro",        include_bytes!("../gifs/intro.gif"));
+        map.insert("librarian",    include_bytes!("../gifs/librarian.gif"));
+        map.insert("oracle",       include_bytes!("../gifs/oracle.gif"));
+        map.insert("orchestrator", include_bytes!("../gifs/orchestrator.gif"));
+        Self { map }
+    }
+
+    pub fn register(&self, ctx: &Context) {
+        egui_extras::install_image_loaders(ctx);
+        for (name, bytes) in &self.map {
+            ctx.include_bytes(format!("bytes://{name}.gif"), *bytes);
+        }
+    }
+
+    pub fn uri(&self, agent: &str) -> String {
+        let name = if self.map.contains_key(agent) { agent } else { "orchestrator" };
+        format!("bytes://{name}.gif")
+    }
+}

+ 45 - 0
companion/src/main.rs

@@ -0,0 +1,45 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+mod app;
+mod gifs;
+mod screen;
+mod singleton;
+mod state;
+
+use singleton::acquire;
+
+fn main() -> eframe::Result {
+    // Exit immediately if another instance is already running
+    if !acquire() {
+        return Ok(());
+    }
+
+    let options = eframe::NativeOptions {
+        viewport: egui::ViewportBuilder::default()
+            .with_decorations(false)
+            .with_transparent(true)
+            .with_inner_size([1.0, 1.0])
+            // Offscreen so the "coordinator" window is invisible
+            .with_position([-500.0, -500.0])
+            .with_active(false),
+        // Run as a macOS accessory app: no Dock icon, never steals focus
+        // from the terminal when the windows appear.
+        event_loop_builder: Some(Box::new(|builder| {
+            #[cfg(target_os = "macos")]
+            {
+                use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
+                builder.with_activation_policy(ActivationPolicy::Accessory);
+                builder.with_activate_ignoring_other_apps(false);
+            }
+            #[cfg(not(target_os = "macos"))]
+            let _ = builder;
+        })),
+        ..Default::default()
+    };
+
+    eframe::run_native(
+        "oh-my-opencode-slim-companion",
+        options,
+        Box::new(|cc| Ok(Box::new(app::CompanionApp::new(cc)))),
+    )
+}

+ 20 - 0
companion/src/screen.rs

@@ -0,0 +1,20 @@
+/// Returns the primary screen's logical size.
+pub fn primary_size() -> [f32; 2] {
+    platform_size().unwrap_or([1440.0, 900.0])
+}
+
+#[cfg(target_os = "macos")]
+fn platform_size() -> Option<[f32; 2]> {
+    let out = std::process::Command::new("osascript")
+        .args(["-e", "tell application \"Finder\" to get bounds of window of desktop"])
+        .output()
+        .ok()?;
+    let s = String::from_utf8(out.stdout).ok()?;
+    let ns: Vec<f32> = s.trim().split(", ").filter_map(|p| p.parse().ok()).collect();
+    (ns.len() == 4).then(|| [ns[2], ns[3]])
+}
+
+#[cfg(not(target_os = "macos"))]
+fn platform_size() -> Option<[f32; 2]> {
+    None
+}

+ 46 - 0
companion/src/singleton.rs

@@ -0,0 +1,46 @@
+use std::path::PathBuf;
+
+fn lock_path() -> PathBuf {
+    let base = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .unwrap_or_else(|| {
+            dirs::home_dir()
+                .unwrap_or_else(|| PathBuf::from("."))
+                .join(".local")
+                .join("share")
+        });
+    base.join("opencode")
+        .join("storage")
+        .join("oh-my-opencode-slim")
+        .join("companion.pid")
+}
+
+/// Returns true if this process should continue running.
+/// Returns false if another companion instance is already alive.
+pub fn acquire() -> bool {
+    let path = lock_path();
+
+    if let Ok(content) = std::fs::read_to_string(&path) {
+        if let Ok(pid) = content.trim().parse::<u32>() {
+            if pid != std::process::id() && is_alive(pid) {
+                return false;
+            }
+        }
+    }
+
+    let _ = std::fs::write(&path, std::process::id().to_string());
+    true
+}
+
+#[cfg(unix)]
+fn is_alive(pid: u32) -> bool {
+    // kill -0 checks if the process exists without sending a signal
+    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
+}
+
+#[cfg(not(unix))]
+fn is_alive(_pid: u32) -> bool {
+    false
+}

+ 103 - 0
companion/src/state.rs

@@ -0,0 +1,103 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use std::sync::mpsc::{self, Receiver, Sender};
+use std::time::Duration;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CompanionConfigState {
+    pub enabled: bool,
+    pub position: String,
+    pub size: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct CompanionState {
+    pub version: u32,
+    #[serde(default)]
+    pub sessions: Vec<SessionInfo>,
+    #[serde(default)]
+    pub config: Option<CompanionConfigState>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionInfo {
+    pub session_id: String,
+    pub cwd: String,
+    #[serde(default)]
+    pub active_agents: Vec<String>,
+    #[serde(default)]
+    pub active_agent: Option<String>,
+    #[serde(default)]
+    pub status: String,
+    #[serde(default)]
+    pub pid: Option<u32>,
+}
+
+impl SessionInfo {
+    pub fn agents(&self) -> &[String] {
+        if self.active_agents.is_empty() {
+            &[]
+        } else {
+            &self.active_agents
+        }
+    }
+}
+
+impl SessionInfo {
+    pub fn project_name(&self) -> String {
+        std::path::Path::new(&self.cwd)
+            .file_name()
+            .and_then(|n| n.to_str())
+            .unwrap_or("unknown")
+            .to_string()
+    }
+}
+
+pub fn state_file_path() -> PathBuf {
+    let base = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .unwrap_or_else(|| {
+            dirs::home_dir()
+                .unwrap_or_else(|| PathBuf::from("."))
+                .join(".local")
+                .join("share")
+        });
+    base.join("opencode")
+        .join("storage")
+        .join("oh-my-opencode-slim")
+        .join("companion-state.json")
+}
+
+pub fn read_state(path: &std::path::Path) -> CompanionState {
+    std::fs::read_to_string(path)
+        .ok()
+        .and_then(|s| serde_json::from_str(&s).ok())
+        .unwrap_or_default()
+}
+
+/// Starts a background thread that polls the state file for changes.
+/// Returns a receiver that fires whenever the file content changes.
+pub fn start_watcher(path: PathBuf) -> Receiver<()> {
+    let (tx, rx) = mpsc::channel();
+    std::thread::spawn(move || poll_loop(path, tx));
+    rx
+}
+
+fn poll_loop(path: PathBuf, tx: Sender<()>) {
+    let mut last_mtime: Option<std::time::SystemTime> = None;
+    loop {
+        if let Ok(meta) = std::fs::metadata(&path) {
+            if let Ok(mtime) = meta.modified() {
+                if Some(mtime) != last_mtime {
+                    last_mtime = Some(mtime);
+                    if tx.send(()).is_err() {
+                        return;
+                    }
+                }
+            }
+        }
+        std::thread::sleep(Duration::from_millis(250));
+    }
+}

+ 2 - 8
docs/authors-preset.md

@@ -13,7 +13,7 @@ This is the exact configuration the author runs day-to-day.
     "openai": { "orchestrator": { "model": "openai/gpt-5.5-fast", "skills": [ "*" ], "mcps": [ "*", "!context7"] },
         "oracle": { "model": "openai/gpt-5.5-fast", "variant": "high", "skills": [], "mcps": [] },
         "council": { "model": "openai/gpt-5.5-fast" },
-        "librarian": { "model": "openai/gpt-5.3-codex-spark", "variant": "low", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+        "librarian": { "model": "openai/gpt-5.3-codex-spark", "variant": "low", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
         "explorer": { "model": "openai/gpt-5.3-codex-spark", "variant": "low", "skills": [], "mcps": [] },
         "designer": { "model": "github-copilot/gemini-3.1-pro-preview", "skills": [], "mcps": [] },
         "fixer": { "model": "openai/gpt-5.3-codex-spark", "variant": "low", "skills": [], "mcps": [] }
@@ -32,12 +32,6 @@ This is the exact configuration the author runs day-to-day.
         "gamma": { "model": "fireworks-ai/accounts/fireworks/routers/kimi-k2p5-turbo" }
       }
     }
-  },
-  "todoContinuation": {
-    "maxContinuations": 50,
-    "cooldownMs": 3000,
-    "autoEnable": false,
-    "autoEnableThreshold": 4
-  } 
+  }
 }
 ```

+ 150 - 0
docs/background-job-board-lessons.md

@@ -0,0 +1,150 @@
+# Background Job Board Lessons
+
+This note captures what we learned while hardening background task tracking,
+cancellation, session reuse, and tmux pane cleanup.
+
+## What the Board Is For
+
+The Background Job Board is a compact prompt reminder for orchestrator-managed
+`task` delegations. It lets the orchestrator see:
+
+- which specialist tasks are still running,
+- which terminal tasks still need reconciliation,
+- which completed sessions can be resumed for related follow-up work,
+- what files a remembered specialist session already read.
+
+The board is not an authority over OpenCode runtime state. It is a coordination
+cache. Live OpenCode events, task output, `/session/status`, and session
+delete/abort behavior can disagree, so cancellation and reuse must be defensive.
+
+## Correct Prompt Contract
+
+The board prompt should say:
+
+```text
+Do not poll running jobs. Reconcile terminal jobs before final response.
+Reuse only completed sessions for the same specialist/context; never reuse
+cancelled or errored sessions.
+```
+
+The older wording was unsafe:
+
+```text
+Reuse any non-running session for the same specialist/context.
+```
+
+That allowed cancelled/error sessions to appear as reusable, for example:
+
+```text
+#### Reusable Sessions
+- ora-1 / ses_... / oracle / cancelled, reconciled
+```
+
+Cancelled and errored sessions are terminal, but they are not good continuation
+targets. They may contain partial state, aborted streams, or a failed prompt loop.
+Reusable sessions should be **completed and reconciled only**.
+
+## Cancellation Is Not Just Board State
+
+Marking a job `cancelled` is not enough. The child OpenCode session can continue
+running after the board says cancelled if we only trust plugin-local state.
+
+The reliable cancellation flow is:
+
+1. Resolve the job by parent-scoped alias or raw `ses_...` ID.
+2. Call `session.abort` to interrupt the active prompt runner.
+3. If the SDK supports it, call `session.delete` immediately after abort.
+4. Verify that the session is no longer busy.
+5. Only then mark the board job as cancelled and unreconciled.
+
+`session.abort` alone can produce a temporary idle event while the background
+task loop later starts another prompt step. `session.delete` is the stronger
+operation for cancellation because it removes the child session and emits
+`session.deleted`.
+
+## `/session/status` Can Be Misleading
+
+During testing, `/session/status` sometimes returned a map that did not include
+the child session, which looks like idle/missing, while the event stream later
+showed the same session was still busy.
+
+Implication:
+
+- Do not treat one missing status-map entry as proof of termination.
+- Use live `session.status` events to update `lastLiveBusyAt` in the job board.
+- If any busy event is observed after cancellation begins, the session is not
+  safely cancelled.
+- Prefer `session.delete` over a long idle polling window for explicit user
+  cancellation.
+
+## Transient Task Errors Are Ambiguous
+
+OpenCode can return:
+
+```text
+Task is not running in this process and has no final output.
+```
+
+That does not always mean the child task is done. It can mean the task is not
+known to the current process while the session is still live elsewhere.
+
+Treat this as a transient process-local error when there is evidence the session
+is still running. Do not terminalize the job or launch a duplicate specialist
+just because this error appeared.
+
+## Pane Cleanup Has Cross-Instance Races
+
+The multiplexer session manager keeps shared in-memory state for tracked panes.
+Multiple plugin instances can observe the same OpenCode event stream. Owner
+gating is useful for ordinary idle/missing cleanup, but it caused a race for
+deleted sessions:
+
+1. Child pane was spawned by instance A.
+2. `session.delete` emitted `session.deleted`.
+3. Instance B saw the delete event first.
+4. Instance B skipped close because it was not the owner.
+5. The pane remained visible until instance A later received another idle event.
+
+For `session.deleted`, any instance that sees the event should close the tracked
+pane by shared pane ID. Keep owner gating for normal idle/missing cleanup, but
+not for deletion.
+
+## Reconciliation Rules
+
+- Running jobs: wait for hook-driven completion.
+- Terminal unreconciled jobs: mention/reconcile before final response.
+- Completed + reconciled jobs: may be reusable if the same specialist/context
+  matches.
+- Cancelled/error jobs: hide from reusable sessions after reconciliation.
+- Cancelled writer tasks: inspect partial file changes before replacing the lane.
+
+## Useful Debug Logs
+
+When debugging cancellations, search plugin logs under
+`~/.local/share/opencode` for:
+
+- `[cancel-task] request received`
+- `[cancel-task] abort call returned`
+- `[cancel-task] deleting session after unstable abort`
+- `[cancel-task] session delete returned`
+- `[cancel-task] delete verification status`
+- `[task-session-manager] busy observed after cancel request`
+- `[multiplexer-session-manager] session deleted, closing pane`
+- `[multiplexer-session-manager] closing deleted pane as non-owner`
+- `[tmux] closePane`
+
+The important question is not whether `cancel_task` returned a cancelled-looking
+message. The important question is whether the logs show the child session was
+deleted and the pane close path ran.
+
+## Practical Takeaways
+
+- Board state is advisory; OpenCode session lifecycle is authoritative.
+- `abort` interrupts work; `delete` terminates the child session lifecycle.
+- Hook-driven completion is the normal path; explicit lifecycle checks are only
+  supporting evidence when diagnosing cancellation or pane cleanup.
+- Event-backed state catches runtime behavior that status maps can miss.
+- Prompt wording matters: “non-running” was too broad; “completed only” is the
+  safer reuse contract.
+- Pane cleanup must handle multi-instance event races, especially on
+  `session.deleted`.

+ 189 - 0
docs/companion.md

@@ -0,0 +1,189 @@
+# Desktop Companion App
+
+The desktop companion app provides a floating status overlay showing running and active agents.
+
+## How to Enable in Configuration
+
+You can enable the companion by adding a `companion` section to your setting configuration file (`~/.config/opencode/oh-my-opencode-slim.json` or `.opencode/oh-my-opencode-slim.json`):
+
+```jsonc
+{
+  "companion": {
+    "enabled": true,
+    "position": "bottom-right",
+    "size": "medium"
+  }
+}
+```
+
+### Supported Position & Size Values
+
+- **`companion.position`**:
+  - `bottom-right` (default)
+  - `bottom-left`
+  - `top-right`
+  - `top-left`
+
+- **`companion.size`**:
+  - `small` (80px)
+  - `medium` (120px) (default)
+  - `large` (160px)
+
+---
+
+## Installer Flag
+
+When running the installer, pass `--companion=yes` to download the native
+binary and generate the enabled config block:
+
+```bash
+bunx oh-my-opencode-slim install --companion=yes
+```
+
+Pass `--companion=no` or omit the flag to skip the native binary and omit the
+config block.
+
+---
+
+## Expected Binary Install Path
+
+The runtime looks for the companion binary at:
+
+```text
+$XDG_DATA_HOME/opencode/storage/oh-my-opencode-slim/bin/oh-my-opencode-slim-companion
+```
+
+If `XDG_DATA_HOME` is unset, this resolves to:
+
+```text
+~/.local/share/opencode/storage/oh-my-opencode-slim/bin/oh-my-opencode-slim-companion
+```
+
+If the binary is not located in this directory, the plugin runtime will not start the companion window.
+
+---
+
+## V2 Release Strategy
+
+For the desktop companion app, the release workflow follows the V2 distribution
+plan:
+
+1. **GitHub Release Assets**: companion binaries are uploaded to the
+   `companion-v0.1.0` GitHub release.
+2. **Separate Companion Versioning**: the companion uses its own version so the
+   plugin can ship beta updates without rebuilding native binaries every time.
+3. **Checksums**: the installer downloads `SHA256SUMS` and verifies the selected
+   archive before installing it.
+4. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
+   target.
+5. **No R2 Required**: GitHub Releases are the source of truth. R2 can be added
+   later as a mirror if download volume becomes a problem.
+
+Current release assets are named:
+
+```text
+oh-my-opencode-slim-companion-v0.1.0-aarch64-apple-darwin.tar.gz
+oh-my-opencode-slim-companion-v0.1.0-x86_64-apple-darwin.tar.gz
+oh-my-opencode-slim-companion-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
+oh-my-opencode-slim-companion-v0.1.0-aarch64-unknown-linux-gnu.tar.gz
+oh-my-opencode-slim-companion-v0.1.0-x86_64-pc-windows-msvc.zip
+SHA256SUMS
+```
+
+Supported installer targets:
+
+- macOS arm64: `aarch64-apple-darwin`
+- macOS x64: `x86_64-apple-darwin`
+- Linux x64: `x86_64-unknown-linux-gnu`
+- Linux arm64: `aarch64-unknown-linux-gnu`
+- Windows x64: `x86_64-pc-windows-msvc`
+
+For release bootstrapping only, checksum verification can be bypassed with
+`SKIP_COMPANION_CHECKSUM=true`, but normal user installs should keep checksum
+verification enabled.
+
+## Maintainer Release Process
+
+Companion binaries are **not** built on every plugin beta. The workflow is
+manual-only so GitHub runner usage stays under maintainer control.
+
+Run a companion release only when the Rust companion changes or when the state
+protocol expected by the plugin changes.
+
+### 1. Choose the companion version
+
+The first companion release is:
+
+```text
+0.1.0
+```
+
+The matching GitHub release tag is:
+
+```text
+companion-v0.1.0
+```
+
+The installer currently downloads from that tag.
+
+### 2. Trigger selected target builds manually
+
+Build only the targets you want to pay for. Start with your current platform if
+you are testing the release path:
+
+```bash
+gh workflow run companion-release.yml \
+  -f version=0.1.0 \
+  -f targets=macos-arm64
+```
+
+Build multiple targets by passing a comma-separated list:
+
+```bash
+gh workflow run companion-release.yml \
+  -f version=0.1.0 \
+  -f targets=macos-arm64,macos-x64,linux-x64,windows-x64
+```
+
+Supported workflow target names:
+
+```text
+macos-arm64
+macos-x64
+linux-x64
+windows-x64
+```
+
+The workflow creates or updates the `companion-v<version>` release and uploads
+the selected archives plus `SHA256SUMS`.
+
+### 3. Verify release assets
+
+After the workflow finishes:
+
+```bash
+gh release view companion-v0.1.0
+gh release download companion-v0.1.0 --pattern SHA256SUMS --output -
+```
+
+Confirm the release contains the archive names expected by the installer for the
+targets you built.
+
+### 4. Install with the plugin installer
+
+Once the release assets exist, users can run:
+
+```bash
+bunx oh-my-opencode-slim@beta install --companion=yes
+```
+
+The installer detects the user's OS/architecture, downloads the matching archive
+from `companion-v0.1.0`, verifies it against `SHA256SUMS`, installs it to the
+runtime binary path, and writes the companion config block.
+
+### Cost controls
+
+- The workflow uses `workflow_dispatch` only. It never runs on push, PR, or tag.
+- Build a single target while testing.
+- Reuse one companion release across many plugin betas.
+- Add more targets only when you are ready to support those users.

+ 28 - 13
docs/configuration.md

@@ -123,9 +123,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |
 | `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` |
-| `sessionManager.maxSessionsPerAgent` | integer | `2` | Maximum remembered resumable child sessions per specialist type in the current orchestrator session (1–10). See [Session Management](session-management.md) |
-| `sessionManager.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in resumable-session context (0–1000) |
-| `sessionManager.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per remembered child session (0–50) |
+| `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10). See [Session Management](session-management.md) |
+| `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) |
+| `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `false` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -140,16 +140,14 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `council.timeout` | number | `180000` | Per-councillor timeout (ms) |
 | `council.councillor_execution_mode` | string | `"parallel"` | Run councillors in `parallel` or `serial`; use `serial` for single-model setups |
 | `council.councillor_retries` | number | `3` | Max retries per councillor on empty provider response (0–5) |
-| `subtask.timeoutMs` | integer | `300000` | Subtask worker timeout in ms. `0` disables the timeout. Max `86400000` (24h) |
-| `todoContinuation.maxContinuations` | integer | `5` | Max consecutive auto-continuations before stopping (1–50) |
-| `todoContinuation.cooldownMs` | integer | `3000` | Delay in ms before auto-continuing — gives user time to abort (0–30000) |
-| `todoContinuation.autoEnable` | boolean | `false` | Automatically enable auto-continue when session has enough todos |
-| `todoContinuation.autoEnableThreshold` | integer | `4` | Number of todos that triggers auto-enable (only used when `autoEnable` is true, 1–50) |
 | `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) |
 | `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) |
 | `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI |
 | `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
 | `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |
+| `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion |
+| `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` |
+| `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) |
 
 ### Council configuration note
 
@@ -157,7 +155,8 @@ Presets can also be switched at runtime without restarting using the `/preset` c
   `presets.<name>.council.model`.
 - The **councillor models** are configured separately under
   `council.presets.<name>.<councillor>.model`.
-- Deprecated `council.master*` fields should not be used in new configs.
+- Deprecated `council.master*` fields are legacy compatibility aliases only;
+  do not use them in new configs.
 
 ### Manual Update Mode
 
@@ -247,11 +246,11 @@ To override a GIF, use either a bundled filename or an absolute path:
 }
 ```
 
-### Session Management
+### Background Job Management
 
-Session management is enabled by default and does not need to be present in the
-starter config. Add `sessionManager` only if you want to tune how many resumable
-child-agent sessions are remembered or how much read context is shown. See
+Background job management is enabled by default and does not need to be present
+in the starter config. Add `backgroundJobs` only if you want to tune how many
+completed/reconciled child-agent sessions are reusable or how much read context is shown. See
 [Session Management](session-management.md) for the concept, defaults, and
 examples.
 
@@ -306,3 +305,19 @@ Notes:
 - Custom agent names must be safe identifiers such as `janitor` or `security-reviewer`
 - Custom agents without a `model` are skipped with a warning
 - Disabled custom agents are not registered or injected into the orchestrator prompt
+
+### Desktop Companion App
+
+The desktop companion app provides a visual status overlay showing running and active agents. For quick installation instructions, binary paths, config defaults, and release information, see the full **[Desktop Companion Guide](companion.md)**.
+
+Once installed, configure it in your `oh-my-opencode-slim` settings:
+
+```jsonc
+{
+  "companion": {
+    "enabled": true,
+    "position": "bottom-right", // optional: bottom-right, bottom-left, top-right, top-left
+    "size": "medium"            // optional: small, medium, large
+  }
+}
+```

+ 82 - 4
docs/installation.md

@@ -24,7 +24,7 @@ bunx oh-my-opencode-slim@latest install
 Or use non-interactive mode:
 
 ```bash
-bunx oh-my-opencode-slim@latest install --no-tui --skills=yes
+bunx oh-my-opencode-slim@latest install --no-tui --skills=yes --background-subagents=yes
 ```
 
 ### Configuration Options
@@ -38,6 +38,50 @@ The installer supports the following options:
 | `--no-tui` | Non-interactive mode |
 | `--dry-run` | Simulate install without writing files |
 | `--reset` | Force overwrite of existing configuration |
+| `--background-subagents=ask\|yes\|no` | Configure `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` in your shell startup file (`ask` by default only in an interactive TTY; otherwise `no`) |
+| `--background-subagents-target=<path>` | Write the background-subagents export to a specific shell/profile file |
+
+### Background Subagents Environment Setup
+
+Background orchestration is the default workflow. It depends on OpenCode's native
+background subagents, which are enabled by this environment variable:
+
+```bash
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
+```
+
+The installer can add that export to your shell startup file. Use one of:
+
+```bash
+# Ask before editing a shell startup file (default in interactive TTY only)
+bunx oh-my-opencode-slim@latest install --background-subagents=ask
+
+# Always configure the export when possible
+bunx oh-my-opencode-slim@latest install --background-subagents=yes
+
+# Do not modify shell startup files
+bunx oh-my-opencode-slim@latest install --background-subagents=no
+
+# Write to an explicit target file
+bunx oh-my-opencode-slim@latest install \
+  --background-subagents=yes \
+  --background-subagents-target="$HOME/.zshrc"
+```
+
+After the installer updates a shell startup file, restart your terminal or source
+the file before launching OpenCode. Examples:
+
+```bash
+source ~/.zshrc
+# or
+source ~/.bashrc
+```
+
+For a one-shot manual launch without changing shell files:
+
+```bash
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+```
 
 ### Non-Destructive Behavior
 
@@ -109,7 +153,7 @@ If not installed, direct the user to https://opencode.ai/docs first.
 The installer generates OpenAI and OpenCode Go presets, with OpenAI active by default:
 
 ```bash
-bunx oh-my-opencode-slim@latest install --no-tui --skills=yes
+bunx oh-my-opencode-slim@latest install --no-tui --skills=yes --background-subagents=yes
 ```
 
 **Examples:**
@@ -118,7 +162,10 @@ bunx oh-my-opencode-slim@latest install --no-tui --skills=yes
 bunx oh-my-opencode-slim@latest install
 
 # Non-interactive with bundled skills
-bunx oh-my-opencode-slim@latest install --no-tui --skills=yes
+bunx oh-my-opencode-slim@latest install --no-tui --skills=yes --background-subagents=yes
+
+# Non-interactive and configure background subagents env setup
+bunx oh-my-opencode-slim@latest install --no-tui --background-subagents=yes
 
 # Make the generated OpenCode Go preset active
 bunx oh-my-opencode-slim@latest install --preset=opencode-go
@@ -135,6 +182,7 @@ The installer automatically:
   `$OPENCODE_CONFIG_DIR` when set, otherwise `~/.config/opencode`
 - Disables default OpenCode agents
 - Enables OpenCode LSP integration when no explicit `lsp` setting exists
+- Configures `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true` when approved
 - Generates agent model mappings in the same OpenCode config directory as
   `oh-my-opencode-slim.json` (or `.jsonc`)
 
@@ -153,7 +201,9 @@ Ask the user to:
 
 1. Authenticate: `opencode auth login`
 2. Refresh models: `opencode models --refresh`
-3. Start OpenCode: `opencode`
+3. Restart the terminal or source the shell file updated by the installer
+   (`source ~/.zshrc` or `source ~/.bashrc`), then start OpenCode: `opencode`
+   - One-shot alternative: `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode`
 4. Run: `ping all agents`
 
 Verify all agents respond successfully.
@@ -203,6 +253,34 @@ If the installer reports that the configuration already exists, you have two opt
 
 3. Check that your provider is configured in `~/.config/opencode/opencode.json`
 
+### Missing Background Task Tools
+
+If background tasks never
+return task IDs, or delegation behaves like a blocking foreground call:
+
+1. Confirm OpenCode was launched with the environment variable:
+   ```bash
+   env | grep OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS
+   ```
+   It should show `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`.
+
+   Also use an OpenCode release that includes native background
+   subagents; run `opencode --version` and update OpenCode if background tasks are missing.
+
+2. Restart your terminal or source the shell file the installer updated, then
+   start OpenCode again. Plain `opencode` is only sufficient after that
+   environment is active.
+
+3. For a quick manual test, launch OpenCode with a one-shot export:
+   ```bash
+   OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+   ```
+
+4. If you intentionally skipped shell setup, rerun the installer with:
+   ```bash
+   bunx oh-my-opencode-slim@latest install --background-subagents=yes
+   ```
+
 ### Authentication Issues
 
 If providers are not working:

+ 0 - 9
docs/interview.md

@@ -42,15 +42,6 @@ You can also resume by basename if it exists in the configured output folder:
 /interview kanban-design-tool
 ```
 
-Promote an interview spec into the current session goal:
-
-```text
-/goal from kanban-design-tool
-```
-
-This uses the interview title and `Current spec` section as the pinned goal, so
-todos, delegation, and verification stay aligned with the clarified spec.
-
 ## What the browser UI gives you
 
 - focused question flow instead of open-ended chat

+ 3 - 3
docs/mcps.md

@@ -10,7 +10,7 @@ Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and
 |-----|---------|----------|
 | `websearch` | Real-time web search via Exa AI | `https://mcp.exa.ai/mcp` |
 | `context7` | Official library documentation (up-to-date) | `https://mcp.context7.com/mcp` |
-| `grep_app` | GitHub code search via grep.app | `https://mcp.grep.app` |
+| `gh_grep` | GitHub code search via grep.app | `https://mcp.grep.app` |
 
 ---
 
@@ -19,7 +19,7 @@ Built-in Model Context Protocol (MCP) servers ship with oh-my-opencode-slim and
 | Agent | Default MCPs |
 |-------|-------------|
 | `orchestrator` | `*`, `!context7` |
-| `librarian` | `websearch`, `context7`, `grep_app` |
+| `librarian` | `websearch`, `context7`, `gh_grep` |
 | `designer` | none |
 | `oracle` | none |
 | `explorer` | none |
@@ -55,7 +55,7 @@ Control which MCPs each agent can use via the `mcps` array in your preset config
         "mcps": ["*", "!context7"]
       },
       "librarian": {
-        "mcps": ["websearch", "context7", "grep_app"]
+        "mcps": ["websearch", "context7", "gh_grep"]
       },
       "oracle": {
         "mcps": ["*", "!websearch"]

+ 2 - 2
docs/quick-reference.md

@@ -13,9 +13,9 @@
 | Doc | Contents |
 |-----|----------|
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
+| [Background Orchestration](v2-background-orchestration.md) | Default scheduler-first orchestrator model for native background subagents |
 | [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
-| [Todo Continuation](todo-continuation.md) | `auto_continue`, `/auto-continue`, cooldowns, safety gates |
 | [Preset Switching](preset-switching.md) | `/preset` command for runtime agent model switching |
 | [Codemap Skill](codemap.md) | Hierarchical codemap generation |
 
@@ -24,7 +24,7 @@
 | Doc | Contents |
 |-----|----------|
 | [Skills](skills.md) | `simplify`, `codemap`, `clonedeps` — skills assignment syntax |
-| [MCPs](mcps.md) | `websearch`, `context7`, `grep_app` — permissions per agent, global disable |
+| [MCPs](mcps.md) | `websearch`, `context7`, `gh_grep` — permissions per agent, global disable |
 | [Tools](tools.md) | Background tasks, LSP, code search (`ast_grep`), formatters |
 | [Configuration](configuration.md) | Config files, prompt overriding, JSONC, full option reference table |
 

+ 0 - 51
docs/session-goal.md

@@ -1,51 +0,0 @@
-# Session Goal
-
-`/goal` pins a session-scoped objective so long work keeps a clear north star.
-
-Use it when the task is bigger than one prompt and has a clear success condition,
-but you do not want a separate project-management system.
-
-## Commands
-
-| Command | Description |
-|---------|-------------|
-| `/goal <objective>` | Set the current session goal |
-| `/goal` | Show the active goal and how it relates to todos and auto-continuation |
-| `/goal clear` | Clear the current session goal |
-| `/goal from <interview>` | Set the goal from an existing interview markdown spec |
-
-Examples:
-
-```text
-/goal Add lightweight session goals. Done when UX, docs, and tests are complete.
-/goal from kanban-design-tool
-/goal clear
-```
-
-## How it fits with other features
-
-```text
-Interview → Goal → Todos → Auto-continuation → Delegation → Verify
-```
-
-- **Goal** is the why and definition of done.
-- **Todos** are the execution ledger.
-- **Auto-continuation** keeps executing unfinished todos when enabled.
-- **Interview** turns a rough idea into a markdown spec that can become a goal.
-- **Task/Subtask delegation** inherits the parent goal as context, while each
-  delegated prompt remains the bounded task.
-
-## Important behavior
-
-Goal does not run anything by itself. It only reminds the orchestrator and child
-sessions what the session is trying to achieve.
-
-Auto-continuation remains todo-driven:
-
-```text
-Goal alone never causes continuation.
-Only incomplete todos trigger auto-continuation.
-```
-
-This keeps the feature slim: one pinned objective, no dashboard, no second todo
-system, and no project-global state.

+ 32 - 22
docs/session-management.md

@@ -1,8 +1,11 @@
 # Session Management
 
-Session management lets the orchestrator keep track of recent delegated child
-sessions so follow-up work can continue in the right specialist context instead
-of starting from scratch every time.
+Background job management lets the orchestrator track native background tasks,
+wait for hook-driven completion, and reuse completed/reconciled child sessions
+when follow-up work matches the same specialist context.
+
+For implementation/debugging notes from hardening cancellation and pane cleanup,
+see [Background Job Board Lessons](background-job-board-lessons.md).
 
 It is enabled by default. You do not need to add anything to your config unless
 you want to change how many sessions are remembered.
@@ -27,7 +30,7 @@ management, the orchestrator can reuse recent child sessions when it makes sense
 
 ## How It Feels in Practice
 
-When a child task runs, the plugin remembers it under a short alias such as:
+When a child task runs, the plugin tracks it under a short alias such as:
 
 ```text
 exp-1
@@ -38,10 +41,16 @@ fix-2
 The orchestrator sees a compact reminder in its system context, for example:
 
 ```text
-### Resumable Sessions
-- explorer: exp-1 Search routing files
-  Context read by exp-1: src/router.ts (120 lines), src/routes/api.ts (74 lines)
-- oracle: ora-1 Review auth architecture
+### Background Job Board
+SENTINEL: background-job-board-v2
+
+#### Active / Unreconciled
+- exp-1 / child-1 / explorer / running
+  Objective: Search routing files
+
+#### Reusable Sessions
+- ora-1 / child-2 / oracle / completed, reconciled
+  Objective: Review auth architecture
 ```
 
 When a child session reads files through OpenCode's `read` tool, the reminder can
@@ -52,9 +61,9 @@ To keep the prompt small, read context only shows files where at least 10 lines
 were read, includes line counts, and caps each remembered session to the most
 recent 8 files by default. Both thresholds are configurable.
 
-On a related follow-up, the orchestrator can reuse that session instead of
-launching a fresh one. If the remembered child session no longer exists, the
-plugin drops the stale entry and falls back to a new session automatically.
+On a related follow-up, the orchestrator can reuse a completed/reconciled session
+instead of launching a fresh one. Running jobs must wait for hook-driven completion;
+terminal jobs must be reconciled before dependent work or a final response.
 
 ---
 
@@ -78,7 +87,8 @@ long-lived global state.
 
 ## Default Behavior
 
-By default, the plugin remembers **2 recent child sessions per specialist type**.
+By default, the plugin keeps **2 reusable completed child sessions per specialist
+type** while active/unreconciled jobs remain visible until resolved.
 
 That means the generated starter config can stay clean:
 
@@ -95,18 +105,18 @@ That means the generated starter config can stay clean:
 }
 ```
 
-Session management still works because the runtime falls back to the built-in
+Background job management still works because the runtime falls back to the built-in
 default.
 
 ---
 
 ## Configuration
 
-Only add `sessionManager` if you want to change the default limits:
+Only add `backgroundJobs` if you want to change the default limits:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 2,
     "readContextMinLines": 10,
     "readContextMaxFiles": 8
@@ -114,22 +124,22 @@ Only add `sessionManager` if you want to change the default limits:
 }
 ```
 
-### `sessionManager.maxSessionsPerAgent`
+### `backgroundJobs.maxSessionsPerAgent`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
-| integer | `2` | `1`–`10` | Number of recent resumable child sessions remembered per specialist type in the current parent session |
+| integer | `2` | `1`–`10` | Number of completed/reconciled reusable child sessions retained per specialist type in the current parent session |
 
-### `sessionManager.readContextMinLines`
+### `backgroundJobs.readContextMinLines`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
-| integer | `10` | `0`–`1000` | Minimum number of lines read from a file before it appears in resumable-session context |
+| integer | `10` | `0`–`1000` | Minimum number of lines read from a file before it appears in reusable job context |
 
 Set this lower if you want short config files to appear. Set it higher to keep
 the prompt focused on substantial file reads.
 
-### `sessionManager.readContextMaxFiles`
+### `backgroundJobs.readContextMaxFiles`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
@@ -158,7 +168,7 @@ Example with a smaller memory window:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 1,
     "readContextMaxFiles": 4
   }
@@ -169,7 +179,7 @@ Example with a larger memory window:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 4,
     "readContextMinLines": 5
   }

+ 70 - 0
docs/skills.md

@@ -15,6 +15,8 @@ Bundled skills are installed by the `oh-my-opencode-slim` installer.
 | [`simplify`](#simplify) | Behavior-preserving code simplification | `oracle` |
 | [`codemap`](#codemap) | Repository codemap generation | `orchestrator` |
 | [`clonedeps`](#clonedeps) | Local dependency source cloning | `orchestrator` |
+| [`deepwork`](#deepwork) | Heavy/complex coding sessions workflow | `orchestrator` |
+| [`oh-my-opencode-slim`](#oh-my-opencode-slim) | Plugin configuration and self-improvement guidance | `orchestrator` |
 
 ---
 
@@ -81,6 +83,74 @@ See **[Clonedeps](clonedeps.md)** for the full workflow and file layout.
 
 ---
 
+## deepwork
+
+**Heavy/complex coding sessions and large modifications workflow.**
+
+`deepwork` is an orchestrator-only workflow skill for managing deep architectural work, multi-phase implementations, and complex refactoring. It provides a structured approach with mandatory review gates while maintaining flexibility in planning.
+
+Start it directly with:
+
+```text
+/deepwork <heavy coding task>
+```
+
+**How it works:**
+1. Orchestrator creates a session artifact at `.slim/deepwork/<task>.md`
+2. Draft plan → Oracle review → Revise until acceptable
+3. Create phased implementation plan → Oracle review
+4. Execute phase by phase with validation
+5. After each phase: validate → Oracle review → fix issues → continue
+
+**Key features:**
+- Persistent session state in markdown files
+- Mandatory oracle reviews at plan and phase boundaries
+- Oracle phase reviews include simplify/readability feedback alongside regular correctness and risk review
+- V2 scheduler integration (dispatch specialists, wait for hook-driven completion, reconcile)
+- OpenCode todo lists for progress tracking
+- Flexible structure - orchestrator adapts format to task needs
+
+**When to use:** Large-scale refactoring, multi-file architectural changes, complex feature development spanning modules.
+
+**When NOT to use:** Simple single-file edits, trivial bug fixes, quick one-off changes.
+
+---
+
+## oh-my-opencode-slim
+
+**Configure, customize, and safely improve this plugin setup.**
+
+`oh-my-opencode-slim` is an orchestrator-only skill that teaches agents how to
+configure the plugin itself: model presets, custom agents, agent prompts,
+`orchestratorPrompt` delegation hints, skills, MCP permissions, optional agents,
+and related OpenCode config files.
+
+It is installed by default with the bundled skills and is available to the
+Orchestrator through the default `skills: ["*"]` configuration.
+
+The skill also tells the Orchestrator to notice repeatable workflow friction and
+suggest safe config or prompt improvements. It must ask before changing config or
+prompts unless the user explicitly requested the exact edit, and it reminds users
+that OpenCode may need a restart for config, prompt, agent, skill, MCP, or plugin
+changes to take effect.
+
+Typical requests:
+
+```text
+Tune my oh-my-opencode-slim models for lower cost.
+Add a custom API reviewer agent.
+Make the Orchestrator more conservative about parallel writer agents.
+Help me configure MCP access for Librarian only.
+```
+
+After config changes, expect guidance like:
+
+```text
+This should apply on the next OpenCode run; restart OpenCode if you need it immediately.
+```
+
+---
+
 ## Skills Assignment
 
 Control which skills each agent can use in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):

+ 0 - 135
docs/subtask.md

@@ -1,135 +0,0 @@
-# Subtask
-
-![Subtask worker session](../img/subtask.png)
-
-`/subtask` lets the current agent spin up a separate, bounded worker session for
-one specific piece of work. The worker runs as an orchestrator in a real child
-session, completes the requested task, and sends a structured summary back to
-the original conversation.
-
-Use it when a bounded, context-heavy task only needs to return a compact result
-to the main thread.
-
-## Usage
-
-```text
-/subtask <focused task for the worker>
-```
-
-Examples:
-
-```text
-/subtask update the subtask docs and run the relevant checks
-/subtask investigate why the auth retry test is flaky and report findings
-/subtask implement the small button spacing polish in the settings panel
-```
-
-Keep the request narrow. A good subtask has a clear finish line.
-
-## What happens
-
-1. The `/subtask` command asks the current agent to prepare a self-contained
-   worker prompt.
-2. The agent calls the `subtask` tool with that prompt and any clearly relevant
-   files.
-3. Slim creates a real child session with `parentID` pointing at the current
-   session.
-4. The child session runs as `orchestrator`, so it can use normal tools and
-   specialist delegation when useful.
-5. Referenced files are injected as synthetic Read-tool context before the
-   worker starts.
-6. If the worker needs missing conversation details, it can call `read_session`
-   to inspect only the source session that spawned it.
-7. When finished, the worker returns a `<subtask_summary>` with status, changes,
-   files touched, validation, and follow-up notes.
-8. Slim extracts the summary, returns it to the original session, and aborts the
-   child session for cleanup.
-
-In tmux or zellij, the subtask appears like other child-agent work because it is
-a real child session. Existing depth limits and pane cleanup handling apply.
-
-If the parent session has an active [Session Goal](session-goal.md), the worker inherits
-it as context. The explicit subtask request still defines the worker's scope.
-
-## Worker scope
-
-The worker prompt is intentionally bounded:
-
-- complete only the requested task,
-- do not broaden scope,
-- do not spawn another subtask,
-- use `read_session` only when needed context is missing,
-- run the most relevant validation checks when practical,
-- stop when the requested task is done.
-
-This keeps subtasks useful for focused execution rather than turning them into a
-second open-ended conversation.
-
-## Tools
-
-| Tool | Purpose |
-|------|---------|
-| `subtask` | Creates a child worker session and returns its summary |
-| `read_session` | Lets a subtask worker read the source session that spawned it |
-
-`read_session` is restricted to subtask workers and only allows reading the
-source session. It is not a general transcript-reading tool.
-
-## File context
-
-Files can be passed explicitly with the `files` argument or referenced in the
-worker prompt with `@path` syntax. Slim resolves those paths inside the current
-workspace and injects readable text files as synthetic context.
-
-Safety rules:
-
-- paths must stay inside the workspace real path,
-- symlinks that resolve outside the workspace are skipped,
-- binary files are skipped,
-- large files are capped before injection,
-- unreadable or missing files are skipped.
-
-## Timeout
-
-Each subtask worker has a timeout. If the worker has not returned a summary
-before the timeout elapses, Slim aborts the child session and the `subtask` tool
-call fails with `Prompt timed out after <ms>ms`.
-
-The default timeout is **5 minutes** (`300000` ms). Override it via
-`subtask.timeoutMs` in your plugin config:
-
-```jsonc
-{
-  // Give workers up to 30 minutes before timing out
-  "subtask": {
-    "timeoutMs": 1800000
-  }
-}
-```
-
-Set `timeoutMs` to `0` to disable the timeout entirely. The maximum accepted
-value is `86400000` (24 hours).
-
-## Summary format
-
-The worker is instructed to finish with:
-
-```text
-<subtask_summary>
-Status: completed | blocked | partial
-
-What changed:
-- ...
-
-Files touched:
-- ...
-
-Validation:
-- ...
-
-Risks / follow-up:
-- ...
-</subtask_summary>
-```
-
-The parent session receives that summary as normal tool output.

+ 1 - 1
docs/thirty-dollars-preset.md

@@ -15,7 +15,7 @@ It uses Codex Plus for the OpenAI models and GitHub Copilot for the premium coun
       "thirtydollars": { "orchestrator": { "model": "openai/gpt-5.5", "skills": [ "*" ], "mcps": [ "*", "websearch"] },
         "oracle": { "model": "openai/gpt-5.5", "variant": "high", "skills": [], "mcps": [] },
         "council": { "model": "openai/gpt-5.5" },
-        "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [ "websearch", "context7", "grep_app" ] },
+        "librarian": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [ "websearch", "context7", "gh_grep" ] },
         "explorer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] },
         "designer": { "model": "github-copilot/gemini-3.1-pro-preview", "skills": [], "mcps": [] },
         "fixer": { "model": "openai/gpt-5.4-mini", "variant": "low", "skills": [], "mcps": [] }

+ 0 - 49
docs/todo-continuation.md

@@ -1,49 +0,0 @@
-# Todo Continuation
-
-Auto-continue the orchestrator when it stops with incomplete todos. Opt-in only — nothing resumes automatically unless you enable it.
-
-If a [Session Goal](session-goal.md) is active, auto-continuation resumes under that
-goal, but the goal does not trigger continuation by itself. Only incomplete
-todos do.
-
-## Controls
-
-| Tool / Command | Description |
-|----------------|-------------|
-| `auto_continue` | Toggle auto-continuation. Call with `{ enabled: true }` to activate, `{ enabled: false }` to disable |
-| `/auto-continue` | Slash command shortcut. Accepts `on`, `off`, or toggles with no argument |
-
-## How It Works
-
-1. When the orchestrator goes idle with incomplete todos, a countdown notification appears
-2. After the cooldown (default 3s), a continuation prompt is injected and the orchestrator resumes work
-3. Press Esc×2 during the cooldown or after injection to stop it
-
-## Safety Gates
-
-All of these must pass before continuation happens:
-
-- Auto-continue is enabled
-- The session is the orchestrator
-- Incomplete todos exist
-- The last assistant message is not a question
-- The consecutive continuation count is under the limit
-- The session is not in the post-abort suppress window (5s)
-- No pending injection is already in flight
-
-## Configuration
-
-Configure it in `~/.config/opencode/oh-my-opencode-slim.json` or `~/.config/opencode/oh-my-opencode-slim.jsonc`:
-
-```jsonc
-{
-  "todoContinuation": {
-    "maxContinuations": 5,      // Max consecutive auto-continuations (1–50)
-    "cooldownMs": 3000,         // Delay before each continuation (0–30000)
-    "autoEnable": false,        // Auto-enable when session has enough todos
-    "autoEnableThreshold": 4    // Number of todos to trigger auto-enable
-  }
-}
-```
-
-> See [Configuration](configuration.md) for the full option reference.

+ 10 - 37
docs/tools.md

@@ -34,24 +34,19 @@ Fast, structural code search and refactoring — more powerful than plain text g
 
 ---
 
-## Session Subtask
+## Background Task Control
 
-Run a focused child worker session for a bounded task and return its summary to
-the caller.
-
-| Command / Tool | Description |
-|----------------|-------------|
-| `/subtask <goal>` | Ask the current agent to prepare and start a bounded worker for the requested task |
-| `subtask` | Creates a child orchestrator session and returns its structured summary |
-| `read_session` | Lets a subtask worker inspect the source session when needed context is missing |
+| Tool | Description |
+|------|-------------|
+| `cancel_task` | Cancel a tracked background specialist task by native task ID or Background Job Board alias |
 
-Slim creates a real child session with the current session as `parentID`, injects
-relevant file context, and asks the worker to complete only the requested task.
-The worker returns a `<subtask_summary>` with status, changes, files touched,
-validation, and follow-up notes. In tmux/zellij this appears like other child
-agent work: a pane can open for the worker and close after cleanup.
+`cancel_task` is orchestrator-only. It only cancels background tasks tracked for
+the current orchestrator session, and it does not roll back partial edits. After
+cancelling a write-capable task, inspect and reconcile file changes before
+launching replacement work.
 
-See [Subtask](subtask.md) for the full workflow.
+See [Background Job Board Lessons](background-job-board-lessons.md) for the
+session lifecycle and cancellation edge cases behind this tool.
 
 ---
 
@@ -64,25 +59,3 @@ Includes Prettier, Biome, `gofmt`, `rustfmt`, `ruff`, and 20+ others.
 > See the [official OpenCode docs](https://opencode.ai/docs/formatters/#built-in) for the complete list.
 
 ---
-
-## Todo Continuation
-
-Auto-continue has its own guide now:
-
-- [Todo Continuation](todo-continuation.md) — controls, safety gates, behavior, and config
-
----
-
-## Session Goal
-
-Pin a session-scoped objective that keeps planning, todos, delegation, and
-verification aligned.
-
-| Command | Description |
-|---------|-------------|
-| `/goal <objective>` | Set the current session goal |
-| `/goal` | Show the active goal |
-| `/goal clear` | Clear the active goal |
-| `/goal from <interview>` | Promote an interview markdown spec into the active goal |
-
-See [Session Goal](session-goal.md) for the full workflow.

+ 373 - 0
docs/v2-background-orchestration.md

@@ -0,0 +1,373 @@
+# Background Orchestration
+
+Background orchestration is the default orchestration model for
+oh-my-opencode-slim. It assumes native OpenCode background subagents are
+available and changes the orchestrator from a primary worker into a scheduler.
+
+The old model was:
+
+```text
+orchestrator works directly → delegates when useful → waits for result
+```
+
+The default background-orchestration model is:
+
+```text
+orchestrator plans → dispatches background specialists → monitors → reconciles → verifies
+```
+
+This is a clean rebuild, not a compatibility layer over the old blocking model.
+
+---
+
+## Runtime Requirement
+
+Background orchestration requires an OpenCode release that includes native
+background subagents, launched with background subagents
+enabled:
+
+```bash
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+```
+
+The required native/background-control tools are:
+
+| Tool | Purpose |
+|------|---------|
+| `task(..., background: true)` | Start a specialist in the background and immediately return a task ID |
+| hook-driven completion | OpenCode injects terminal background task results automatically |
+| `cancel_task` | Plugin-provided tool to cancel a tracked background task by task ID or Background Job Board alias |
+
+If these are not available, the scheduler cannot use the default background
+workflow. Configure the environment variable through the installer or use the
+one-shot export above before starting OpenCode.
+
+Use an OpenCode release that includes native background subagents and hook-driven completion; run `opencode --version` and update if background tasks are missing.
+
+---
+
+## Core Principle
+
+The orchestrator is not the default implementation worker.
+
+Its job is to:
+
+- understand the user request,
+- break work into dependent and independent units,
+- choose the right specialist for each unit,
+- schedule background work,
+- track task IDs and states,
+- avoid conflicting writes,
+- integrate specialist results,
+- run or route final verification,
+- communicate concise progress and outcomes to the user.
+
+Specialists do the work. The orchestrator manages the work.
+
+---
+
+## Execution Loop
+
+Every non-trivial request follows this loop:
+
+```text
+Understand
+  ↓
+Plan dependency graph
+  ↓
+Dispatch independent specialists in background
+  ↓
+Track task IDs and ownership
+  ↓
+Continue only independent coordination work
+  ↓
+Wait for hook-driven completion
+  ↓
+Reconcile results and resolve conflicts
+  ↓
+Dispatch follow-up work if needed
+  ↓
+Verify
+  ↓
+Final response
+```
+
+The orchestrator should not act on assumptions from a still-running task. It can
+continue scheduling independent work, but dependent work waits for terminal task
+results.
+
+---
+
+## Scheduler Responsibilities
+
+### 1. Build a dependency graph
+
+Before dispatching agents, the orchestrator identifies:
+
+- which questions must be answered before implementation,
+- which tasks can run in parallel,
+- which tasks must be sequential,
+- which files or subsystems each writer owns,
+- which outputs are needed for final verification.
+
+This does not need to be a long plan. It should be just enough structure to
+avoid wasted work and conflicting edits.
+
+### 2. Dispatch background specialists
+
+Independent work should be launched with background tasks:
+
+```text
+task(
+  description="Search auth flow",
+  subagent_type="explorer",
+  background=true,
+  prompt="Find the auth entry points, session storage, and login callback paths. Return file paths and a concise map. Do not edit files."
+)
+```
+
+The orchestrator records the returned task ID and keeps working only on safe,
+independent coordination.
+
+### 3. Track ownership
+
+The scheduler must prevent write conflicts.
+
+Rules:
+
+- Only one write-capable specialist owns a file at a time.
+- Do not run two `fixer` tasks against overlapping folders unless ownership is
+  explicit.
+- UI work that touches shared components should not run beside implementation
+  work that edits the same components.
+- Review tasks can run in parallel with read-only discovery, but not with edits
+  they are supposed to review.
+
+### 4. Wait, cancel, and reconcile
+
+Background tasks are not complete until OpenCode injects their terminal result or
+hook-driven completion marks them terminal.
+
+The orchestrator should use background completion events to:
+
+- wait for dependent results,
+- check long-running tasks,
+- collect outputs before final response,
+- surface failures or blocked tasks clearly.
+
+The orchestrator should use `cancel_task` only when the user asks, or when a
+running lane is obsolete, wrong, or conflicts with a safer replacement plan.
+Cancellation is not rollback: if cancelling a writer, inspect and reconcile
+partial file changes before launching a replacement lane.
+
+**Note on reconciliation:** Idle-based reconciliation is a heuristic. A job marked
+as reconciled means its terminal result was injected into an orchestrator turn
+that completed and the parent returned to idle; it is not proof the result was
+explicitly acknowledged or used. The orchestrator should still verify it consumed
+the relevant outputs before finalizing.
+
+Specialist outputs are inputs, not final truth. The orchestrator reconciles them
+against each other and the original user goal.
+
+### 5. Verify
+
+Verification remains orchestrator-owned, but not necessarily orchestrator-run.
+
+Examples:
+
+- route UI review to `designer`,
+- route code review to `oracle`,
+- route test writing or test updates to `fixer`,
+- run final shell checks directly only when appropriate.
+
+The final response should only happen after relevant background work is terminal
+and reconciled.
+
+---
+
+## Specialist Roles
+
+### Explorer
+
+Read-only reconnaissance and codebase mapping. Usually the first background task
+for unfamiliar work.
+
+### Librarian
+
+External docs, version-specific API behavior, and real-world examples. Runs in
+parallel with Explorer when implementation depends on current library behavior.
+
+### Fixer
+
+Bounded implementation worker. Receives a clear objective, file ownership,
+constraints, and validation expectations.
+
+### Designer
+
+User-facing UI/UX implementation and review. Owns visual polish, responsive
+layout, interaction quality, and design consistency.
+
+### Oracle
+
+Architecture, code review, simplification, risk analysis, and high-stakes
+debugging. Often used after implementation or before risky refactors.
+
+### Council
+
+Multi-model decision support for critical trade-offs. It is not a worker pool;
+it is for judgment where disagreement is useful.
+
+### Observer
+
+Visual/media analysis isolated from the orchestrator context.
+
+---
+
+## Direct Work Boundary
+
+Background orchestration removes the orchestrator-as-worker default.
+
+The orchestrator may directly:
+
+- ask clarifying questions,
+- read minimal context needed to route work,
+- create and update todos,
+- launch and monitor tasks,
+- synthesize results,
+- run final checks when that is cheaper than delegating.
+
+The orchestrator should delegate:
+
+- broad code search,
+- unfamiliar library research,
+- implementation,
+- test creation or updates,
+- UI polish,
+- architecture review,
+- visual/media analysis.
+
+This keeps the main context focused on coordination instead of filling it with
+worker detail.
+
+---
+
+## Task Prompt Contract
+
+Every delegated task should be self-contained.
+
+Include:
+
+- objective,
+- constraints,
+- relevant files or search scope,
+- ownership boundaries,
+- expected output format,
+- whether edits are allowed,
+- validation to run or report,
+- what not to do.
+
+Good background task prompt:
+
+```text
+Investigate src/hooks/task-session-manager for assumptions that a task tool
+result means the child task has finished. Do not edit files. Return:
+1. exact files/functions involved,
+2. which assumptions break with background tasks,
+3. recommended code changes,
+4. tests that should be added.
+```
+
+Bad background task prompt:
+
+```text
+Look into background tasks.
+```
+
+---
+
+## State The Orchestrator Must Track
+
+The prompt/runtime treats background tasks as a small job board:
+
+| Field | Meaning |
+|-------|---------|
+| task ID | Native OpenCode background task/session ID |
+| specialist | Agent type assigned |
+| objective | What the task is responsible for |
+| state | running, completed, error, cancelled, timed out |
+| ownership | Files/folders/subsystems the task may edit |
+| dependencies | Tasks that must complete first |
+| result | Final task output once terminal |
+
+The current todo list can represent user-visible work, but task IDs and file
+ownership need to be explicit in the orchestrator's working context.
+
+---
+
+## Runtime Integration
+
+The plugin is aware that a `task` return can mean "background job launched"
+rather than "work complete". It tracks running task IDs, exposes recent work in
+the background job board, updates aliases from task results, and keeps
+multiplexer panes attached while the parent orchestrator continues scheduling.
+
+---
+
+## Startup Behavior
+
+The installer and docs configure background subagents as a requirement for the
+default scheduler workflow. If background subagents are
+unavailable, treat it as an environment or OpenCode-version issue rather than an
+intentional V1 fallback:
+
+```text
+Background orchestration requires OpenCode background subagents.
+Start OpenCode with:
+
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+```
+
+No automatic legacy fallback keeps the mental model clean.
+
+---
+
+## Example Flow
+
+User asks:
+
+```text
+Make background subagents first-class in this plugin.
+```
+
+The orchestrator should do something like:
+
+1. Create todos for discovery, design, implementation, docs, tests, review.
+2. Launch Explorer in background to map task-session hooks and task lifecycle.
+3. Launch Oracle in background to review architecture risks.
+4. Continue by preparing the dependency graph and file ownership plan.
+5. Wait for Explorer and Oracle via hook-driven completion.
+6. Dispatch Fixer to implement prompt/config/hook changes with clear ownership.
+7. Dispatch a second Fixer for tests if file ownership is separate.
+8. Wait for implementation results.
+9. Dispatch Oracle for final review.
+10. Run final checks.
+11. Report final state.
+
+At no point does the orchestrator become the main implementer.
+
+---
+
+## Success Criteria
+
+Background orchestration is working when:
+
+- the orchestrator launches independent specialists in background by default,
+- task IDs are tracked until terminal state,
+- dependent work waits for real task results,
+- file ownership prevents concurrent write conflicts,
+- final responses only happen after reconciliation and verification,
+- users see faster progress on multi-step work,
+- the orchestrator context stays focused on decisions instead of worker detail.
+
+Background orchestration is not just "parallel agents." It is a
+scheduler-centered operating model for OpenCode's native background subagents.

+ 32 - 0
docs/v2-workstreams.md

@@ -0,0 +1,32 @@
+# Internal: V2 Workstreams Archive
+
+> **Internal historical planning doc.** This file tracked V2 feature branches
+> and beta integration while background orchestration was being built. It is not
+> current user-facing install or release guidance. For the default release, see
+> [Installation](installation.md) and
+> [Background Orchestration](v2-background-orchestration.md).
+
+This archive tracked focused V2 branches and local worktrees while background
+orchestration was being built. It is not current release guidance.
+
+| Branch | Worktree | Purpose | Status | Notes |
+|---|---|---|---|---|
+| `v2-beta` | repo root | V2 integration/release | Historical | Former source of truth for combined V2 pre-release validation. |
+| `v2/misc` | `.slim/worktrees/v2-misc` | Misc V2 cleanup | Merged | Removed custom subtask feature; can continue misc follow-ups here if desired. |
+| `v2/tui` | `.slim/worktrees/v2-tui` | TUI integration | Planned | No feature work merged yet. |
+
+Useful status commands:
+
+```bash
+git worktree list
+git branch --list 'v2/*' -vv
+git branch --merged v2-beta
+git log --oneline --decorate --graph --all --branches='v2/*'
+```
+
+After a feature branch is merged and no longer needed locally:
+
+```bash
+git worktree remove .slim/worktrees/v2-<feature-name>
+git branch -d v2/<feature-name>
+```

+ 621 - 0
docs/v2_core.md

@@ -0,0 +1,621 @@
+# Internal: V2 Core Refactor Plan
+
+> **Internal historical planning doc.** This file records the V2 implementation
+> plan that led to the current default background-orchestration release. It is
+> not the beta user guide and may mention branch or rollout details that are now
+> historical. For user-facing setup, see [Installation](installation.md) and
+> [Background Orchestration](v2-background-orchestration.md).
+
+This archival document records the implementation plan for the V2 orchestration
+core. It is retained for maintainers who need the design history.
+
+Scope for this pass:
+
+- core prompts,
+- scheduler/job-board behavior,
+- native `task` and hook-driven completion integration,
+- task-session-manager changes,
+- tmux/zellij multiplexer compatibility.
+
+Out of scope for this pass:
+
+- Divoom integration,
+- install/startup flag checks,
+- README/index documentation updates,
+- legacy fallback behavior.
+
+V2 assumes native OpenCode background subagents are available and enabled.
+
+---
+
+## Core Thesis
+
+V2 changes the orchestrator from a worker-with-delegation into a scheduler.
+
+V1 mental model:
+
+```text
+orchestrator works directly → delegates when useful → waits for result
+```
+
+V2 mental model:
+
+```text
+orchestrator plans → dispatches background specialists → monitors jobs
+→ reconciles terminal results → verifies final state
+```
+
+The orchestrator should not be the default implementation worker. Specialists do
+the work; the orchestrator manages the work.
+
+---
+
+## Native Background Task Lifecycle
+
+OpenCode background task semantics are the foundation:
+
+```text
+task(background: true)
+  → returns immediately with task_id
+  → child session continues elsewhere
+  → OpenCode injects completion when terminal
+  → orchestrator consumes terminal result
+```
+
+Important distinction:
+
+- `task` result means **launched**.
+- Injected terminal completion means **finished**.
+- Finished is not the same as reconciled.
+
+V2 must model these as separate states.
+
+---
+
+## Core State Model
+
+Introduce a small scheduler/job-board model for background delegates.
+
+Suggested state shape:
+
+```ts
+type BackgroundJobState =
+  | 'running'
+  | 'completed'
+  | 'error'
+  | 'cancelled'
+  | 'reconciled';
+
+interface BackgroundJobRecord {
+  taskID: string;
+  parentSessionID: string;
+  agent: string;
+  description: string;
+  objective: string;
+  ownership?: string[];
+  dependencies?: string[];
+  state: BackgroundJobState;
+  launchedAt: number;
+  updatedAt: number;
+  completedAt?: number;
+  timedOut?: boolean;
+  terminalUnreconciled?: boolean;
+  resultSummary?: string;
+}
+```
+
+Native task output states are `running`, `completed`, `error`, and
+`cancelled`. A wait timeout is not a terminal native state; represent it as a
+`timedOut` overlay while the job remains `running`.
+
+This does not need to be persisted initially. Start in memory, scoped to the
+parent orchestrator session.
+
+Start with minimal reliable fields:
+
+```ts
+{
+  taskID,
+  parentSessionID,
+  agent,
+  description,
+  objective,
+  state,
+  timedOut,
+  terminalUnreconciled,
+  launchedAt,
+  updatedAt,
+  completedAt,
+  resultSummary,
+}
+```
+
+Keep `ownership` and `dependencies` advisory until there is a reliable data
+source. Native `task` arguments do not contain those fields, so initial V2 should
+not pretend the plugin can infer them perfectly.
+
+### Shared scheduler module
+
+Do not bury this state inside `task-session-manager`.
+
+Create a small shared utility, for example:
+
+- `src/utils/background-job-board.ts`, or
+- `src/hooks/scheduler-state/` if it grows into a hook-owned subsystem.
+
+It should expose methods such as:
+
+```ts
+registerLaunch(record)
+updateStatus(taskID, status)
+markReconciled(taskID)
+hasRunning(parentSessionID)
+hasTerminalUnreconciled(parentSessionID)
+formatForPrompt(parentSessionID)
+```
+
+Then pass the shared state into:
+
+- task-session-manager,
+- any future prompt/system-context hook that needs scheduler state.
+
+### Reconciliation rule
+
+The plugin needs one concrete reconciliation transition.
+
+Initial rule:
+
+1. Task output or an auto-injected completion message marks a job terminal and
+   `terminalUnreconciled: true`.
+2. The next orchestrator assistant turn after that terminal result is treated as
+   the reconciliation turn for all terminal unreconciled jobs visible in context.
+3. On orchestrator assistant turn completion, when the parent session returns to
+   idle after that assistant response, mark the terminal unreconciled jobs that
+   were injected into that turn's prompt as `reconciled`.
+
+This is intentionally simple. It avoids terminal jobs living forever while still
+forcing at least one orchestrator turn to see and account for each result.
+
+**Important:** Idle-based reconciliation is a heuristic. Reconciled status means
+a terminal result was injected into an orchestrator turn that completed and the
+parent returned to idle; it is not proof the result was explicitly acknowledged
+or used by the orchestrator. Initial V2 should not try to infer from free text
+whether the orchestrator mentioned, ignored, blocked, or failed a job. If a more
+precise protocol is needed later, add an explicit marker/tool for reconciliation.
+
+---
+
+## Prompt Refactor
+
+Primary file:
+
+- `src/agents/orchestrator.ts`
+
+Related reminder file:
+
+- `src/config/constants.ts`
+
+### Role rewrite
+
+Replace the current role framing with scheduler-first language:
+
+```text
+You are a workflow manager for coding work. Your job is to plan, schedule,
+delegate, monitor, reconcile, and verify specialist-agent work. You are not the
+default implementation worker.
+```
+
+The orchestrator may directly:
+
+- ask clarifying questions,
+- read minimal context required to route work,
+- manage todos,
+- dispatch specialists,
+- track task state through hook-driven completion,
+- synthesize results,
+- run final checks when that is the simplest verification path.
+
+The orchestrator should delegate:
+
+- broad search,
+- external docs/API research,
+- implementation,
+- test writing or test updates,
+- UI polish,
+- architecture review,
+- visual/media analysis.
+
+### Replace blocking execution section
+
+Remove the V1 text that says delegated specialists block the parent until result.
+
+New execution model:
+
+```text
+### OpenCode V2 scheduler model
+- Delegated specialists should be launched as background tasks whenever work can
+  run independently using `task(..., background: true)`.
+- A dispatch returns a task/session ID immediately; it does not mean completion.
+- Track each task ID with specialist, objective, state, and any advisory
+  ownership/dependency labels available from the dispatch plan.
+- Continue orchestration while tasks run: planning, scheduling independent lanes,
+  preparing synthesis, and asking needed user questions.
+- Wait for hook-driven completion before consuming
+  outputs or starting dependent work.
+- Parallel background tasks are allowed only when their write scopes do not
+  conflict.
+- Final response requires relevant tasks to be terminal and reconciled.
+```
+
+### Replace execute workflow
+
+V2 workflow should be:
+
+```text
+## Dispatch
+1. Split work into independent and dependency-ordered lanes.
+2. Plan advisory ownership for write-capable lanes.
+3. Dispatch independent specialists as background tasks.
+4. Record task IDs, state, and advisory ownership/dependency labels.
+5. Continue only independent orchestration while jobs run.
+6. Wait for terminal results via hook-driven completion.
+7. Reconcile results, resolve conflicts, and gate dependent lanes.
+8. Dispatch follow-up jobs if needed.
+9. Verify final state.
+```
+
+### Phase reminder rewrite
+
+Update `PHASE_REMINDER_TEXT` so it reinforces scheduler behavior:
+
+```text
+Build a short work graph with independent lanes, dependencies, and advisory
+ownership.
+Dispatch independent specialists as background tasks, record task/session IDs,
+then continue orchestration. Wait for hook-driven completion and only consume outputs or advance
+dependent work when results are terminal.
+```
+
+---
+
+## Task Prompt Contract
+
+Each background task prompt should be self-contained and bounded.
+
+Include:
+
+- objective,
+- constraints,
+- relevant files or search scope,
+- ownership boundaries,
+- whether edits are allowed,
+- expected output format,
+- validation expectations,
+- what not to do.
+
+Good prompt:
+
+```text
+Inspect src/hooks/task-session-manager for assumptions that a task result means
+child work is finished. Do not edit files. Return exact files/functions,
+background-task risks, and recommended changes.
+```
+
+Bad prompt:
+
+```text
+Look into background tasks.
+```
+
+---
+
+## Task Session Manager Refactor
+
+Primary files:
+
+- `src/hooks/task-session-manager/index.ts`
+- `src/utils/task.ts`
+- `src/utils/background-job-board.ts`
+
+Current behavior:
+
+- `src/index.ts` creates one shared `BackgroundJobBoard` using
+  `backgroundJobs` caps/context config and passes it to task-session-manager,
+  cancel-task, and multiplexer integration.
+- `tool.execute.before(task)` validates `subagent_type`, strips stale/invalid
+  `task_id` aliases when they cannot safely resolve, and only resolves reusable
+  aliases for matching completed/reconciled jobs.
+- No `task_status` hook is installed; upstream no longer exposes that tool
+  running or terminal tasks.
+- `tool.execute.after(task)` parses native launch output and records running
+  jobs in the shared board; it does not treat launch as completion.
+- `tool.execute.after(task)` and synthetic completion messages parse
+  status output into running/terminal job-board state.
+- Prompt injection is owned by the job board: running and terminal unreconciled
+  jobs appear under `### Background Job Board`; completed/reconciled jobs appear
+  only in the reusable section.
+
+V2 behavior:
+
+- `task` tool output creates or updates a job as `launched` or `running`.
+- Task output updates the job to `running`, `completed`, `error`, or
+  `cancelled`; timeout is metadata while the job remains `running`.
+- only terminal jobs become ready for reconciliation.
+- only reconciled/appropriate sessions should be offered for reuse.
+
+### Required changes
+
+1. Split parsing helpers:
+
+   ```ts
+   parseTaskLaunchOutput(output) → { taskID, state: 'running' | ... }
+   parseTaskStatusOutput(output) → { taskID, state, result? }
+   ```
+
+2. Store background job records in `src/utils/background-job-board.ts`, scoped by
+   parent orchestrator session.
+
+3. Update `tool.execute.after` for `task`:
+
+   - parse launch output,
+   - register job as launched/running,
+   - do not treat it as completed.
+
+4. Handle task output and injected completions:
+
+   - parse status output,
+   - update job state,
+   - attach result summary for terminal states.
+
+5. Update system-context injection:
+
+   - inject the unified `### Background Job Board`,
+   - include compact running/terminal unreconciled jobs,
+   - keep aliases short.
+
+6. Do not expose running background jobs as resumable sessions. A running job
+   alias should not be used with running `task(task_id=...)`. Only completed and
+   reconciled sessions should enter the reusable section.
+
+---
+
+## Background Job Board Prompt Context
+
+The orchestrator needs a compact view of active work.
+
+Target injected shape:
+
+```text
+### Background Job Board
+Do not poll running jobs. Reconcile terminal jobs before
+final response.
+
+- exp-4 / ses_abc / explorer / running
+  Objective: Map multiplexer flow
+  Ownership: read-only
+  Dependencies: none
+
+- fix-2 / ses_def / fixer / completed, unreconciled
+  Objective: Update task-session-manager task output handling
+  Ownership: src/hooks/task-session-manager/**
+```
+
+Keep this small. The point is scheduling state, not full task transcripts.
+
+---
+
+## Task Completion Integration
+
+Primary files:
+
+- `src/index.ts`
+- `src/hooks/task-session-manager/index.ts`
+
+Track native `task` output and injected completion messages.
+
+Target flow:
+
+```text
+tool.execute.after(task)
+  → parse task_id + state
+  → update job board
+  → if terminal, attach compact result summary
+  → mark as terminal/unreconciled
+```
+
+The orchestrator prompt should then see terminal jobs and reconcile them before
+continuing dependent work.
+
+Do not rely only on OpenCode auto-resume notifications. The plugin should build
+its own compact scheduler state from tool results and events.
+
+### Auto-injected completion path
+
+Native background tasks can also complete through an OpenCode-injected parent
+message as a synthetic completion message. V2 must ingest that path too.
+
+Parse this in the chat/message transform path that already inspects parent
+conversation messages, most likely `experimental.chat.messages.transform` in the
+same hook family as task-session-manager context injection. If native OpenCode
+adds a dedicated event later, move the parser to that event path.
+
+Add parsing for synthetic completion content containing fields like:
+
+```text
+Background task completed: <description>
+task_id: <id>
+state: completed | error
+
+<task_result>
+...
+</task_result>
+```
+
+That path should update the same shared job-board state as task output.
+Initially parse verified auto-message states only. `cancelled` can still be
+handled through task output unless verified in auto-injected
+messages.
+
+```text
+auto-injected completion message
+  → parse task_id + state + result
+  → update job board
+  → mark terminal/unreconciled
+```
+
+---
+
+## Multiplexer Integration
+
+Primary files:
+
+- `src/multiplexer/session-manager.ts`
+- `src/multiplexer/tmux/index.ts`
+- `src/multiplexer/zellij/index.ts`
+- `src/index.ts`
+
+Current multiplexer behavior is already close to V2:
+
+- child session created → spawn pane,
+- child session busy → ensure pane exists,
+- child session idle/deleted → close pane,
+- fallback polling checks `/session/status`.
+
+V2 requirements:
+
+1. Panes represent child sessions, not parent blocking state.
+2. Parent may continue while panes run.
+3. Pane title should make background work understandable.
+4. Cleanup should be tied to actual child session idle/deleted state, not parent
+   task-tool return.
+5. Tests should cover long-running background children and delayed completion.
+
+Likely first implementation can keep close-on-idle if native child sessions emit
+accurate idle events. Verify with real background tasks before changing cleanup
+semantics.
+
+Potential later improvement:
+
+```text
+[BG explorer] exp-4 Map multiplexer flow
+[BG fixer] fix-2 task-session-manager
+```
+
+---
+
+## Agent Lane Reframing
+
+V2 should describe specialists as execution lanes, not optional helpers.
+
+- Explorer: discovery lane.
+- Librarian: external knowledge lane.
+- Fixer: implementation lane.
+- Designer: UI/UX lane.
+- Oracle: review/risk/architecture lane.
+- Council: high-stakes decision lane.
+- Observer: visual/media lane.
+
+The orchestrator schedules lanes according to dependency and ownership.
+
+---
+
+## Implementation Phases
+
+### Phase 0 — Pre-Prompt Groundwork
+
+Before changing the prompt, build enough parser/job-board behavior that the
+prompt can rely on visible scheduler state.
+
+### Phase 1 — Parser And Job Board Core
+
+- add task launch/status parsers,
+- parse only `task` output with `state: running` as a background launch,
+- add shared in-memory scheduler state,
+- keep timeout as an overlay on `running`, not a native state,
+- keep ownership/dependencies advisory until reliable.
+
+### Phase 2 — Prompt Core
+
+- rewrite orchestrator role,
+- rewrite execution model,
+- rewrite dispatch workflow,
+- update phase reminder,
+- reframe specialists as lanes.
+
+### Phase 3 — Prompt Job Board Injection
+
+- inject compact job board into orchestrator context.
+
+### Phase 4 — Task Completion Handling
+
+- hook `task` output,
+- update job states from status output,
+- mark terminal jobs as unreconciled,
+- keep running jobs visible.
+
+### Phase 5 — Auto-Injected Completion Handling
+
+- parse OpenCode background completion messages,
+- update the same shared job board,
+- prevent jobs from staying stale when the parent auto-resumes.
+
+### Phase 6 — Reconciliation Transition
+
+- mark terminal jobs injected into a prompt as reconciled after the next
+  orchestrator assistant turn completes and the parent session returns idle,
+- test this transition directly.
+
+### Phase 7 — Session/Mux Safety
+
+- verify tmux/zellij pane lifecycle with real background tasks,
+- add tests for delayed completion,
+- adjust close-on-idle only if native events prove insufficient.
+
+## First Code Targets
+
+Start here:
+
+1. `src/utils/task.ts`
+   - task launch/status parsing helpers.
+
+2. `src/utils/background-job-board.ts` or equivalent shared scheduler module
+   - background job state, queries, reconciliation marking, prompt formatting.
+
+3. `src/hooks/task-session-manager/index.ts`
+   - register launches/statuses with the shared job board and avoid exposing
+     running jobs as resumable sessions.
+
+4. `src/index.ts`
+   - route `task` after-hooks into the task-session-manager hook.
+
+5. `src/agents/orchestrator.ts`
+   - prompt role and workflow rewrite after scheduler state exists.
+
+6. `src/config/constants.ts`
+   - phase reminder rewrite.
+
+7. `src/multiplexer/session-manager.test.ts`
+   - keep multiplexer lifecycle coverage aligned with background-job-board-owned
+     task state.
+
+---
+
+## Success Criteria For Core V2
+
+Core V2 is working when:
+
+- orchestrator prompt consistently schedules rather than implements,
+- background `task` output registers running jobs,
+- terminal task output updates job board state,
+- orchestrator context shows running and terminal unreconciled jobs,
+- dependent work waits for terminal results,
+- prompt-level advisory ownership reduces conflicting background workers,
+- multiplexer panes show background child sessions while parent continues,
+- final responses do not depend on unresolved background jobs.
+
+The core invariant:
+
+```text
+task creates jobs; task output or auto-completion finishes jobs; orchestrator
+reconciles jobs.
+```

BIN
img/subtask.png


BIN
img/v2beta.webp


+ 26 - 43
oh-my-opencode-slim.schema.json

@@ -494,7 +494,7 @@
         }
       }
     },
-    "sessionManager": {
+    "backgroundJobs": {
       "type": "object",
       "properties": {
         "maxSessionsPerAgent": {
@@ -576,48 +576,6 @@
         }
       }
     },
-    "todoContinuation": {
-      "type": "object",
-      "properties": {
-        "maxContinuations": {
-          "default": 5,
-          "description": "Maximum consecutive auto-continuations before stopping to ask user",
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 50
-        },
-        "cooldownMs": {
-          "default": 3000,
-          "description": "Delay in ms before auto-continuing (gives user time to abort)",
-          "type": "integer",
-          "minimum": 0,
-          "maximum": 30000
-        },
-        "autoEnable": {
-          "default": false,
-          "description": "Automatically enable auto-continue when the orchestrator session has enough todos",
-          "type": "boolean"
-        },
-        "autoEnableThreshold": {
-          "default": 4,
-          "description": "Number of todos that triggers auto-enable (only used when autoEnable is true)",
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 50
-        }
-      }
-    },
-    "subtask": {
-      "type": "object",
-      "properties": {
-        "timeoutMs": {
-          "description": "Subtask worker timeout in ms. 0 disables the timeout. Defaults to 300000 (5 minutes).",
-          "type": "integer",
-          "minimum": 0,
-          "maximum": 86400000
-        }
-      }
-    },
     "fallback": {
       "type": "object",
       "properties": {
@@ -757,6 +715,31 @@
       "required": [
         "presets"
       ]
+    },
+    "companion": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean"
+        },
+        "position": {
+          "type": "string",
+          "enum": [
+            "bottom-right",
+            "bottom-left",
+            "top-right",
+            "top-left"
+          ]
+        },
+        "size": {
+          "type": "string",
+          "enum": [
+            "small",
+            "medium",
+            "large"
+          ]
+        }
+      }
     }
   },
   "title": "oh-my-opencode-slim",

+ 6 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "1.1.2",
+  "version": "2.0.0-beta.15",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
@@ -49,10 +49,11 @@
     "LICENSE"
   ],
   "scripts": {
+    "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
     "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/* --external @opencode-ai/sdk --external @opencode-ai/sdk/* --external @opentui/core --external @opentui/solid --external jsdom --external zod",
     "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/plugin/* --external @opencode-ai/sdk --external @opencode-ai/sdk/* --external jsdom --external zod",
     "copy:divoom-assets": "bun run scripts/copy-divoom-assets.ts",
-    "build": "bun run build:plugin && bun run build:cli && bun run copy:divoom-assets && tsc --emitDeclarationOnly && bun run generate-schema",
+    "build": "bun run clean:dist && bun run build:plugin && bun run build:cli && bun run copy:divoom-assets && tsc --emitDeclarationOnly && bun run generate-schema",
     "prepare": "bun run build",
     "contributors:add": "all-contributors add",
     "contributors:check": "all-contributors check",
@@ -70,7 +71,9 @@
     "prepublishOnly": "bun run build",
     "release:patch": "npm version patch && git push --follow-tags && npm publish",
     "release:minor": "npm version minor && git push --follow-tags && npm publish",
-    "release:major": "npm version major && git push --follow-tags && npm publish"
+    "release:major": "npm version major && git push --follow-tags && npm publish",
+    "release:beta": "npm version premajor --preid beta && git push --follow-tags && npm publish --tag beta",
+    "release:beta:next": "npm version prerelease --preid beta && git push --follow-tags && npm publish --tag beta"
   },
   "dependencies": {
     "@ast-grep/cli": "^0.42.1",

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

@@ -42,6 +42,8 @@ const packagedRequiredFiles = [
   'src/skills/simplify/SKILL.md',
   'src/skills/codemap/SKILL.md',
   'src/skills/clonedeps/SKILL.md',
+  'src/skills/deepwork/SKILL.md',
+  'src/skills/oh-my-opencode-slim/SKILL.md',
 ];
 
 function fail(message: string): never {

+ 3 - 0
src/agents/council.ts

@@ -1,3 +1,4 @@
+import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import { shortModelLabel } from '../utils/session';
 import { type AgentDefinition, resolvePrompt } from './orchestrator';
 
@@ -41,6 +42,8 @@ key insight and unique contribution by name
 - Be transparent about trade-offs when different approaches have valid pros/cons
 - Don't just average responses — choose the best approach and improve upon it
 
+${READONLY_FILE_OPERATIONS_RULES}
+
 **Required Output Format**:
 Always include these sections in your final response:
 

+ 3 - 0
src/agents/councillor.ts

@@ -1,3 +1,4 @@
+import { NO_SHELL_READONLY_FILE_OPERATIONS_RULES } from '../config';
 import { type AgentDefinition, resolvePrompt } from './orchestrator';
 
 /**
@@ -30,6 +31,8 @@ problem.
 You CANNOT edit files, write files, run shell commands, or delegate to \
 other agents. You are an advisor, not an implementer.
 
+${NO_SHELL_READONLY_FILE_OPERATIONS_RULES}
+
 **Behavior**:
 - **Examine the codebase** before answering — your read access is what makes \
   council valuable. Don't guess at code you can see.

+ 4 - 0
src/agents/designer.ts

@@ -1,3 +1,4 @@
+import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who creates and reviews intentional, polished experiences.
@@ -47,6 +48,9 @@ const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX specialist who cr
 - Respect existing design systems when present
 - Leverage component libraries where available
 - Prioritize visual excellence—code perfection comes second
+- Use grounded, normal, regular english - don't use jargon or overly technical language
+
+${WRITABLE_FILE_OPERATIONS_RULES}
 
 ## Review Responsibilities
 - Review existing UI for usability, responsiveness, visual consistency, and polish when asked

+ 3 - 0
src/agents/explorer.ts

@@ -1,3 +1,4 @@
+import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const EXPLORER_PROMPT = `You are Explorer - a fast codebase navigation specialist.
@@ -9,6 +10,8 @@ const EXPLORER_PROMPT = `You are Explorer - a fast codebase navigation specialis
 - **Structural patterns** (function shapes, class structures): ast_grep_search
 - **File discovery** (find by name/extension): glob
 
+${READONLY_FILE_OPERATIONS_RULES}
+
 **Behavior**:
 - Be fast and thorough
 - Fire multiple searches in parallel if needed

+ 4 - 1
src/agents/fixer.ts

@@ -1,3 +1,4 @@
+import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
@@ -13,8 +14,10 @@ const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 - Run relevant validation when requested or clearly applicable (otherwise note as skipped with reason)
 - Report completion with summary of changes
 
+${WRITABLE_FILE_OPERATIONS_RULES}
+
 **Constraints**:
-- NO external research (no websearch, context7, grep_app)
+- NO external research (no websearch, context7, gh_grep)
 - NO delegation or spawning subagents
 - No multi-step research/planning; minimal execution sequence ok
 - If context is insufficient: use grep/glob/read directly — do not delegate

+ 18 - 2
src/agents/index.test.ts

@@ -146,6 +146,12 @@ describe('orchestrator agent', () => {
     );
   });
 
+  test('orchestrator is allowed to invoke cancel_task', () => {
+    const agents = createAgents();
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect((orchestrator?.config.permission as any).cancel_task).toBe('allow');
+  });
+
   test('orchestrator accepts overrides', () => {
     const config: PluginConfig = {
       agents: {
@@ -305,6 +311,16 @@ describe('tool permissions', () => {
     const councillor = agents.find((a) => a.name === 'councillor');
     expect((councillor?.config.permission as any).council_session).toBe('deny');
   });
+
+  test('subagents are denied access to cancel_task', () => {
+    const agents = createAgents({
+      council: councilConfig(),
+    });
+    for (const name of ['oracle', 'explorer', 'fixer', 'council']) {
+      const agent = agents.find((a) => a.name === name);
+      expect((agent?.config.permission as any).cancel_task).toBe('deny');
+    }
+  });
 });
 
 describe('isSubagent type guard', () => {
@@ -745,9 +761,9 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
     expect(result.success).toBe(true);
   });
 
-  test('accepts sessionManager config', () => {
+  test('accepts backgroundJobs config', () => {
     const result = PluginConfigSchema.safeParse({
-      sessionManager: {
+      backgroundJobs: {
         maxSessionsPerAgent: 2,
         readContextMinLines: 10,
         readContextMaxFiles: 8,

+ 5 - 0
src/agents/index.ts

@@ -37,6 +37,7 @@ type AgentFactory = (
 ) => AgentDefinition;
 
 const COUNCIL_TOOL_ALLOWED_AGENTS = new Set(['council']);
+const CANCEL_TASK_ALLOWED_AGENTS = new Set(['orchestrator']);
 const SAFE_AGENT_ALIAS_RE = /^[a-z][a-z0-9_-]*$/i;
 
 function normalizeDisplayName(displayName: string): string {
@@ -179,11 +180,15 @@ function applyDefaultPermissions(
   const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name)
     ? (existing.council_session ?? 'allow')
     : 'deny';
+  const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name)
+    ? (existing.cancel_task ?? 'allow')
+    : 'deny';
 
   agent.config.permission = {
     ...existing,
     question: questionPerm,
     council_session: councilSessionPerm,
+    cancel_task: cancelTaskPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {
       ...(typeof existing.skill === 'object' ? existing.skill : {}),

+ 4 - 1
src/agents/librarian.ts

@@ -1,3 +1,4 @@
+import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebases and documentation.
@@ -12,9 +13,11 @@ const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebase
 
 **Tools to Use**:
 - context7: Official documentation lookup
-- grep_app: Search GitHub repositories
+- gh_grep: Search GitHub repositories
 - websearch: General web search for docs
 
+${READONLY_FILE_OPERATIONS_RULES}
+
 **Behavior**:
 - Provide evidence-based answers with sources
 - Quote relevant code snippets

+ 3 - 0
src/agents/observer.ts

@@ -1,3 +1,4 @@
+import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const OBSERVER_PROMPT = `You are Observer — a visual analysis specialist.
@@ -17,6 +18,8 @@ const OBSERVER_PROMPT = `You are Observer — a visual analysis specialist.
 - Save context tokens — the Orchestrator never processes the raw file
 - Match the language of the request
 - If info not found, state clearly what's missing
+
+${READONLY_FILE_OPERATIONS_RULES}
 `;
 
 export function createObserverAgent(

+ 3 - 0
src/agents/oracle.ts

@@ -1,3 +1,4 @@
+import { READONLY_FILE_OPERATIONS_RULES } from '../config';
 import type { AgentDefinition } from './orchestrator';
 
 const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor and code reviewer.
@@ -22,6 +23,8 @@ const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor and code r
 - READ-ONLY: You advise, you don't implement
 - Focus on strategy, not execution
 - Point to specific files/lines when relevant
+
+${READONLY_FILE_OPERATIONS_RULES}
 `;
 
 export function createOracleAgent(

+ 75 - 75
src/agents/orchestrator.ts

@@ -1,4 +1,5 @@
 import type { AgentConfig } from '@opencode-ai/sdk/v2';
+import { WRITABLE_FILE_OPERATIONS_RULES } from '../config';
 
 export interface AgentDefinition {
   name: string;
@@ -27,50 +28,56 @@ export function resolvePrompt(
 // Agent descriptions for the orchestrator prompt
 const AGENT_DESCRIPTIONS: Record<string, string> = {
   explorer: `@explorer
-- Role: Parallel search specialist for discovering unknowns across the codebase
-- Permissions: Read files
+- Lane: Fast codebase recon that returns compressed context
+- Permissions: read_files
 - Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
 - Capabilities: Glob, grep, AST queries to locate files, symbols, patterns
 - **Delegate when:** Need to discover what exists before planning • Parallel searches speed discovery • Need summarized map vs full contents • Broad/uncertain scope
 - **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
 
   librarian: `@librarian
-- Role: Authoritative source for current library docs and API references
-- Permissions: External docs/search MCPs; no file edits
-- Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
-- Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
-- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices
+- Lane: External knowledge and library research, fast web research
+- Role: Authoritative source for current library docs, API references, examples, bug investigations, and web retrieval
+- Stats: 2x faster web research than orchestrator, 1/2 cost of orchestrator
+- **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices • Working on fixing tricky bug or problem and need latest web research information
 - **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
-- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → yourself.`,
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly. How does others solve or workaround this tricky issue?" → @librarian.`,
 
   oracle: `@oracle
+- Lane: Architecture, risk, debugging strategy, and review
 - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
-- Permissions: Read files
+- Permissions: read_files
 - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
 - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
 - **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • When a workflow calls for a **reviewer** subagent • Code needs simplification or YAGNI scrutiny
 - **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
-- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Just do it and PR? → yourself.`,
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.`,
 
   designer: `@designer
-- Role: UI/UX specialist for intentional, polished experiences
-- Permissions: Read/write files
+- Lane: UI/UX design, related edits, design polish and review
+- Permissions: read_files, write_files
 - Stats: 10x better UI/UX than orchestrator
-- Capabilities: Visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
+- Capabilities: Good design taste, visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
+- Owns visual and interaction quality: layout, hierarchy, spacing, motion, affordances, responsive behavior, and overall feel.
+- Weakness: copywriting. Ask designer to use grounded, normal wording, then have orchestrator review/fix copy after design work without changing visual or interaction intent.
+- Avoid: "Let me us designer how it should look and implement yourself" → instead: "Let me ask designer to design and implement the UI/UX changes for me"
 - **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
-- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet
-- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional? → yourself.`,
+- **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet.
+- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.`,
 
   fixer: `@fixer
-- Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
-- Permissions: Read/write files
-- Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
+- Lane: Bounded implementation and executioner
+- Role: Fast execution specialist for well-defined tasks
+- Permissions: read_files, write_files
+- Stats: 2x faster code edits, 1/2 cost of orchestrator
+- Weakness: design, taste
 - Tools/Constraints: Execution-focused—no research, no architectural decisions
-- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Writing or updating tests • Tasks that touch test files, fixtures, mocks, or test helpers. Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
-- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Sequential dependencies
-- **Rule of thumb:** Explaining > doing? → yourself. Test file modifications and bounded implementation work usually go to @fixer. Bigger or lots of edits, splitting makes sense, parallelized by spawning @fixers per certain scope.`,
+- **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
+- **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Requires design taste, visual hierarchy, interaction polish, responsive layout decisions, animation/motion, component feel, or UI copy/design trade-offs
+- **Rule of thumb:** Headless/mechanical implementation → @fixer. User-visible design or polish → @designer. If @designer already set direction, @fixer may only do bounded mechanical follow-up that preserves that design exactly.`,
 
   council: `@council
+- Lane: High-stakes multi-model decision support
 - Role: Multi-LLM consensus engine that runs several councillors, synthesizes their views, and returns a structured council report.
 - Permissions: Read files
 - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
@@ -79,24 +86,25 @@ const AGENT_DESCRIPTIONS: Record<string, string> = {
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
 - **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
 - **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
-- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert agent or direct execution? → use the specialist or yourself.`,
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.`,
 
   observer: `@observer
+- Lane: Visual/media analysis isolated from orchestrator context
 - Role: Visual analysis specialist for images, PDFs, and diagrams
 - Permissions: Read files
 - Stats: Saves main context tokens — Observer processes raw files, returns structured observations
 - Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
 - **Delegate when:** Need to analyze a multimedia file• Extract information
 - **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
-- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer — it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for editing? → Read it yourself.
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer — it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
 - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png — describe the UI elements and error messages."`,
 };
 
 // Validation routing lines that reference agents
 const VALIDATION_ROUTING = [
   '- Route UI/UX validation and review to @designer',
-  '- Route code review, simplification, maintainability review, and YAGNI checks to @oracle',
-  '- Route test writing, test updates, and changes touching test files to @fixer',
+  '- Route code review, code simplification and maintainability review checks to @oracle',
+  '- Route implementation to @fixer or multiple @fixer instances for maximum parallel execution',
   '- Route visual/media analysis and interpretation to @observer',
   '- If a request spans multiple lanes, delegate only the lanes that add clear value',
 ];
@@ -138,7 +146,10 @@ export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
   ).join('\n');
 
   return `<Role>
-You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
+You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
+
+Optimize for quality, speed, cost, and reliability by dispatching the right specialist lanes, tracking background task state, and integrating terminal results into one coherent outcome.
+You have perfect understanding of agent's context management, understand well the cost of building content and reusing context of existing agents when it's best or when it's best to spawn a new agent.
 </Role>
 
 <Agents>
@@ -153,70 +164,59 @@ ${enabledAgents}
 Parse request: explicit requirements + implicit needs.
 
 ## 2. Path Selection
-Evaluate approach by: quality, speed, cost, reliability.
+Evaluate approach by: quality, speed and cost.
 Choose the path that optimizes all four.
 
 ## 3. Delegation Check
-**STOP. Review specialists before acting.**
+Review available agents and lane rules.
 
-!!! Review available agents and delegation rules. Decide whether to delegate or do it yourself. !!!
-
-**Delegation efficiency:**
+**Dispatch efficiency:**
 - Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
-- Provide context summaries, let specialists read what they need
 - Brief user on delegation goal before each call
-- Skip delegation if overhead ≥ doing it yourself
-
-## 4. Split and Parallelize
-Can tasks be split into subtasks and run in parallel?
-${enabledParallelExamples}
+- For trivial conversational answers or tiny mechanical edits, direct execution is allowed when scheduling overhead would clearly dominate
+- Record task IDs, state, and advisory ownership/dependency labels
+- Do not immediately wait after spawning independent background tasks unless the next step truly depends on their result
+- Reconcile results, resolve conflicts, and gate dependent lanes
 
-Balance: respect dependencies, avoid parallelizing what must be sequential.
+${WRITABLE_FILE_OPERATIONS_RULES}
 
-### Context Isolation
-If no specialist delegation is needed, consider \`subtask\` before doing
-context-heavy work directly.
+## 4. Plan and Parallelize
+Build a short work graph before dispatching:
+- Independent lanes that can run now
+- Dependency-ordered lanes that must wait
+- Advisory ownership for write-capable lanes
+- Verification/review lanes that run after implementation
 
-Ask whether the parent context needs the details or only the result. Use
-\`subtask\` when the work is bounded, context-heavy, and the parent only needs a
-compact outcome.
-
-Use \`subtask\` for focused investigation, bounded analysis, cleanup, or
-verification across files/logs/messages.
-
-Do not use \`subtask\` for tiny tasks, open-ended work, interactive decisions,
-work better handled by a named specialist, or cases where the parent must reason
-over the details.
-
-When calling \`subtask\`, give a self-contained prompt with objective,
-constraints, relevant context, deliverable, and validation. Pass only clearly
-relevant files. Wait for the summary, then integrate and verify it.
-
-### OpenCode subagent execution model
-- A delegated specialist runs in a separate child session.
-- Delegation is blocking for the parent at that point: send work out, then continue that line after results return.
-- Parallel delegation means launching multiple independent child-session branches.
-- Only parallelize branches that are truly independent; reconcile dependent steps after delegated results come back.
+Can tasks be split into background specialist work?
+${enabledParallelExamples}
 
-## 5. Execute
-1. Break complex tasks into todos
-2. Fire parallel research/implementation
-3. Delegate to specialists or do it yourself based on step 3
-4. Integrate results
-5. Adjust if needed
+Balance: respect dependencies, avoid parallelizing what must be sequential, and avoid overlapping write ownership.
+
+### Background Task Discipline
+- Prefer \`task(..., background: true)\` for delegated work that can run independently.
+- Track each task's specialist, objective, task/session ID, and file/topic ownership.
+- Continue orchestration only on non-overlapping work; otherwise briefly report what was launched and stop.
+- Before local edits or another writer task, compare against running task scopes.
+- Parallel background tasks are allowed only when their write scopes do not conflict.
+- Before final response, reconcile any terminal jobs shown in the Background Job Board.
+- Use \`cancel_task\` only when the user asks, or when a running lane is obsolete, wrong, or conflicts with a safer replacement plan.
+- Cancellation is not rollback: if cancelling a writer, inspect and reconcile partial file changes before launching a replacement lane.
+
+### Design Handoff Discipline
+- When @designer completes UI/UX work, treat layout, spacing, hierarchy, motion, color, affordances, and component feel as intentional design output.
+- Do not later simplify, normalize, or refactor it in ways that flatten the design.
+- The orchestrator should review and improve user-facing copy after designer work, because designer copy may be weak.
+- Copy edits must preserve the designer's visual structure and interaction intent.
+- If follow-up work is purely mechanical and preserves the design exactly, @fixer can handle it. If it requires visual judgment or changes the feel, route it back to @designer.
 
 ### Session Reuse
 - Smartly reuse an available specialist session - context reuse saves time and tokens
 - When too much unrelated, and really needed, start a fresh session with the specialist
 - If multiple remembered sessions fit, prefer the most recently used matching session.
 - Prefer re-uses over creating new sessions all the time
-
-### Auto-Continue
-When working through multi-step tasks, consider enabling auto-continue to avoid stopping between batches:
-- **Enable when:** User requests autonomous/batch work, or you create 4+ todos in a session
-- **Don't enable when:** User is in an interactive/conversational flow, or each step needs explicit review
-- Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
-- The user can toggle this anytime via the \`/auto-continue\` command.
+- When reusing a specialist session, you MUST pass the existing session or alias in the task tool's \`task_id\` argument. Saying "reuse" in prose is not enough.
+- If the Background Job Board lists \`fix-1 / ses_abc / fixer\`, call task with \`subagent_type: "fixer"\` and \`task_id: "fix-1"\` or \`task_id: "ses_abc"\`.
+- Do not leave \`task_id\` empty when intending to reuse; omitted or empty \`task_id\` creates a new specialist session.
 
 ### Validation routing
 - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
@@ -258,7 +258,7 @@ When user's approach seems problematic:
 **Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
 
 **Good:** "Checking Next.js App Router docs via @librarian..."
-[proceeds with implementation]
+[continues scheduling or integration]
 
 </Communication>
 `;

+ 235 - 0
src/cli/background-subagents.test.ts

@@ -0,0 +1,235 @@
+/// <reference types="bun-types" />
+
+import { afterEach, describe, expect, spyOn, test } from 'bun:test';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+  detectBackgroundSubagentsTarget,
+  detectShellKind,
+  expandHomePath,
+  getBackgroundSubagentsBlock,
+  isBackgroundSubagentsEnabled,
+  manualBackgroundSubagentsInstructions,
+  upsertBackgroundSubagentsBlock,
+  writeBackgroundSubagentsBlock,
+} from './background-subagents';
+import { parseArgs } from './index';
+import {
+  configureBackgroundSubagents,
+  shouldPromptForBackgroundSubagents,
+} from './install';
+
+describe('background subagents helpers', () => {
+  test('detects true-like environment values', () => {
+    expect(isBackgroundSubagentsEnabled('true')).toBe(true);
+    expect(isBackgroundSubagentsEnabled('1')).toBe(true);
+    expect(isBackgroundSubagentsEnabled('yes')).toBe(true);
+    expect(isBackgroundSubagentsEnabled('false')).toBe(false);
+    expect(isBackgroundSubagentsEnabled('0')).toBe(false);
+    expect(isBackgroundSubagentsEnabled(undefined)).toBe(false);
+  });
+
+  test('detects supported shell kinds', () => {
+    expect(detectShellKind('/bin/zsh')).toBe('zsh');
+    expect(detectShellKind('/usr/local/bin/bash')).toBe('bash');
+    expect(detectShellKind('/opt/homebrew/bin/fish')).toBe('fish');
+    expect(detectShellKind('/bin/sh')).toBeUndefined();
+  });
+
+  test('detects shell startup targets including fish XDG config', () => {
+    expect(
+      detectBackgroundSubagentsTarget({ SHELL: '/bin/zsh' })?.endsWith(
+        '/.zshrc',
+      ),
+    ).toBe(true);
+    expect(
+      detectBackgroundSubagentsTarget({ SHELL: '/bin/bash' })?.endsWith(
+        '/.bashrc',
+      ),
+    ).toBe(true);
+    expect(
+      detectBackgroundSubagentsTarget({
+        SHELL: '/usr/bin/fish',
+        XDG_CONFIG_HOME: '/tmp/xdg',
+      }),
+    ).toBe('/tmp/xdg/fish/conf.d/opencode-background-subagents.fish');
+  });
+
+  test('builds shell-specific managed blocks with true', () => {
+    expect(getBackgroundSubagentsBlock('/tmp/.bashrc')).toContain(
+      'export OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true',
+    );
+    expect(getBackgroundSubagentsBlock('/tmp/config.fish')).toContain(
+      'set -gx OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS true',
+    );
+  });
+
+  test('prints fish manual instructions for fish targets', () => {
+    const instructions = manualBackgroundSubagentsInstructions({
+      targetPath: '/tmp/config.fish',
+    });
+
+    expect(instructions).toContain(
+      'set -gx OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS true',
+    );
+    expect(instructions).toContain(
+      'env OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode',
+    );
+  });
+
+  test('expands tilde target paths', () => {
+    expect(expandHomePath('~')).not.toBe('~');
+    expect(expandHomePath('~/profile')).not.toContain('~');
+    expect(expandHomePath('/tmp/profile')).toBe('/tmp/profile');
+  });
+
+  test('upserts the managed block idempotently', () => {
+    const first = upsertBackgroundSubagentsBlock('before\n', 'BLOCK');
+    const second = upsertBackgroundSubagentsBlock(
+      first,
+      getBackgroundSubagentsBlock('/tmp/.zshrc'),
+    );
+    const third = upsertBackgroundSubagentsBlock(
+      second,
+      getBackgroundSubagentsBlock('/tmp/.zshrc'),
+    );
+
+    expect(third).toBe(second);
+    expect(
+      third.match(/OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS/g),
+    ).toHaveLength(1);
+  });
+});
+
+describe('background subagents writing', () => {
+  let tempDir: string | undefined;
+
+  afterEach(() => {
+    if (tempDir) rmSync(tempDir, { recursive: true, force: true });
+    tempDir = undefined;
+  });
+
+  test('writes managed block without duplicates', () => {
+    tempDir = mkdtempSync(join(tmpdir(), 'omoo-bg-'));
+    const target = join(tempDir, '.bashrc');
+    writeFileSync(target, 'existing=true\n');
+
+    writeBackgroundSubagentsBlock(target);
+    writeBackgroundSubagentsBlock(target);
+
+    const content = readFileSync(target, 'utf8');
+    expect(content).toContain('existing=true');
+    expect(
+      content.match(/OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS/g),
+    ).toHaveLength(1);
+  });
+});
+
+describe('parseArgs background subagents', () => {
+  test('parses mode and target override', () => {
+    expect(
+      parseArgs([
+        '--background-subagents=yes',
+        '--background-subagents-target=/tmp/profile',
+      ]),
+    ).toMatchObject({
+      backgroundSubagents: 'yes',
+      backgroundSubagentsTarget: '/tmp/profile',
+    });
+  });
+
+  test('--no-tui defaults background subagents to no', () => {
+    expect(parseArgs(['--no-tui']).backgroundSubagents).toBe('no');
+  });
+});
+
+describe('configureBackgroundSubagents', () => {
+  let tempDir: string | undefined;
+  const originalBackgroundEnv =
+    process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS;
+
+  afterEach(() => {
+    if (tempDir) rmSync(tempDir, { recursive: true, force: true });
+    tempDir = undefined;
+    if (originalBackgroundEnv === undefined) {
+      delete process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS;
+    } else {
+      process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS =
+        originalBackgroundEnv;
+    }
+  });
+
+  test('does not prompt for ask mode when noninteractive', async () => {
+    tempDir = mkdtempSync(join(tmpdir(), 'omoo-bg-'));
+    delete process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS;
+    const target = join(tempDir, '.bashrc');
+    const log = spyOn(console, 'log').mockImplementation(() => undefined);
+    const originalIsTty = process.stdin.isTTY;
+    Object.defineProperty(process.stdin, 'isTTY', {
+      configurable: true,
+      value: false,
+    });
+
+    try {
+      expect(
+        shouldPromptForBackgroundSubagents({
+          hasTmux: false,
+          installCustomSkills: false,
+          promptForStar: false,
+          reset: false,
+          backgroundSubagents: 'ask',
+          backgroundSubagentsTarget: target,
+        }),
+      ).toBe(false);
+
+      const result = await configureBackgroundSubagents({
+        hasTmux: false,
+        installCustomSkills: false,
+        promptForStar: false,
+        reset: false,
+        backgroundSubagents: 'ask',
+        backgroundSubagentsTarget: target,
+      });
+
+      expect(result).toEqual({ enabledNow: false });
+      expect(log.mock.calls.join('\n')).toContain(
+        'Skipped background subagents shell configuration.',
+      );
+    } finally {
+      Object.defineProperty(process.stdin, 'isTTY', {
+        configurable: true,
+        value: originalIsTty,
+      });
+      log.mockRestore();
+    }
+  });
+
+  test('returns no configured target when writing shell config fails', async () => {
+    tempDir = mkdtempSync(join(tmpdir(), 'omoo-bg-'));
+    delete process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS;
+    const blockingFile = join(tempDir, 'not-a-directory');
+    writeFileSync(blockingFile, 'already a file');
+    const target = join(blockingFile, '.bashrc');
+    const log = spyOn(console, 'log').mockImplementation(() => undefined);
+
+    try {
+      const result = await configureBackgroundSubagents({
+        hasTmux: false,
+        installCustomSkills: false,
+        promptForStar: false,
+        reset: false,
+        backgroundSubagents: 'yes',
+        backgroundSubagentsTarget: target,
+      });
+
+      expect(result).toEqual({ enabledNow: false });
+      expect(log.mock.calls.join('\n')).toContain(
+        'Could not write background subagents shell config:',
+      );
+      expect(log.mock.calls.join('\n')).toContain('Add the setting manually');
+    } finally {
+      log.mockRestore();
+    }
+  });
+});

+ 109 - 0
src/cli/background-subagents.ts

@@ -0,0 +1,109 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+
+export type BackgroundSubagentsMode = 'ask' | 'yes' | 'no';
+export type ShellKind = 'bash' | 'fish' | 'zsh';
+
+const ENV_NAME = 'OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS';
+const START_MARKER = '# >>> oh-my-opencode-slim background subagents >>>';
+const END_MARKER = '# <<< oh-my-opencode-slim background subagents <<<';
+
+export function isBackgroundSubagentsEnabled(
+  value: string | undefined,
+): boolean {
+  if (!value) return false;
+  const normalized = value.trim().toLowerCase();
+  return normalized !== '' && !['0', 'false', 'no', 'off'].includes(normalized);
+}
+
+export function detectShellKind(
+  shell: string | undefined,
+): ShellKind | undefined {
+  const name = shell?.split('/').at(-1);
+  if (name === 'zsh' || name === 'bash' || name === 'fish') return name;
+  return undefined;
+}
+
+export function detectBackgroundSubagentsTarget(
+  env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+  const shell = detectShellKind(env.SHELL);
+  if (shell === 'zsh') return join(homedir(), '.zshrc');
+  if (shell === 'bash') return join(homedir(), '.bashrc');
+  if (shell === 'fish') {
+    const configHome = env.XDG_CONFIG_HOME || join(homedir(), '.config');
+    return join(
+      configHome,
+      'fish',
+      'conf.d',
+      'opencode-background-subagents.fish',
+    );
+  }
+  return undefined;
+}
+
+export function getBackgroundSubagentsBlock(targetPath: string): string {
+  const isFish = targetPath.endsWith('.fish');
+  const command = isFish
+    ? `set -gx ${ENV_NAME} true`
+    : `export ${ENV_NAME}=true`;
+
+  return `${START_MARKER}\n${command}\n${END_MARKER}`;
+}
+
+export function manualBackgroundSubagentsInstructions(options?: {
+  targetPath?: string;
+  shell?: ShellKind;
+}): string {
+  const shell =
+    options?.shell ??
+    (options?.targetPath?.endsWith('.fish') ? 'fish' : undefined) ??
+    detectShellKind(options?.targetPath);
+  const bashZshSnippet = `export ${ENV_NAME}=true`;
+  const fishSnippet = `set -gx ${ENV_NAME} true`;
+
+  if (shell === 'fish') {
+    return `Start OpenCode with background subagents enabled:\n  env ${ENV_NAME}=true opencode\n\nOr add this to your fish startup file:\n  ${fishSnippet}`;
+  }
+
+  if (shell === 'bash' || shell === 'zsh') {
+    return `Start OpenCode with background subagents enabled:\n  ${ENV_NAME}=true opencode\n\nOr add this to your shell startup file:\n  ${bashZshSnippet}`;
+  }
+
+  return `Start OpenCode with background subagents enabled:\n  ${ENV_NAME}=true opencode\n\nOr add one of these to your shell startup file:\n  bash/zsh: ${bashZshSnippet}\n  fish: ${fishSnippet}`;
+}
+
+export function expandHomePath(targetPath: string): string {
+  if (targetPath === '~') return homedir();
+  if (targetPath.startsWith('~/')) return join(homedir(), targetPath.slice(2));
+  return targetPath;
+}
+
+export function upsertBackgroundSubagentsBlock(
+  content: string,
+  block: string,
+): string {
+  const start = content.indexOf(START_MARKER);
+  const end = content.indexOf(END_MARKER);
+
+  if (start !== -1 && end !== -1 && end > start) {
+    const afterEnd = end + END_MARKER.length;
+    return `${content.slice(0, start)}${block}${content.slice(afterEnd)}`;
+  }
+
+  const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : '';
+  const prefix =
+    content.length > 0 && content.endsWith('\n') ? '\n' : separator;
+  return `${content}${prefix}${block}\n`;
+}
+
+export function writeBackgroundSubagentsBlock(targetPath: string): void {
+  const block = getBackgroundSubagentsBlock(targetPath);
+  const content = existsSync(targetPath)
+    ? readFileSync(targetPath, 'utf8')
+    : '';
+  const nextContent = upsertBackgroundSubagentsBlock(content, block);
+  mkdirSync(dirname(targetPath), { recursive: true });
+  writeFileSync(targetPath, nextContent);
+}

+ 232 - 0
src/cli/companion.ts

@@ -0,0 +1,232 @@
+import { createHash } from 'node:crypto';
+import {
+  chmodSync,
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  renameSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { homedir, tmpdir } from 'node:os';
+import * as path from 'node:path';
+import type { ConfigMergeResult, InstallConfig } from './types';
+
+const COMPANION_VERSION = '0.1.0';
+const COMPANION_TAG = 'companion-v0.1.0';
+const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
+
+export function getCompanionTarget(): string | null {
+  const p = process.platform;
+  const a = process.arch;
+  if (p === 'darwin') {
+    if (a === 'arm64') return 'aarch64-apple-darwin';
+    if (a === 'x64') return 'x86_64-apple-darwin';
+  } else if (p === 'linux') {
+    if (a === 'x64') return 'x86_64-unknown-linux-gnu';
+    if (a === 'arm64') return 'aarch64-unknown-linux-gnu';
+  } else if (p === 'win32') {
+    if (a === 'x64') return 'x86_64-pc-windows-msvc';
+  }
+  return null;
+}
+
+export function getCompanionBinaryPath(): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), '.local', 'share');
+  return path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'bin',
+    process.platform === 'win32'
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion',
+  );
+}
+
+export async function installCompanion(
+  config: InstallConfig,
+): Promise<ConfigMergeResult> {
+  const target = getCompanionTarget();
+  const finalBinaryPath = getCompanionBinaryPath();
+
+  if (!target) {
+    return {
+      success: false,
+      configPath: finalBinaryPath,
+      error: `Unsupported platform/architecture: ${process.platform} ${process.arch}`,
+    };
+  }
+
+  const isWindows = process.platform === 'win32';
+  const ext = isWindows ? 'zip' : 'tar.gz';
+  const archiveName = `oh-my-opencode-slim-companion-v${COMPANION_VERSION}-${target}.${ext}`;
+  const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/${COMPANION_TAG}/${archiveName}`;
+  const checksumUrl = `https://github.com/${GITHUB_REPO}/releases/download/${COMPANION_TAG}/SHA256SUMS`;
+
+  if (config.dryRun) {
+    console.log(`  [dry-run] Detected companion target: ${target}`);
+    console.log(`  [dry-run] Would download archive: ${downloadUrl}`);
+    console.log(`  [dry-run] Would download checksum: ${checksumUrl}`);
+    console.log(
+      '  [dry-run] Would verify via SHA256SUMS unless SKIP_COMPANION_CHECKSUM=true',
+    );
+    console.log(`  [dry-run] Would extract and install to: ${finalBinaryPath}`);
+    return {
+      success: true,
+      configPath: finalBinaryPath,
+    };
+  }
+
+  const skipChecksum = process.env.SKIP_COMPANION_CHECKSUM === 'true';
+  let shaSumsText = '';
+
+  if (!skipChecksum) {
+    try {
+      const res = await fetch(checksumUrl);
+      if (!res.ok) {
+        return {
+          success: false,
+          configPath: finalBinaryPath,
+          error: `Failed to fetch checksum manifest (HTTP ${res.status}): ${res.statusText}. For release bootstrap, you can bypass this error by setting the SKIP_COMPANION_CHECKSUM=true environment variable.`,
+        };
+      }
+      shaSumsText = await res.text();
+    } catch (err) {
+      return {
+        success: false,
+        configPath: finalBinaryPath,
+        error: `Failed to fetch SHA256SUMS: ${err instanceof Error ? err.message : String(err)}. For release bootstrap, you can bypass this error by setting the SKIP_COMPANION_CHECKSUM=true environment variable.`,
+      };
+    }
+  }
+
+  let buffer: ArrayBuffer;
+  try {
+    const res = await fetch(downloadUrl);
+    if (!res.ok) {
+      return {
+        success: false,
+        configPath: finalBinaryPath,
+        error: `Failed to download companion binary (HTTP ${res.status}): ${res.statusText}`,
+      };
+    }
+    buffer = await res.arrayBuffer();
+  } catch (err) {
+    return {
+      success: false,
+      configPath: finalBinaryPath,
+      error: `Failed to fetch companion archive: ${err instanceof Error ? err.message : String(err)}`,
+    };
+  }
+
+  if (!skipChecksum) {
+    const lines = shaSumsText.split('\n');
+    let expectedHash: string | undefined;
+    for (const line of lines) {
+      const parts = line.trim().split(/\s+/);
+      if (parts.length >= 2) {
+        const [hashVal, fileVal] = parts;
+        const cleanFilename = fileVal.startsWith('*')
+          ? fileVal.slice(1)
+          : fileVal;
+        if (cleanFilename === archiveName) {
+          expectedHash = hashVal.toLowerCase();
+          break;
+        }
+      }
+    }
+
+    if (!expectedHash) {
+      return {
+        success: false,
+        configPath: finalBinaryPath,
+        error: `No SHA256 checksum entry found for ${archiveName} in SHA256SUMS. For release bootstrap, you can bypass this error by setting the SKIP_COMPANION_CHECKSUM=true environment variable.`,
+      };
+    }
+
+    const computedHash = createHash('sha256')
+      .update(Buffer.from(buffer))
+      .digest('hex');
+    if (computedHash !== expectedHash) {
+      return {
+        success: false,
+        configPath: finalBinaryPath,
+        error: `SHA256 checksum mismatch for ${archiveName}. Expected ${expectedHash}, got ${computedHash}`,
+      };
+    }
+  }
+
+  let tempDir = '';
+  try {
+    tempDir = mkdtempSync(path.join(tmpdir(), 'companion-install-'));
+    const archivePath = path.join(tempDir, archiveName);
+    writeFileSync(archivePath, Buffer.from(buffer));
+
+    const extractedDir = path.join(tempDir, 'extracted');
+    mkdirSync(extractedDir, { recursive: true });
+
+    if (isWindows) {
+      const { extractZip } = await import('../utils/zip-extractor');
+      await extractZip(archivePath, extractedDir);
+    } else {
+      const { crossSpawn } = await import('../utils/compat');
+      const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', extractedDir]);
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) {
+        const stderr = await proc.stderr();
+        return {
+          success: false,
+          configPath: finalBinaryPath,
+          error: `Archive extraction failed (tar exited with ${exitCode}): ${stderr}`,
+        };
+      }
+    }
+
+    const binaryName = isWindows
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion';
+    const extractedBinaryPath = path.join(extractedDir, binaryName);
+
+    if (!existsSync(extractedBinaryPath)) {
+      return {
+        success: false,
+        configPath: finalBinaryPath,
+        error: `Binary ${binaryName} not found in extracted archive`,
+      };
+    }
+
+    const binDir = path.dirname(finalBinaryPath);
+    mkdirSync(binDir, { recursive: true });
+
+    const tmpFinalPath = `${finalBinaryPath}.tmp`;
+    copyFileSync(extractedBinaryPath, tmpFinalPath);
+
+    if (!isWindows) {
+      chmodSync(tmpFinalPath, 0o755);
+    }
+
+    renameSync(tmpFinalPath, finalBinaryPath);
+
+    return {
+      success: true,
+      configPath: finalBinaryPath,
+    };
+  } catch (err) {
+    return {
+      success: false,
+      configPath: finalBinaryPath,
+      error: `Failed to install companion: ${err instanceof Error ? err.message : String(err)}`,
+    };
+  } finally {
+    if (tempDir) {
+      try {
+        rmSync(tempDir, { recursive: true, force: true });
+      } catch {}
+    }
+  }
+}

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

@@ -46,6 +46,20 @@ export const CUSTOM_SKILLS: CustomSkill[] = [
     allowedAgents: ['orchestrator'],
     sourcePath: 'src/skills/clonedeps',
   },
+  {
+    name: 'deepwork',
+    description:
+      'Heavy/complex coding sessions and large modifications workflow',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/deepwork',
+  },
+  {
+    name: 'oh-my-opencode-slim',
+    description:
+      'Configure, customize, and safely improve oh-my-opencode-slim setups',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/oh-my-opencode-slim',
+  },
 ];
 
 /**

+ 38 - 6
src/cli/index.ts

@@ -2,12 +2,13 @@
 import { doctor, parseDoctorArgs } from './doctor';
 import { install } from './install';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
-import type { BooleanArg, InstallArgs } from './types';
+import type { BackgroundSubagentsArg, BooleanArg, InstallArgs } from './types';
 
-function parseArgs(args: string[]): InstallArgs {
+export function parseArgs(args: string[]): InstallArgs {
   const result: InstallArgs = {
     tui: true,
     skills: 'yes',
+    companion: 'no',
   };
 
   for (const arg of args) {
@@ -15,6 +16,13 @@ function parseArgs(args: string[]): InstallArgs {
       result.tui = false;
     } else if (arg.startsWith('--skills=')) {
       result.skills = arg.split('=')[1] as BooleanArg;
+    } else if (arg.startsWith('--companion=')) {
+      const mode = arg.split('=')[1] as BooleanArg;
+      if (!['yes', 'no'].includes(mode)) {
+        console.error('Unsupported --companion value: use yes or no');
+        process.exit(1);
+      }
+      result.companion = mode;
     } else if (arg.startsWith('--preset=')) {
       const preset = arg.split('=')[1];
       if (!isGeneratedPresetName(preset)) {
@@ -24,6 +32,17 @@ function parseArgs(args: string[]): InstallArgs {
         process.exit(1);
       }
       result.preset = preset;
+    } else if (arg.startsWith('--background-subagents=')) {
+      const mode = arg.split('=')[1] as BackgroundSubagentsArg;
+      if (!['ask', 'yes', 'no'].includes(mode)) {
+        console.error(
+          'Unsupported --background-subagents value: use ask, yes, or no',
+        );
+        process.exit(1);
+      }
+      result.backgroundSubagents = mode;
+    } else if (arg.startsWith('--background-subagents-target=')) {
+      result.backgroundSubagentsTarget = arg.split('=')[1];
     } else if (arg === '--dry-run') {
       result.dryRun = true;
     } else if (arg === '--reset') {
@@ -34,6 +53,9 @@ function parseArgs(args: string[]): InstallArgs {
     }
   }
 
+  result.backgroundSubagents ??=
+    result.tui && process.stdin.isTTY ? 'ask' : 'no';
+
   return result;
 }
 
@@ -47,7 +69,14 @@ Usage:
 
 Options:
   --skills=yes|no        Install bundled skills (default: yes)
+  --companion=yes|no     Install desktop companion binary and enable config
+                         (default: no)
   --preset=<name>        Active generated config preset (default: openai)
+  --background-subagents=ask|yes|no
+                         Persist required OpenCode background subagent env
+                         (default: ask in interactive TTY, otherwise no)
+  --background-subagents-target=<path>
+                         Shell startup file to update
   --no-tui               Non-interactive mode
   --dry-run              Simulate install without writing files
   --reset                Force overwrite of existing configuration
@@ -65,6 +94,7 @@ For the full config reference, see docs/configuration.md.
 Examples:
   bunx oh-my-opencode-slim install
   bunx oh-my-opencode-slim install --no-tui --skills=yes
+  bunx oh-my-opencode-slim install --background-subagents=yes
   bunx oh-my-opencode-slim install --preset=opencode-go
   bunx oh-my-opencode-slim install --reset
   bunx oh-my-opencode-slim doctor
@@ -93,7 +123,9 @@ async function main(): Promise<void> {
   }
 }
 
-main().catch((err) => {
-  console.error('Fatal error:', err);
-  process.exit(1);
-});
+if (import.meta.main) {
+  main().catch((err) => {
+    console.error('Fatal error:', err);
+    process.exit(1);
+  });
+}

+ 124 - 2
src/cli/install.ts

@@ -1,5 +1,14 @@
 import { existsSync } from 'node:fs';
 import { createInterface } from 'node:readline/promises';
+import {
+  detectBackgroundSubagentsTarget,
+  expandHomePath,
+  getBackgroundSubagentsBlock,
+  isBackgroundSubagentsEnabled,
+  manualBackgroundSubagentsInstructions,
+  writeBackgroundSubagentsBlock,
+} from './background-subagents';
+import { installCompanion } from './companion';
 import {
   addPluginToOpenCodeConfig,
   addPluginToOpenCodeTuiConfig,
@@ -130,6 +139,92 @@ async function checkOpenCodeInstalled(): Promise<{
   return { ok: true, version: version ?? undefined, path: path ?? undefined };
 }
 
+export function shouldPromptForBackgroundSubagents(
+  config: InstallConfig,
+): boolean {
+  return Boolean(config.promptForStar && process.stdin.isTTY);
+}
+
+export async function configureBackgroundSubagents(
+  config: InstallConfig,
+): Promise<{ enabledNow: boolean; configuredTarget?: string }> {
+  if (
+    isBackgroundSubagentsEnabled(
+      process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS,
+    )
+  ) {
+    printSuccess(
+      'OpenCode background subagents already enabled in environment',
+    );
+    return { enabledNow: true };
+  }
+
+  const target =
+    config.backgroundSubagentsTarget !== undefined
+      ? expandHomePath(config.backgroundSubagentsTarget)
+      : detectBackgroundSubagentsTarget();
+
+  if (config.backgroundSubagents === 'no') {
+    printInfo('OpenCode background subagents are not enabled.');
+    console.log(manualBackgroundSubagentsInstructions({ targetPath: target }));
+    return { enabledNow: false };
+  }
+
+  if (!target) {
+    printInfo('No safe shell startup file detected.');
+    console.log(manualBackgroundSubagentsInstructions());
+    return { enabledNow: false };
+  }
+
+  const block = getBackgroundSubagentsBlock(target);
+
+  if (config.dryRun) {
+    printInfo(
+      'Dry run mode - background subagents block that would be written:',
+    );
+    console.log(`Target: ${target}`);
+    console.log(`\n${block}\n`);
+    return { enabledNow: false, configuredTarget: target };
+  }
+
+  if (config.backgroundSubagents === 'ask') {
+    if (!shouldPromptForBackgroundSubagents(config)) {
+      printInfo('Skipped background subagents shell configuration.');
+      console.log(
+        manualBackgroundSubagentsInstructions({ targetPath: target }),
+      );
+      return { enabledNow: false };
+    }
+
+    const shouldWrite = await confirm(
+      `Enable OpenCode background subagents in ${target}?`,
+      true,
+    );
+    if (!shouldWrite) {
+      printInfo('Skipped background subagents shell configuration.');
+      console.log(
+        manualBackgroundSubagentsInstructions({ targetPath: target }),
+      );
+      return { enabledNow: false };
+    }
+  }
+
+  try {
+    writeBackgroundSubagentsBlock(target);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    printError(`Could not write background subagents shell config: ${message}`);
+    printInfo('Add the setting manually instead:');
+    console.log(manualBackgroundSubagentsInstructions({ targetPath: target }));
+    return { enabledNow: false };
+  }
+
+  printSuccess(
+    `Background subagents enabled ${SYMBOLS.arrow} ${DIM}${target}${RESET}`,
+  );
+  return { enabledNow: false, configuredTarget: target };
+}
+
 function handleStepResult(
   result: ConfigMergeResult,
   successMsg: string,
@@ -150,8 +245,9 @@ async function runInstall(config: InstallConfig): Promise<number> {
 
   printHeader(isUpdate);
 
-  let totalSteps = 6;
+  let totalSteps = 7;
   if (config.installCustomSkills) totalSteps += 1;
+  if (config.companion === 'yes') totalSteps += 1;
   totalSteps += 1;
 
   let step = 1;
@@ -213,6 +309,15 @@ async function runInstall(config: InstallConfig): Promise<number> {
     if (!handleStepResult(lspResult, 'LSP enabled')) return 1;
   }
 
+  printStep(step++, totalSteps, 'Configuring OpenCode background subagents...');
+  const backgroundSubagents = await configureBackgroundSubagents(config);
+
+  if (config.companion === 'yes') {
+    printStep(step++, totalSteps, 'Installing desktop companion binary...');
+    const companionResult = await installCompanion(config);
+    if (!handleStepResult(companionResult, 'Companion installed')) return 1;
+  }
+
   printStep(step++, totalSteps, 'Writing oh-my-opencode-slim configuration...');
   if (config.dryRun) {
     const liteConfig = generateLiteConfig(config);
@@ -288,7 +393,21 @@ async function runInstall(config: InstallConfig): Promise<number> {
   console.log(`     ${BLUE}${configPath}${RESET}`);
   console.log();
   console.log('  4. Start OpenCode:');
-  console.log(`     ${BLUE}$ opencode${RESET}`);
+  if (backgroundSubagents.enabledNow) {
+    console.log(`     ${BLUE}$ opencode${RESET}`);
+  } else if (backgroundSubagents.configuredTarget) {
+    console.log(
+      `     ${BLUE}$ source ${backgroundSubagents.configuredTarget}${RESET}`,
+    );
+    console.log(`     ${BLUE}$ opencode${RESET}`);
+    console.log(
+      `     ${DIM}Or restart your terminal before running opencode.${RESET}`,
+    );
+  } else {
+    console.log(
+      `     ${BLUE}$ OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode${RESET}`,
+    );
+  }
   console.log();
   console.log('  5. Verify the agents are responding:');
   console.log(`     ${BLUE}> ping all agents${RESET}`);
@@ -320,6 +439,9 @@ export async function install(args: InstallArgs): Promise<number> {
     promptForStar: args.tui,
     dryRun: args.dryRun,
     reset: args.reset ?? false,
+    backgroundSubagents: args.backgroundSubagents ?? 'no',
+    backgroundSubagentsTarget: args.backgroundSubagentsTarget,
+    companion: args.companion,
   };
 
   return runInstall(config);

+ 32 - 1
src/cli/providers.test.ts

@@ -131,6 +131,37 @@ describe('providers', () => {
     expect((config.tmux as any).layout).toBe('main-vertical');
   });
 
+  test('generateLiteConfig companion: yes', () => {
+    const config = generateLiteConfig({
+      hasTmux: false,
+      installCustomSkills: false,
+      reset: false,
+      companion: 'yes',
+    });
+
+    expect(config.companion).toBeDefined();
+    expect((config.companion as any).enabled).toBe(true);
+    expect((config.companion as any).position).toBe('bottom-right');
+    expect((config.companion as any).size).toBe('medium');
+  });
+
+  test('generateLiteConfig companion: no or omitted', () => {
+    const configYes = generateLiteConfig({
+      hasTmux: false,
+      installCustomSkills: false,
+      reset: false,
+      companion: 'no',
+    });
+    expect(configYes.companion).toBeUndefined();
+
+    const configOmitted = generateLiteConfig({
+      hasTmux: false,
+      installCustomSkills: false,
+      reset: false,
+    });
+    expect(configOmitted.companion).toBeUndefined();
+  });
+
   test('generateLiteConfig includes default skills', () => {
     const config = generateLiteConfig({
       hasTmux: false,
@@ -183,7 +214,7 @@ describe('providers', () => {
     expect(agents.orchestrator.mcps).toEqual(['*', '!context7']);
     expect(agents.librarian.mcps).toContain('websearch');
     expect(agents.librarian.mcps).toContain('context7');
-    expect(agents.librarian.mcps).toContain('grep_app');
+    expect(agents.librarian.mcps).toContain('gh_grep');
     expect(agents.designer.mcps).toEqual([]);
   });
 });

+ 8 - 0
src/cli/providers.ts

@@ -145,5 +145,13 @@ export function generateLiteConfig(
     };
   }
 
+  if (installConfig.companion === 'yes') {
+    config.companion = {
+      enabled: true,
+      position: 'bottom-right',
+      size: 'medium',
+    };
+  }
+
   return config;
 }

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

@@ -23,6 +23,8 @@ describe('skills permissions', () => {
 
     const orchestratorPerms = getSkillPermissionsForAgent('orchestrator');
     expect(orchestratorPerms.clonedeps).toBe('allow');
+    expect(orchestratorPerms.deepwork).toBe('allow');
+    expect(orchestratorPerms['oh-my-opencode-slim']).toBe('allow');
   });
 
   it('should honor explicit skill list overrides', () => {

+ 7 - 0
src/cli/types.ts

@@ -1,4 +1,5 @@
 export type BooleanArg = 'yes' | 'no';
+export type BackgroundSubagentsArg = 'ask' | 'yes' | 'no';
 
 export interface InstallArgs {
   tui: boolean;
@@ -6,6 +7,9 @@ export interface InstallArgs {
   preset?: string;
   dryRun?: boolean;
   reset?: boolean;
+  backgroundSubagents?: BackgroundSubagentsArg;
+  backgroundSubagentsTarget?: string;
+  companion?: BooleanArg;
 }
 
 export interface OpenCodeConfig {
@@ -22,6 +26,9 @@ export interface InstallConfig {
   promptForStar?: boolean;
   dryRun?: boolean;
   reset: boolean;
+  backgroundSubagents: BackgroundSubagentsArg;
+  backgroundSubagentsTarget?: string;
+  companion?: BooleanArg;
 }
 
 export interface ConfigMergeResult {

+ 6 - 6
src/codemap.md

@@ -4,13 +4,13 @@
 
 - `src/index.ts` delivers the plugin assembly layer: it loads configuration, resolves agent definitions, precomputes runtime model fallback chains, wires multiplexer/session orchestration, registers tools/MCPs/hooks, and returns the OpenCode plugin registration object.
 - `config/`, `agents/`, `tools/`, `multiplexer/`, `hooks/`, and `utils/` contain the reusable building blocks (loader/schema/constants, agent factories/permission helpers, tool factories, session mirroring managers, hook implementations, and runtime utilities) that power that entry point.
-- `hooks/task-session-manager` is now part of the core plugin flow to support resumable child task sessions with concise aliases and reminder injection for orchestrator calls.
+- `hooks/task-session-manager` is now part of the core plugin flow to support background job-board tracking, concise aliases, and reminder injection for orchestrator calls.
 - `cli/` remains the installer surface (argument parsing, interactive prompts, config edits, skill/provider installation).
 
 ## Design
 
 - Agent creation follows explicit factories (`agents/index.ts`, per-agent creators under `agents/`) with override/permission helpers (`config/schema.ts`, `cli/skills.ts`, `config/agent-mcps.ts`) so defaults live in `config/constants.ts`, prompts can be swapped via `config/loader.ts`, and variant labels propagate through `utils/agent-variant.ts`.
-- Session orchestration combines `SubagentDepthTracker`, `MultiplexerSessionManager`, `CouncilManager`, and `ForegroundFallbackManager`; these coordinate subagent depth limits, pane lifecycle, council session creation, and foreground model failover.
+- Session orchestration combines `SubagentDepthTracker`, `BackgroundJobBoard`, `MultiplexerSessionManager`, `CouncilManager`, and `ForegroundFallbackManager`; these coordinate subagent depth limits, background task state, pane lifecycle, council session creation, and foreground model failover.
 - Hook composition is centralized in `src/index.ts`: lifecycle event handlers and tool transform handlers fan out to specialized hooks, then some hooks post-process system messages in-place for provider compatibility.
 - Supplemental tools bundle AST-grep search/replace, council orchestration, and web fetching behind the OpenCode `tool` interface and are mounted in `index.ts` alongside hooks and MCP helpers.
 
@@ -20,7 +20,7 @@
   - `loadPluginConfig` builds effective config from user/project presets.
   - `createAgents` + `getAgentConfigs` construct final agent registry and resolved prompts.
   - Runtime model chains are built from configured arrays plus fallback chains.
-  - `SubagentDepthTracker`, `MultiplexerSessionManager`, `CouncilManager`, `ForegroundFallbackManager`, and hook factories are initialized before registration.
+  - `SubagentDepthTracker`, shared `BackgroundJobBoard`, `MultiplexerSessionManager`, `CouncilManager`, `ForegroundFallbackManager`, and hook factories are initialized before registration.
 - Plugin registration: `index.ts` merges/overlays agent configs into OpenCode's config, registers tools (`council`, `webfetch`, `ast_grep_*`, todo tools), MCPs (`createBuiltinMcps`), and all hook handlers (`event`, `tool.execute.before/after`, `experimental.chat.system/messages.transform`, `command.execute.before`, etc.).
 - Runtime event flow (`event`): updates depth tree, multiplexer pane state, auto-update checks, interview/preset state, and task-session cleanup for deleted sessions.
 - `experimental.chat.system.transform` pipeline:
@@ -34,8 +34,8 @@
 
 - Connects directly to `@opencode-ai/plugin`: returns the plugin object, mutates runtime agent configuration, handles event hooks, and routes RPC via `ctx.client`/`ctx.client.session`.
 - Integrates with host multiplexer backends through `src/multiplexer`, and with session lifecycle constraints through `SubagentDepthTracker`.
-- Hooks/subtask integration points now include:
-  - `createTaskSessionManagerHook` for resumable Task sessions,
+- Hook integration points now include:
+  - `createTaskSessionManagerHook` for V2 background job board state,
   - `createTodoContinuationHook`, `createPhaseReminderHook`, `createFilterAvailableSkillsHook`, and `createPostFileToolNudgeHook` for chat/tool behavior,
   - `createInterviewManager` / `createPresetManager` command handlers.
-- Utility integration is visible at runtime through `utils/session-manager.ts` + `utils/task.ts` (task resume support), `utils/system-collapse.ts` (system message normalization), and legacy utility support (`logger`, `env`, `polling`, `session`, etc.).
+- Utility integration is visible at runtime through `utils/background-job-board.ts` + `utils/task.ts` (background task state, prompt formatting, and task output parsing), `utils/system-collapse.ts` (system message normalization), and legacy utility support (`logger`, `env`, `polling`, `session`, etc.).

+ 301 - 0
src/companion/manager.test.ts

@@ -0,0 +1,301 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
+import { mkdirSync, readFileSync, rmSync } from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CompanionManager, stateFilePath } from './manager';
+
+// Point writes at a temp dir so tests don't touch the real state file.
+const TEST_DIR = path.join(os.tmpdir(), `companion-test-${process.pid}`);
+const XDG_DIR = path.join(TEST_DIR, 'xdg');
+
+function readState() {
+  return JSON.parse(readFileSync(stateFilePath(), 'utf8'));
+}
+
+beforeEach(() => {
+  mkdirSync(TEST_DIR, { recursive: true });
+  process.env.XDG_DATA_HOME = XDG_DIR;
+});
+
+afterEach(() => {
+  rmSync(TEST_DIR, { recursive: true, force: true });
+  delete process.env.XDG_DATA_HOME;
+});
+
+function make(
+  id = 'test-session',
+  cwd = '/home/user/myproject',
+  config: any = { enabled: true, position: 'bottom-right', size: 'medium' },
+) {
+  return new CompanionManager(id, cwd, config);
+}
+
+describe('CompanionManager', () => {
+  it('writes an intro entry on load', () => {
+    const m = make();
+    m.onLoad();
+    const state = readState();
+    expect(state.version).toBe(1);
+    expect(state.sessions).toHaveLength(1);
+    expect(state.sessions[0].session_id).toBe('test-session');
+    expect(state.sessions[0].cwd).toBe('/home/user/myproject');
+    expect(state.sessions[0].active_agents).toEqual(['intro']);
+    expect(state.sessions[0].status).toBe('idle');
+    expect(state.sessions[0].pid).toBe(process.pid);
+  });
+
+  it('shows orchestrator while orchestrator is busy with no specialists', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'busy',
+    });
+    expect(readState().sessions[0].active_agents).toEqual(['orchestrator']);
+    expect(readState().sessions[0].status).toBe('busy');
+  });
+
+  it('shows a specialist while its session is busy', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'busy',
+    });
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'oracle', status: 'busy' });
+    expect(readState().sessions[0].active_agents).toEqual(['oracle']);
+  });
+
+  it('shows all concurrently busy specialists', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'explorer',
+      status: 'busy',
+    });
+    m.onSessionStatus({ sessionId: 'ses_b', agent: 'fixer', status: 'busy' });
+    m.onSessionStatus({
+      sessionId: 'ses_c',
+      agent: 'librarian',
+      status: 'busy',
+    });
+    const agents = readState().sessions[0].active_agents;
+    expect(agents).toHaveLength(3);
+    expect(agents).toContain('explorer');
+    expect(agents).toContain('fixer');
+    expect(agents).toContain('librarian');
+  });
+
+  it('removes a specialist when its session goes idle', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'busy',
+    });
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'explorer',
+      status: 'busy',
+    });
+    m.onSessionStatus({ sessionId: 'ses_b', agent: 'fixer', status: 'busy' });
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'explorer',
+      status: 'idle',
+    });
+    expect(readState().sessions[0].active_agents).toEqual(['fixer']);
+  });
+
+  it('falls back to orchestrator when last specialist finishes but orchestrator still busy', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'busy',
+    });
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'oracle', status: 'busy' });
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'oracle', status: 'idle' });
+    expect(readState().sessions[0].active_agents).toEqual(['orchestrator']);
+  });
+
+  it('keeps background specialists visible when orchestrator goes idle', () => {
+    // Background orchestration: orchestrator dispatches and idles while the
+    // specialist keeps running in its own session.
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'busy',
+    });
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'fixer', status: 'busy' });
+    m.onSessionStatus({
+      sessionId: 'ses_orch',
+      agent: 'orchestrator',
+      status: 'idle',
+    });
+    expect(readState().sessions[0].active_agents).toEqual(['fixer']);
+    // Specialist finishes afterwards → back to intro
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'fixer', status: 'idle' });
+    expect(readState().sessions[0].active_agents).toEqual(['intro']);
+    expect(readState().sessions[0].status).toBe('idle');
+  });
+
+  it('removes a finished specialist even when its agent name is unknown', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'oracle', status: 'busy' });
+    m.onSessionStatus({ sessionId: 'ses_a', agent: undefined, status: 'idle' });
+    expect(readState().sessions[0].active_agents).toEqual(['intro']);
+  });
+
+  it('removes a specialist when its session is deleted', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'explorer',
+      status: 'busy',
+    });
+    m.onSessionDeleted('ses_a');
+    expect(readState().sessions[0].active_agents).toEqual(['intro']);
+  });
+
+  it('ignores status events without agent or with unknown status', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({ sessionId: 'ses_x', agent: undefined, status: 'busy' });
+    m.onSessionStatus({ sessionId: 'ses_y', agent: 'fixer', status: 'retry' });
+    expect(readState().sessions[0].active_agents).toEqual(['intro']);
+  });
+
+  it('shows input gif while waiting for user input', () => {
+    const m = make();
+    m.onLoad();
+    m.onWaitingInput();
+    expect(readState().sessions[0].active_agents).toEqual(['input']);
+    expect(readState().sessions[0].status).toBe('waiting-input');
+    m.onInputResolved();
+    expect(readState().sessions[0].status).toBe('idle');
+  });
+
+  it('keeps showing busy specialists over the input gif after input resolves', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'designer',
+      status: 'busy',
+    });
+    m.onWaitingInput();
+    m.onInputResolved();
+    expect(readState().sessions[0].status).toBe('busy');
+    expect(readState().sessions[0].active_agents).toEqual(['designer']);
+  });
+
+  it('deduplicates by session, not by agent type', () => {
+    const m = make();
+    m.onLoad();
+    m.onSessionStatus({ sessionId: 'ses_a', agent: 'fixer', status: 'busy' });
+    m.onSessionStatus({ sessionId: 'ses_b', agent: 'fixer', status: 'busy' });
+    expect(readState().sessions[0].active_agents).toEqual(['fixer', 'fixer']);
+  });
+
+  it('removes its entry on exit', () => {
+    const m = make('sess-a', '/a');
+    const m2 = make('sess-b', '/b');
+    m.onLoad();
+    m2.onLoad();
+    expect(readState().sessions).toHaveLength(2);
+    m.onExit();
+    const state = readState();
+    expect(state.sessions).toHaveLength(1);
+    expect(state.sessions[0].session_id).toBe('sess-b');
+  });
+
+  it('coexists with a second session without clobbering either', () => {
+    const a = make('a', '/proj/alpha');
+    const b = make('b', '/proj/beta');
+    a.onLoad();
+    b.onLoad();
+    a.onSessionStatus({
+      sessionId: 'ses_1',
+      agent: 'designer',
+      status: 'busy',
+    });
+    b.onSessionStatus({
+      sessionId: 'ses_2',
+      agent: 'librarian',
+      status: 'busy',
+    });
+    const state = readState();
+    const sa = state.sessions.find(
+      (s: { session_id: string }) => s.session_id === 'a',
+    );
+    const sb = state.sessions.find(
+      (s: { session_id: string }) => s.session_id === 'b',
+    );
+    expect(sa.active_agents).toEqual(['designer']);
+    expect(sb.active_agents).toEqual(['librarian']);
+  });
+
+  it('is disabled by default and does not write state', () => {
+    const m = new CompanionManager('test-disabled', '/path');
+    m.onLoad();
+    expect(() => readState()).toThrow(); // File shouldn't exist because it's disabled: false by default
+  });
+
+  it('enabled writes config defaults', () => {
+    const m = make('test-defaults', '/path', {
+      enabled: true,
+      position: 'bottom-right',
+      size: 'medium',
+    });
+    m.onLoad();
+    const state = readState();
+    expect(state.config).toEqual({
+      enabled: true,
+      position: 'bottom-right',
+      size: 'medium',
+    });
+  });
+
+  it('supports custom position and size', () => {
+    const m = make('test-custom', '/path', {
+      enabled: true,
+      position: 'top-left',
+      size: 'large',
+    });
+    m.onLoad();
+    const state = readState();
+    expect(state.config).toEqual({
+      enabled: true,
+      position: 'top-left',
+      size: 'large',
+    });
+  });
+
+  it('methods are no-ops when disabled', () => {
+    const m = new CompanionManager('test-noop', '/path', {
+      enabled: false,
+      position: 'bottom-right',
+      size: 'medium',
+    });
+    m.onLoad();
+    m.onSessionStatus({
+      sessionId: 'ses_a',
+      agent: 'explorer',
+      status: 'busy',
+    });
+    m.onWaitingInput();
+    m.onInputResolved();
+    m.onSessionDeleted('ses_a');
+    expect(() => readState()).toThrow();
+  });
+});

+ 260 - 0
src/companion/manager.ts

@@ -0,0 +1,260 @@
+import { spawn } from 'node:child_process';
+import {
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  renameSync,
+  writeFileSync,
+} from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import type { CompanionConfig } from '../config/schema';
+import { log } from '../utils/logger';
+
+interface CompanionSession {
+  session_id: string;
+  cwd: string;
+  active_agents: string[];
+  status: string;
+  pid: number;
+}
+
+interface CompanionState {
+  version: 1;
+  sessions: CompanionSession[];
+  config?: {
+    enabled: boolean;
+    position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
+    size: 'small' | 'medium' | 'large';
+  };
+}
+
+export function stateFilePath(): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg)
+      ? xdg
+      : path.join(os.homedir(), '.local', 'share');
+  return path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'companion-state.json',
+  );
+}
+
+function binaryPath(): string | null {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg)
+      ? xdg
+      : path.join(os.homedir(), '.local', 'share');
+  const binaryName =
+    os.platform() === 'win32'
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion';
+  const bin = path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'bin',
+    binaryName,
+  );
+  return existsSync(bin) ? bin : null;
+}
+
+function readState(): CompanionState {
+  try {
+    const raw = readFileSync(stateFilePath(), 'utf8');
+    const parsed = JSON.parse(raw) as Partial<CompanionState>;
+    if (parsed?.version === 1 && Array.isArray(parsed.sessions)) {
+      return parsed as CompanionState;
+    }
+  } catch {}
+  return { version: 1, sessions: [] };
+}
+
+function writeState(state: CompanionState): void {
+  const file = stateFilePath();
+  try {
+    mkdirSync(path.dirname(file), { recursive: true });
+    const tmp = `${file}.tmp`;
+    writeFileSync(tmp, JSON.stringify(state));
+    renameSync(tmp, file);
+  } catch (err) {
+    log('[companion] write failed', String(err));
+  }
+}
+
+/**
+ * Tracks live agent activity per session and mirrors it to the companion
+ * state file. Source of truth is OpenCode's session.status events: every
+ * spawned specialist (foreground or background) runs in its own session,
+ * which reports busy/idle independently. Tool-call lifecycles are NOT used
+ * because background Task launches return immediately while the agent keeps
+ * running in its child session.
+ */
+export class CompanionManager {
+  private readonly id: string;
+  private readonly cwd: string;
+  private status = 'idle';
+  /** sessionId → agent name, for sessions currently busy. */
+  private readonly busyAgentSessions = new Map<string, string>();
+  private readonly config?: CompanionConfig;
+
+  constructor(sessionId: string, cwd: string, config?: CompanionConfig) {
+    this.id = sessionId;
+    this.cwd = cwd;
+    this.config = config;
+  }
+
+  onLoad(): void {
+    if (this.config?.enabled !== true) {
+      try {
+        const state = readState();
+        const filtered = state.sessions.filter((s) => s.session_id !== this.id);
+        if (filtered.length !== state.sessions.length) {
+          state.sessions = filtered;
+          writeState(state);
+        }
+      } catch {}
+      return;
+    }
+    process.on('exit', () => this.onExit());
+    this.flush();
+    this.spawnIfAvailable();
+  }
+
+  /**
+   * Feed every session.status event here, with the agent name resolved
+   * from sessionAgentMap. Orchestrator sessions drive overall status;
+   * specialist sessions drive the per-agent GIF grid.
+   */
+  onSessionStatus(input: {
+    sessionId?: string;
+    agent?: string;
+    status?: string;
+  }): void {
+    if (this.config?.enabled !== true) return;
+    const { sessionId, agent, status } = input;
+    if (!sessionId || (status !== 'busy' && status !== 'idle')) return;
+
+    if (agent === 'orchestrator') {
+      // Orchestrator going idle does NOT clear specialists: with background
+      // orchestration it idles while dispatched agents are still running.
+      // Specialists are removed only by their own idle/deleted events.
+      this.status = status;
+      this.flush();
+      return;
+    }
+
+    if (status === 'busy') {
+      if (!agent) return;
+      this.busyAgentSessions.set(sessionId, agent);
+    } else {
+      // Remove by session even when the agent name is unknown, so a
+      // finished specialist can never get stuck on screen.
+      this.busyAgentSessions.delete(sessionId);
+    }
+    this.flush();
+  }
+
+  onSessionDeleted(sessionId: string | undefined): void {
+    if (this.config?.enabled !== true) return;
+    if (!sessionId) return;
+    if (this.busyAgentSessions.delete(sessionId)) {
+      this.flush();
+    }
+  }
+
+  onWaitingInput(): void {
+    if (this.config?.enabled !== true) return;
+    this.status = 'waiting-input';
+    this.flush();
+  }
+
+  onInputResolved(): void {
+    if (this.config?.enabled !== true) return;
+    this.status = this.busyAgentSessions.size > 0 ? 'busy' : 'idle';
+    this.flush();
+  }
+
+  onExit(): void {
+    if (this.config?.enabled !== true) return;
+    const state = readState();
+    state.sessions = state.sessions.filter((s) => s.session_id !== this.id);
+    writeState(state);
+  }
+
+  /** One entry per running agent instance (two fixers → two cells). */
+  private activeAgents(): string[] {
+    const agents = Array.from(this.busyAgentSessions.values());
+    if (agents.length > 0) return agents.slice(0, 9);
+    if (this.status === 'waiting-input') return ['input'];
+    if (this.status === 'busy') return ['orchestrator'];
+    return ['intro'];
+  }
+
+  private flush(): void {
+    if (this.config?.enabled !== true) return;
+    try {
+      const state = readState();
+      const entry: CompanionSession = {
+        session_id: this.id,
+        cwd: this.cwd,
+        active_agents: this.activeAgents(),
+        status: this.status,
+        pid: process.pid,
+      };
+      const idx = state.sessions.findIndex((s) => s.session_id === this.id);
+      if (idx >= 0) {
+        state.sessions[idx] = entry;
+      } else {
+        state.sessions.push(entry);
+      }
+      if (this.config) {
+        state.config = {
+          enabled: this.config.enabled ?? false,
+          position: this.config.position ?? 'bottom-right',
+          size: this.config.size ?? 'medium',
+        };
+      }
+      writeState(state);
+    } catch (err) {
+      log('[companion] flush failed', String(err));
+    }
+  }
+
+  private spawnIfAvailable(): void {
+    if (this.config?.enabled !== true) return;
+    const bin = binaryPath();
+    if (!bin) {
+      const xdg = process.env.XDG_DATA_HOME?.trim();
+      const base =
+        xdg && path.isAbsolute(xdg)
+          ? xdg
+          : path.join(os.homedir(), '.local', 'share');
+      const expected = path.join(
+        base,
+        'o‍pencode',
+        'storage',
+        'oh-my-o‍pencode-slim',
+        'bin',
+        'oh-my-o‍pencode-slim-companion',
+      );
+      log(
+        `[companion] enabled but companion binary not found at expected path: ${expected}. Please install/download the companion binary separately.`,
+      );
+      return;
+    }
+    try {
+      const child = spawn(bin, [], { detached: true, stdio: 'ignore' });
+      child.unref();
+      log('[companion] spawned', bin);
+    } catch (err) {
+      log('[companion] spawn failed', String(err));
+    }
+  }
+}

+ 2 - 2
src/config/agent-mcps.test.ts

@@ -19,10 +19,10 @@ describe('parseList', () => {
       parseList(DEFAULT_AGENT_MCPS.orchestrator, [
         'websearch',
         'context7',
-        'grep_app',
+        'gh_grep',
         'custom-mcp',
       ]),
-    ).toEqual(['websearch', 'grep_app', 'custom-mcp']);
+    ).toEqual(['websearch', 'gh_grep', 'custom-mcp']);
   });
 
   test('wildcard with exclusions', () => {

+ 1 - 1
src/config/agent-mcps.ts

@@ -11,7 +11,7 @@ export const DEFAULT_AGENT_MCPS: Record<AgentName, string[]> = {
   orchestrator: ['*', '!context7'],
   designer: [],
   oracle: [],
-  librarian: ['websearch', 'context7', 'grep_app'],
+  librarian: ['websearch', 'context7', 'gh_grep'],
   explorer: [],
   fixer: [],
   observer: [],

+ 1 - 1
src/config/codemap.md

@@ -30,7 +30,7 @@ resolution, and helper APIs used by agents, council, and runtime subsystems.
 3. Validate with schema. Invalid/malformed files are warned and ignored by
    returning `null` for that file.
 4. Merge user+project configs where project takes precedence:
-   nested merges for `agents`, `tmux`, `multiplexer`, `interview`, `sessionManager`,
+   nested merges for `agents`, `tmux`, `multiplexer`, `interview`, `backgroundJobs`,
    `fallback`, `council`.
    top-level arrays/values are overridden.
 5. If `tmux` is enabled and no explicit `multiplexer` is configured,

+ 19 - 3
src/config/constants.ts

@@ -93,9 +93,25 @@ export const FALLBACK_FAILOVER_TIMEOUT_MS = 15_000;
 export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
 
 // Workflow reminders
-export const PHASE_REMINDER_TEXT = `!IMPORTANT! Recall the workflow rules:
-Understand → choose the best parallelized path based on your capabilities and agents delegation rules → recall session reuse rules → execute → verify.
-If delegating, launch the specialist in the same turn you mention it !END!`;
+export const PHASE_REMINDER_TEXT = `!IMPORTANT! Scheduler workflow: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!`;
+
+export const WRITABLE_FILE_OPERATIONS_RULES = `**File Operations Rules**:
+- Prefer dedicated file tools for normal code work: glob/grep/ast_grep_search for discovery, read for file contents, and edit/write/apply_patch for targeted source changes.
+- Use bash for execution and automation: git, package managers, tests, builds, scripts, diagnostics, and shell-native filesystem operations.
+- Shell is acceptable for bulk or mechanical filesystem changes when it is clearer or safer than many individual edits (for example: truncate generated logs, remove build artifacts, batch rename/move files), especially when the user explicitly asks for that shell operation.
+- Before destructive or broad shell operations, verify the target set and quote paths. Prefer a dry-run/listing first when practical.
+- Do not use cat/head/tail/sed/awk only to read code into context; use read/grep unless a shell pipeline is genuinely the better diagnostic.`;
+
+export const READONLY_FILE_OPERATIONS_RULES = `**File Operations Rules**:
+- READ-ONLY: inspect and report; do not modify files.
+- Prefer dedicated file tools for codebase inspection: glob/grep/ast_grep_search for discovery and read for file contents.
+- Bash is allowed for non-mutating diagnostics and shell-native inspection when it is the clearest tool, but not for modifying files.
+- Do not use cat/head/tail/sed/awk only to read code into context; use read/grep unless a shell pipeline is genuinely the better diagnostic.`;
+
+export const NO_SHELL_READONLY_FILE_OPERATIONS_RULES = `**File Operations Rules**:
+- READ-ONLY: inspect and report; do not modify files.
+- Use glob/grep/ast_grep_search for discovery and read for file contents.
+- Do not use bash or shell commands.`;
 
 // Tmux pane spawn delay (ms) — gives TmuxSessionManager time to create pane
 export const TMUX_SPAWN_DELAY_MS = 500;

+ 0 - 20
src/config/loader.test.ts

@@ -695,26 +695,6 @@ describe('deepMerge behavior', () => {
     const config = loadPluginConfig(projectDir);
     expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.5']);
   });
-
-  test('empty project subtask block does not clobber user subtask.timeoutMs', () => {
-    const userOpencodeDir = path.join(userConfigDir, 'opencode');
-    fs.mkdirSync(userOpencodeDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({ subtask: { timeoutMs: 1800000 } }),
-    );
-
-    const projectDir = path.join(tempDir, 'project');
-    const projectConfigDir = path.join(projectDir, '.opencode');
-    fs.mkdirSync(projectConfigDir, { recursive: true });
-    fs.writeFileSync(
-      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
-      JSON.stringify({ subtask: {} }),
-    );
-
-    const config = loadPluginConfig(projectDir);
-    expect(config.subtask?.timeoutMs).toBe(1800000);
-  });
 });
 
 describe('preset resolution', () => {

+ 14 - 2
src/config/loader.ts

@@ -200,11 +200,14 @@ export function mergePluginConfigs(
     tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
-    sessionManager: deepMerge(base.sessionManager, override.sessionManager),
+    backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     divoom: deepMerge(base.divoom, override.divoom),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
-    subtask: deepMerge(base.subtask, override.subtask),
+    companion: deepMerge(
+      base.companion as Record<string, unknown> | undefined,
+      override.companion as Record<string, unknown> | undefined,
+    ) as PluginConfig['companion'],
   };
 }
 
@@ -315,6 +318,15 @@ export function loadPluginConfig(
     }
   }
 
+  // Normalize companion config defaults
+  if (config.companion) {
+    config.companion = {
+      enabled: config.companion.enabled ?? false,
+      position: config.companion.position ?? 'bottom-right',
+      size: config.companion.size ?? 'medium',
+    };
+  }
+
   return config;
 }
 

+ 15 - 62
src/config/schema.ts

@@ -168,7 +168,7 @@ export const WebsearchConfigSchema = z.object({
 export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>;
 
 // MCP names
-export const McpNameSchema = z.enum(['websearch', 'context7', 'grep_app']);
+export const McpNameSchema = z.enum(['websearch', 'context7', 'gh_grep']);
 export type McpName = z.infer<typeof McpNameSchema>;
 
 export const InterviewConfigSchema = z.object({
@@ -186,13 +186,13 @@ export const InterviewConfigSchema = z.object({
 
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
-export const SessionManagerConfigSchema = z.object({
+export const BackgroundJobsConfigSchema = z.object({
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
 });
 
-export type SessionManagerConfig = z.infer<typeof SessionManagerConfigSchema>;
+export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
 
 export const DivoomConfigSchema = z.object({
   enabled: z.boolean().default(false),
@@ -218,62 +218,6 @@ export const DivoomConfigSchema = z.object({
 
 export type DivoomConfig = z.infer<typeof DivoomConfigSchema>;
 
-// Todo continuation configuration
-export const TodoContinuationConfigSchema = z.object({
-  maxContinuations: z
-    .number()
-    .int()
-    .min(1)
-    .max(50)
-    .default(5)
-    .describe(
-      'Maximum consecutive auto-continuations before stopping to ask user',
-    ),
-  cooldownMs: z
-    .number()
-    .int()
-    .min(0)
-    .max(30_000)
-    .default(3000)
-    .describe('Delay in ms before auto-continuing (gives user time to abort)'),
-  autoEnable: z
-    .boolean()
-    .default(false)
-    .describe(
-      'Automatically enable auto-continue when the orchestrator session has enough todos',
-    ),
-  autoEnableThreshold: z
-    .number()
-    .int()
-    .min(1)
-    .max(50)
-    .default(4)
-    .describe(
-      'Number of todos that triggers auto-enable (only used when autoEnable is true)',
-    ),
-});
-
-export type TodoContinuationConfig = z.infer<
-  typeof TodoContinuationConfigSchema
->;
-
-export const SubtaskConfigSchema = z.object({
-  // Intentionally no .default(): an empty `subtask: {}` block must parse to
-  // `{}` so it cannot shallow-overwrite an inherited value during config
-  // merging. The runtime fallback in createSubtaskTool applies the default.
-  timeoutMs: z
-    .number()
-    .int()
-    .min(0)
-    .max(24 * 60 * 60 * 1000)
-    .optional()
-    .describe(
-      'Subtask worker timeout in ms. 0 disables the timeout. Defaults to 300000 (5 minutes).',
-    ),
-});
-
-export type SubtaskConfig = z.infer<typeof SubtaskConfigSchema>;
-
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
   timeoutMs: z.number().min(0).default(15000),
@@ -290,6 +234,16 @@ export const FailoverConfigSchema = z.object({
 
 export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 
+export const CompanionConfigSchema = z.object({
+  enabled: z.boolean().optional(),
+  position: z
+    .enum(['bottom-right', 'bottom-left', 'top-right', 'top-left'])
+    .optional(),
+  size: z.enum(['small', 'medium', 'large']).optional(),
+});
+
+export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
+
 function validateCustomOnlyPromptFields(
   overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
   ctx: z.RefinementCtx,
@@ -354,12 +308,11 @@ export const PluginConfigSchema = z
     tmux: TmuxConfigSchema.optional(),
     websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
-    sessionManager: SessionManagerConfigSchema.optional(),
+    backgroundJobs: BackgroundJobsConfigSchema.optional(),
     divoom: DivoomConfigSchema.optional(),
-    todoContinuation: TodoContinuationConfigSchema.optional(),
-    subtask: SubtaskConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
+    companion: CompanionConfigSchema.optional(),
   })
   .superRefine((value, ctx) => {
     if (value.agents) {

+ 5 - 9
src/hooks/codemap.md

@@ -51,11 +51,11 @@ and managers for all hook-based runtime behaviors used by
 | `tool.execute.before` | Pre-process tool inputs | `apply-patch`, `task-session-manager` |
 | `tool.execute.after` | Post-process tool outputs | `delegate-task-retry`, `json-error-recovery`, `post-file-tool-nudge`, `task-session-manager` |
 | `experimental.chat.messages.transform` | Rewrite outbound user content | `filter-available-skills`, `phase-reminder` |
-| `experimental.chat.system.transform` | Inject system-level directives | `todo-continuation`, `post-file-tool-nudge`, `task-session-manager` |
+| `experimental.chat.system.transform` | Inject system-level directives | `post-file-tool-nudge`, `task-session-manager` |
 | `chat.headers` | Mutate request headers | `chat-headers` |
-| `chat.message` | Track runtime session/agent mapping | `todo-continuation` |
-| `command.execute.before` | Handle slash-command UX | `todo-continuation` (`auto-continue`) |
-| `event` | React to session lifecycle and runtime failures | `foreground-fallback`, `todo-continuation`, `post-file-tool-nudge`, `auto-update-checker`, multiplexer managers, `task-session-manager` |
+| `chat.message` | Track runtime session/agent mapping | `src/index.ts` session map |
+| `command.execute.before` | Handle slash-command UX | `interview`, `preset-manager`, `deepwork` |
+| `event` | React to session lifecycle and runtime failures | `foreground-fallback`, `post-file-tool-nudge`, `auto-update-checker`, multiplexer managers, `task-session-manager` |
 
 ## Implementation Notes
 
@@ -64,11 +64,7 @@ and managers for all hook-based runtime behaviors used by
 - `ForegroundFallbackManager` listens to event traffic and remediates
   foreground rate-limit failures by aborting the current prompt and re-queuing the
   latest user message on the next model in a per-agent chain.
-- `createTodoContinuationHook` spans multiple surfaces: message transform,
-  system transform, command interception, tool-after, and events. It owns
-  auto-injection state, cooldown, suppress windows, and orchestration session
-  tracking.
-- `createTaskSessionManagerHook` tracks task sessions for resumability: generates
+- `createTaskSessionManagerHook` tracks V2 background jobs and reusable completed sessions: generates
   user-facing aliases, resolves alias/task IDs before delegation, remembers fresh
   task IDs after completion, and drops stale entries on missing-session failure,
   renamed task IDs, or session deletion.

+ 78 - 0
src/hooks/deepwork/index.test.ts

@@ -0,0 +1,78 @@
+import { describe, expect, test } from 'bun:test';
+import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
+import { createDeepworkCommandHook } from './index';
+
+describe('deepwork command hook', () => {
+  test('registers /deepwork command when absent', () => {
+    const hook = createDeepworkCommandHook();
+    const config: Record<string, unknown> = {};
+
+    hook.registerCommand(config);
+
+    const command = (config.command as Record<string, unknown>).deepwork as {
+      template?: string;
+      description?: string;
+    };
+    expect(command).toBeDefined();
+    expect(command.template).toContain('deepwork');
+    expect(command.description).toContain('heavy');
+  });
+
+  test('does not overwrite existing /deepwork command', () => {
+    const hook = createDeepworkCommandHook();
+    const existing = { template: 'custom', description: 'custom command' };
+    const config: Record<string, unknown> = { command: { deepwork: existing } };
+
+    hook.registerCommand(config);
+
+    expect((config.command as Record<string, unknown>).deepwork).toBe(existing);
+  });
+
+  test('asks for a task when no arguments are provided', async () => {
+    const hook = createDeepworkCommandHook();
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'deepwork', sessionID: 's1', arguments: '  ' },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('What task should deepwork manage?');
+    expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
+  });
+
+  test('expands arguments into a deepwork activation prompt', async () => {
+    const hook = createDeepworkCommandHook();
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'deepwork',
+        sessionID: 's1',
+        arguments: 'refactor scheduler state',
+      },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(1);
+    expect(output.parts[0].text).toContain('Use the deepwork skill');
+    expect(output.parts[0].text).toContain('.slim/deepwork/');
+    expect(output.parts[0].text).toContain('@oracle');
+    expect(output.parts[0].text).toContain('simplify/readability');
+    expect(output.parts[0].text).toContain('refactor scheduler state');
+    expect(output.parts[0].text).not.toContain(SLIM_INTERNAL_INITIATOR_MARKER);
+  });
+
+  test('ignores other commands', async () => {
+    const hook = createDeepworkCommandHook();
+    const output = { parts: [{ type: 'text', text: 'template' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'preset', sessionID: 's1', arguments: 'x' },
+      output,
+    );
+
+    expect(output.parts).toEqual([{ type: 'text', text: 'template' }]);
+  });
+});

+ 62 - 0
src/hooks/deepwork/index.ts

@@ -0,0 +1,62 @@
+import { createInternalAgentTextPart } from '../../utils';
+
+const COMMAND_NAME = 'deepwork';
+
+function activationPrompt(task: string): string {
+  return [
+    'Use the deepwork skill for this task. Treat it as a heavy coding session.',
+    '',
+    'Deepwork requirements:',
+    '- create/update a `.slim/deepwork/` progress file;',
+    '- keep OpenCode todos synced with the current phase;',
+    '- draft a plan and get `@oracle` review before implementation;',
+    '- create and review a phased implementation/delegation plan;',
+    '- execute phase by phase with background specialists where useful;',
+    '- wait for hook-driven background completion, reconcile results, validate, and ask `@oracle` to review each phase;',
+    '- ask `@oracle` to include simplify/readability feedback in phase reviews;',
+    '- fix actionable review issues before continuing.',
+    '',
+    'Task:',
+    task,
+  ].join('\n');
+}
+
+export function createDeepworkCommandHook(): {
+  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 commandConfig = opencodeConfig.command as
+        | Record<string, unknown>
+        | undefined;
+      if (commandConfig?.[COMMAND_NAME]) return;
+      if (!opencodeConfig.command) opencodeConfig.command = {};
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Start a deepwork session for a complex coding task',
+        description:
+          'Use the deepwork workflow for heavy multi-phase coding work',
+      };
+    },
+
+    handleCommandExecuteBefore: async (input, output) => {
+      if (input.command !== COMMAND_NAME) return;
+
+      output.parts.length = 0;
+      const task = input.arguments.trim();
+      if (!task) {
+        output.parts.push(
+          createInternalAgentTextPart(
+            'What task should deepwork manage? Run `/deepwork <task>`.',
+          ),
+        );
+        return;
+      }
+
+      output.parts.push({ type: 'text', text: activationPrompt(task) });
+    },
+  };
+}

+ 1 - 2
src/hooks/index.ts

@@ -2,6 +2,7 @@ export { createApplyPatchHook } from './apply-patch';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
 export { createChatHeadersHook } from './chat-headers';
+export { createDeepworkCommandHook } from './deepwork';
 export { createDelegateTaskRetryHook } from './delegate-task-retry';
 export { createFilterAvailableSkillsHook } from './filter-available-skills';
 export {
@@ -12,6 +13,4 @@ export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
-export { createSessionGoalHook } from './session-goal';
 export { createTaskSessionManagerHook } from './task-session-manager';
-export { createTodoContinuationHook } from './todo-continuation';

+ 1 - 1
src/hooks/json-error-recovery/hook.ts

@@ -5,7 +5,7 @@ export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
   'read',
   'glob',
   'webfetch',
-  'grep_app_searchgithub',
+  'gh_grep_searchgithub',
   'websearch_web_search_exa',
 ] as const;
 

+ 0 - 231
src/hooks/session-goal/index.test.ts

@@ -1,231 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
-import { tmpdir } from 'node:os';
-import path from 'node:path';
-import { createSessionGoalHook } from './index';
-
-function createHook(directory = '.') {
-  return createSessionGoalHook(
-    { directory } as Parameters<typeof createSessionGoalHook>[0],
-    { interview: { outputFolder: 'interview' } } as Parameters<
-      typeof createSessionGoalHook
-    >[1],
-    { getAgentName: () => 'orchestrator' },
-  );
-}
-
-describe('createSessionGoalHook', () => {
-  test('sets and shows a manual session goal', async () => {
-    const hook = createHook();
-    const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-    await hook.handleCommandExecuteBefore(
-      {
-        command: 'goal',
-        sessionID: 'ses_1',
-        arguments: 'Ship the goal feature. Done when tests pass.',
-      },
-      output,
-    );
-
-    expect(output.parts[0].text).toContain('Set active goal:');
-    expect(hook.getGoal('ses_1')?.text).toBe(
-      'Ship the goal feature. Done when tests pass.',
-    );
-
-    const showOutput = { parts: [] as Array<{ type: string; text?: string }> };
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: '' },
-      showOutput,
-    );
-
-    expect(showOutput.parts[0].text).toContain('Active goal:');
-    expect(showOutput.parts[0].text).toContain('Auto-continuation');
-  });
-
-  test('injects active goal into orchestrator system prompt', async () => {
-    const hook = createHook();
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: 'Stay on target.' },
-      { parts: [] },
-    );
-    const output = { system: ['base prompt'] };
-
-    hook.handleSystemTransform({ sessionID: 'ses_1' }, output);
-
-    expect(output.system.join('\n')).toContain('<active_goal>');
-    expect(output.system.join('\n')).toContain('Stay on target.');
-    expect(output.system.join('\n')).toContain(
-      'Use todos as the execution ledger',
-    );
-  });
-
-  test('inherits parent goal for child sessions', async () => {
-    const hook = createSessionGoalHook(
-      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
-      {} as Parameters<typeof createSessionGoalHook>[1],
-      { getAgentName: () => 'explorer' },
-    );
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'parent', arguments: 'Parent objective.' },
-      { parts: [] },
-    );
-
-    hook.handleEvent({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child', parentID: 'parent' } },
-      },
-    });
-
-    const output = { system: [] as string[] };
-    hook.handleSystemTransform({ sessionID: 'child' }, output);
-
-    expect(output.system.join('\n')).toContain('<parent_goal>');
-    expect(output.system.join('\n')).toContain('Parent objective.');
-    expect(output.system.join('\n')).toContain('bounded task');
-  });
-
-  test('child sessions resolve updated parent goal live', async () => {
-    const hook = createSessionGoalHook(
-      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
-      {} as Parameters<typeof createSessionGoalHook>[1],
-      { getAgentName: () => 'explorer' },
-    );
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'parent', arguments: 'Original.' },
-      { parts: [] },
-    );
-    hook.handleEvent({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child', parentID: 'parent' } },
-      },
-    });
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'parent', arguments: 'Updated.' },
-      { parts: [] },
-    );
-
-    const output = { system: [] as string[] };
-    hook.handleSystemTransform({ sessionID: 'child' }, output);
-
-    expect(output.system.join('\n')).toContain('Updated.');
-    expect(output.system.join('\n')).not.toContain('Original.');
-  });
-
-  test('grandchild sessions inherit the root goal', async () => {
-    const hook = createSessionGoalHook(
-      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
-      {} as Parameters<typeof createSessionGoalHook>[1],
-      { getAgentName: () => 'explorer' },
-    );
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'root', arguments: 'Root objective.' },
-      { parts: [] },
-    );
-    hook.handleEvent({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child', parentID: 'root' } },
-      },
-    });
-    hook.handleEvent({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'grandchild', parentID: 'child' } },
-      },
-    });
-
-    const output = { system: [] as string[] };
-    hook.handleSystemTransform({ sessionID: 'grandchild' }, output);
-
-    expect(output.system.join('\n')).toContain('<parent_goal>');
-    expect(output.system.join('\n')).toContain('Root objective.');
-    expect(output.system.join('\n')).not.toContain('Objective: \n');
-  });
-
-  test('child sessions stop inheriting after parent goal is cleared', async () => {
-    const hook = createSessionGoalHook(
-      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
-      {} as Parameters<typeof createSessionGoalHook>[1],
-      { getAgentName: () => 'explorer' },
-    );
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'parent', arguments: 'Parent objective.' },
-      { parts: [] },
-    );
-    hook.handleEvent({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child', parentID: 'parent' } },
-      },
-    });
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'parent', arguments: 'clear' },
-      { parts: [] },
-    );
-
-    const output = { system: [] as string[] };
-    hook.handleSystemTransform({ sessionID: 'child' }, output);
-
-    expect(output.system).toEqual([]);
-    expect(hook.getGoal('child')).toBeUndefined();
-  });
-
-  test('sets goal from an interview document', async () => {
-    const directory = await mkdtemp(path.join(tmpdir(), 'goal-test-'));
-    const interviewDir = path.join(directory, 'interview');
-    await mkdir(interviewDir, { recursive: true });
-    await writeFile(
-      path.join(interviewDir, 'feature.md'),
-      [
-        '# Feature Goal',
-        '',
-        '## Current spec',
-        '',
-        'Build the feature with minimal scope.',
-        '',
-        '## Q&A history',
-        '',
-        'No answers yet.',
-      ].join('\n'),
-      'utf8',
-    );
-
-    const hook = createHook(directory);
-    const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: 'from feature' },
-      output,
-    );
-
-    expect(output.parts[0].text).toContain('Set active goal from interview');
-    expect(hook.getGoal('ses_1')?.text).toBe(
-      'From interview: Feature Goal\n\nBuild the feature with minimal scope.',
-    );
-  });
-
-  test('clears goals on command and session deletion', async () => {
-    const hook = createHook();
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: 'Temporary goal.' },
-      { parts: [] },
-    );
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: 'clear' },
-      { parts: [] },
-    );
-    expect(hook.getGoal('ses_1')).toBeUndefined();
-
-    await hook.handleCommandExecuteBefore(
-      { command: 'goal', sessionID: 'ses_1', arguments: 'Temporary goal.' },
-      { parts: [] },
-    );
-    hook.handleEvent({
-      event: { type: 'session.deleted', properties: { sessionID: 'ses_1' } },
-    });
-    expect(hook.getGoal('ses_1')).toBeUndefined();
-  });
-});

+ 0 - 243
src/hooks/session-goal/index.ts

@@ -1,243 +0,0 @@
-import * as fs from 'node:fs/promises';
-import type { PluginInput } from '@opencode-ai/plugin';
-import type { PluginConfig } from '../../config';
-import {
-  extractSummarySection,
-  extractTitle,
-  resolveExistingInterviewPath,
-} from '../../interview/document';
-import { createInternalAgentTextPart } from '../../utils';
-
-const COMMAND_NAME = 'goal';
-const MAX_GOAL_LENGTH = 4000;
-
-interface GoalState {
-  text: string;
-  source?: 'manual' | 'interview';
-  sourcePath?: string;
-  inheritedFrom?: string;
-  createdAt: number;
-}
-
-interface SystemTransformOutput {
-  system: string[];
-}
-
-function normalizeGoalText(text: string): string {
-  return text.trim().replace(/\s+/g, ' ').slice(0, MAX_GOAL_LENGTH);
-}
-
-function trimGoalText(text: string): string {
-  return text.trim().slice(0, MAX_GOAL_LENGTH);
-}
-
-function pushText(
-  output: { parts: Array<{ type: string; text?: string }> },
-  text: string,
-) {
-  output.parts.push(createInternalAgentTextPart(text));
-}
-
-function formatGoal(state: GoalState, inherited: boolean): string {
-  const tag = inherited ? 'parent_goal' : 'active_goal';
-  const guidance = inherited
-    ? 'This is context only. Your delegated prompt remains the bounded task.'
-    : 'Use todos as the execution ledger. Keep planning, delegation, edits, and verification aligned to this goal. Do not broaden scope unless the user changes the goal.';
-  return `<${tag}>\nObjective: ${state.text}\n${guidance}\n</${tag}>`;
-}
-
-async function readInterviewGoal(
-  directory: string,
-  outputFolder: string,
-  value: string,
-): Promise<{ text: string; sourcePath: string } | null> {
-  try {
-    const sourcePath = resolveExistingInterviewPath(
-      directory,
-      outputFolder,
-      value,
-    );
-    if (!sourcePath) return null;
-
-    const content = await fs.readFile(sourcePath, 'utf8');
-    const title = extractTitle(content);
-    const summary = extractSummarySection(content);
-    const text = trimGoalText(
-      [title ? `From interview: ${title}` : '', summary]
-        .filter(Boolean)
-        .join('\n\n'),
-    );
-    return text ? { text, sourcePath } : null;
-  } catch {
-    return null;
-  }
-}
-
-function resolveGoal(
-  goals: Map<string, GoalState>,
-  sessionID: string,
-): { goal: GoalState; inherited: boolean } | null {
-  const seen = new Set<string>();
-  let currentSessionID = sessionID;
-  let inherited = false;
-
-  while (true) {
-    if (seen.has(currentSessionID)) {
-      goals.delete(sessionID);
-      return null;
-    }
-    seen.add(currentSessionID);
-
-    const goal = goals.get(currentSessionID);
-    if (!goal) {
-      goals.delete(sessionID);
-      return null;
-    }
-
-    if (!goal.inheritedFrom) {
-      return { goal, inherited };
-    }
-
-    inherited = true;
-    currentSessionID = goal.inheritedFrom;
-  }
-}
-
-export function createSessionGoalHook(
-  ctx: PluginInput,
-  config: PluginConfig,
-  options?: { getAgentName?: (sessionID: string) => string | undefined },
-): {
-  registerCommand: (config: Record<string, unknown>) => void;
-  handleCommandExecuteBefore: (
-    input: { command: string; sessionID: string; arguments: string },
-    output: { parts: Array<{ type: string; text?: string }> },
-  ) => Promise<void>;
-  handleEvent: (input: {
-    event: { type: string; properties?: Record<string, unknown> };
-  }) => void;
-  handleSystemTransform: (
-    input: { sessionID?: string },
-    output: SystemTransformOutput,
-  ) => void;
-  getGoal: (sessionID: string) => GoalState | undefined;
-} {
-  const goals = new Map<string, GoalState>();
-  const outputFolder = config.interview?.outputFolder ?? 'interview';
-
-  return {
-    registerCommand: (opencodeConfig) => {
-      const commandConfig = opencodeConfig.command as
-        | Record<string, unknown>
-        | undefined;
-      if (commandConfig?.[COMMAND_NAME]) return;
-      if (!opencodeConfig.command) opencodeConfig.command = {};
-      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
-        template: 'Set or show the current session goal',
-        description:
-          'Pin a session objective that keeps todos, delegation, and verification aligned',
-      };
-    },
-
-    handleCommandExecuteBefore: async (input, output) => {
-      if (input.command !== COMMAND_NAME) return;
-
-      output.parts.length = 0;
-
-      const args = input.arguments.trim();
-      if (!args) {
-        const resolved = resolveGoal(goals, input.sessionID);
-        pushText(
-          output,
-          resolved
-            ? `Active goal:\n${resolved.goal.text}\n\nUse todos for execution steps. Auto-continuation continues only while todos remain.`
-            : 'No active goal. Set one with /goal <objective>.',
-        );
-        return;
-      }
-
-      if (args === 'clear') {
-        goals.delete(input.sessionID);
-        pushText(output, 'Cleared the active goal for this session.');
-        return;
-      }
-
-      if (args.startsWith('from ')) {
-        const value = args.slice('from '.length).trim();
-        const interviewGoal = await readInterviewGoal(
-          ctx.directory,
-          outputFolder,
-          value,
-        );
-        if (!interviewGoal) {
-          pushText(
-            output,
-            `Could not find a readable interview spec for "${value}".`,
-          );
-          return;
-        }
-        goals.set(input.sessionID, {
-          text: interviewGoal.text,
-          source: 'interview',
-          sourcePath: interviewGoal.sourcePath,
-          createdAt: Date.now(),
-        });
-        pushText(
-          output,
-          `Set active goal from interview:\n${interviewGoal.text}`,
-        );
-        return;
-      }
-
-      const text = normalizeGoalText(args);
-      goals.set(input.sessionID, {
-        text,
-        source: 'manual',
-        createdAt: Date.now(),
-      });
-      pushText(output, `Set active goal:\n${text}`);
-    },
-
-    handleEvent: (input) => {
-      const event = input.event;
-      if (event.type === 'session.created') {
-        const info = event.properties?.info as
-          | { id?: string; parentID?: string }
-          | undefined;
-        if (!info?.id || !info.parentID) return;
-        const parentGoal = goals.get(info.parentID);
-        if (!parentGoal) return;
-        goals.set(info.id, {
-          inheritedFrom: info.parentID,
-          createdAt: Date.now(),
-          text: '',
-        });
-        return;
-      }
-
-      if (event.type === 'session.deleted') {
-        const props = event.properties as
-          | { info?: { id?: string }; sessionID?: string }
-          | undefined;
-        const sessionID = props?.info?.id ?? props?.sessionID;
-        if (sessionID) goals.delete(sessionID);
-      }
-    },
-
-    handleSystemTransform: (input, output) => {
-      if (!input.sessionID) return;
-      const resolved = resolveGoal(goals, input.sessionID);
-      if (!resolved) return;
-
-      const agentName = options?.getAgentName?.(input.sessionID);
-      const { goal, inherited } = resolved;
-      if (!inherited && agentName && agentName !== 'orchestrator') return;
-
-      const block = formatGoal(goal, inherited);
-      if (output.system.some((entry) => entry.includes(block))) return;
-      output.system.push(block);
-    },
-
-    getGoal: (sessionID) => resolveGoal(goals, sessionID)?.goal,
-  };
-}

+ 28 - 42
src/hooks/task-session-manager/codemap.md

@@ -2,59 +2,45 @@
 
 ## Responsibility
 
-Provides resumable-task state for `task` tool calls so orchestrator users can
-resume work in a parent session by using short aliases (`exp-1`, `ora-2`) instead
-of raw child session IDs.
+Provides V2 background job-board state for `task` output and injected completion messages so the
+orchestrator can track active jobs and reuse only completed, reconciled child
+sessions by short aliases (`exp-1`, `ora-2`).
 
 ## Design
 
 - `createTaskSessionManagerHook(ctx, options)` returns handlers for:
   - `tool.execute.before`
   - `tool.execute.after`
-  - `experimental.chat.system.transform`
+  - `experimental.chat.messages.transform`
   - `event`
-- Internally uses `SessionManager` from `src/utils/session-manager.ts` to store
-  remembered task sessions with bounded per-agent history.
+- Uses `BackgroundJobBoard` from `src/utils/background-job-board.ts` as the
+  single source of truth for active jobs, terminal unreconciled jobs, reusable
+  completed sessions, aliases, read context, and LRU caps.
 - Task labels are derived from `description`/`prompt` via
-  `deriveTaskSessionLabel` and converted to compact aliases by `SessionManager`.
-- In-flight calls are tracked by `callID` in a capped ordered map (`MAX_PENDING_TASK_CALLS`)
-  to rewrite inputs and correlate outputs safely.
-- Session governance is feature-gated by `shouldManageSession(sessionID)`, allowing
-  the hook to run only for orchestrator-managed sessions.
+  `deriveTaskSessionLabel` and stored on job-board records.
+- In-flight calls are tracked by `callID` in a capped ordered map
+  (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely.
 
 ## Flow
 
-1. `tool.execute.before` receives a `task` call.
-2. If `subagent_type` is a recognized agent, it derives a short label.
-3. When `task_id` is provided, it attempts resolution against remembered aliases
-   for the current parent session/agent.
-4. On success, `args.task_id` is rewritten to the real task ID; on miss it is
-   removed to force fresh task creation.
-5. The call metadata is stored in the pending-call map to correlate the
-   subsequent post-tool event.
-6. `tool.execute.after` reads the output task ID from `task` output text.
-7. On first successful parse, it `remember()`s the task entry and associates it
-   with the alias map.
-8. If this call was a resume attempt, and the returned ID changed, the stale
-   predecessor alias is dropped.
-9. If resume returns an error like `[ERROR] Session not found`/`Session no
-   session`, the predecessor alias is dropped so future commands fall back to
-   fresh execution.
-10. `experimental.chat.system.transform` injects a rendered block from
-    `SessionManager.formatForPrompt` under `### Resumable Sessions`.
-11. On `session.deleted`, the hook clears all task state for that parent session
-    and removes any pending task call records for that parent.
+1. `tool.execute.before` receives `task` calls.
+2. `task.task_id` aliases resolve only to completed/reconciled jobs for the same
+   specialist; misses remove `task_id` to force fresh task creation.
+3. `tool.execute.after` registers launches and status transitions from native V2
+   output; bare task IDs without state do not create reusable jobs.
+5. Read context from child sessions is attached to board records with line-count
+   and file caps.
+6. `experimental.chat.messages.transform` injects one `### Background Job Board`
+   section with Active / Unreconciled and Reusable Sessions subsections.
+7. Parent idle events reconcile terminal jobs only after they have been injected
+   into the prompt.
+8. `session.deleted` drops a child job or clears all parent jobs and pending call
+   records.
 
 ## Integration
 
-- Wired in `src/index.ts`:
-  - invoked in `tool.execute.before`
-  - invoked in `tool.execute.after`
-  - injected into `experimental.chat.system.transform`
-  - cleaned up in `event` on `session.deleted`
-- Exposes no side effects outside hook handling and `SessionManager`.
-- Depends on:
-  - `SessionManager` and `deriveTaskSessionLabel` (from `src/utils/session-manager.ts`)
-  - `parseTaskIdFromTaskOutput` (from `src/utils/task.ts`)
-  - plugin configuration (`maxSessionsPerAgent`) and runtime session filtering from
-    `src/index.ts` (`shouldManageSession`).
+- Wired in `src/index.ts` for before/after tool hooks, message transforms, and
+  lifecycle events.
+- Depends on `BackgroundJobBoard`, task-output parsing utilities, plugin
+  configuration (`backgroundJobs` caps), and runtime session filtering from
+  `src/index.ts` (`shouldManageSession`).

File diff suppressed because it is too large
+ 1229 - 401
src/hooks/task-session-manager/index.test.ts


+ 527 - 52
src/hooks/task-session-manager/index.ts

@@ -2,12 +2,16 @@ import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { AgentName } from '../../config';
 import {
+  BackgroundJobBoard,
+  type BackgroundJobRecord,
   type ContextFile,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
-  SessionManager,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
+import { log } from '../../utils/logger';
 
 interface TaskArgs {
   description?: unknown;
@@ -55,12 +59,68 @@ interface ChatMessage {
     role: string;
     agent?: string;
     sessionID?: string;
+    id?: string;
   };
   parts: ChatMessagePart[];
 }
 
-const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
-const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
+const BACKGROUND_JOB_BOARD_SENTINEL = 'SENTINEL: background-job-board-v2';
+const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
+const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
+const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
+const RAW_SESSION_ID_PATTERN = /^ses_[A-Za-z0-9_-]+$/;
+
+/**
+ * Simple deterministic string hash for stable occurrence IDs.
+ * Uses DJB2 algorithm - fast and good distribution for short strings.
+ */
+function djb2Hash(str: string): string {
+  let hash = 5381;
+  for (let i = 0; i < str.length; i++) {
+    hash = (hash << 5) + hash + str.charCodeAt(i); // hash * 33 + char
+  }
+  // Convert to unsigned 32-bit and then to hex
+  return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+/**
+ * Create a stable occurrence ID for synthetic completion deduplication.
+ * Prefers part.id, then message.info.id + partIndex, then content-derived hash.
+ */
+function createOccurrenceId(
+  part: ChatMessagePart,
+  message: ChatMessage,
+  partIndex: number,
+): string {
+  // Prefer explicit part.id if available
+  if (typeof part.id === 'string') {
+    return part.id;
+  }
+
+  // Fall back to message.info.id + partIndex
+  if (typeof message.info.id === 'string') {
+    return `${message.info.id}:${partIndex}`;
+  }
+
+  // Final fallback: content-derived hash from sessionID + parsed taskID/state/result
+  // This ensures the same anonymous synthetic completion is deduped
+  // even when its message index changes between transform calls
+  const sessionID = message.info.sessionID ?? 'unknown';
+  const content = typeof part.text === 'string' ? part.text : '';
+
+  // Parse task status to get stable identifiers
+  const status = parseTaskStatusOutput(content);
+  if (status) {
+    // Use taskID + state + result for a stable hash
+    const stableKey = `${sessionID}:${status.taskID}:${status.state}:${status.result ?? ''}`;
+    const hash = djb2Hash(stableKey);
+    return `anon:${hash}`;
+  }
+
+  // Fallback to hashing the full content if parsing fails
+  const hash = djb2Hash(`${sessionID}:${content}`);
+  return `anon:${hash}`;
+}
 
 function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
@@ -74,6 +134,11 @@ function extractPath(output: string): string | undefined {
   return /<path>([^<]+)<\/path>/.exec(output)?.[1];
 }
 
+function extractTaskSummary(output: string): string | undefined {
+  const summary = /<summary>\s*([\s\S]*?)\s*<\/summary>/i.exec(output)?.[1];
+  return summary?.trim() || undefined;
+}
+
 function normalizePath(root: string, file: string): string {
   const relative = path.relative(root, file);
   if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
@@ -115,17 +180,24 @@ export function createTaskSessionManagerHook(
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
+    backgroundJobBoard?: BackgroundJobBoard;
     shouldManageSession: (sessionID: string) => boolean;
   },
 ) {
-  const sessionManager = new SessionManager(options.maxSessionsPerAgent, {
-    readContextMinLines: options.readContextMinLines,
-    readContextMaxFiles: options.readContextMaxFiles,
-  });
+  const backgroundJobBoard =
+    options.backgroundJobBoard ??
+    new BackgroundJobBoard({
+      maxReusablePerAgent: options.maxSessionsPerAgent,
+      readContextMinLines: options.readContextMinLines,
+      readContextMaxFiles: options.readContextMaxFiles,
+    });
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCallOrder: string[] = [];
   const contextByTask = new Map<string, Map<string, PendingContextFile>>();
   const pendingManagedTaskIds = new Set<string>();
+  const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const processedInjectedCompletions = new Set<string>();
+  const processedInjectedCompletionOrder: string[] = [];
   let anonymousPendingCallId = 0;
 
   function addTaskContext(taskId: string, files: ContextFile[]): void {
@@ -149,7 +221,7 @@ export function createTaskSessionManagerHook(
       context.set(file.path, pending);
     }
 
-    sessionManager.addContext(taskId, contextFilesForPrompt(context));
+    backgroundJobBoard.addContext(taskId, contextFilesForPrompt(context));
   }
 
   function contextFilesForPrompt(
@@ -165,12 +237,13 @@ export function createTaskSessionManagerHook(
 
   function canTrackTaskContext(taskId: string): boolean {
     return (
-      pendingManagedTaskIds.has(taskId) || sessionManager.taskIds().has(taskId)
+      pendingManagedTaskIds.has(taskId) ||
+      backgroundJobBoard.taskIDs().has(taskId)
     );
   }
 
   function pruneContext(): void {
-    const remembered = sessionManager.taskIds();
+    const remembered = backgroundJobBoard.taskIDs();
     for (const taskId of contextByTask.keys()) {
       if (!pendingManagedTaskIds.has(taskId) && !remembered.has(taskId)) {
         contextByTask.delete(taskId);
@@ -178,6 +251,153 @@ export function createTaskSessionManagerHook(
     }
   }
 
+  function updateBackgroundJobFromOutput(
+    output: unknown,
+  ): BackgroundJobRecord | undefined {
+    if (typeof output !== 'string') return undefined;
+
+    const status = parseTaskStatusOutput(output);
+    if (!status) return undefined;
+
+    log('[task-session-manager] parsed task output status', {
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      hasResult: Boolean(status.result),
+    });
+
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (isLateCancelledTaskError(existing, status.state)) {
+      log('[task-session-manager] suppressed late cancelled task error', {
+        taskID: status.taskID,
+        alias: existing?.alias,
+        state: existing?.state,
+        terminalState: existing?.terminalState,
+        result: status.result,
+      });
+      return existing;
+    }
+
+    const updated = backgroundJobBoard.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+    if (!updated) {
+      log('[task-session-manager] ignored status for unknown background job', {
+        taskID: status.taskID,
+        state: status.state,
+      });
+      return undefined;
+    }
+
+    log('[task-session-manager] background job status updated', {
+      taskID: updated.taskID,
+      alias: updated.alias,
+      parentSessionID: updated.parentSessionID,
+      state: updated.state,
+      terminalUnreconciled: updated.terminalUnreconciled,
+      timedOut: updated.timedOut,
+    });
+
+    if (updated.terminalUnreconciled) {
+      pendingManagedTaskIds.delete(updated.taskID);
+      backgroundJobBoard.addContext(
+        updated.taskID,
+        contextFilesForPrompt(contextByTask.get(updated.taskID)),
+      );
+      pruneContext();
+    }
+
+    return updated;
+  }
+
+  function updateFromInjectedCompletion(
+    part: ChatMessagePart,
+    message: ChatMessage,
+    _messageIndex: number,
+    partIndex: number,
+  ): BackgroundJobRecord | undefined {
+    if (part.type !== 'text' || typeof part.text !== 'string') {
+      return undefined;
+    }
+
+    if (part.synthetic !== true) return undefined;
+
+    const status = parseTaskStatusOutput(part.text);
+    if (!status) return undefined;
+    if (status.state !== 'completed' && status.state !== 'error') {
+      return undefined;
+    }
+
+    const summary = extractTaskSummary(part.text);
+    const isCompleted = summary
+      ? BACKGROUND_COMPLETION_COMPLETED.test(summary)
+      : status.state === 'completed';
+    const isFailed = summary
+      ? BACKGROUND_COMPLETION_FAILED.test(summary)
+      : status.state === 'error';
+    if (summary && !isCompleted && !isFailed) return undefined;
+
+    const occurrenceId = createOccurrenceId(part, message, partIndex);
+
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (isFailed && isLateCancelledTaskError(existing, status.state)) {
+      part.text = formatCancelledTaskStatusOutput(
+        status.taskID,
+        existing?.resultSummary,
+      );
+      log('[task-session-manager] normalized late cancelled injected failure', {
+        taskID: status.taskID,
+        alias: existing?.alias,
+        state: existing?.state,
+        terminalState: existing?.terminalState,
+        result: status.result,
+      });
+      rememberProcessedInjectedCompletion(occurrenceId);
+      return existing;
+    }
+
+    // Enforce summary/state consistency when upstream includes a completion
+    // summary. Current upstream renders synthetic completions as task XML with
+    // the completion/failure label inside <summary> rather than as the first
+    // line of text.
+    if (isCompleted && status.state !== 'completed') return undefined;
+    if (isFailed && status.state !== 'error') return undefined;
+
+    // Dedupe by synthetic message occurrence using stable occurrence ID
+    if (processedInjectedCompletions.has(occurrenceId)) return undefined;
+
+    const updated = updateBackgroundJobFromOutput(part.text);
+    if (!updated) return undefined;
+
+    log('[task-session-manager] processed injected background completion', {
+      taskID: updated.taskID,
+      alias: updated.alias,
+      parentSessionID: updated.parentSessionID,
+      state: updated.state,
+      occurrenceId,
+    });
+
+    rememberProcessedInjectedCompletion(occurrenceId);
+    return updated;
+  }
+
+  function rememberProcessedInjectedCompletion(signature: string): void {
+    processedInjectedCompletions.add(signature);
+    processedInjectedCompletionOrder.push(signature);
+
+    while (
+      processedInjectedCompletionOrder.length >
+      MAX_PROCESSED_INJECTED_COMPLETIONS
+    ) {
+      const evicted = processedInjectedCompletionOrder.shift();
+      if (!evicted) break;
+      processedInjectedCompletions.delete(evicted);
+    }
+  }
+
   function isMissingRememberedSessionError(output: string): boolean {
     const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
     return (
@@ -242,19 +462,60 @@ export function createTaskSessionManagerHook(
     );
   }
 
+  function rememberInjectedTerminalJobs(parentSessionID: string): void {
+    const taskIDs = backgroundJobBoard
+      .list(parentSessionID)
+      .filter((job) => job.terminalUnreconciled)
+      .map((job) => job.taskID);
+    if (taskIDs.length === 0) return;
+
+    log('[task-session-manager] terminal jobs injected for reconciliation', {
+      parentSessionID,
+      taskIDs,
+    });
+
+    const existing =
+      terminalJobsInjectedByParent.get(parentSessionID) ?? new Set<string>();
+    for (const taskID of taskIDs) {
+      existing.add(taskID);
+    }
+    terminalJobsInjectedByParent.set(parentSessionID, existing);
+  }
+
+  function reconcileInjectedTerminalJobs(parentSessionID: string): void {
+    const taskIDs = terminalJobsInjectedByParent.get(parentSessionID);
+    if (!taskIDs) return;
+
+    log('[task-session-manager] reconciling injected terminal jobs', {
+      parentSessionID,
+      taskIDs: [...taskIDs],
+    });
+
+    for (const taskID of taskIDs) {
+      backgroundJobBoard.markReconciled(taskID);
+    }
+    terminalJobsInjectedByParent.delete(parentSessionID);
+  }
+
   return {
     'tool.execute.before': async (
       input: { tool: string; sessionID?: string; callID?: string },
       output: { args?: unknown },
     ): Promise<void> => {
-      if (input.tool.toLowerCase() !== 'task') return;
+      const toolName = input.tool.toLowerCase();
+      if (toolName !== 'task') return;
       if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
         return;
       }
       if (!isObjectRecord(output.args)) return;
 
       const args = output.args as TaskArgs;
-      if (!isAgentName(args.subagent_type)) return;
+      if (!isAgentName(args.subagent_type)) {
+        if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+          delete args.task_id;
+        }
+        return;
+      }
 
       const label = deriveTaskSessionLabel({
         description:
@@ -279,25 +540,26 @@ export function createTaskSessionManagerHook(
       }
 
       const requested = args.task_id.trim();
-      const remembered = sessionManager.resolve(
+      const remembered = backgroundJobBoard.resolveReusable(
         input.sessionID,
-        args.subagent_type,
         requested,
+        args.subagent_type,
       );
 
       if (!remembered) {
+        if (RAW_SESSION_ID_PATTERN.test(requested)) {
+          pendingCall.resumedTaskId = requested;
+          rememberPendingCall(pendingCall);
+          return;
+        }
         delete args.task_id;
         return;
       }
 
-      args.task_id = remembered.taskId;
-      pendingManagedTaskIds.add(remembered.taskId);
-      sessionManager.markUsed(
-        input.sessionID,
-        args.subagent_type,
-        remembered.taskId,
-      );
-      pendingCall.resumedTaskId = remembered.taskId;
+      args.task_id = remembered.taskID;
+      pendingManagedTaskIds.add(remembered.taskID);
+      backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+      pendingCall.resumedTaskId = remembered.taskID;
       rememberPendingCall(pendingCall);
     },
 
@@ -320,38 +582,87 @@ export function createTaskSessionManagerHook(
       const pending = takePendingCall(input.callID, input.sessionID);
 
       if (!pending || typeof output.output !== 'string') return;
+      const launch = parseTaskLaunchOutput(output.output);
+      if (launch && !launch.result?.match(/Timed out after \d+ms/i)) {
+        const record = backgroundJobBoard.registerLaunch({
+          taskID: launch.taskID,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          description: pending.label,
+          objective: pending.label,
+        });
+        log('[task-session-manager] background task launch registered', {
+          taskID: record.taskID,
+          alias: record.alias,
+          parentSessionID: record.parentSessionID,
+          agent: record.agent,
+          description: record.description,
+          state: record.state,
+        });
+        backgroundJobBoard.addContext(
+          launch.taskID,
+          contextFilesForPrompt(contextByTask.get(launch.taskID)),
+        );
+        pendingManagedTaskIds.add(launch.taskID);
+        return;
+      }
+
+      normalizeLateCancelledTaskOutput(output);
+      const status = parseTaskStatusOutput(output.output);
+      if (status) {
+        const existing = backgroundJobBoard.get(status.taskID);
+        const record =
+          existing ??
+          backgroundJobBoard.registerLaunch({
+            taskID: status.taskID,
+            parentSessionID: pending.parentSessionId,
+            agent: pending.agentType,
+            description: pending.label,
+            objective: pending.label,
+          });
+        const updated = backgroundJobBoard.updateStatus({
+          taskID: status.taskID,
+          state: status.state,
+          timedOut: status.timedOut,
+          resultSummary: status.result,
+        });
+        log('[task-session-manager] foreground task status registered', {
+          taskID: status.taskID,
+          alias: updated?.alias ?? record.alias,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          state: updated?.state ?? record.state,
+        });
+        if (pending.resumedTaskId && pending.resumedTaskId !== status.taskID) {
+          backgroundJobBoard.drop(pending.resumedTaskId);
+        }
+        pendingManagedTaskIds.delete(status.taskID);
+        const contextFiles = contextFilesForPrompt(
+          contextByTask.get(status.taskID),
+        );
+        backgroundJobBoard.addContext(status.taskID, contextFiles);
+        pruneContext();
+        return;
+      }
+
       const taskId = parseTaskIdFromTaskOutput(output.output);
       if (!taskId) {
         if (
           pending.resumedTaskId &&
           isMissingRememberedSessionError(output.output)
         ) {
-          sessionManager.drop(
-            pending.parentSessionId,
-            pending.agentType,
-            pending.resumedTaskId,
-          );
+          backgroundJobBoard.drop(pending.resumedTaskId);
         }
         return;
       }
 
       if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
-        sessionManager.drop(
-          pending.parentSessionId,
-          pending.agentType,
-          pending.resumedTaskId,
-        );
+        backgroundJobBoard.drop(pending.resumedTaskId);
       }
 
-      sessionManager.remember({
-        parentSessionId: pending.parentSessionId,
-        taskId,
-        agentType: pending.agentType,
-        label: pending.label,
-      });
       pendingManagedTaskIds.delete(taskId);
       const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
-      sessionManager.addContext(taskId, contextFiles);
+      backgroundJobBoard.addContext(taskId, contextFiles);
       pruneContext();
     },
 
@@ -359,6 +670,23 @@ export function createTaskSessionManagerHook(
       _input: Record<string, never>,
       output: { messages: ChatMessage[] },
     ): Promise<void> => {
+      for (const [messageIndex, message] of output.messages.entries()) {
+        if (message.info.role !== 'user') continue;
+        if (message.info.agent && message.info.agent !== 'orchestrator') {
+          continue;
+        }
+        if (
+          !message.info.sessionID ||
+          !options.shouldManageSession(message.info.sessionID)
+        ) {
+          continue;
+        }
+
+        for (const [partIndex, part] of message.parts.entries()) {
+          updateFromInjectedCompletion(part, message, messageIndex, partIndex);
+        }
+      }
+
       for (let i = output.messages.length - 1; i >= 0; i -= 1) {
         const message = output.messages[i];
         if (message.info.role !== 'user') continue;
@@ -370,23 +698,22 @@ export function createTaskSessionManagerHook(
           return;
         }
 
-        const reminder = sessionManager.formatForPrompt(message.info.sessionID);
-        if (!reminder) return;
+        const reminders = [
+          backgroundJobBoard.formatForPrompt(message.info.sessionID),
+        ].filter((item): item is string => Boolean(item));
+        if (reminders.length === 0) return;
 
         const textPart = message.parts.find(
           (part) => part.type === 'text' && typeof part.text === 'string',
         );
         if (!textPart) return;
         if (textPart.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER)) return;
-        if (textPart.text?.includes(RESUMABLE_SESSIONS_START)) return;
-
-        textPart.text = [
-          textPart.text ?? '',
-          '',
-          RESUMABLE_SESSIONS_START,
-          reminder,
-          RESUMABLE_SESSIONS_END,
-        ].join('\n');
+        if (textPart.text?.includes(BACKGROUND_JOB_BOARD_SENTINEL)) return;
+
+        rememberInjectedTerminalJobs(message.info.sessionID);
+        textPart.text = [textPart.text ?? '', '', reminders.join('\n\n')].join(
+          '\n',
+        );
         return;
       }
     },
@@ -397,11 +724,20 @@ export function createTaskSessionManagerHook(
         properties?: {
           info?: { id?: string; parentID?: string };
           sessionID?: string;
+          status?: { type?: string };
+          error?: { name?: string };
         };
       };
     }): Promise<void> => {
       if (input.event.type === 'session.created') {
         const info = input.event.properties?.info;
+        log('[task-session-manager] session.created observed', {
+          sessionID: info?.id,
+          parentSessionID: info?.parentID,
+          managesParent: info?.parentID
+            ? options.shouldManageSession(info.parentID)
+            : false,
+        });
         if (
           info?.id &&
           info.parentID &&
@@ -412,13 +748,104 @@ export function createTaskSessionManagerHook(
         return;
       }
 
+      if (
+        input.event.type === 'session.idle' ||
+        (input.event.type === 'session.status' &&
+          (input.event.properties as { status?: { type?: string } } | undefined)
+            ?.status?.type === 'idle')
+      ) {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        log('[task-session-manager] idle/status idle observed', {
+          sessionID: sessionId,
+          managesSession: sessionId
+            ? options.shouldManageSession(sessionId)
+            : false,
+          terminalJobsPending: sessionId
+            ? (terminalJobsInjectedByParent.get(sessionId)?.size ?? 0)
+            : 0,
+        });
+        if (sessionId && options.shouldManageSession(sessionId)) {
+          reconcileInjectedTerminalJobs(sessionId);
+        }
+        return;
+      }
+
+      if (input.event.type === 'session.error') {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        if (sessionId && options.shouldManageSession(sessionId)) {
+          terminalJobsInjectedByParent.delete(sessionId);
+        }
+
+        return;
+      }
+
+      if (
+        input.event.type === 'session.status' &&
+        (input.event.properties as { status?: { type?: string } } | undefined)
+          ?.status?.type === 'busy'
+      ) {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        const before = sessionId
+          ? backgroundJobBoard.get(sessionId)
+          : undefined;
+        const updated = sessionId
+          ? backgroundJobBoard.markRunningFromLiveSession(sessionId)
+          : undefined;
+        if (before?.cancellationRequested) {
+          log('[task-session-manager] busy observed after cancel request', {
+            sessionID: sessionId,
+            previousState: before.state,
+            previousTerminalState: before.terminalState,
+            terminalUnreconciled: before.terminalUnreconciled,
+            resultSummary: before.resultSummary,
+            updatedState: updated?.state,
+            updatedCancellationRequested: updated?.cancellationRequested,
+          });
+        }
+        log('[task-session-manager] busy/status busy observed', {
+          sessionID: sessionId,
+          managesSession: sessionId
+            ? options.shouldManageSession(sessionId)
+            : false,
+          previousState: before?.state,
+          previousTerminalState: before?.terminalState,
+          previousCancellationRequested: before?.cancellationRequested,
+          previousLastLiveBusyAt: before?.lastLiveBusyAt,
+          updatedState: updated?.state,
+          updatedCancellationRequested: updated?.cancellationRequested,
+          updatedLastLiveBusyAt: updated?.lastLiveBusyAt,
+        });
+        return;
+      }
+
       if (input.event.type !== 'session.deleted') return;
       const sessionId =
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
       if (!sessionId) return;
 
-      sessionManager.dropTask(sessionId);
-      sessionManager.clearParent(sessionId);
+      log(
+        '[task-session-manager] session.deleted observed; clearing job state',
+        {
+          sessionID: sessionId,
+          deletedJob: backgroundJobBoard.get(sessionId)
+            ? {
+                state: backgroundJobBoard.get(sessionId)?.state,
+                parentSessionID:
+                  backgroundJobBoard.get(sessionId)?.parentSessionID,
+                alias: backgroundJobBoard.get(sessionId)?.alias,
+              }
+            : undefined,
+          childJobCount: backgroundJobBoard.list(sessionId).length,
+          managesSession: options.shouldManageSession(sessionId),
+        },
+      );
+
+      backgroundJobBoard.drop(sessionId);
+      backgroundJobBoard.clearParent(sessionId);
+      terminalJobsInjectedByParent.delete(sessionId);
       contextByTask.delete(sessionId);
       pendingManagedTaskIds.delete(sessionId);
       pruneContext();
@@ -431,4 +858,52 @@ export function createTaskSessionManagerHook(
       }
     },
   };
+
+  function normalizeLateCancelledTaskOutput(output: {
+    output: unknown;
+    metadata?: unknown;
+  }): void {
+    if (typeof output.output !== 'string') return;
+    const status = parseTaskStatusOutput(output.output);
+    if (!status) return;
+    const existing = backgroundJobBoard.get(status.taskID);
+    if (!isLateCancelledTaskError(existing, status.state)) return;
+    log('[task-session-manager] normalized late cancelled task output', {
+      taskID: status.taskID,
+      alias: existing?.alias,
+      state: existing?.state,
+      terminalState: existing?.terminalState,
+      result: status.result,
+    });
+    output.output = formatCancelledTaskStatusOutput(
+      status.taskID,
+      existing?.resultSummary,
+    );
+    if (isObjectRecord(output) && isObjectRecord(output.metadata)) {
+      output.metadata.state = 'cancelled';
+    }
+  }
+}
+
+function isLateCancelledTaskError(
+  job: BackgroundJobRecord | undefined,
+  state: string,
+): boolean {
+  if (state !== 'error') return false;
+  if (!job?.cancellationRequested) return false;
+  return job.state === 'cancelled' || job.terminalState === 'cancelled';
+}
+
+function formatCancelledTaskStatusOutput(
+  taskID: string,
+  summary = 'cancelled',
+): string {
+  return [
+    `task_id: ${taskID}`,
+    'state: cancelled',
+    '',
+    '<task_error>',
+    summary,
+    '</task_error>',
+  ].join('\n');
 }

+ 0 - 77
src/hooks/todo-continuation/codemap.md

@@ -1,77 +0,0 @@
-# src/hooks/todo-continuation/
-
-## Responsibility
-
-Implements orchestrator-only auto-continuation for incomplete todo lists with
-strict safety controls so automation does not loop or fight the user. It also
-hosts todo-state hygiene reminders after relevant tool actions.
-
-## Design
-
-- `index.ts` exports `createTodoContinuationHook(ctx, config?)`, returning:
-  - `handleMessagesTransform`
-  - `handleChatSystemTransform`
-  - `handleToolExecuteAfter`
-  - `handleEvent`
-  - `handleChatMessage`
-  - `handleCommandExecuteBefore`
-  - `tool` map containing `auto_continue`
-- State model (`ContinuationState`) tracks:
-  - enabled flag, consecutive continuation count, cooldown timer
-  - suppression window after abort, orchestrator session IDs
-  - in-flight notification and injection guards
-- `todo-hygiene.ts` owns lightweight reminder arming/injection using
-  todo-queue transitions and message-context signals.
-- Request signatures are used in `handleMessagesTransform` to avoid duplicate
-  per-request work.
-
-## Flow
-
-### Auto-continuation path
-
-1. `handleMessagesTransform` identifies the latest external user message,
-   infers session/agent, and starts a new continuation cycle for orchestrator
-   sessions.
-2. On `session.idle`/idle `session.status`, if enabled, the hook validates:
-   incomplete todos, non-question last assistant message, max-continuation limit,
-   suppress/notification guard, and timer/injection state.
-3. If all guards pass, it schedules a cooldown timer and sends a lightweight
-   no-reply notification via `session.prompt`.
-4. After cooldown, it injects `CONTINUATION_PROMPT` via `session.prompt`, updates
-   `consecutiveContinuations`, and logs progress.
-5. Event handling resets counters, clears pending timers, or applies a short
-   suppression window on abort-like errors.
-6. On `session.deleted`, orchestrator session state is torn down and notification
-   state is cleared.
-
-### Command path
-
-1. `handleCommandExecuteBefore` intercepts `/auto-continue` before runtime
-   execution.
-2. It toggles enabled state (`on`, `off`, or flip), clears timers as needed,
-   and injects a direct status response into output parts.
-3. When enabling and todos are pending, it appends continuation-ready status
-   text; when no todos remain, it reports that state.
-
-### Todo hygiene path
-
-1. `createTodoHygiene.handleToolExecuteAfter` arms reminders after supported
-   tooling activity, with reset/ignore rules for specific tools.
-2. `createTodoHygiene.handleChatSystemTransform` injects one reminder per request
-   when open todos remain (`TODO_HYGIENE_REMINDER` or
-   `TODO_FINAL_ACTIVE_REMINDER`).
-3. `handleEvent` clears hygiene state on `session.deleted`.
-
-## Integration
-
-- Registered in `src/index.ts` across:
-  - `experimental.chat.messages.transform`
-  - `experimental.chat.system.transform`
-  - `chat.message`
-  - `command.execute.before`
-  - `event`
-  - `tool.execute.after`
-- Uses shared utilities: `log`, `createInternalAgentTextPart`, and
-  `SLIM_INTERNAL_INITIATOR_MARKER`.
-- Session/agent identity is coordinated with `session.message` events and
-  maintained in the plugin for serve-mode routing consistency.

+ 0 - 3043
src/hooks/todo-continuation/index.test.ts

@@ -1,3043 +0,0 @@
-import { describe, expect, mock, test } from 'bun:test';
-import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
-import { createTodoContinuationHook } from './index';
-import {
-  TODO_FINAL_ACTIVE_REMINDER,
-  TODO_HYGIENE_REMINDER,
-} from './todo-hygiene';
-
-describe('createTodoContinuationHook', () => {
-  function createMockContext(overrides?: {
-    todoResult?: {
-      data?: Array<{
-        id: string;
-        content: string;
-        status: string;
-        priority: string;
-      }>;
-    };
-    messagesResult?: {
-      data?: Array<{
-        info?: { role?: string };
-        parts?: Array<{ type?: string; text?: string }>;
-      }>;
-    };
-  }) {
-    return {
-      client: {
-        session: {
-          todo: mock(async () => overrides?.todoResult ?? { data: [] }),
-          messages: mock(async () => overrides?.messagesResult ?? { data: [] }),
-          prompt: mock(async () => ({})),
-        },
-      },
-    } as any;
-  }
-
-  async function delay(ms: number): Promise<void> {
-    await new Promise((resolve) => setTimeout(resolve, ms));
-  }
-
-  // Notification prompts (noReply:true, no marker) fire immediately when
-  // scheduling a continuation. These helpers check only for actual
-  // continuation prompts (with SLIM_INTERNAL_INITIATOR_MARKER).
-  function hasContinuation(m: ReturnType<typeof mock>): boolean {
-    return m.mock.calls.some((c: any[]) =>
-      (c[0]?.body?.parts as any[])?.some((p: any) =>
-        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
-      ),
-    );
-  }
-  function contCount(m: ReturnType<typeof mock>): number {
-    return m.mock.calls.filter((c: any[]) =>
-      (c[0]?.body?.parts as any[])?.some((p: any) =>
-        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
-      ),
-    ).length;
-  }
-  function contCall(m: ReturnType<typeof mock>): any[] {
-    const call = m.mock.calls.find((c: any[]) =>
-      (c[0]?.body?.parts as any[])?.some((p: any) =>
-        p.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER),
-      ),
-    );
-    if (!call) {
-      throw new Error('No continuation call found');
-    }
-    return call;
-  }
-
-  function userMessages(
-    text: string,
-    sessionID = 'main1',
-    agent?: string,
-    parts?: Array<{ type: string; text?: string }>,
-    id?: string,
-  ) {
-    return {
-      messages: [
-        {
-          info: { id, role: 'user', agent, sessionID },
-          parts: parts ?? [{ type: 'text', text }],
-        },
-      ],
-    };
-  }
-
-  function allMessageText(output: {
-    messages: Array<{ parts: Array<{ type?: string; text?: string }> }>;
-  }) {
-    return output.messages
-      .flatMap((message) => message.parts)
-      .filter((part) => part.type === 'text' && typeof part.text === 'string')
-      .map((part) => part.text)
-      .join('\n');
-  }
-
-  describe('tool toggle', () => {
-    test('calling auto_continue execute with { enabled: true } sets state', async () => {
-      const ctx = createMockContext();
-      const hook = createTodoContinuationHook(ctx);
-
-      const result = await hook.tool.auto_continue.execute({ enabled: true });
-
-      expect(result).toContain('Auto-continue enabled');
-      expect(result).toContain('up to 5');
-    });
-
-    test('calling auto_continue execute with { enabled: false } disables', async () => {
-      const ctx = createMockContext();
-      const hook = createTodoContinuationHook(ctx);
-
-      const result = await hook.tool.auto_continue.execute({ enabled: false });
-
-      expect(result).toBe('Auto-continue disabled.');
-    });
-  });
-
-  describe('todo hygiene routing', () => {
-    test('does not inject hygiene reminder for unknown non-orchestrator session', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'task result' };
-
-      await hook.handleMessagesTransform(
-        userMessages('continue previous work', 'sub1', 'explorer'),
-      );
-      await hook.handleToolExecuteAfter(
-        { tool: 'task', sessionID: 'sub1' },
-        toolOutput,
-      );
-
-      expect(toolOutput.output).toBe('task result');
-      expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('does not expose a system transform handler', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-
-      expect('handleChatSystemTransform' in hook).toBe(false);
-    });
-
-    test('injects hygiene reminder into latest user message after todowrite activity', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = userMessages('primera request', 'main1', 'orchestrator');
-      const toolOutput = { output: 'read result' };
-
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        toolOutput,
-      );
-      await hook.handleMessagesTransform(output);
-
-      expect(toolOutput.output).toBe('read result');
-      expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
-      expect(allMessageText(output)).toContain(
-        '<instruction name="todo_hygiene">',
-      );
-    });
-
-    test('skips hygiene reminder when todo state lookup times out', async () => {
-      const ctx = createMockContext();
-      ctx.client.session.todo = mock(() => new Promise(() => {}));
-      const hook = createTodoContinuationHook(ctx);
-      const output = userMessages('primera request', 'main1', 'orchestrator');
-
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(output);
-
-      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('compaction-like transform does not consume pending reminder', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const live = userMessages('primera request', 'main1', 'orchestrator');
-      const compactionClone = structuredClone(live);
-
-      await hook.handleMessagesTransform(live);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(compactionClone);
-      expect(allMessageText(compactionClone)).toContain(TODO_HYGIENE_REMINDER);
-
-      await hook.handleMessagesTransform(live);
-      expect(allMessageText(live)).toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('new request clears stale pending reminder state', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const first = userMessages('primera request', 'main1', 'orchestrator');
-      const blocked = userMessages(
-        'segunda request distinta',
-        'main1',
-        'orchestrator',
-      );
-      const allowed = userMessages(
-        'segunda request distinta',
-        'main1',
-        'orchestrator',
-      );
-
-      await hook.handleMessagesTransform(first);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(blocked);
-      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
-
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(allowed);
-
-      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('attachment-only requests reset stale state without synthetic text parts', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const first = userMessages('primera request', 'main1', 'orchestrator');
-      const attachmentOnly = userMessages('', 'main1', 'orchestrator', [
-        { type: 'image' },
-      ]);
-
-      await hook.handleMessagesTransform(first);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(attachmentOnly);
-
-      expect(attachmentOnly.messages[0].parts).toHaveLength(1);
-      expect(allMessageText(attachmentOnly)).not.toContain(
-        TODO_HYGIENE_REMINDER,
-      );
-    });
-
-    test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = {
-        messages: [
-          {
-            info: { role: 'user', agent: 'orchestrator' },
-            parts: [{ type: 'text', text: 'new request boundary' }],
-          },
-        ],
-      };
-
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleMessagesTransform(output);
-
-      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    });
-
-    test('does not promote sessions with missing agent metadata to orchestrator', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'task result' };
-
-      await hook.handleMessagesTransform(
-        userMessages('continue previous work', 'sub1'),
-      );
-      await hook.handleToolExecuteAfter(
-        { tool: 'task', sessionID: 'sub1' },
-        toolOutput,
-      );
-
-      expect(toolOutput.output).toBe('task result');
-      expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
-    });
-
-    test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = userMessages('new request boundary', 'main1');
-
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleMessagesTransform(output);
-
-      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    });
-
-    test('the same user message id consumes pending reminder even if array index shifts', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const shifted = {
-        messages: [
-          {
-            info: { role: 'assistant', sessionID: 'main1' },
-            parts: [{ type: 'text', text: 'intermediate output' }],
-          },
-          {
-            info: {
-              id: 'u1',
-              role: 'user',
-              agent: 'orchestrator',
-              sessionID: 'main1',
-            },
-            parts: [{ type: 'text', text: 'request boundary' }],
-          },
-        ],
-      };
-
-      await hook.handleMessagesTransform(
-        userMessages(
-          'request boundary',
-          'main1',
-          'orchestrator',
-          undefined,
-          'u1',
-        ),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(shifted);
-
-      expect(allMessageText(shifted)).toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('a new user message id resets the request even if text is unchanged', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const blocked = userMessages(
-        'same text',
-        'main1',
-        'orchestrator',
-        undefined,
-        'u2',
-      );
-      const allowed = userMessages(
-        'same text',
-        'main1',
-        'orchestrator',
-        undefined,
-        'u2',
-      );
-
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(blocked);
-      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
-
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(allowed);
-
-      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('a repeated text without message ids resets when a later user turn appears', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const blocked = {
-        messages: [
-          {
-            info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
-            parts: [{ type: 'text', text: 'same text' }],
-          },
-          {
-            info: { role: 'assistant', sessionID: 'main1' },
-            parts: [{ type: 'text', text: 'intermediate output' }],
-          },
-          {
-            info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
-            parts: [{ type: 'text', text: 'same text' }],
-          },
-        ],
-      };
-      const allowed = structuredClone(blocked);
-
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator'),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(blocked);
-      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
-
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(allowed);
-
-      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const unknown = {
-        messages: [
-          {
-            info: { role: 'user', agent: 'orchestrator' },
-            parts: [{ type: 'text', text: 'boundary without session id' }],
-          },
-        ],
-      };
-
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(
-        userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-
-      await hook.handleMessagesTransform(unknown);
-
-      expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
-    });
-
-    test('does not inject from continuation-like wording alone', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'read result' };
-
-      await hook.handleMessagesTransform(
-        userMessages(
-          'sigue este formato pero empieza de cero',
-          'main1',
-          'orchestrator',
-        ),
-      );
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        toolOutput,
-      );
-
-      expect(toolOutput.output).toBe('read result');
-      expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(toolOutput.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
-    });
-
-    test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = userMessages(
-        'finish the previous work',
-        'main1',
-        'orchestrator',
-      );
-
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleMessagesTransform(output);
-
-      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    });
-
-    test('final active todo after todowrite uses the stronger finishing reminder', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'in_progress',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = userMessages('haz esto', 'main1', 'orchestrator');
-
-      await hook.handleMessagesTransform(output);
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleMessagesTransform(output);
-
-      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
-      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
-    });
-  });
-
-  describe('continuation scheduling', () => {
-    test('session idle + enabled + incomplete todos → schedules continuation', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-            { id: '2', content: 'todo2', status: 'completed', priority: 'low' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Here is the result' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-      });
-
-      // Enable auto-continue
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Fire session.idle event
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait for cooldown
-      await delay(60);
-
-      // Verify session.prompt was called with continuation prompt
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      const promptCall = contCall(ctx.client.session.prompt);
-      expect(promptCall[0].path.id).toBe('session-123');
-      expect(promptCall[0].body.parts[0].text).toContain(
-        '[Auto-continue: enabled - there are incomplete todos remaining.',
-      );
-      expect(promptCall[0].body.parts[0].text).toContain(
-        SLIM_INTERNAL_INITIATOR_MARKER,
-      );
-    });
-
-    test('disabled → no continuation', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Done' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      // Do NOT enable auto-continue
-
-      // Fire session.idle event
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait for cooldown
-      await delay(60);
-
-      // Verify session.prompt was NOT called
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('last message is a question → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [
-                { type: 'text', text: 'Should I proceed with the next step?' },
-              ],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      // Enable auto-continue
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Fire session.idle event
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait for cooldown
-      await delay(60);
-
-      // Verify continuation NOT scheduled
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('question detection with question mark → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Ready to continue?' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('question detection with "would you like" phrase → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [
-                {
-                  type: 'text',
-                  text: 'Would you like me to proceed?',
-                },
-              ],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('max continuations reached → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 2,
-        cooldownMs: 50,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Fire idle events up to maxContinuations
-      for (let i = 0; i < 2; i++) {
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 'session-123' },
-          },
-        });
-        await delay(60);
-      }
-
-      // Reset mock for the 3rd attempt
-      ctx.client.session.prompt.mockClear();
-
-      // On the N+1th idle, verify no continuation scheduled
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('abort suppress window → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      // Seed orchestrator session
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Fire session.error with MessageAbortedError
-      await hook.handleEvent({
-        event: {
-          type: 'session.error',
-          properties: {
-            sessionID: 'session-123',
-            error: { name: 'MessageAbortedError' },
-          },
-        },
-      });
-
-      // Immediately fire session.idle
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait less than suppress window (5s) - just enough to verify it's working
-      await delay(100);
-
-      // Verify no continuation within suppress window
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('session busy → cancel pending timer', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 500,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Schedule a continuation
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // After the notification grace but before cooldown expires, fire busy.
-      await delay(300);
-      await hook.handleEvent({
-        event: {
-          type: 'session.status',
-          properties: {
-            sessionID: 'session-123',
-            status: { type: 'busy' },
-          },
-        },
-      });
-
-      // Advance past original cooldown
-      await delay(250);
-
-      // Verify timer was cancelled and prompt NOT called
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-    });
-
-    test('sub-agent session.busy does NOT cancel orchestrator timer', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 100,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Schedule a continuation for orchestrator session
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // A sub-agent (different session) goes busy
-      await delay(50);
-      await hook.handleEvent({
-        event: {
-          type: 'session.status',
-          properties: {
-            sessionID: 'sub-agent-456',
-            status: { type: 'busy' },
-          },
-        },
-      });
-
-      // Advance past original cooldown
-      await delay(250);
-
-      // Orchestrator timer should still fire — prompt was called
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('all todos complete → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'completed',
-              priority: 'high',
-            },
-            { id: '2', content: 'todo2', status: 'cancelled', priority: 'low' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'All done' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('non-orchestrator session → skip', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // First idle from session A (becomes orchestrator)
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-A' },
-        },
-      });
-
-      await delay(60);
-
-      // Verify prompt was called for session A
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-
-      // Reset mock
-      ctx.client.session.prompt.mockClear();
-
-      // Second idle from session B (different sessionID)
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-B' },
-        },
-      });
-
-      await delay(60);
-
-      // Verify no continuation for session B
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('cooldownMs from config', async () => {
-      const customCooldownMs = 150;
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: customCooldownMs,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Advance timer by well under the custom cooldown to avoid timer jitter
-      await delay(60);
-
-      // Verify prompt not called yet
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-
-      // Advance timer past the configured cooldown
-      await delay(100);
-
-      // Now prompt should be called
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-  });
-
-  describe('event handling - session.error', () => {
-    test('MessageAbortedError sets suppress window', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      // Seed orchestrator session
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Fire session.error with MessageAbortedError
-      await hook.handleEvent({
-        event: {
-          type: 'session.error',
-          properties: {
-            sessionID: 'session-123',
-            error: { name: 'MessageAbortedError' },
-          },
-        },
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait less than suppress window
-      await delay(100);
-
-      // Verify no continuation within suppress window
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('AbortError sets suppress window', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      // Seed orchestrator session (disabled, so no continuation fires)
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.error',
-          properties: {
-            sessionID: 'session-123',
-            error: { name: 'AbortError' },
-          },
-        },
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait less than suppress window
-      await delay(100);
-
-      // Verify no continuation within suppress window
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('other errors do not set suppress window', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.error',
-          properties: {
-            sessionID: 'session-123',
-            error: { name: 'NetworkError' },
-          },
-        },
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      // Prompt should be called immediately (no suppress window)
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-  });
-
-  describe('event handling - session.deleted', () => {
-    test('clears pending timer on session delete', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 100,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Schedule continuation
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Delete session before timer fires
-      await delay(50);
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: {
-            sessionID: 'session-123',
-          },
-        },
-      });
-
-      // Advance past original cooldown
-      await delay(250);
-
-      // Verify timer was cancelled and prompt NOT called
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-    });
-
-    test('sub-agent session.deleted does NOT cancel orchestrator timer', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 100,
-      });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Schedule continuation for orchestrator session
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // A sub-agent (different session) gets deleted
-      await delay(50);
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: {
-            sessionID: 'sub-agent-456',
-          },
-        },
-      });
-
-      // Advance past original cooldown
-      await delay(250);
-
-      // Orchestrator timer should still fire — prompt was called
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('resets orchestrator session when deleted session matches', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // First idle sets orchestrator
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-A' },
-        },
-      });
-
-      await delay(60);
-
-      // Delete orchestrator session
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: {
-            sessionID: 'session-A',
-          },
-        },
-      });
-
-      // Second idle from new session should become orchestrator
-      ctx.client.session.prompt.mockClear();
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-B' },
-        },
-      });
-
-      await delay(60);
-
-      // Prompt should be called for session-B (new orchestrator)
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      const promptCall = contCall(ctx.client.session.prompt);
-      expect(promptCall[0].path.id).toBe('session-B');
-    });
-  });
-
-  describe('error handling', () => {
-    test('fetch todos failure → skips continuation', async () => {
-      const ctx = createMockContext({
-        todoResult: undefined as any,
-      });
-      ctx.client.session.todo = mock(async () => {
-        throw new Error('Failed to fetch todos');
-      });
-
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('fetch messages failure → skips continuation', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-      });
-      ctx.client.session.messages = mock(async () => {
-        throw new Error('Failed to fetch messages');
-      });
-
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('command.execute.before interception', () => {
-    test('unrelated command → no interception', async () => {
-      const ctx = createMockContext();
-      const hook = createTodoContinuationHook(ctx);
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-      await hook.handleCommandExecuteBefore(
-        { command: 'help', sessionID: 'session-123', arguments: '' },
-        output,
-      );
-
-      expect(output.parts).toHaveLength(0);
-    });
-
-    test('/auto-continue enables and injects continuation when incomplete todos', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'pending',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        output,
-      );
-
-      expect(output.parts).toHaveLength(1);
-      expect(output.parts[0].text).toContain(
-        '[Auto-continue: enabled - there are incomplete todos remaining.',
-      );
-      expect(output.parts[0].text).toContain(SLIM_INTERNAL_INITIATOR_MARKER);
-    });
-
-    test('/auto-continue enables but no continuation when all todos complete', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'completed',
-              priority: 'high',
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        output,
-      );
-
-      expect(output.parts).toHaveLength(1);
-      expect(output.parts[0].text).toContain('No incomplete todos right now');
-    });
-
-    test('/auto-continue toggles off when already enabled', async () => {
-      const ctx = createMockContext();
-      const hook = createTodoContinuationHook(ctx);
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-      // Enable via tool
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Toggle off via command
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        output,
-      );
-
-      expect(output.parts).toHaveLength(1);
-      expect(output.parts[0].text).toContain('disabled by user command');
-    });
-
-    test('/auto-continue resets consecutive continuations on toggle', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            {
-              id: '1',
-              content: 'todo1',
-              status: 'pending',
-              priority: 'high',
-            },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 2,
-        cooldownMs: 50,
-      });
-
-      // Enable and run up to max
-      await hook.tool.auto_continue.execute({ enabled: true });
-      for (let i = 0; i < 2; i++) {
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 'session-123' },
-          },
-        });
-        await delay(60);
-      }
-
-      // Toggle off then on via command (resets count)
-      const outputOff = {
-        parts: [] as Array<{ type: string; text?: string }>,
-      };
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        outputOff,
-      );
-      expect(outputOff.parts[0].text).toContain('disabled');
-
-      const outputOn = {
-        parts: [] as Array<{ type: string; text?: string }>,
-      };
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        outputOn,
-      );
-      // Should have continuation prompt again (count was reset)
-      expect(outputOn.parts[0].text).toContain(
-        '[Auto-continue: enabled - there are incomplete todos remaining.',
-      );
-    });
-
-    test('/auto-continue with todo fetch failure → enables without continuation', async () => {
-      const ctx = createMockContext();
-      ctx.client.session.todo = mock(async () => {
-        throw new Error('Network error');
-      });
-      const hook = createTodoContinuationHook(ctx);
-      const output = { parts: [] as Array<{ type: string; text?: string }> };
-
-      await hook.handleCommandExecuteBefore(
-        { command: 'auto-continue', sessionID: 'session-123', arguments: '' },
-        output,
-      );
-
-      // Should still enable but skip continuation (no todos fetched)
-      expect(output.parts).toHaveLength(1);
-      expect(output.parts[0].text).toContain('No incomplete todos right now');
-    });
-  });
-
-  describe('config defaults', () => {
-    test('default config: maxContinuations = 5, cooldownMs = 3000', async () => {
-      const ctx = createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-      const hook = createTodoContinuationHook(ctx); // No config passed
-
-      const result = await hook.tool.auto_continue.execute({ enabled: true });
-
-      expect(result).toContain('up to 5');
-
-      // Test default cooldown - we'll just verify it waits before calling
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-123' },
-        },
-      });
-
-      // Wait less than default cooldown
-      await delay(100);
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-
-      // Wait past default cooldown
-      await delay(2900);
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-  });
-
-  describe('council review findings', () => {
-    describe('CRITICAL-1: counter bypass via session.status→busy', () => {
-      test('counter persists when busy fires during auto-injection', async () => {
-        let promptResolve!: () => void;
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-
-        // Make prompt hang so isAutoInjecting stays true
-        ctx.client.session.prompt = mock(async () => {
-          await new Promise<void>((r) => {
-            promptResolve = r;
-          });
-        });
-
-        const hook = createTodoContinuationHook(ctx, {
-          maxContinuations: 2,
-          cooldownMs: 50,
-        });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Cycle 1: idle → timer → prompt hangs
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        // Session goes busy from prompt — isAutoInjecting is true,
-        // so counter should NOT be reset
-        await hook.handleEvent({
-          event: {
-            type: 'session.status',
-            properties: {
-              sessionID: 's1',
-              status: { type: 'busy' },
-            },
-          },
-        });
-
-        // Resolve prompt → counter = 1
-        promptResolve();
-        await delay(10);
-
-        // Cycle 2: idle → timer → prompt hangs
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        // Session goes busy again — counter still not reset
-        await hook.handleEvent({
-          event: {
-            type: 'session.status',
-            properties: {
-              sessionID: 's1',
-              status: { type: 'busy' },
-            },
-          },
-        });
-
-        // Resolve prompt → counter = 2
-        promptResolve();
-        await delay(10);
-
-        // Cycle 3: counter = 2 >= maxContinuations = 2 → BLOCKED
-        ctx.client.session.prompt = mock(async () => ({}));
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-      });
-    });
-
-    describe('CRITICAL-2: disable cancels pending timer', () => {
-      test('tool disable during cooldown prevents injection', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire idle → timer scheduled (100ms cooldown)
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-
-        // Disable before timer fires
-        await delay(50);
-        await hook.tool.auto_continue.execute({ enabled: false });
-
-        // Wait past original cooldown
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-      });
-
-      test('command disable during cooldown prevents injection', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 100 });
-
-        // Enable via command
-        const outputOn = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: 'on',
-          },
-          outputOn,
-        );
-
-        // Fire idle → timer scheduled
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-
-        // Disable via command before timer fires
-        await delay(50);
-        const outputOff = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: 'off',
-          },
-          outputOff,
-        );
-
-        // Wait past original cooldown
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-      });
-    });
-
-    describe('MAJOR-1: session.deleted resets counter', () => {
-      test('deleted orchestrator session resets counter for next session', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, {
-          maxContinuations: 2,
-          cooldownMs: 50,
-        });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Cycle 1: idle → inject → counter = 1
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        // Delete orchestrator session → counter should reset
-        await hook.handleEvent({
-          event: {
-            type: 'session.deleted',
-            properties: { sessionID: 's1' },
-          },
-        });
-
-        // New session becomes orchestrator — counter starts from 0
-        ctx.client.session.prompt.mockClear();
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's2' },
-          },
-        });
-        await delay(60); // counter = 1
-
-        // One more cycle → counter = 2 (reaches max)
-        ctx.client.session.prompt.mockClear();
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's2' },
-          },
-        });
-        await delay(60);
-
-        // Third cycle blocked (counter = 2 >= max = 2)
-        ctx.client.session.prompt.mockClear();
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's2' },
-          },
-        });
-        await delay(60);
-
-        expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-      });
-    });
-
-    describe('MAJOR-2: suppressUntil cleared on re-enable', () => {
-      test('tool re-enable clears suppress window', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire abort → sets suppress window
-        await hook.handleEvent({
-          event: {
-            type: 'session.error',
-            properties: {
-              sessionID: 's1',
-              error: { name: 'AbortError' },
-            },
-          },
-        });
-
-        // Re-enable within suppress window → clears suppressUntil
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire idle → should NOT be suppressed
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      });
-
-      test('command re-enable clears suppress window', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire abort → sets suppress window
-        await hook.handleEvent({
-          event: {
-            type: 'session.error',
-            properties: {
-              sessionID: 's1',
-              error: { name: 'AbortError' },
-            },
-          },
-        });
-
-        // Re-enable via command → clears suppressUntil
-        const output = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: 'on',
-          },
-          output,
-        );
-
-        // Fire idle → should NOT be suppressed
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      });
-    });
-
-    describe('error paths', () => {
-      test('prompt failure in timer callback is handled gracefully', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        ctx.client.session.prompt = mock(async () => {
-          throw new Error('API error');
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-
-        // Seed orchestrator session
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        // Error caught; isAutoInjecting should be cleared via finally.
-        // Verify by checking a second idle still works.
-        ctx.client.session.prompt = mock(async () => ({}));
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      });
-    });
-
-    describe('edge cases', () => {
-      test('session.idle with missing sessionID returns early', async () => {
-        const ctx = createMockContext();
-        const hook = createTodoContinuationHook(ctx);
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire idle without sessionID — should not throw
-        await hook.handleEvent({
-          event: { type: 'session.idle', properties: {} },
-        });
-
-        expect(ctx.client.session.todo).not.toHaveBeenCalled();
-      });
-
-      test('session.deleted with properties.info.id path', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Set orchestrator via idle
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-
-        // Delete via info.id path (alternative shape from session store)
-        await hook.handleEvent({
-          event: {
-            type: 'session.deleted',
-            properties: { info: { id: 's1' } },
-          },
-        });
-
-        // New session should become orchestrator
-        ctx.client.session.prompt.mockClear();
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's2' },
-          },
-        });
-        await delay(60);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-        expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('s2');
-      });
-
-      test('cooldownMs = 0 fires on next tick', async () => {
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        const hook = createTodoContinuationHook(ctx, {
-          cooldownMs: 0,
-          maxContinuations: 5,
-        });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(10);
-
-        expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      });
-    });
-
-    describe('MAJOR-3: double-fire prevention', () => {
-      test('rapid idle events during prompt delivery — single continuation', async () => {
-        let promptResolve!: () => void;
-        const ctx = createMockContext({
-          todoResult: {
-            data: [
-              {
-                id: '1',
-                content: 't1',
-                status: 'pending',
-                priority: 'high',
-              },
-            ],
-          },
-          messagesResult: {
-            data: [
-              {
-                info: { role: 'assistant' },
-                parts: [{ type: 'text', text: 'Work' }],
-              },
-            ],
-          },
-        });
-        ctx.client.session.prompt = mock(async () => {
-          await new Promise<void>((r) => {
-            promptResolve = r;
-          });
-        });
-
-        const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // Fire idle → timer → prompt hangs (isAutoInjecting = true)
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        // Fire another idle while prompt is in flight
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-
-        // Only one prompt call (blocked by isAutoInjecting gate)
-        expect(contCount(ctx.client.session.prompt)).toBe(1);
-
-        // Resolve prompt
-        promptResolve();
-        await delay(10);
-
-        // Now idle should schedule a new timer
-        ctx.client.session.prompt = mock(async () => ({}));
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 's1' },
-          },
-        });
-        await delay(60);
-
-        expect(contCount(ctx.client.session.prompt)).toBe(1);
-      });
-    });
-
-    describe('MAJOR-4: command explicit on|off arguments', () => {
-      test('command "on" keeps enabled state when already enabled', async () => {
-        const ctx = createMockContext();
-        const hook = createTodoContinuationHook(ctx);
-
-        // Enable via tool
-        await hook.tool.auto_continue.execute({ enabled: true });
-
-        // /auto-continue on → should KEEP enabled (not toggle to off)
-        const output = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: 'on',
-          },
-          output,
-        );
-
-        expect(output.parts[0].text).not.toContain('disabled');
-      });
-
-      test('command "off" keeps disabled state when already disabled', async () => {
-        const ctx = createMockContext();
-        const hook = createTodoContinuationHook(ctx);
-
-        // Start disabled (default)
-        const output = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: 'off',
-          },
-          output,
-        );
-
-        expect(output.parts[0].text).toContain('disabled');
-      });
-
-      test('command with no argument toggles state', async () => {
-        const ctx = createMockContext();
-        const hook = createTodoContinuationHook(ctx);
-
-        // First toggle: disabled → enabled
-        const output1 = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: '',
-          },
-          output1,
-        );
-        expect(output1.parts[0].text).not.toContain('disabled');
-
-        // Second toggle: enabled → disabled
-        const output2 = {
-          parts: [] as Array<{ type: string; text?: string }>,
-        };
-        await hook.handleCommandExecuteBefore(
-          {
-            command: 'auto-continue',
-            sessionID: 's1',
-            arguments: '',
-          },
-          output2,
-        );
-        expect(output2.parts[0].text).toContain('disabled');
-      });
-    });
-  });
-
-  describe('session routing and notification cancellation', () => {
-    function createPendingCtx() {
-      return createMockContext({
-        todoResult: {
-          data: [
-            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
-          ],
-        },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Work in progress' }],
-            },
-          ],
-        },
-      });
-    }
-
-    test('chat.message registers orchestrator sessions without first-idle lockout', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, {
-        cooldownMs: 50,
-      });
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'sub1' },
-        },
-      });
-      await delay(60);
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(false);
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main2' },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main2');
-    });
-
-    test('chat.message without agent does not block legacy first-idle fallback', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, {
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 1,
-      });
-
-      hook.handleChatMessage({ sessionID: 'main1' });
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('subagent chat.message prevents first-idle fallback registration', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, {
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 1,
-      });
-
-      hook.handleChatMessage({ sessionID: 'sub1', agent: 'fixer' });
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'sub1' },
-        },
-      });
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('session.status idle triggers continuation like session.idle', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-      await hook.tool.auto_continue.execute({ enabled: true });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.status',
-          properties: { sessionID: 'main1', status: { type: 'idle' } },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('deleting another orchestrator does not cancel the active session timer', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-      await hook.tool.auto_continue.execute({ enabled: true });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: { sessionID: 'main2' },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      expect(contCall(ctx.client.session.prompt)[0].path.id).toBe('main1');
-    });
-
-    test('deleting all orchestrators restores legacy first-idle fallback', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, {
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 1,
-      });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await hook.handleEvent({
-        event: {
-          type: 'session.deleted',
-          properties: { sessionID: 'main2' },
-        },
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'legacy-main' },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-      expect(contCall(ctx.client.session.prompt)[0].path.id).toBe(
-        'legacy-main',
-      );
-    });
-
-    test('countdown notification busy status does not reset max-continuation counter', async () => {
-      const ctx = createPendingCtx();
-      const releaseNotifications: Array<() => void> = [];
-      ctx.client.session.prompt = mock(async (args: any) => {
-        if (args?.body?.noReply === true) {
-          await new Promise<void>((resolve) => {
-            releaseNotifications.push(resolve);
-          });
-        }
-        return {};
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        cooldownMs: 50,
-        maxContinuations: 2,
-      });
-      await hook.tool.auto_continue.execute({ enabled: true });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-
-      for (let i = 0; i < 2; i++) {
-        await hook.handleEvent({
-          event: {
-            type: 'session.idle',
-            properties: { sessionID: 'main1' },
-          },
-        });
-        await hook.handleEvent({
-          event: {
-            type: 'session.status',
-            properties: { sessionID: 'main1', status: { type: 'busy' } },
-          },
-        });
-        await delay(60);
-        releaseNotifications.shift()?.();
-        await delay(10);
-      }
-
-      ctx.client.session.prompt.mockClear();
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('late countdown notification busy status does not cancel continuation timer', async () => {
-      const ctx = createPendingCtx();
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-      await hook.tool.auto_continue.execute({ enabled: true });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await delay(10);
-      await hook.handleEvent({
-        event: {
-          type: 'session.status',
-          properties: { sessionID: 'main1', status: { type: 'busy' } },
-        },
-      });
-      await delay(60);
-
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('countdown notification busy status does not cancel continuation timer', async () => {
-      const ctx = createPendingCtx();
-      let callCount = 0;
-      ctx.client.session.prompt = mock(async () => {
-        callCount++;
-        return {};
-      });
-      const hook = createTodoContinuationHook(ctx, { cooldownMs: 50 });
-      await hook.tool.auto_continue.execute({ enabled: true });
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'main1' },
-        },
-      });
-      await hook.handleEvent({
-        event: {
-          type: 'session.status',
-          properties: { sessionID: 'main1', status: { type: 'busy' } },
-        },
-      });
-      await delay(60);
-
-      expect(callCount).toBeGreaterThanOrEqual(2);
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-  });
-
-  describe('auto-enable on todo count', () => {
-    function createAutoEnableCtx(
-      todos: Array<{
-        id: string;
-        content: string;
-        status: string;
-        priority: string;
-      }>,
-    ) {
-      return createMockContext({
-        todoResult: { data: todos },
-        messagesResult: {
-          data: [
-            {
-              info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Working...' }],
-            },
-          ],
-        },
-      });
-    }
-
-    test('autoEnable=true, todos >= threshold → auto-enables and continues', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-        { id: '3', content: 't3', status: 'pending', priority: 'high' },
-        { id: '4', content: 't4', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      // Do NOT manually enable — auto-enable should trigger
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // Should have scheduled continuation (auto-enabled)
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('autoEnable=true, todos < threshold → does NOT auto-enable', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-        { id: '3', content: 't3', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // Should NOT auto-enable or continue
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('autoEnable=false (default) → never auto-enables regardless of todo count', async () => {
-      const ctx = createAutoEnableCtx(
-        Array.from({ length: 10 }, (_, i) => ({
-          id: String(i),
-          content: `t${i}`,
-          status: 'pending',
-          priority: 'high',
-        })),
-      );
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        // autoEnable defaults to false
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('auto-enable does not re-enable if already manually enabled', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      // Manually enable first
-      await hook.tool.auto_continue.execute({ enabled: true });
-
-      // Only 2 todos (< threshold) — but already enabled, so should continue
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // Continues because already manually enabled (auto-enable check skipped)
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('auto-enable respects custom threshold', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 2,
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // 2 todos >= threshold 2 → auto-enables
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('auto-enable skipped for non-orchestrator session', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-        { id: '3', content: 't3', status: 'pending', priority: 'high' },
-        { id: '4', content: 't4', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      // First idle sets orchestrator to session-A
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-A' },
-        },
-      });
-      await delay(60);
-
-      // Reset mock
-      ctx.client.session.prompt.mockClear();
-
-      // Second idle from session-B — not orchestrator, should skip
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 'session-B' },
-        },
-      });
-      await delay(60);
-
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('auto-enable with todo fetch failure → no auto-enable, no crash', async () => {
-      const ctx = createMockContext();
-      ctx.client.session.todo = mock(async () => {
-        throw new Error('Network error');
-      });
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      // Should not throw
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // No auto-enable, no continuation
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-
-    test('auto-enable resets consecutive counter and suppress window', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'pending', priority: 'high' },
-        { id: '2', content: 't2', status: 'pending', priority: 'high' },
-        { id: '3', content: 't3', status: 'pending', priority: 'high' },
-        { id: '4', content: 't4', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      // Manually enable, run a continuation, disable
-      await hook.tool.auto_continue.execute({ enabled: true });
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-      await delay(60);
-
-      // Fire abort to set suppress window
-      await hook.handleEvent({
-        event: {
-          type: 'session.error',
-          properties: {
-            sessionID: 's1',
-            error: { name: 'AbortError' },
-          },
-        },
-      });
-
-      // Disable
-      await hook.tool.auto_continue.execute({ enabled: false });
-
-      // Reset mock
-      ctx.client.session.prompt.mockClear();
-
-      // Fire idle again — auto-enable should trigger (4 todos >= 4),
-      // resetting counter and suppress window
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // Should continue (suppressed window was cleared by auto-enable)
-      expect(hasContinuation(ctx.client.session.prompt)).toBe(true);
-    });
-
-    test('auto-enable counts incomplete todos only, not completed', async () => {
-      const ctx = createAutoEnableCtx([
-        { id: '1', content: 't1', status: 'completed', priority: 'high' },
-        { id: '2', content: 't2', status: 'completed', priority: 'high' },
-        { id: '3', content: 't3', status: 'pending', priority: 'high' },
-        { id: '4', content: 't4', status: 'pending', priority: 'high' },
-      ]);
-      const hook = createTodoContinuationHook(ctx, {
-        maxContinuations: 5,
-        cooldownMs: 50,
-        autoEnable: true,
-        autoEnableThreshold: 4,
-      });
-
-      await hook.handleEvent({
-        event: {
-          type: 'session.idle',
-          properties: { sessionID: 's1' },
-        },
-      });
-
-      await delay(60);
-
-      // Only 2 incomplete todos < threshold 4 → does NOT auto-enable
-      expect(ctx.client.session.prompt).not.toHaveBeenCalled();
-    });
-  });
-});

+ 0 - 879
src/hooks/todo-continuation/index.ts

@@ -1,879 +0,0 @@
-import type { PluginInput } from '@opencode-ai/plugin';
-import { tool } from '@opencode-ai/plugin';
-import {
-  createInternalAgentTextPart,
-  log,
-  SLIM_INTERNAL_INITIATOR_MARKER,
-  withTimeout,
-} from '../../utils';
-import { createTodoHygiene } from './todo-hygiene';
-
-const HOOK_NAME = 'todo-continuation';
-const COMMAND_NAME = 'auto-continue';
-const TODO_STATE_TIMEOUT_MS = 500;
-
-const CONTINUATION_PROMPT =
-  '[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]';
-const TODO_HYGIENE_INSTRUCTION_OPEN = '<instruction name="todo_hygiene">';
-const TODO_HYGIENE_INSTRUCTION_CLOSE = '</instruction>';
-
-// Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
-// re-continuing something the user explicitly stopped
-const SUPPRESS_AFTER_ABORT_MS = 5_000;
-const NOTIFICATION_BUSY_GRACE_MS = 250;
-
-const QUESTION_PHRASES = [
-  'would you like',
-  'should i',
-  'do you want',
-  'please review',
-  'let me know',
-  'what do you think',
-  'can you confirm',
-  'would you prefer',
-  'shall i',
-  'any thoughts',
-];
-
-// Statuses that indicate a todo is terminal (won't be worked on further).
-// Uses denylist approach: any status not listed here is considered incomplete.
-const TERMINAL_TODO_STATUSES = ['completed', 'cancelled'];
-
-interface ContinuationState {
-  enabled: boolean;
-  consecutiveContinuations: number;
-  pendingTimer: ReturnType<typeof setTimeout> | null;
-  pendingTimerSessionId: string | null;
-  suppressUntil: number;
-  orchestratorSessionIds: Set<string>;
-  sawChatMessage: boolean;
-  // True while our auto-injection prompt is in flight — prevents counter reset
-  // on session.status→busy and blocks duplicate injections
-  isAutoInjecting: boolean;
-  // session IDs with an in-flight noReply countdown notification.
-  notifyingSessionIds: Set<string>;
-  // sessionID → timestamp until which just-completed noReply countdown
-  // notification busy transitions are ignored, covering HTTP/SSE reordering.
-  notificationBusyUntilBySession: Map<string, number>;
-}
-
-function isQuestion(text: string): boolean {
-  const lowerText = text.toLowerCase().trim();
-  // Match trailing '?' with optional whitespace after it
-  if (/\?\s*$/.test(lowerText)) {
-    return true;
-  }
-  return QUESTION_PHRASES.some((phrase) => lowerText.includes(phrase));
-}
-
-interface TodoItem {
-  id: string;
-  content: string;
-  status: string;
-  priority: string;
-}
-
-interface MessageInfo {
-  role?: string;
-  [key: string]: unknown;
-}
-
-interface MessagePart {
-  type?: string;
-  text?: string;
-  [key: string]: unknown;
-}
-
-interface ChatTransformMessage {
-  info: {
-    id?: string;
-    role?: string;
-    agent?: string;
-    sessionID?: string;
-  };
-  parts: MessagePart[];
-}
-
-interface LastExternalUserMessage {
-  sessionID?: string;
-  agent?: string;
-  signature: string;
-  message: ChatTransformMessage;
-}
-
-interface Message {
-  info?: MessageInfo;
-  parts?: MessagePart[];
-}
-
-function cancelPendingTimer(state: ContinuationState): void {
-  if (state.pendingTimer) {
-    clearTimeout(state.pendingTimer);
-    state.pendingTimer = null;
-  }
-  state.pendingTimerSessionId = null;
-}
-
-function resetState(state: ContinuationState): void {
-  cancelPendingTimer(state);
-  state.consecutiveContinuations = 0;
-  state.suppressUntil = 0;
-  state.isAutoInjecting = false;
-  state.notifyingSessionIds.clear();
-  state.notificationBusyUntilBySession.clear();
-}
-
-function stripTodoHygieneInstruction(text: string): string {
-  const trimmed = text.trimEnd();
-  if (!trimmed.endsWith(TODO_HYGIENE_INSTRUCTION_CLOSE)) {
-    return trimmed;
-  }
-
-  const start = trimmed.lastIndexOf(TODO_HYGIENE_INSTRUCTION_OPEN);
-  if (start === -1) {
-    return trimmed;
-  }
-
-  return trimmed.slice(0, start).trimEnd();
-}
-
-function appendTodoHygieneInstruction(
-  message: ChatTransformMessage,
-  reminder: string,
-): void {
-  const textPart = [...message.parts]
-    .reverse()
-    .find((part) => part.type === 'text' && typeof part.text === 'string');
-  if (!textPart) return;
-
-  const baseText = stripTodoHygieneInstruction(textPart.text ?? '');
-  const instruction = `${TODO_HYGIENE_INSTRUCTION_OPEN}\n${reminder}\n${TODO_HYGIENE_INSTRUCTION_CLOSE}`;
-  textPart.text = baseText ? `${baseText}\n\n${instruction}` : instruction;
-}
-
-function stripTodoHygieneInstructionFromMessage(
-  message: ChatTransformMessage,
-): void {
-  const textPart = [...message.parts]
-    .reverse()
-    .find((part) => part.type === 'text' && typeof part.text === 'string');
-  if (!textPart) return;
-
-  textPart.text = stripTodoHygieneInstruction(textPart.text ?? '');
-}
-
-export function createTodoContinuationHook(
-  ctx: PluginInput,
-  config?: {
-    maxContinuations?: number;
-    cooldownMs?: number;
-    autoEnable?: boolean;
-    autoEnableThreshold?: number;
-  },
-): {
-  tool: Record<string, unknown>;
-  handleToolExecuteAfter: (
-    input: {
-      tool: string;
-      sessionID?: string;
-    },
-    output?: { output?: unknown },
-  ) => Promise<void>;
-  handleMessagesTransform: (output: {
-    messages: ChatTransformMessage[];
-  }) => Promise<void>;
-  handleEvent: (input: {
-    event: { type: string; properties?: Record<string, unknown> };
-  }) => Promise<void>;
-  handleChatMessage: (input: { sessionID: string; agent?: string }) => void;
-  handleCommandExecuteBefore: (
-    input: {
-      command: string;
-      sessionID: string;
-      arguments: string;
-    },
-    output: { parts: Array<{ type: string; text?: string }> },
-  ) => Promise<void>;
-} {
-  const maxContinuations = config?.maxContinuations ?? 5;
-  const cooldownMs = config?.cooldownMs ?? 3000;
-  const autoEnable = config?.autoEnable ?? false;
-  const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
-  const requestSignatureBySession = new Map<string, string>();
-
-  const state: ContinuationState = {
-    enabled: false,
-    consecutiveContinuations: 0,
-    pendingTimer: null,
-    pendingTimerSessionId: null,
-    suppressUntil: 0,
-    orchestratorSessionIds: new Set<string>(),
-    sawChatMessage: false,
-    isAutoInjecting: false,
-    notifyingSessionIds: new Set<string>(),
-    notificationBusyUntilBySession: new Map<string, number>(),
-  };
-
-  async function fetchTodos(sessionID: string): Promise<TodoItem[]> {
-    const result = await withTimeout(
-      ctx.client.session.todo({
-        path: { id: sessionID },
-      }),
-      TODO_STATE_TIMEOUT_MS,
-      `Todo state lookup timed out after ${TODO_STATE_TIMEOUT_MS}ms`,
-    );
-    return result.data as TodoItem[];
-  }
-
-  const hygiene = createTodoHygiene({
-    getTodoState: async (sessionID) => {
-      const todos = await fetchTodos(sessionID);
-      const openTodos = todos.filter(
-        (todo) => !TERMINAL_TODO_STATUSES.includes(todo.status),
-      );
-      return {
-        hasOpenTodos: openTodos.length > 0,
-        openCount: openTodos.length,
-        inProgressCount: openTodos.filter(
-          (todo) => todo.status === 'in_progress',
-        ).length,
-        pendingCount: openTodos.filter((todo) => todo.status === 'pending')
-          .length,
-      };
-    },
-    shouldInject: (sessionID) => isOrchestratorSession(sessionID),
-    log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta),
-  });
-
-  function inferSessionID(
-    messages: ChatTransformMessage[],
-    index: number,
-  ): string | undefined {
-    const direct = messages[index]?.info.sessionID;
-    if (direct) {
-      return direct;
-    }
-
-    for (let i = index - 1; i >= 0; i--) {
-      const sessionID = messages[i]?.info.sessionID;
-      if (sessionID) {
-        return sessionID;
-      }
-    }
-
-    for (let i = index + 1; i < messages.length; i++) {
-      const sessionID = messages[i]?.info.sessionID;
-      if (sessionID) {
-        return sessionID;
-      }
-    }
-
-    if (state.orchestratorSessionIds.size === 1) {
-      return Array.from(state.orchestratorSessionIds)[0];
-    }
-
-    return undefined;
-  }
-
-  function isExternalUserMessage(message: ChatTransformMessage): boolean {
-    if (message.info.role !== 'user') {
-      return false;
-    }
-
-    const visibleText = message.parts
-      .filter(
-        (part) =>
-          part.type === 'text' &&
-          typeof part.text === 'string' &&
-          !part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
-      )
-      .map((part) => part.text?.trim() ?? '')
-      .filter(Boolean)
-      .join('\n');
-    const hasNonTextPart = message.parts.some((part) => part.type !== 'text');
-
-    return !(
-      !visibleText &&
-      !hasNonTextPart &&
-      message.parts.some(
-        (part) =>
-          part.type === 'text' &&
-          typeof part.text === 'string' &&
-          part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
-      )
-    );
-  }
-
-  function getLastExternalUserMessage(
-    messages: ChatTransformMessage[],
-  ): LastExternalUserMessage | null {
-    for (let i = messages.length - 1; i >= 0; i--) {
-      const message = messages[i];
-      if (!isExternalUserMessage(message)) {
-        continue;
-      }
-
-      const sessionID = inferSessionID(messages, i);
-
-      const partSignature = message.parts
-        .map((part) => {
-          if (part.type === 'text' && typeof part.text === 'string') {
-            const text = stripTodoHygieneInstruction(part.text);
-            return `${part.type}:${text.includes(SLIM_INTERNAL_INITIATOR_MARKER) ? '<internal>' : text.trim()}`;
-          }
-          return part.type ?? 'unknown';
-        })
-        .join('|');
-      const ordinal = messages
-        .slice(0, i + 1)
-        .filter((item) => isExternalUserMessage(item)).length;
-
-      return {
-        sessionID,
-        agent: message.info.agent,
-        message,
-        signature: message.info.id
-          ? `${message.info.id}:${partSignature}`
-          : `${ordinal}:${partSignature}`,
-      };
-    }
-
-    return null;
-  }
-
-  async function handleMessagesTransform(output: {
-    messages: ChatTransformMessage[];
-  }): Promise<void> {
-    const lastUserMessage = getLastExternalUserMessage(output.messages);
-    if (!lastUserMessage) {
-      return;
-    }
-
-    if (lastUserMessage.agent && lastUserMessage.agent !== 'orchestrator') {
-      return;
-    }
-
-    if (!lastUserMessage.sessionID) {
-      for (const sessionID of state.orchestratorSessionIds) {
-        requestSignatureBySession.delete(sessionID);
-        hygiene.handleRequestStart({ sessionID });
-      }
-      return;
-    }
-
-    const knownOrchestrator = isOrchestratorSession(lastUserMessage.sessionID);
-    if (lastUserMessage.agent === 'orchestrator') {
-      registerOrchestratorSession(lastUserMessage.sessionID);
-    } else if (!knownOrchestrator) {
-      return;
-    }
-
-    if (
-      requestSignatureBySession.get(lastUserMessage.sessionID) ===
-      lastUserMessage.signature
-    ) {
-      const reminder = hygiene.getPendingReminder(lastUserMessage.sessionID);
-      if (reminder) {
-        appendTodoHygieneInstruction(lastUserMessage.message, reminder);
-      } else {
-        stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
-      }
-      return;
-    }
-
-    requestSignatureBySession.set(
-      lastUserMessage.sessionID,
-      lastUserMessage.signature,
-    );
-    stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
-    hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
-  }
-
-  function markNotificationStarted(sessionID: string): void {
-    state.notifyingSessionIds.add(sessionID);
-  }
-
-  function markNotificationFinished(sessionID: string): void {
-    state.notifyingSessionIds.delete(sessionID);
-    state.notificationBusyUntilBySession.set(
-      sessionID,
-      Date.now() + NOTIFICATION_BUSY_GRACE_MS,
-    );
-  }
-
-  function clearNotificationState(sessionID: string): void {
-    state.notifyingSessionIds.delete(sessionID);
-    state.notificationBusyUntilBySession.delete(sessionID);
-  }
-
-  function isNotificationBusy(sessionID: string): boolean {
-    if (state.notifyingSessionIds.has(sessionID)) {
-      return true;
-    }
-
-    const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
-    if (until <= Date.now()) {
-      state.notificationBusyUntilBySession.delete(sessionID);
-      return false;
-    }
-    return true;
-  }
-
-  function isOrchestratorSession(sessionID: string): boolean {
-    return state.orchestratorSessionIds.has(sessionID);
-  }
-
-  function registerOrchestratorSession(sessionID: string): void {
-    state.orchestratorSessionIds.add(sessionID);
-  }
-
-  function handleChatMessage(input: {
-    sessionID: string;
-    agent?: string;
-  }): void {
-    if (!input.agent) {
-      return;
-    }
-
-    state.sawChatMessage = true;
-    if (input.agent === 'orchestrator') {
-      registerOrchestratorSession(input.sessionID);
-    }
-  }
-
-  const autoContinue = tool({
-    description:
-      'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
-    args: { enabled: tool.schema.boolean() },
-    execute: async (args) => {
-      const enabled = args.enabled;
-      state.enabled = enabled;
-      state.consecutiveContinuations = 0;
-
-      if (enabled) {
-        state.suppressUntil = 0;
-        log(`[${HOOK_NAME}] Auto-continue enabled`, { maxContinuations });
-        return `Auto-continue enabled. Will auto-continue for up to ${maxContinuations} consecutive injections.`;
-      }
-
-      // Cancel any pending timer on disable
-      cancelPendingTimer(state);
-      log(`[${HOOK_NAME}] Auto-continue disabled`);
-      return 'Auto-continue disabled.';
-    },
-  });
-
-  async function handleEvent(input: {
-    event: { type: string; properties?: Record<string, unknown> };
-  }): Promise<void> {
-    const { event } = input;
-    const properties = event.properties ?? {};
-
-    hygiene.handleEvent({
-      type: event.type,
-      properties: {
-        info: properties.info as { id?: string } | undefined,
-        sessionID: properties.sessionID as string | undefined,
-      },
-    });
-
-    if (
-      event.type === 'session.idle' ||
-      (event.type === 'session.status' &&
-        (properties.status as { type?: string } | undefined)?.type === 'idle')
-    ) {
-      const sessionID = properties.sessionID as string;
-      if (!sessionID) {
-        return;
-      }
-
-      log(`[${HOOK_NAME}] Session idle`, { sessionID });
-
-      // Backward compatibility: if no chat.message has identified the
-      // orchestrator yet, fall back to the first idle session.
-      if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
-        registerOrchestratorSession(sessionID);
-        log(`[${HOOK_NAME}] Tracked orchestrator session`, {
-          sessionID,
-        });
-      }
-
-      // Gate: session is orchestrator (needed before auto-enable check)
-      if (!isOrchestratorSession(sessionID)) {
-        log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
-          sessionID,
-        });
-        return;
-      }
-
-      // Auto-enable check: if configured, not yet enabled, and enough
-      // todos exist, automatically enable auto-continue.
-      if (autoEnable && !state.enabled) {
-        try {
-          const todos = await fetchTodos(sessionID);
-          const incompleteCount = todos.filter(
-            (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
-          ).length;
-          if (incompleteCount >= autoEnableThreshold) {
-            state.enabled = true;
-            state.consecutiveContinuations = 0;
-            state.suppressUntil = 0;
-            log(
-              `[${HOOK_NAME}] Auto-enabled: ${incompleteCount} incomplete todos >= threshold ${autoEnableThreshold}`,
-              { sessionID },
-            );
-          } else {
-            log(
-              `[${HOOK_NAME}] Auto-enable skipped: ${incompleteCount} incomplete todos < threshold ${autoEnableThreshold}`,
-              { sessionID },
-            );
-          }
-        } catch (error) {
-          log(
-            `[${HOOK_NAME}] Warning: failed to fetch todos for auto-enable check`,
-            {
-              sessionID,
-              error: error instanceof Error ? error.message : String(error),
-            },
-          );
-        }
-      }
-
-      // Safety gate 1: enabled
-      if (!state.enabled) {
-        log(`[${HOOK_NAME}] Skipped: auto-continue not enabled`, {
-          sessionID,
-        });
-        return;
-      }
-
-      // Safety gate 2: incomplete todos exist
-      let hasIncompleteTodos = false;
-      let incompleteCount = 0;
-      try {
-        const todos = await fetchTodos(sessionID);
-        incompleteCount = todos.filter(
-          (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
-        ).length;
-        hasIncompleteTodos = incompleteCount > 0;
-        log(`[${HOOK_NAME}] Fetched todos`, {
-          sessionID,
-          hasIncompleteTodos,
-          total: todos.length,
-        });
-      } catch (error) {
-        log(`[${HOOK_NAME}] Warning: failed to fetch todos`, {
-          sessionID,
-          error: error instanceof Error ? error.message : String(error),
-        });
-        return;
-      }
-
-      if (!hasIncompleteTodos) {
-        log(`[${HOOK_NAME}] Skipped: no incomplete todos`, { sessionID });
-        return;
-      }
-
-      // Safety gate 3: last assistant message is not a question
-      let lastAssistantIsQuestion = false;
-      try {
-        const messagesResult = await ctx.client.session.messages({
-          path: { id: sessionID },
-        });
-        const messages = messagesResult.data as Message[];
-        const lastAssistantMessage = messages
-          .slice()
-          .reverse()
-          .find((m) => m.info?.role === 'assistant');
-        if (lastAssistantMessage?.parts) {
-          const lastText = lastAssistantMessage.parts
-            .map((p) => p.text ?? '')
-            .join(' ');
-          lastAssistantIsQuestion = isQuestion(lastText);
-        }
-        log(`[${HOOK_NAME}] Fetched messages`, {
-          sessionID,
-          lastAssistantIsQuestion,
-        });
-      } catch (error) {
-        log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
-          sessionID,
-          error: error instanceof Error ? error.message : String(error),
-        });
-        return;
-      }
-
-      if (lastAssistantIsQuestion) {
-        log(`[${HOOK_NAME}] Skipped: last message is question`, {
-          sessionID,
-        });
-        return;
-      }
-
-      // Safety gate 4: below max continuations
-      if (state.consecutiveContinuations >= maxContinuations) {
-        log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
-          sessionID,
-          consecutive: state.consecutiveContinuations,
-          max: maxContinuations,
-        });
-        return;
-      }
-
-      // Safety gate 5: not in suppress window
-      const now = Date.now();
-      if (now < state.suppressUntil) {
-        log(`[${HOOK_NAME}] Skipped: in suppress window`, {
-          sessionID,
-          suppressUntil: state.suppressUntil,
-        });
-        return;
-      }
-
-      // Safety gate 6: no pending timer AND no injection in flight
-      if (state.pendingTimer !== null || state.isAutoInjecting) {
-        log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
-          sessionID,
-        });
-        return;
-      }
-
-      // Schedule continuation
-      log(`[${HOOK_NAME}] Scheduling continuation`, {
-        sessionID,
-        delayMs: cooldownMs,
-      });
-
-      // Show countdown notification (noReply = agent doesn't respond)
-      markNotificationStarted(sessionID);
-      ctx.client.session
-        .prompt({
-          path: { id: sessionID },
-          body: {
-            noReply: true,
-            parts: [
-              {
-                type: 'text',
-                text: [
-                  `⎔ Auto-continue: ${incompleteCount} incomplete todos remaining — resuming in ${cooldownMs / 1000}s — Esc×2 to cancel`,
-                  '',
-                  '[system status: continue without acknowledging this notification]',
-                ].join('\n'),
-              },
-            ],
-          },
-        })
-        .catch(() => {
-          /* best-effort notification */
-        })
-        .finally(() => {
-          markNotificationFinished(sessionID);
-        });
-
-      state.pendingTimerSessionId = sessionID;
-      state.pendingTimer = setTimeout(async () => {
-        state.pendingTimer = null;
-        state.pendingTimerSessionId = null;
-        clearNotificationState(sessionID);
-
-        // Guard: may have been disabled during cooldown
-        if (!state.enabled) {
-          log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
-            sessionID,
-          });
-          return;
-        }
-
-        state.isAutoInjecting = true;
-        try {
-          await ctx.client.session.prompt({
-            path: { id: sessionID },
-            body: {
-              parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
-            },
-          });
-          state.consecutiveContinuations++;
-          log(`[${HOOK_NAME}] Continuation injected`, {
-            sessionID,
-            consecutive: state.consecutiveContinuations,
-          });
-        } catch (error) {
-          log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
-            sessionID,
-            error: error instanceof Error ? error.message : String(error),
-          });
-        } finally {
-          state.isAutoInjecting = false;
-        }
-      }, cooldownMs);
-    } else if (event.type === 'session.status') {
-      const status = properties.status as { type: string };
-      const sessionID = properties.sessionID as string;
-      if (status?.type === 'busy') {
-        const isOrchestrator = isOrchestratorSession(sessionID);
-        const isNotification = isNotificationBusy(sessionID);
-
-        // Only cancel timer for orchestrator session — sub-agents going
-        // busy must not silently kill the orchestrator's continuation.
-        if (
-          isOrchestrator &&
-          !isNotification &&
-          state.pendingTimerSessionId === sessionID
-        ) {
-          cancelPendingTimer(state);
-        }
-
-        // Only reset consecutive counter for user-initiated activity,
-        // not for our own auto-injection prompt. Scope to orchestrator only.
-        if (
-          !state.isAutoInjecting &&
-          !isNotification &&
-          isOrchestrator &&
-          state.consecutiveContinuations > 0
-        ) {
-          state.consecutiveContinuations = 0;
-          log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
-            sessionID,
-          });
-        }
-      }
-    } else if (event.type === 'session.error') {
-      const error = properties.error as { name?: string };
-      const sessionID = properties.sessionID as string;
-      const errorName = error?.name;
-      const isOrchestrator = isOrchestratorSession(sessionID);
-      if (
-        isOrchestrator &&
-        (errorName === 'MessageAbortedError' || errorName === 'AbortError')
-      ) {
-        state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
-        log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
-          sessionID,
-          errorName,
-        });
-      }
-      if (isOrchestrator) {
-        cancelPendingTimer(state);
-        log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
-          sessionID,
-        });
-      }
-    } else if (event.type === 'session.deleted') {
-      // OpenCode sends sessionID in two shapes:
-      // properties.info.id (from session store) or properties.sessionID (from event)
-      const deletedSessionId =
-        (properties.info as { id?: string })?.id ??
-        (properties.sessionID as string);
-
-      if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
-        requestSignatureBySession.delete(deletedSessionId);
-        if (state.pendingTimerSessionId === deletedSessionId) {
-          cancelPendingTimer(state);
-          log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
-            sessionID: deletedSessionId,
-          });
-        }
-
-        state.orchestratorSessionIds.delete(deletedSessionId);
-        clearNotificationState(deletedSessionId);
-        if (state.orchestratorSessionIds.size === 0) {
-          resetState(state);
-          state.sawChatMessage = false;
-        }
-        log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
-          sessionID: deletedSessionId,
-        });
-      }
-    }
-  }
-
-  async function handleCommandExecuteBefore(
-    input: {
-      command: string;
-      sessionID: string;
-      arguments: string;
-    },
-    output: { parts: Array<{ type: string; text?: string }> },
-  ): Promise<void> {
-    if (input.command !== COMMAND_NAME) {
-      return;
-    }
-
-    // Seed orchestrator session from slash command (more reliable than
-    // first-idle heuristic — slash commands only fire in main chat)
-    registerOrchestratorSession(input.sessionID);
-
-    // Clear template text — hook handles everything directly
-    output.parts.length = 0;
-
-    // Accept explicit on/off argument, toggle only when no arg
-    const arg = input.arguments.trim().toLowerCase();
-    let newEnabled: boolean;
-    if (arg === 'on') {
-      newEnabled = true;
-    } else if (arg === 'off') {
-      newEnabled = false;
-    } else {
-      newEnabled = !state.enabled;
-    }
-
-    state.enabled = newEnabled;
-    state.consecutiveContinuations = 0;
-
-    if (!newEnabled) {
-      // Cancel any pending timer on disable
-      cancelPendingTimer(state);
-      output.parts.push(
-        createInternalAgentTextPart(
-          '[Auto-continue: disabled by user command.]',
-        ),
-      );
-      log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
-      return;
-    }
-
-    // Clear suppress window on explicit re-enable
-    state.suppressUntil = 0;
-
-    log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
-      maxContinuations,
-    });
-
-    // Check for incomplete todos to decide on immediate continuation
-    let hasIncompleteTodos = false;
-    try {
-      const todos = await fetchTodos(input.sessionID);
-      hasIncompleteTodos = todos.some(
-        (t) => !TERMINAL_TODO_STATUSES.includes(t.status),
-      );
-    } catch (error) {
-      log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
-        sessionID: input.sessionID,
-        error: error instanceof Error ? error.message : String(error),
-      });
-    }
-
-    if (hasIncompleteTodos) {
-      output.parts.push(
-        createInternalAgentTextPart(
-          `${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
-        ),
-      );
-    } else {
-      output.parts.push(
-        createInternalAgentTextPart(
-          `[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`,
-        ),
-      );
-    }
-  }
-
-  return {
-    tool: { auto_continue: autoContinue },
-    handleToolExecuteAfter: hygiene.handleToolExecuteAfter,
-    handleMessagesTransform,
-    handleEvent,
-    handleChatMessage,
-    handleCommandExecuteBefore,
-  };
-}

+ 0 - 204
src/hooks/todo-continuation/todo-hygiene.test.ts

@@ -1,204 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import {
-  createTodoHygiene,
-  TODO_FINAL_ACTIVE_REMINDER,
-  TODO_HYGIENE_REMINDER,
-} from './todo-hygiene';
-
-function createState(
-  overrides?: Partial<{
-    hasOpenTodos: boolean;
-    openCount: number;
-    inProgressCount: number;
-    pendingCount: number;
-  }>,
-) {
-  return {
-    hasOpenTodos: overrides?.hasOpenTodos ?? true,
-    openCount: overrides?.openCount ?? 1,
-    inProgressCount: overrides?.inProgressCount ?? 0,
-    pendingCount: overrides?.pendingCount ?? 1,
-  };
-}
-
-describe('todo hygiene', () => {
-  test('new request clears pending state from the previous turn', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    hook.handleRequestStart({ sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBeNull();
-
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-  });
-
-  test('does not arm before the current request calls todowrite', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBeNull();
-  });
-
-  test('arms after the first relevant tool following todowrite', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    expect(hook.getPendingReminder('s1')).toBeNull();
-  });
-
-  test('upgrades to final-active on a later round', async () => {
-    let call = 0;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        call++;
-        if (call <= 3) return createState();
-        return createState({
-          openCount: 1,
-          inProgressCount: 1,
-          pendingCount: 0,
-        });
-      },
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    expect(hook.getPendingReminder('s1')).toBe(TODO_FINAL_ACTIVE_REMINDER);
-  });
-
-  test('todowrite can arm final-active immediately', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () =>
-        createState({
-          openCount: 1,
-          inProgressCount: 1,
-          pendingCount: 0,
-        }),
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBe(TODO_FINAL_ACTIVE_REMINDER);
-  });
-
-  test('once final-active is armed, later tools skip extra todo lookups in the same round', async () => {
-    let calls = 0;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        calls++;
-        return createState({
-          openCount: 1,
-          inProgressCount: 1,
-          pendingCount: 0,
-        });
-      },
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-
-    expect(calls).toBe(1);
-  });
-
-  test('shouldInject rejection prevents reset lookup and reminders', async () => {
-    let calls = 0;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        calls++;
-        return createState({
-          openCount: 1,
-          inProgressCount: 1,
-          pendingCount: 0,
-        });
-      },
-      shouldInject: () => false,
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-
-    expect(calls).toBe(0);
-    expect(hook.getPendingReminder('s1')).toBeNull();
-  });
-
-  test('reading a pending reminder does not inspect todos', async () => {
-    let fail = false;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        if (fail) throw new Error('boom');
-        return createState();
-      },
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    fail = true;
-
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-  });
-
-  test('todowrite lookup failures do not disable the current request', async () => {
-    let fail = false;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        if (fail) throw new Error('boom');
-        return createState();
-      },
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    fail = true;
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    fail = false;
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-
-    expect(hook.getPendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
-  });
-
-  test('session.deleted clears all state', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    hook.handleEvent({
-      type: 'session.deleted',
-      properties: { info: { id: 's1' } },
-    });
-
-    expect(hook.getPendingReminder('s1')).toBeNull();
-  });
-});

+ 0 - 207
src/hooks/todo-continuation/todo-hygiene.ts

@@ -1,207 +0,0 @@
-export const TODO_HYGIENE_REMINDER =
-  'If the active task changed or finished, update the todo list to match the current work state.';
-export const TODO_FINAL_ACTIVE_REMINDER =
-  'If you are finishing now, do not leave the active todo in_progress. Mark it completed, or move unfinished work back to pending.';
-
-const RESET = new Set(['todowrite']);
-const IGNORE = new Set(['auto_continue']);
-
-type Reason = 'general' | 'final_active';
-
-interface ToolInput {
-  tool: string;
-  sessionID?: string;
-}
-
-interface EventInput {
-  type: string;
-  properties?: {
-    info?: { id?: string };
-    sessionID?: string;
-  };
-}
-
-interface RequestStartInput {
-  sessionID: string;
-}
-
-interface Options {
-  getTodoState: (sessionID: string) => Promise<{
-    hasOpenTodos: boolean;
-    openCount: number;
-    inProgressCount: number;
-    pendingCount: number;
-  }>;
-  shouldInject?: (sessionID: string) => boolean;
-  log?: (message: string, meta?: Record<string, unknown>) => void;
-}
-
-export function createTodoHygiene(options: Options) {
-  const pending = new Map<string, Set<Reason>>();
-  const active = new Set<string>();
-
-  function clearCycle(sessionID: string): void {
-    pending.delete(sessionID);
-  }
-
-  function clear(sessionID: string): void {
-    clearCycle(sessionID);
-    active.delete(sessionID);
-  }
-
-  function isFinalActive(state: {
-    openCount: number;
-    inProgressCount: number;
-    pendingCount: number;
-  }): boolean {
-    return (
-      state.inProgressCount === 1 &&
-      state.pendingCount === 0 &&
-      state.openCount === 1
-    );
-  }
-
-  function mark(sessionID: string, reason: Reason): void {
-    const reasons = pending.get(sessionID) ?? new Set<Reason>();
-    reasons.add(reason);
-    pending.set(sessionID, reasons);
-  }
-
-  function pick(reasons: Set<Reason>): string {
-    if (reasons.has('final_active')) {
-      return TODO_FINAL_ACTIVE_REMINDER;
-    }
-
-    return TODO_HYGIENE_REMINDER;
-  }
-
-  return {
-    handleRequestStart(input: RequestStartInput): void {
-      clear(input.sessionID);
-    },
-
-    async handleToolExecuteAfter(
-      input: ToolInput,
-      _output?: unknown,
-    ): Promise<void> {
-      if (!input.sessionID) {
-        return;
-      }
-
-      const tool = input.tool.toLowerCase();
-      if (IGNORE.has(tool)) {
-        return;
-      }
-
-      try {
-        if (RESET.has(tool)) {
-          if (options.shouldInject && !options.shouldInject(input.sessionID)) {
-            clear(input.sessionID);
-            return;
-          }
-
-          active.add(input.sessionID);
-          clearCycle(input.sessionID);
-          const state = await options.getTodoState(input.sessionID);
-          if (!state.hasOpenTodos) {
-            active.delete(input.sessionID);
-            options.log?.('Cleared todo hygiene cycle', {
-              sessionID: input.sessionID,
-              tool,
-            });
-            return;
-          }
-
-          if (!isFinalActive(state)) {
-            options.log?.('Reset todo hygiene cycle', {
-              sessionID: input.sessionID,
-              tool,
-            });
-            return;
-          }
-
-          mark(input.sessionID, 'final_active');
-          options.log?.('Armed final-active todo hygiene reminder', {
-            sessionID: input.sessionID,
-            tool,
-          });
-          return;
-        }
-
-        if (!active.has(input.sessionID)) {
-          return;
-        }
-
-        if (pending.get(input.sessionID)?.has('final_active')) {
-          return;
-        }
-
-        if (options.shouldInject && !options.shouldInject(input.sessionID)) {
-          clear(input.sessionID);
-          return;
-        }
-
-        const state = await options.getTodoState(input.sessionID);
-        if (!state.hasOpenTodos) {
-          clear(input.sessionID);
-          return;
-        }
-
-        if (isFinalActive(state)) {
-          mark(input.sessionID, 'final_active');
-        } else {
-          mark(input.sessionID, 'general');
-        }
-
-        options.log?.('Armed todo hygiene reminder', {
-          sessionID: input.sessionID,
-          tool,
-          reasons: Array.from(pending.get(input.sessionID) ?? []),
-        });
-      } catch (error) {
-        options.log?.(
-          'Skipped todo hygiene reminder: failed to inspect todos',
-          {
-            sessionID: input.sessionID,
-            tool,
-            error: error instanceof Error ? error.message : String(error),
-          },
-        );
-      }
-    },
-
-    getPendingReminder(sessionID: string): string | null {
-      const reasons = pending.get(sessionID);
-      if (!reasons || reasons.size === 0) {
-        return null;
-      }
-
-      if (options.shouldInject && !options.shouldInject(sessionID)) {
-        clear(sessionID);
-        return null;
-      }
-
-      const reminder = pick(reasons);
-      options.log?.('Read todo hygiene reminder', {
-        sessionID,
-        reminder,
-        reasons: Array.from(reasons),
-      });
-      return reminder;
-    },
-
-    handleEvent(event: EventInput): void {
-      if (event.type !== 'session.deleted') {
-        return;
-      }
-
-      const sessionID =
-        event.properties?.sessionID ?? event.properties?.info?.id;
-      if (!sessionID) {
-        return;
-      }
-
-      clear(sessionID);
-    },
-  };
-}

+ 60 - 112
src/index.ts

@@ -1,6 +1,7 @@
 import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
+import { CompanionManager } from './companion/manager';
 import {
   type AgentOverrideConfig,
   deepMerge,
@@ -21,14 +22,13 @@ import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
   createChatHeadersHook,
+  createDeepworkCommandHook,
   createDelegateTaskRetryHook,
   createFilterAvailableSkillsHook,
   createJsonErrorRecoveryHook,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
-  createSessionGoalHook,
   createTaskSessionManagerHook,
-  createTodoContinuationHook,
   ForegroundFallbackManager,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
@@ -42,16 +42,14 @@ import {
 import {
   ast_grep_replace,
   ast_grep_search,
+  createCancelTaskTool,
   createCouncilTool,
   createPresetManager,
-  createReadSessionTool,
-  createSubtaskCommandManager,
-  createSubtaskState,
-  createSubtaskTool,
   createWebfetchTool,
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
+  BackgroundJobBoard,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
 } from './utils';
@@ -138,19 +136,19 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
   let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
   let foregroundFallback: ForegroundFallbackManager;
-  let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
-  let sessionGoalHook: ReturnType<typeof createSessionGoalHook>;
+  let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
+  let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
+  let companionManager: CompanionManager;
   let divoomManager: ReturnType<typeof createDivoomManager>;
   let councilTools: Record<string, unknown>;
+  let cancelTaskTools: Record<string, unknown>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
   let rewriteDisplayNameMentions: ReturnType<
     typeof createDisplayNameMentionRewriter
   >;
-  let subtaskCommandManager: ReturnType<typeof createSubtaskCommandManager>;
-  let subtaskState: ReturnType<typeof createSubtaskState>;
 
   // Counters for post-init health check (set inside try, checked outside)
   let toolCount = 0;
@@ -262,12 +260,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
     webfetch = createWebfetchTool(ctx);
+    backgroundJobBoard = new BackgroundJobBoard({
+      maxReusablePerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
+      readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
+      readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
+    });
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
     multiplexerSessionManager = new MultiplexerSessionManager(
       ctx,
       multiplexerConfig,
+      backgroundJobBoard,
     );
 
     // Initialize auto-update checker hook
@@ -307,37 +311,35 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         Object.keys(runtimeChains).length > 0,
     );
 
-    // Initialize todo-continuation hook (opt-in auto-continue for
-    // incomplete todos)
-    todoContinuationHook = createTodoContinuationHook(ctx, {
-      maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
-      cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
-      autoEnable: config.todoContinuation?.autoEnable ?? false,
-      autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
-    });
-    sessionGoalHook = createSessionGoalHook(ctx, config, {
-      getAgentName: (sessionID) => sessionAgentMap.get(sessionID),
-    });
+    deepworkCommandHook = createDeepworkCommandHook();
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
-      maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
-      readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
-      readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
+      maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
+      readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
+      readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
+      backgroundJobBoard,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
     interviewManager = createInterviewManager(ctx, config);
     presetManager = createPresetManager(ctx, config);
+    companionManager = new CompanionManager(
+      `proc_${process.pid}`,
+      ctx.directory,
+      config.companion,
+    );
     divoomManager = createDivoomManager(config.divoom);
-
-    subtaskState = createSubtaskState();
-    subtaskCommandManager = createSubtaskCommandManager(ctx, subtaskState);
+    cancelTaskTools = createCancelTaskTool({
+      client: ctx.client,
+      backgroundJobBoard,
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+    });
 
     toolCount =
       Object.keys(councilTools).length +
-      Object.keys(todoContinuationHook.tool).length +
+      Object.keys(cancelTaskTools).length +
       1 + // webfetch
-      2 + // ast_grep_search, ast_grep_replace
-      2; // subtask, read_session
+      2; // ast_grep_search, ast_grep_replace
   } catch (err) {
     // Plugin init failed: log visibly before re-throwing so the user
     // sees something actionable instead of a silent "loaded but empty".
@@ -395,6 +397,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   });
 
   divoomManager.onPluginLoad();
+  companionManager.onLoad();
 
   return {
     name: 'oh-my-opencode-slim',
@@ -403,14 +406,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     tool: {
       ...councilTools,
+      ...cancelTaskTools,
       webfetch,
-      ...todoContinuationHook.tool,
       ast_grep_search,
       ast_grep_replace,
-      subtask: createSubtaskTool(ctx, subtaskState, depthTracker, {
-        timeoutMs: config.subtask?.timeoutMs,
-      }),
-      read_session: createReadSessionTool(ctx.client, subtaskState),
     },
 
     mcp: mcps,
@@ -732,27 +731,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         agentConfigEntry.permission = agentPermission;
       }
 
-      // Register /auto-continue command so OpenCode recognizes it.
-      // Actual handling is done by command.execute.before hook below
-      // (no LLM round-trip — injected directly into output.parts).
-      const configCommand = opencodeConfig.command as
-        | Record<string, unknown>
-        | undefined;
-      if (!configCommand?.['auto-continue']) {
-        if (!opencodeConfig.command) {
-          opencodeConfig.command = {};
-        }
-        (opencodeConfig.command as Record<string, unknown>)['auto-continue'] = {
-          template: 'Call the auto_continue tool with enabled=true',
-          description:
-            'Enable auto-continuation — orchestrator keeps working through incomplete todos',
-        };
-      }
-
       interviewManager.registerCommand(opencodeConfig);
-      sessionGoalHook.registerCommand(opencodeConfig);
+      deepworkCommandHook.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
-      subtaskCommandManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {
@@ -810,15 +791,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Runtime model fallback for foreground agents (rate-limit detection)
       await foregroundFallback.handleEvent(input.event);
 
-      // Todo-continuation: auto-continue orchestrator on incomplete todos
-      await todoContinuationHook.handleEvent(input);
-
-      sessionGoalHook.handleEvent(
-        input as {
-          event: { type: string; properties?: Record<string, unknown> };
-        },
-      );
-
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
 
@@ -837,18 +809,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
-      subtaskCommandManager.handleEvent(
-        input as {
-          event: {
-            type: string;
-            properties?: {
-              info?: { id?: string; parentID?: string };
-              sessionID?: string;
-            };
-          };
-        },
-      );
-
       if (
         event.type === 'permission.asked' ||
         event.type === 'question.asked'
@@ -860,6 +820,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           sessionId: props?.sessionID,
           requestId: props?.id ?? props?.requestID,
         });
+        companionManager.onWaitingInput();
       }
 
       if (
@@ -874,6 +835,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           sessionId: props?.sessionID,
           requestId: props?.requestID ?? props?.id,
         });
+        companionManager.onInputResolved();
       }
 
       if (input.event.type === 'session.status') {
@@ -881,12 +843,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           | { sessionID?: string; status?: { type?: string } }
           | undefined;
         const sessionID = props?.sessionID;
+        const isOrch = sessionID
+          ? sessionAgentMap.get(sessionID) === 'orchestrator'
+          : false;
         divoomManager.onOrchestratorStatus({
           sessionId: sessionID,
           status: props?.status?.type,
-          isOrchestrator: sessionID
-            ? sessionAgentMap.get(sessionID) === 'orchestrator'
-            : false,
+          isOrchestrator: isOrch,
+        });
+        companionManager.onSessionStatus({
+          sessionId: sessionID,
+          agent: sessionID ? sessionAgentMap.get(sessionID) : undefined,
+          status: props?.status?.type,
         });
       }
 
@@ -895,12 +863,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           | { info?: { id?: string }; sessionID?: string }
           | undefined;
         const sessionID = props?.info?.id ?? props?.sessionID;
+        const isOrch = sessionID
+          ? sessionAgentMap.get(sessionID) === 'orchestrator'
+          : false;
         divoomManager.onSessionDeleted({
           sessionId: sessionID,
-          isOrchestrator: sessionID
-            ? sessionAgentMap.get(sessionID) === 'orchestrator'
-            : false,
+          isOrchestrator: isOrch,
         });
+        companionManager.onSessionDeleted(sessionID);
       }
 
       if (input.event.type === 'session.deleted') {
@@ -949,18 +919,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
     },
 
-    // Direct interception of /auto-continue command — bypasses LLM
-    // round-trip
     'command.execute.before': async (input, output) => {
-      await todoContinuationHook.handleCommandExecuteBefore(
-        input as {
-          command: string;
-          sessionID: string;
-          arguments: string;
-        },
-        output as { parts: Array<{ type: string; text?: string }> },
-      );
-
       await interviewManager.handleCommandExecuteBefore(
         input as {
           command: string;
@@ -979,7 +938,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output as { parts: Array<{ type: string; text?: string }> },
       );
 
-      await sessionGoalHook.handleCommandExecuteBefore(
+      await deepworkCommandHook.handleCommandExecuteBefore(
         input as {
           command: string;
           sessionID: string;
@@ -1012,11 +971,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       if (agent) {
         sessionAgentMap.set(input.sessionID, agent);
+        // A chat message means this session is actively working. This also
+        // covers the race where session.status busy fires before the
+        // session's agent is known.
+        companionManager.onSessionStatus({
+          sessionId: input.sessionID,
+          agent,
+          status: 'busy',
+        });
       }
-      todoContinuationHook.handleChatMessage({
-        sessionID: input.sessionID,
-        agent,
-      });
     },
 
     // Inject orchestrator system prompt for serve-mode sessions. In serve
@@ -1059,8 +1022,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
-      sessionGoalHook.handleSystemTransform(input, output);
-
       // Collapse to single system message for provider compatibility.
       // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
       // system messages. Sub-hooks above may push additional entries; join
@@ -1112,9 +1073,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         log,
       });
 
-      await todoContinuationHook.handleMessagesTransform({
-        messages: typedOutput.messages,
-      });
       await taskSessionManagerHook['experimental.chat.messages.transform'](
         input,
         typedOutput,
@@ -1176,16 +1134,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         ),
       );
 
-      await runPostToolHook('todo-continuation', () =>
-        todoContinuationHook.handleToolExecuteAfter(
-          input as {
-            tool: string;
-            sessionID?: string;
-          },
-          output as { output?: unknown },
-        ),
-      );
-
       await runPostToolHook('post-file-tool-nudge', () =>
         postFileToolNudgeHook['tool.execute.after'](
           input as {

+ 1 - 1
src/mcp/grep-app.ts

@@ -4,7 +4,7 @@ import type { RemoteMcpConfig } from './types';
  * grep.app - ultra-fast code search across GitHub repositories
  * @see https://grep.app
  */
-export const grep_app: RemoteMcpConfig = {
+export const gh_grep: RemoteMcpConfig = {
   type: 'remote',
   url: 'https://mcp.grep.app',
   oauth: false,

+ 11 - 11
src/mcp/index.test.ts

@@ -8,7 +8,7 @@ describe('createBuiltinMcps', () => {
 
     expect(names).toContain('websearch');
     expect(names).toContain('context7');
-    expect(names).toContain('grep_app');
+    expect(names).toContain('gh_grep');
   });
 
   test('returns all MCPs with empty disabled list', () => {
@@ -18,7 +18,7 @@ describe('createBuiltinMcps', () => {
     expect(names.length).toBe(3);
     expect(names).toContain('websearch');
     expect(names).toContain('context7');
-    expect(names).toContain('grep_app');
+    expect(names).toContain('gh_grep');
   });
 
   test('excludes single disabled MCP', () => {
@@ -27,21 +27,21 @@ describe('createBuiltinMcps', () => {
 
     expect(names).not.toContain('websearch');
     expect(names).toContain('context7');
-    expect(names).toContain('grep_app');
+    expect(names).toContain('gh_grep');
   });
 
   test('excludes multiple disabled MCPs', () => {
-    const mcps = createBuiltinMcps(['websearch', 'grep_app']);
+    const mcps = createBuiltinMcps(['websearch', 'gh_grep']);
     const names = Object.keys(mcps);
 
     expect(names).not.toContain('websearch');
-    expect(names).not.toContain('grep_app');
+    expect(names).not.toContain('gh_grep');
     expect(names).toContain('context7');
     expect(names.length).toBe(1);
   });
 
   test('excludes all MCPs when all disabled', () => {
-    const mcps = createBuiltinMcps(['websearch', 'context7', 'grep_app']);
+    const mcps = createBuiltinMcps(['websearch', 'context7', 'gh_grep']);
     const names = Object.keys(mcps);
 
     expect(names.length).toBe(0);
@@ -55,7 +55,7 @@ describe('createBuiltinMcps', () => {
     expect(names.length).toBe(3);
     expect(names).toContain('websearch');
     expect(names).toContain('context7');
-    expect(names).toContain('grep_app');
+    expect(names).toContain('gh_grep');
   });
 
   test('MCP configs have required properties', () => {
@@ -86,11 +86,11 @@ describe('createBuiltinMcps', () => {
     expect('url' in context7).toBe(true);
   });
 
-  test('grep_app MCP has correct structure', () => {
+  test('gh_grep MCP has correct structure', () => {
     const mcps = createBuiltinMcps();
-    const grep_app = mcps.grep_app;
+    const gh_grep = mcps.gh_grep;
 
-    expect(grep_app).toBeDefined();
-    expect('url' in grep_app).toBe(true);
+    expect(gh_grep).toBeDefined();
+    expect('url' in gh_grep).toBe(true);
   });
 });

+ 2 - 2
src/mcp/index.ts

@@ -1,6 +1,6 @@
 import type { McpName, WebsearchConfig } from '../config';
 import { context7 } from './context7';
-import { grep_app } from './grep-app';
+import { gh_grep } from './grep-app';
 import type { McpConfig } from './types';
 import { createWebsearchConfig, websearch } from './websearch';
 
@@ -9,7 +9,7 @@ export type { LocalMcpConfig, McpConfig, RemoteMcpConfig } from './types';
 const allBuiltinMcps: Record<McpName, McpConfig> = {
   websearch,
   context7,
-  grep_app,
+  gh_grep,
 };
 
 /**

Some files were not shown because too many files changed in this diff