diff --git a/.gitignore b/.gitignore index 06aa8147b..2eb885215 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ test-injection/ notepad.md oauth-success.html *.bun-build +.omx/ diff --git a/.opencode/skills/github-triage/SKILL.md b/.opencode/skills/github-triage/SKILL.md index 371ce750c..e3733fd37 100644 --- a/.opencode/skills/github-triage/SKILL.md +++ b/.opencode/skills/github-triage/SKILL.md @@ -79,47 +79,65 @@ Pass `REPO`, `REPORT_DIR`, and `COMMIT_SHA` to every subagent. --- -## Phase 1: Fetch All Open Items +--- - -Paginate if 500 results returned. +## Phase 1: Fetch All Open Items (CORRECTED) + +**IMPORTANT:** `body` and `comments` fields may contain control characters that break jq parsing. Fetch basic metadata first, then fetch full details per-item in subagents. ```bash -ISSUES=$(gh issue list --repo $REPO --state open --limit 500 \ - --json number,title,state,createdAt,updatedAt,labels,author,body,comments) -ISSUE_LEN=$(echo "$ISSUES" | jq length) -if [ "$ISSUE_LEN" -eq 500 ]; then - LAST_DATE=$(echo "$ISSUES" | jq -r '.[-1].createdAt') +# Step 1: Fetch basic metadata (without body/comments to avoid JSON parsing issues) +ISSUES_LIST=$(gh issue list --repo $REPO --state open --limit 500 \ + --json number,title,labels,author,createdAt) +ISSUE_COUNT=$(echo "$ISSUES_LIST" | jq length) + +# Paginate if needed +if [ "$ISSUE_COUNT" -eq 500 ]; then + LAST_DATE=$(echo "$ISSUES_LIST" | jq -r '.[-1].createdAt') while true; do PAGE=$(gh issue list --repo $REPO --state open --limit 500 \ --search "created:<$LAST_DATE" \ - --json number,title,state,createdAt,updatedAt,labels,author,body,comments) - PAGE_LEN=$(echo "$PAGE" | jq length) - [ "$PAGE_LEN" -eq 0 ] && break - ISSUES=$(echo "[$ISSUES, $PAGE]" | jq -s 'add | unique_by(.number)') - [ "$PAGE_LEN" -lt 500 ] && break + --json number,title,labels,author,createdAt) + PAGE_COUNT=$(echo "$PAGE" | jq length) + [ "$PAGE_COUNT" -eq 0 ] && break + ISSUES_LIST=$(echo "$ISSUES_LIST" "$PAGE" | jq -s '.[0] + .[1] | unique_by(.number)') + ISSUE_COUNT=$(echo "$ISSUES_LIST" | jq length) + [ "$PAGE_COUNT" -lt 500 ] && break LAST_DATE=$(echo "$PAGE" | jq -r '.[-1].createdAt') done fi -PRS=$(gh pr list --repo $REPO --state open --limit 500 \ - --json number,title,state,createdAt,updatedAt,labels,author,body,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup) -PR_LEN=$(echo "$PRS" | jq length) -if [ "$PR_LEN" -eq 500 ]; then - LAST_DATE=$(echo "$PRS" | jq -r '.[-1].createdAt') +# Same for PRs +PRS_LIST=$(gh pr list --repo $REPO --state open --limit 500 \ + --json number,title,labels,author,headRefName,baseRefName,isDraft,createdAt) +PR_COUNT=$(echo "$PRS_LIST" | jq length) + +if [ "$PR_COUNT" -eq 500 ]; then + LAST_DATE=$(echo "$PRS_LIST" | jq -r '.[-1].createdAt') while true; do PAGE=$(gh pr list --repo $REPO --state open --limit 500 \ --search "created:<$LAST_DATE" \ - --json number,title,state,createdAt,updatedAt,labels,author,body,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup) - PAGE_LEN=$(echo "$PAGE" | jq length) - [ "$PAGE_LEN" -eq 0 ] && break - PRS=$(echo "[$PRS, $PAGE]" | jq -s 'add | unique_by(.number)') - [ "$PAGE_LEN" -lt 500 ] && break + --json number,title,labels,author,headRefName,baseRefName,isDraft,createdAt) + PAGE_COUNT=$(echo "$PAGE" | jq length) + [ "$PAGE_COUNT" -eq 0 ] && break + PRS_LIST=$(echo "$PRS_LIST" "$PAGE" | jq -s '.[0] + .[1] | unique_by(.number)') + PR_COUNT=$(echo "$PRS_LIST" | jq length) + [ "$PAGE_COUNT" -lt 500 ] && break LAST_DATE=$(echo "$PAGE" | jq -r '.[-1].createdAt') done fi + +echo "Total issues: $ISSUE_COUNT, Total PRs: $PR_COUNT" ``` - + +**LARGE REPOSITORY HANDLING:** +If total items exceeds 50, you MUST process ALL items. Use the pagination code above to fetch every single open issue and PR. +**DO NOT** sample or limit to 50 items - process the entire backlog. + +Example: If there are 500 open issues, spawn 500 subagents. If there are 1000 open PRs, spawn 1000 subagents. + +**Note:** Background task system will queue excess tasks automatically. + --- diff --git a/AGENTS.md b/AGENTS.md index f0f44cdab..e774f3fb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC. +OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 48 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1268 TypeScript files, 160k LOC. ## STRUCTURE @@ -14,14 +14,14 @@ oh-my-opencode/ │ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) -│ ├── hooks/ # 46 hooks across 45 directories + 11 standalone files +│ ├── hooks/ # 48 lifecycle hooks across dedicated modules and standalone files │ ├── tools/ # 26 tools across 15 directories │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) │ ├── shared/ # 95+ utility files in 13 categories │ ├── config/ # Zod v4 schema system (24 files) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) -│ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition +│ ├── plugin/ # 8 OpenCode hook handlers + 48 hook composition │ └── plugin-handlers/ # 6-phase config loading pipeline ├── packages/ # Monorepo: cli-runner, 12 platform binaries └── local-ignore/ # Dev-only test fixtures @@ -34,7 +34,7 @@ OhMyOpenCodePlugin(ctx) ├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate ├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler ├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools) - ├─→ createHooks() # 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks + ├─→ createHooks() # 3-tier: Core(39) + Continuation(7) + Skill(2) = 48 hooks └─→ createPluginInterface() # 8 OpenCode hook handlers → PluginInterface ``` @@ -97,7 +97,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom - **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes) - **CI test split**: mock-heavy tests run in isolation (separate `bun test` processes), rest in batch - **Factory pattern**: `createXXX()` for all tools, hooks, agents -- **Hook tiers**: Session (23) → Tool-Guard (10) → Transform (4) → Continuation (7) → Skill (2) +- **Hook tiers**: Session (23) → Tool-Guard (12) → Transform (4) → Continuation (7) → Skill (2) - **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all` - **Model resolution**: 4-step: override → category-default → provider-fallback → system-default - **Config format**: JSONC with comments, Zod v4 validation, snake_case keys diff --git a/README.md b/README.md index b4062f709..81d3af043 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ See full [Features Documentation](docs/reference/features.md). - **Claude Code Compatibility**: Full hook system, commands, skills, agents, MCPs - **Built-in MCPs**: websearch (Exa), context7 (docs), grep_app (GitHub search) - **Session Tools**: List, read, search, and analyze session history -- **Productivity Features**: Ralph Loop, Todo Enforcer, GPT permission-tail continuation, Comment Checker, Think Mode, and more +- **Productivity Features**: Ralph Loop, Todo Enforcer, Comment Checker, Think Mode, and more - **Model Setup**: Agent-model matching is built into the [Installation Guide](docs/guide/installation.md#step-5-understand-your-model-setup) ## Configuration @@ -321,7 +321,7 @@ See [Configuration Documentation](docs/reference/configuration.md). - **Sisyphus Agent**: Main orchestrator with Prometheus (Planner) and Metis (Plan Consultant) - **Background Tasks**: Configure concurrency limits per provider/model - **Categories**: Domain-specific task delegation (`visual`, `business-logic`, custom) -- **Hooks**: 25+ built-in hooks, including `gpt-permission-continuation`, all configurable via `disabled_hooks` +- **Hooks**: 25+ built-in hooks, all configurable via `disabled_hooks` - **MCPs**: Built-in websearch (Exa), context7 (docs), grep_app (GitHub search) - **LSP**: Full LSP support with refactoring tools - **Experimental**: Aggressive truncation, auto-resume, and more diff --git a/docs/guide/agent-model-matching.md b/docs/guide/agent-model-matching.md index ba2130bf0..ec040b636 100644 --- a/docs/guide/agent-model-matching.md +++ b/docs/guide/agent-model-matching.md @@ -121,6 +121,7 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents | ----------------- | ----------------------------------------------------------------------------------------------- | | **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. | | **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle, Momus, and a key fallback for Prometheus / Atlas. Uses xhigh variant for Momus. | +| **GPT-5.4 Mini** | Fast + strong reasoning. Good for lightweight autonomous tasks. Default for quick category. | | **GPT-5-Nano** | Ultra-cheap, fast. Good for simple utility tasks. | ### Other Models @@ -170,7 +171,7 @@ When agents delegate work, they don't pick a model name — they pick a **catego | `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 | | `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro | | `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 | -| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano | +| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano | | `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 | | `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash | | `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet | diff --git a/docs/guide/installation.md b/docs/guide/installation.md index c092c6f45..edef25592 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -287,6 +287,7 @@ Not all models behave the same way. Understanding which models are "similar" hel | ----------------- | -------------------------------- | ------------------------------------------------- | | **GPT-5.3-codex** | openai, github-copilot, opencode | Deep coding powerhouse. Required for Hephaestus. | | **GPT-5.4** | openai, github-copilot, opencode | High intelligence. Default for Oracle. | +| **GPT-5.4 Mini** | openai, github-copilot, opencode | Fast + strong reasoning. Default for quick category. | | **GPT-5-Nano** | opencode | Ultra-cheap, fast. Good for simple utility tasks. | **Different-Behavior Models**: diff --git a/docs/guide/orchestration.md b/docs/guide/orchestration.md index 6dd8d2caa..babc5c0e8 100644 --- a/docs/guide/orchestration.md +++ b/docs/guide/orchestration.md @@ -298,7 +298,7 @@ task({ category: "quick", prompt: "..." }); // "Just get it done fast" | `visual-engineering` | Gemini 3.1 Pro | Frontend, UI/UX, design, styling, animation | | `ultrabrain` | GPT-5.4 (xhigh) | Deep logical reasoning, complex architecture decisions | | `artistry` | Gemini 3.1 Pro (high) | Highly creative or artistic tasks, novel ideas | -| `quick` | Claude Haiku 4.5 | Trivial tasks - single file changes, typo fixes | +| `quick` | GPT-5.4 Mini | Trivial tasks - single file changes, typo fixes | | `deep` | GPT-5.3 Codex (medium) | Goal-oriented autonomous problem-solving, thorough research | | `unspecified-low` | Claude Sonnet 4.6 | Tasks that don't fit other categories, low effort | | `unspecified-high` | Claude Opus 4.6 (max) | Tasks that don't fit other categories, high effort | diff --git a/docs/guide/overview.md b/docs/guide/overview.md index 800586360..78f34937f 100644 --- a/docs/guide/overview.md +++ b/docs/guide/overview.md @@ -41,7 +41,7 @@ We used to call this "Claude Code on steroids." That was wrong. This isn't about making Claude Code better. It's about breaking free from the idea that one model, one provider, one way of working is enough. Anthropic wants you locked in. OpenAI wants you locked in. Everyone wants you locked in. -Oh My OpenCode doesn't play that game. It orchestrates across models, picking the right brain for the right job. Claude for orchestration. GPT for deep reasoning. Gemini for frontend. Haiku for quick tasks. All working together, automatically. +Oh My OpenCode doesn't play that game. It orchestrates across models, picking the right brain for the right job. Claude for orchestration. GPT for deep reasoning. Gemini for frontend. GPT-5.4 Mini for quick tasks. All working together, automatically. --- @@ -99,9 +99,9 @@ Use Hephaestus when you need deep architectural reasoning, complex debugging acr **Why this beats vanilla Codex CLI:** -- **Multi-model orchestration.** Pure Codex is single-model. OmO routes different tasks to different models automatically. GPT for deep reasoning. Gemini for frontend. Haiku for speed. The right brain for the right job. +- **Multi-model orchestration.** Pure Codex is single-model. OmO routes different tasks to different models automatically. GPT for deep reasoning. Gemini for frontend. GPT-5.4 Mini for speed. The right brain for the right job. - **Background agents.** Fire 5+ agents in parallel. Something Codex simply cannot do. While one agent writes code, another researches patterns, another checks documentation. Like a real dev team. -- **Category system.** Tasks are routed by intent, not model name. `visual-engineering` gets Gemini. `ultrabrain` gets GPT-5.4. `quick` gets Haiku. No manual juggling. +- **Category system.** Tasks are routed by intent, not model name. `visual-engineering` gets Gemini. `ultrabrain` gets GPT-5.4. `quick` gets GPT-5.4 Mini. No manual juggling. - **Accumulated wisdom.** Subagents learn from previous results. Conventions discovered in task 1 are passed to task 5. Mistakes made early aren't repeated. The system gets smarter as it works. ### Prometheus: The Strategic Planner @@ -195,8 +195,8 @@ You can override specific agents or categories in your config: // General high-effort work "unspecified-high": { "model": "anthropic/claude-opus-4-6", "variant": "max" }, - // Quick tasks: use the cheapest models - "quick": { "model": "anthropic/claude-haiku-4-5" }, + // Quick tasks: use GPT-5.4-mini (fast and cheap) + "quick": { "model": "openai/gpt-5.4-mini" }, // Deep reasoning: GPT-5.4 "ultrabrain": { "model": "openai/gpt-5.4", "variant": "xhigh" }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cbb5276ea..bfa80ed5a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -228,7 +228,7 @@ Domain-specific model delegation used by the `task()` tool. When Sisyphus delega | `ultrabrain` | `openai/gpt-5.4` (xhigh) | Deep logical reasoning, complex architecture | | `deep` | `openai/gpt-5.3-codex` (medium) | Autonomous problem-solving, thorough research | | `artistry` | `google/gemini-3.1-pro` (high) | Creative/unconventional approaches | -| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks, typo fixes, single-file changes | +| `quick` | `openai/gpt-5.4-mini` | Trivial tasks, typo fixes, single-file changes | | `unspecified-low` | `anthropic/claude-sonnet-4-6` | General tasks, low effort | | `unspecified-high` | `anthropic/claude-opus-4-6` (max) | General tasks, high effort | | `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing | @@ -286,7 +286,7 @@ Disable categories: `{ "disabled_categories": ["ultrabrain"] }` | **ultrabrain** | `gpt-5.4` | `gpt-5.4` → `gemini-3.1-pro` → `claude-opus-4-6` | | **deep** | `gpt-5.3-codex` | `gpt-5.3-codex` → `claude-opus-4-6` → `gemini-3.1-pro` | | **artistry** | `gemini-3.1-pro` | `gemini-3.1-pro` → `claude-opus-4-6` → `gpt-5.4` | -| **quick** | `claude-haiku-4-5` | `claude-haiku-4-5` → `gemini-3-flash` → `gpt-5-nano` | +| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini` → `claude-haiku-4-5` → `gemini-3-flash` → `minimax-m2.5` → `gpt-5-nano` | | **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6` → `gpt-5.3-codex` → `gemini-3-flash` | | **unspecified-high** | `claude-opus-4-6` | `claude-opus-4-6` → `gpt-5.4 (high)` → `glm-5` → `k2p5` → `kimi-k2.5` | | **writing** | `gemini-3-flash` | `gemini-3-flash` → `claude-sonnet-4-6` | @@ -418,15 +418,14 @@ Disable built-in skills: `{ "disabled_skills": ["playwright"] }` Disable built-in hooks via `disabled_hooks`: ```json -{ "disabled_hooks": ["comment-checker", "gpt-permission-continuation"] } +{ "disabled_hooks": ["comment-checker"] } ``` -Available hooks: `gpt-permission-continuation`, `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` +Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` **Notes:** - `directory-agents-injector` — auto-disabled on OpenCode 1.1.37+ (native AGENTS.md support) -- `gpt-permission-continuation` — resumes GPT sessions only when the last assistant reply ends with a permission-seeking tail like `If you want, ...`. Disable it if you prefer GPT sessions to wait for explicit user follow-up. - `no-sisyphus-gpt` — **do not disable**. It blocks incompatible GPT models for Sisyphus while allowing the dedicated GPT-5.4 prompt path. - `startup-toast` is a sub-feature of `auto-update-checker`. Disable just the toast by adding `startup-toast` to `disabled_hooks`. diff --git a/docs/reference/features.md b/docs/reference/features.md index 37fc5a122..09082dc3b 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -111,7 +111,7 @@ By combining these two concepts, you can generate optimal agents through `task`. | `ultrabrain` | `openai/gpt-5.4` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis | | `deep` | `openai/gpt-5.3-codex` (medium) | Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding. | | `artistry` | `google/gemini-3.1-pro` (high) | Highly creative/artistic tasks, novel ideas | -| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications | +| `quick` | `openai/gpt-5.4-mini` | Trivial tasks - single file changes, typo fixes, simple modifications | | `unspecified-low` | `anthropic/claude-sonnet-4-6` | Tasks that don't fit other categories, low effort required | | `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required | | `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing | @@ -680,7 +680,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | **ralph-loop** | Event + Message | Manages self-referential loop continuation. | | **start-work** | Message | Handles /start-work command execution. | | **auto-slash-command** | Message | Automatically executes slash commands from prompts. | -| **gpt-permission-continuation** | Event | Auto-continues GPT sessions when the final assistant reply ends with a permission-seeking tail such as `If you want, ...`. | | **stop-continuation-guard** | Event + Message | Guards the stop-continuation mechanism. | | **category-skill-reminder** | Event + PostToolUse | Reminds agents about available category skills for delegation. | | **anthropic-effort** | Params | Adjusts Anthropic API effort level based on context. | @@ -735,7 +734,6 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | Hook | Event | Description | | ------------------------------ | ----- | ---------------------------------------------------------- | -| **gpt-permission-continuation** | Event | Continues GPT replies that end in a permission-seeking tail. | | **todo-continuation-enforcer** | Event | Enforces todo completion — yanks idle agents back to work. | | **compaction-todo-preserver** | Event | Preserves todo state during session compaction. | | **unstable-agent-babysitter** | Event | Handles unstable agent behavior with recovery strategies. | @@ -787,12 +785,10 @@ Disable specific hooks in config: ```json { - "disabled_hooks": ["comment-checker", "gpt-permission-continuation"] + "disabled_hooks": ["comment-checker"] } ``` -Use `gpt-permission-continuation` when you want GPT sessions to stop at permission-seeking endings instead of auto-resuming. - ## MCPs ### Built-in MCPs diff --git a/script/build-binaries.ts b/script/build-binaries.ts index cfc65c685..1668fa19b 100644 --- a/script/build-binaries.ts +++ b/script/build-binaries.ts @@ -101,7 +101,9 @@ async function main() { console.log("\n✅ All platform binaries built successfully!\n"); } -main().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -}); +if (import.meta.main) { + main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }); +} diff --git a/signatures/cla.json b/signatures/cla.json index c8d84b362..a7c4a0e38 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2239,6 +2239,62 @@ "created_at": "2026-03-17T20:42:42Z", "repoId": 1108837393, "pullRequestNo": 2656 + }, + { + "name": "walioo", + "id": 25835823, + "comment_id": 4087098221, + "created_at": "2026-03-19T02:13:02Z", + "repoId": 1108837393, + "pullRequestNo": 2688 + }, + { + "name": "trafgals", + "id": 6454757, + "comment_id": 4087725932, + "created_at": "2026-03-19T04:22:32Z", + "repoId": 1108837393, + "pullRequestNo": 2690 + }, + { + "name": "tonymfer", + "id": 66512584, + "comment_id": 4091847232, + "created_at": "2026-03-19T17:13:49Z", + "repoId": 1108837393, + "pullRequestNo": 2701 + }, + { + "name": "nguyentamdat", + "id": 16253213, + "comment_id": 4096267323, + "created_at": "2026-03-20T07:34:22Z", + "repoId": 1108837393, + "pullRequestNo": 2718 + }, + { + "name": "whackur", + "id": 26926041, + "comment_id": 4102330445, + "created_at": "2026-03-21T05:27:17Z", + "repoId": 1108837393, + "pullRequestNo": 2733 + }, + { + "name": "ndaemy", + "id": 18691542, + "comment_id": 4103008804, + "created_at": "2026-03-21T10:18:22Z", + "repoId": 1108837393, + "pullRequestNo": 2734 + }, + { + "name": "0xYiliu", + "id": 3838688, + "comment_id": 4104738337, + "created_at": "2026-03-21T22:59:33Z", + "repoId": 1108837393, + "pullRequestNo": 2738 } ] } \ No newline at end of file diff --git a/src/AGENTS.md b/src/AGENTS.md index 584c36630..2c839aa22 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -14,7 +14,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create | `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation | | `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler | | `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) | -| `create-hooks.ts` | 3-tier: Core(37) + Continuation(7) + Skill(2) = 46 hooks | +| `create-hooks.ts` | 3-tier: Core(39) + Continuation(7) + Skill(2) = 48 hooks | | `plugin-interface.ts` | 8 OpenCode hook handlers: config, tool, chat.message, chat.params, chat.headers, event, tool.execute.before, tool.execute.after | ## CONFIG LOADING @@ -32,10 +32,10 @@ loadPluginConfig(directory, ctx) ``` createHooks() - ├─→ createCoreHooks() # 37 hooks + ├─→ createCoreHooks() # 39 hooks │ ├─ createSessionHooks() # 23: contextWindowMonitor, thinkMode, ralphLoop, modelFallback, runtimeFallback, noSisyphusGpt, noHephaestusNonGpt, anthropicEffort, intentGate... - │ ├─ createToolGuardHooks() # 10: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer... + │ ├─ createToolGuardHooks() # 12: commentChecker, rulesInjector, writeExistingFileGuard, jsonErrorRecovery, hashlineReadEnhancer... │ └─ createTransformHooks() # 4: claudeCodeHooks, keywordDetector, contextInjector, thinkingBlockValidator - ├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, ralphLoopActivator... + ├─→ createContinuationHooks() # 7: todoContinuationEnforcer, atlas, stopContinuationGuard, compactionContextInjector... └─→ createSkillHooks() # 2: categorySkillReminder, autoSlashCommand ``` diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 541731684..29eef9db0 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -248,8 +248,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only "variant": "medium", }, "quick": { - "model": "openai/gpt-5.3-codex", - "variant": "low", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -334,8 +333,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa "variant": "medium", }, "quick": { - "model": "openai/gpt-5.3-codex", - "variant": "low", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -533,7 +531,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "medium", }, "quick": { - "model": "anthropic/claude-haiku-4-5", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -608,7 +606,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "medium", }, "quick": { - "model": "anthropic/claude-haiku-4-5", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -684,7 +682,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on "variant": "medium", }, "quick": { - "model": "opencode/claude-haiku-4-5", + "model": "opencode/gpt-5.4-mini", }, "ultrabrain": { "model": "opencode/gpt-5.4", @@ -759,7 +757,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is "variant": "medium", }, "quick": { - "model": "opencode/claude-haiku-4-5", + "model": "opencode/gpt-5.4-mini", }, "ultrabrain": { "model": "opencode/gpt-5.4", @@ -830,7 +828,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when "variant": "high", }, "quick": { - "model": "github-copilot/claude-haiku-4.5", + "model": "github-copilot/gpt-5.4-mini", }, "ultrabrain": { "model": "github-copilot/gemini-3.1-pro-preview", @@ -900,7 +898,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with "variant": "high", }, "quick": { - "model": "github-copilot/claude-haiku-4.5", + "model": "github-copilot/gpt-5.4-mini", }, "ultrabrain": { "model": "github-copilot/gemini-3.1-pro-preview", @@ -1092,7 +1090,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "variant": "medium", }, "quick": { - "model": "anthropic/claude-haiku-4-5", + "model": "opencode/gpt-5.4-mini", }, "ultrabrain": { "model": "opencode/gpt-5.4", @@ -1167,7 +1165,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb "variant": "medium", }, "quick": { - "model": "github-copilot/claude-haiku-4.5", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -1375,7 +1373,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider "variant": "medium", }, "quick": { - "model": "github-copilot/claude-haiku-4.5", + "model": "github-copilot/gpt-5.4-mini", }, "ultrabrain": { "model": "opencode/gpt-5.4", @@ -1453,7 +1451,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "medium", }, "quick": { - "model": "anthropic/claude-haiku-4-5", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", @@ -1531,7 +1529,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "medium", }, "quick": { - "model": "anthropic/claude-haiku-4-5", + "model": "openai/gpt-5.4-mini", }, "ultrabrain": { "model": "openai/gpt-5.4", diff --git a/src/cli/openai-only-model-catalog.test.ts b/src/cli/openai-only-model-catalog.test.ts index eebb0e8fa..ef384ab5e 100644 --- a/src/cli/openai-only-model-catalog.test.ts +++ b/src/cli/openai-only-model-catalog.test.ts @@ -40,7 +40,7 @@ describe("generateModelConfig OpenAI-only model catalog", () => { // #then expect(result.categories?.artistry).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" }) - expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.3-codex", variant: "low" }) + expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" }) expect(result.categories?.["visual-engineering"]).toEqual({ model: "openai/gpt-5.4", variant: "high" }) expect(result.categories?.writing).toEqual({ model: "openai/gpt-5.4", variant: "medium" }) }) @@ -55,6 +55,6 @@ describe("generateModelConfig OpenAI-only model catalog", () => { // #then expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.5" }) expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.5" }) - expect(result.categories?.quick).toEqual({ model: "opencode-go/minimax-m2.5" }) + expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" }) }) }) diff --git a/src/cli/openai-only-model-catalog.ts b/src/cli/openai-only-model-catalog.ts index a6428a1ef..186b600b2 100644 --- a/src/cli/openai-only-model-catalog.ts +++ b/src/cli/openai-only-model-catalog.ts @@ -7,7 +7,7 @@ const OPENAI_ONLY_AGENT_OVERRIDES: Record = { const OPENAI_ONLY_CATEGORY_OVERRIDES: Record = { artistry: { model: "openai/gpt-5.4", variant: "xhigh" }, - quick: { model: "openai/gpt-5.3-codex", variant: "low" }, + quick: { model: "openai/gpt-5.4-mini" }, "visual-engineering": { model: "openai/gpt-5.4", variant: "high" }, writing: { model: "openai/gpt-5.4", variant: "medium" }, } diff --git a/src/cli/run/runner.test.ts b/src/cli/run/runner.test.ts index fa5d80b51..d37c00ebe 100644 --- a/src/cli/run/runner.test.ts +++ b/src/cli/run/runner.test.ts @@ -115,6 +115,42 @@ describe("waitForEventProcessorShutdown", () => { }) }) +describe("run environment setup", () => { + let originalClient: string | undefined + let originalRunMode: string | undefined + + beforeEach(() => { + originalClient = process.env.OPENCODE_CLIENT + originalRunMode = process.env.OPENCODE_CLI_RUN_MODE + }) + + afterEach(() => { + if (originalClient === undefined) { + delete process.env.OPENCODE_CLIENT + } else { + process.env.OPENCODE_CLIENT = originalClient + } + if (originalRunMode === undefined) { + delete process.env.OPENCODE_CLI_RUN_MODE + } else { + process.env.OPENCODE_CLI_RUN_MODE = originalRunMode + } + }) + + it("sets OPENCODE_CLIENT to 'run' to exclude question tool from registry", async () => { + //#given + delete process.env.OPENCODE_CLIENT + + //#when - run() sets env vars synchronously before any async work + const { run } = await import(`./runner?env-setup-${Date.now()}`) + run({ message: "test" }).catch(() => {}) + + //#then + expect(String(process.env.OPENCODE_CLIENT)).toBe("run") + expect(String(process.env.OPENCODE_CLI_RUN_MODE)).toBe("true") + }) +}) + describe("run with invalid model", () => { it("given invalid --model value, when run, then returns exit code 1 with error message", async () => { // given diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 0730204a8..247726fa8 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -31,6 +31,7 @@ export async function waitForEventProcessorShutdown( export async function run(options: RunOptions): Promise { process.env.OPENCODE_CLI_RUN_MODE = "true" + process.env.OPENCODE_CLIENT = "run" const startTime = Date.now() const { diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 93c4afaf8..0b9e7e219 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -14,7 +14,7 @@ config/schema/ ├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14) ├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent) ├── categories.ts # 8 built-in + custom categories -├── hooks.ts # HookNameSchema (46 hooks) +├── hooks.ts # HookNameSchema (48 hooks) ├── skills.ts # SkillsConfigSchema (sources, paths, recursive) ├── commands.ts # BuiltinCommandNameSchema ├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000) diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index f2e84853b..00e04404e 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -1,7 +1,6 @@ import { z } from "zod" export const HookNameSchema = z.enum([ - "gpt-permission-continuation", "todo-continuation-enforcer", "context-window-monitor", "session-recovery", diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 9f4d70c99..ea7e479c5 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -12,6 +12,7 @@ import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" import { NotificationConfigSchema } from "./notification" +import { OpenClawConfigSchema } from "./openclaw" import { RalphLoopConfigSchema } from "./ralph-loop" import { RuntimeFallbackConfigSchema } from "./runtime-fallback" import { SkillsConfigSchema } from "./skills" @@ -55,6 +56,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(), background_task: BackgroundTaskConfigSchema.optional(), notification: NotificationConfigSchema.optional(), + openclaw: OpenClawConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(), git_master: GitMasterConfigSchema.optional(), browser_automation_engine: BrowserAutomationConfigSchema.optional(), diff --git a/src/config/schema/openclaw.ts b/src/config/schema/openclaw.ts new file mode 100644 index 000000000..a768728c8 --- /dev/null +++ b/src/config/schema/openclaw.ts @@ -0,0 +1,50 @@ +import { z } from "zod" + +export const OpenClawGatewaySchema = z.object({ + type: z.enum(["http", "command"]).default("http"), + // HTTP specific + url: z.string().optional(), + method: z.string().default("POST"), + headers: z.record(z.string(), z.string()).optional(), + // Command specific + command: z.string().optional(), + // Shared + timeout: z.number().optional(), +}) + +export const OpenClawHookSchema = z.object({ + enabled: z.boolean().default(true), + gateway: z.string(), + instruction: z.string(), +}) + +export const OpenClawReplyListenerConfigSchema = z.object({ + discordBotToken: z.string().optional(), + discordChannelId: z.string().optional(), + discordMention: z.string().optional(), // For allowed_mentions + authorizedDiscordUserIds: z.array(z.string()).default([]), + + telegramBotToken: z.string().optional(), + telegramChatId: z.string().optional(), + + pollIntervalMs: z.number().default(3000), + rateLimitPerMinute: z.number().default(10), + maxMessageLength: z.number().default(500), + includePrefix: z.boolean().default(true), +}) + +export const OpenClawConfigSchema = z.object({ + enabled: z.boolean().default(false), + + // Outbound Configuration + gateways: z.record(z.string(), OpenClawGatewaySchema).default({}), + hooks: z.record(z.string(), OpenClawHookSchema).default({}), + + // Inbound Configuration (Reply Listener) + replyListener: OpenClawReplyListenerConfigSchema.optional(), +}) + +export type OpenClawConfig = z.infer +export type OpenClawGateway = z.infer +export type OpenClawHook = z.infer +export type OpenClawReplyListenerConfig = z.infer diff --git a/src/features/boulder-state/index.ts b/src/features/boulder-state/index.ts index f404e4e0e..17618996b 100644 --- a/src/features/boulder-state/index.ts +++ b/src/features/boulder-state/index.ts @@ -1,3 +1,4 @@ export * from "./types" export * from "./constants" export * from "./storage" +export * from "./top-level-task" diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index e52174cef..a8740662d 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -11,8 +11,11 @@ import { getPlanName, createBoulderState, findPrometheusPlans, + getTaskSessionState, + upsertTaskSessionState, } from "./storage" import type { BoulderState } from "./types" +import { readCurrentTopLevelTask } from "./top-level-task" describe("boulder-state", () => { const TEST_DIR = join(tmpdir(), "boulder-state-test-" + Date.now()) @@ -134,6 +137,24 @@ describe("boulder-state", () => { expect(result?.session_ids).toEqual(["session-1", "session-2"]) expect(result?.plan_name).toBe("my-plan") }) + + test("should default task_sessions to empty object when missing from JSON", () => { + // given - boulder.json without task_sessions field + const boulderFile = join(SISYPHUS_DIR, "boulder.json") + writeFileSync(boulderFile, JSON.stringify({ + active_plan: "/path/to/plan.md", + started_at: "2026-01-01T00:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + })) + + // when + const result = readBoulderState(TEST_DIR) + + // then + expect(result).not.toBeNull() + expect(result!.task_sessions).toEqual({}) + }) }) describe("writeBoulderState", () => { @@ -249,6 +270,115 @@ describe("boulder-state", () => { }) }) + describe("task session state", () => { + test("should persist and read preferred session for a top-level plan task", () => { + // given - existing boulder state + const state: BoulderState = { + active_plan: "/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + } + writeBoulderState(TEST_DIR, state) + + // when + upsertTaskSessionState(TEST_DIR, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "Implement auth flow", + sessionId: "ses_task_123", + agent: "sisyphus-junior", + category: "deep", + }) + const result = getTaskSessionState(TEST_DIR, "todo:1") + + // then + expect(result).not.toBeNull() + expect(result?.session_id).toBe("ses_task_123") + expect(result?.task_title).toBe("Implement auth flow") + expect(result?.agent).toBe("sisyphus-junior") + expect(result?.category).toBe("deep") + }) + + test("should overwrite preferred session for the same top-level plan task", () => { + // given - existing boulder state with prior preferred session + const state: BoulderState = { + active_plan: "/plan.md", + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "plan", + task_sessions: { + "todo:1": { + task_key: "todo:1", + task_label: "1", + task_title: "Implement auth flow", + session_id: "ses_old", + updated_at: "2026-01-02T10:00:00Z", + }, + }, + } + writeBoulderState(TEST_DIR, state) + + // when + upsertTaskSessionState(TEST_DIR, { + taskKey: "todo:1", + taskLabel: "1", + taskTitle: "Implement auth flow", + sessionId: "ses_new", + }) + const result = getTaskSessionState(TEST_DIR, "todo:1") + + // then + expect(result?.session_id).toBe("ses_new") + }) + }) + + describe("readCurrentTopLevelTask", () => { + test("should return the first unchecked top-level task in TODOs", () => { + // given - plan with nested and top-level unchecked tasks + const planPath = join(TEST_DIR, "current-task-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Finished task + - [ ] nested acceptance checkbox +- [ ] 2. Current task + +## Final Verification Wave +- [ ] F1. Final review +`) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).not.toBeNull() + expect(result?.key).toBe("todo:2") + expect(result?.title).toBe("Current task") + }) + + test("should fall back to final-wave task when implementation tasks are complete", () => { + // given - plan with only final-wave work remaining + const planPath = join(TEST_DIR, "final-wave-current-task-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Finished task + +## Final Verification Wave +- [ ] F1. Final review +`) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).not.toBeNull() + expect(result?.key).toBe("final-wave:f1") + expect(result?.title).toBe("Final review") + }) + }) + describe("getPlanProgress", () => { test("should count completed and uncompleted checkboxes", () => { // given - plan file with checkboxes diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index c9ac83993..ffbbb69a7 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -6,9 +6,11 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs" import { dirname, join, basename } from "node:path" -import type { BoulderState, PlanProgress } from "./types" +import type { BoulderState, PlanProgress, TaskSessionState } from "./types" import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants" +const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"]) + export function getBoulderFilePath(directory: string): string { return join(directory, BOULDER_DIR, BOULDER_FILE) } @@ -29,6 +31,9 @@ export function readBoulderState(directory: string): BoulderState | null { if (!Array.isArray(parsed.session_ids)) { parsed.session_ids = [] } + if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) { + parsed.task_sessions = {} + } return parsed as BoulderState } catch { return null @@ -85,6 +90,54 @@ export function clearBoulderState(directory: string): boolean { } } +export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null { + const state = readBoulderState(directory) + if (!state?.task_sessions) { + return null + } + + return state.task_sessions[taskKey] ?? null +} + +export function upsertTaskSessionState( + directory: string, + input: { + taskKey: string + taskLabel: string + taskTitle: string + sessionId: string + agent?: string + category?: string + }, +): BoulderState | null { + const state = readBoulderState(directory) + if (!state) { + return null + } + + if (RESERVED_KEYS.has(input.taskKey)) { + return null + } + + const taskSessions = state.task_sessions ?? {} + taskSessions[input.taskKey] = { + task_key: input.taskKey, + task_label: input.taskLabel, + task_title: input.taskTitle, + session_id: input.sessionId, + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(input.category !== undefined ? { category: input.category } : {}), + updated_at: new Date().toISOString(), + } + + state.task_sessions = taskSessions + if (writeBoulderState(directory, state)) { + return state + } + + return null +} + /** * Find Prometheus plan files for this project. * Prometheus stores plans at: {project}/.sisyphus/plans/{name}.md diff --git a/src/features/boulder-state/top-level-task.test.ts b/src/features/boulder-state/top-level-task.test.ts new file mode 100644 index 000000000..9de781cdc --- /dev/null +++ b/src/features/boulder-state/top-level-task.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from "bun:test" +import { writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +import { readCurrentTopLevelTask } from "./top-level-task" + +function writePlanFile(fileName: string, content: string): string { + const planPath = join(tmpdir(), fileName) + writeFileSync(planPath, content, "utf-8") + return planPath +} + +describe("readCurrentTopLevelTask", () => { + test("returns first unchecked top-level task in TODOs", () => { + // given + const planPath = writePlanFile( + `top-level-task-happy-${Date.now()}.md`, + `# Plan + +## TODOs +- [x] 1. Done task +- [ ] 2. Current task + +## Final Verification Wave +- [ ] F1. Final review +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toEqual({ + key: "todo:2", + section: "todo", + label: "2", + title: "Current task", + }) + }) + + test("returns null when all tasks are checked", () => { + // given + const planPath = writePlanFile( + `top-level-task-all-checked-${Date.now()}.md`, + `# Plan + +## TODOs +- [x] 1. Done task +- [x] 2. Another done task + +## Final Verification Wave +- [x] F1. Final done review +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toBeNull() + }) + + test("returns null for empty plan file", () => { + // given + const planPath = writePlanFile(`top-level-task-empty-${Date.now()}.md`, "") + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toBeNull() + }) + + test("returns null when plan file does not exist", () => { + // given + const planPath = join(tmpdir(), `top-level-task-missing-${Date.now()}.md`) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toBeNull() + }) + + test("skips nested or indented checkboxes", () => { + // given + const planPath = writePlanFile( + `top-level-task-nested-${Date.now()}.md`, + `# Plan + +## TODOs +- [x] 1. Done task + - [ ] nested should be ignored +- [ ] 2. Top-level pending +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result?.key).toBe("todo:2") + }) + + test("falls back to Final Verification Wave when TODOs are all checked", () => { + // given + const planPath = writePlanFile( + `top-level-task-fallback-${Date.now()}.md`, + `# Plan + +## TODOs +- [x] 1. Done task +- [x] 2. Done task + +## Final Verification Wave +- [ ] F1. Final review pending +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toEqual({ + key: "final-wave:f1", + section: "final-wave", + label: "F1", + title: "Final review pending", + }) + }) + + test("selects the first unchecked task among mixed checked and unchecked TODOs", () => { + // given + const planPath = writePlanFile( + `top-level-task-mixed-${Date.now()}.md`, + `# Plan + +## TODOs +- [x] 1. Done task +- [ ] 2. First unchecked +- [ ] 3. Second unchecked +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result?.key).toBe("todo:2") + expect(result?.title).toBe("First unchecked") + }) + + test("ignores malformed labels and continues to next unchecked task", () => { + // given + const planPath = writePlanFile( + `top-level-task-malformed-${Date.now()}.md`, + `# Plan + +## TODOs +- [ ] no number prefix +- [ ] 2. Valid task after malformed label +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toEqual({ + key: "todo:2", + section: "todo", + label: "2", + title: "Valid task after malformed label", + }) + }) + + test("supports unchecked tasks with asterisk bullets", () => { + // given + const planPath = writePlanFile( + `top-level-task-asterisk-${Date.now()}.md`, + `# Plan + +## TODOs +* [ ] 1. Task using asterisk bullet +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result?.key).toBe("todo:1") + expect(result?.title).toBe("Task using asterisk bullet") + }) + + test("returns final-wave task when plan has only Final Verification Wave section", () => { + // given + const planPath = writePlanFile( + `top-level-task-final-only-${Date.now()}.md`, + `# Plan + +## Final Verification Wave +- [ ] F2. Final-only task +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result).toEqual({ + key: "final-wave:f2", + section: "final-wave", + label: "F2", + title: "Final-only task", + }) + }) + + test("returns the first unchecked task when multiple unchecked tasks exist", () => { + // given + const planPath = writePlanFile( + `top-level-task-multiple-${Date.now()}.md`, + `# Plan + +## TODOs +- [ ] 1. First unchecked task +- [ ] 2. Second unchecked task +- [ ] 3. Third unchecked task +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result?.label).toBe("1") + expect(result?.title).toBe("First unchecked task") + }) + + test("ignores unchecked content in non-target sections during section transitions", () => { + // given + const planPath = writePlanFile( + `top-level-task-sections-${Date.now()}.md`, + `# Plan + +## Notes +- [ ] 99. Should be ignored because section is not tracked + +## TODOs +- [x] 1. Done implementation task + +## Decisions +- [ ] 100. Should also be ignored + +## Final Verification Wave +- [ ] F3. Final verification task +`, + ) + + // when + const result = readCurrentTopLevelTask(planPath) + + // then + expect(result?.key).toBe("final-wave:f3") + expect(result?.section).toBe("final-wave") + }) +}) diff --git a/src/features/boulder-state/top-level-task.ts b/src/features/boulder-state/top-level-task.ts new file mode 100644 index 000000000..d92970b56 --- /dev/null +++ b/src/features/boulder-state/top-level-task.ts @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from "node:fs" + +import type { TopLevelTaskRef } from "./types" + +const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i +const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i +const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/ +const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ +const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/ +const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i + +type PlanSection = "todo" | "final-wave" | "other" + +function buildTaskRef( + section: "todo" | "final-wave", + taskLabel: string, +): TopLevelTaskRef | null { + const pattern = section === "todo" ? TODO_TASK_PATTERN : FINAL_WAVE_TASK_PATTERN + const match = taskLabel.match(pattern) + if (!match) { + return null + } + + const rawLabel = match[1] + const title = match[2].trim() + + return { + key: `${section}:${rawLabel.toLowerCase()}`, + section, + label: rawLabel, + title, + } +} + +export function readCurrentTopLevelTask(planPath: string): TopLevelTaskRef | null { + if (!existsSync(planPath)) { + return null + } + + try { + const content = readFileSync(planPath, "utf-8") + const lines = content.split(/\r?\n/) + let section: PlanSection = "other" + + for (const line of lines) { + if (SECOND_LEVEL_HEADING_PATTERN.test(line)) { + section = TODO_HEADING_PATTERN.test(line) + ? "todo" + : FINAL_VERIFICATION_HEADING_PATTERN.test(line) + ? "final-wave" + : "other" + } + + const uncheckedTaskMatch = line.match(UNCHECKED_CHECKBOX_PATTERN) + if (!uncheckedTaskMatch) { + continue + } + + if (uncheckedTaskMatch[1].length > 0) { + continue + } + + if (section !== "todo" && section !== "final-wave") { + continue + } + + const taskRef = buildTaskRef(section, uncheckedTaskMatch[2].trim()) + if (taskRef) { + return taskRef + } + } + + return null + } catch { + return null + } +} diff --git a/src/features/boulder-state/types.ts b/src/features/boulder-state/types.ts index b1a225380..ba488f381 100644 --- a/src/features/boulder-state/types.ts +++ b/src/features/boulder-state/types.ts @@ -18,6 +18,8 @@ export interface BoulderState { agent?: string /** Absolute path to the git worktree root where work happens */ worktree_path?: string + /** Preferred reusable subagent sessions keyed by current top-level plan task */ + task_sessions?: Record } export interface PlanProgress { @@ -28,3 +30,31 @@ export interface PlanProgress { /** Whether all tasks are done */ isComplete: boolean } + +export interface TaskSessionState { + /** Stable identifier for the current top-level plan task (e.g. todo:1 / final-wave:F1) */ + task_key: string + /** Original task label from the plan file */ + task_label: string + /** Full task title from the plan file */ + task_title: string + /** Preferred reusable subagent session */ + session_id: string + /** Agent associated with the task session, when known */ + agent?: string + /** Category associated with the task session, when known */ + category?: string + /** Last update timestamp */ + updated_at: string +} + +export interface TopLevelTaskRef { + /** Stable identifier for the current top-level plan task */ + key: string + /** Task section in the Prometheus plan */ + section: "todo" | "final-wave" + /** Original label token (e.g. 1 / F1) */ + label: string + /** Full task title extracted from the checkbox line */ + title: string +} diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 4a25ccb4b..e7dfc4e2c 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -1,10 +1,10 @@ -# src/hooks/ — 46 Lifecycle Hooks +# src/hooks/ — 48 Lifecycle Hooks **Generated:** 2026-03-06 ## OVERVIEW -46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. +48 hooks across dedicated modules and standalone files. Three-tier composition: Core(39) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. ## HOOK TIERS @@ -85,7 +85,7 @@ hooks/ | noHephaestusNonGpt | chat.message | Block Hephaestus from using non-GPT models | | runtimeFallback | event | Auto-switch models on API provider errors | -### Tier 2: Tool Guard Hooks (10) — `create-tool-guard-hooks.ts` +### Tier 2: Tool Guard Hooks (12) — `create-tool-guard-hooks.ts` | Hook | Event | Purpose | |------|-------|---------| diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index 855cdacb6..ca71bb8d9 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -2,11 +2,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createAtlasEventHandler } from "./event-handler" import { createToolExecuteAfterHandler } from "./tool-execute-after" import { createToolExecuteBeforeHandler } from "./tool-execute-before" -import type { AtlasHookOptions, SessionState } from "./types" +import type { AtlasHookOptions, PendingTaskRef, SessionState } from "./types" export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { const sessions = new Map() const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() const autoCommit = options?.autoCommit ?? true function getState(sessionID: string): SessionState { @@ -20,7 +21,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { return { handler: createAtlasEventHandler({ ctx, options, sessions, getState }), - "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }), - "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit, getState }), + "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }), + "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }), } } diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 4f8e35802..2e01e9ab7 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -15,6 +15,8 @@ export async function injectBoulderContinuation(input: { total: number agent?: string worktreePath?: string + preferredTaskSessionId?: string + preferredTaskTitle?: string backgroundManager?: BackgroundManager sessionState: SessionState }): Promise { @@ -26,6 +28,8 @@ export async function injectBoulderContinuation(input: { total, agent, worktreePath, + preferredTaskSessionId, + preferredTaskTitle, backgroundManager, sessionState, } = input @@ -40,9 +44,13 @@ export async function injectBoulderContinuation(input: { } const worktreeContext = worktreePath ? `\n\n[Worktree: ${worktreePath}]` : "" + const preferredSessionContext = preferredTaskSessionId + ? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]` + : "" const prompt = BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + + preferredSessionContext + worktreeContext try { diff --git a/src/hooks/atlas/final-wave-approval-gate-regression.test.ts b/src/hooks/atlas/final-wave-approval-gate-regression.test.ts index b653c66d4..ab509d828 100644 --- a/src/hooks/atlas/final-wave-approval-gate-regression.test.ts +++ b/src/hooks/atlas/final-wave-approval-gate-regression.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { createOpencodeClient } from "@opencode-ai/sdk" -import type { AssistantMessage } from "@opencode-ai/sdk" +import type { AssistantMessage, Session } from "@opencode-ai/sdk" import type { BoulderState } from "../../features/boulder-state" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" @@ -52,6 +52,23 @@ describe("Atlas final-wave approval gate regressions", () => { response: new Response(), })) + Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => { + const parentID = path.id === "ses_nested_scope_review" + ? "atlas-nested-final-wave-session" + : path.id.startsWith("ses_parallel_review_") + ? "atlas-parallel-final-wave-session" + : "main-session-123" + + return { + data: { + id: path.id, + parentID, + } as Session, + request: new Request(`http://localhost/session/${path.id}`), + response: new Response(), + } + }) + return { directory: testDirectory, project: {} as AtlasHookContext["project"], diff --git a/src/hooks/atlas/final-wave-approval-gate.test.ts b/src/hooks/atlas/final-wave-approval-gate.test.ts index 5812c4ba1..5c0e44492 100644 --- a/src/hooks/atlas/final-wave-approval-gate.test.ts +++ b/src/hooks/atlas/final-wave-approval-gate.test.ts @@ -60,10 +60,18 @@ describe("Atlas final verification approval gate", () => { } }) - Reflect.set(client.session, "get", async () => { + Reflect.set(client.session, "get", async ({ path }: { path: { id: string } }) => { + const parentID = path.id === "ses_final_wave_review" + ? "atlas-final-wave-session" + : path.id === "ses_feature_task" + ? "atlas-non-final-session" + : "main-session-123" return { - data: { parentID: "main-session-123" } as Session, - request: new Request("http://localhost/session/main-session-123"), + data: { + id: path.id, + parentID, + } as Session, + request: new Request(`http://localhost/session/${path.id}`), response: new Response(), } }) diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 1f5cfeb2c..26714e328 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -1,5 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { + getPlanProgress, + getTaskSessionState, + readBoulderState, + readCurrentTopLevelTask, +} from "../../features/boulder-state" import { log } from "../../shared/logger" import { injectBoulderContinuation } from "./boulder-continuation-injector" import { HOOK_NAME } from "./hook-name" @@ -8,6 +13,7 @@ import type { AtlasHookOptions, SessionState } from "./types" const CONTINUATION_COOLDOWN_MS = 5000 const FAILURE_BACKOFF_MS = 5 * 60 * 1000 +const MAX_CONSECUTIVE_PROMPT_FAILURES = 10 const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000 function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean { @@ -31,6 +37,14 @@ async function injectContinuation(input: { input.sessionState.lastContinuationInjectedAt = Date.now() try { + const currentBoulder = readBoulderState(input.ctx.directory) + const currentTask = currentBoulder + ? readCurrentTopLevelTask(currentBoulder.active_plan) + : null + const preferredTaskSession = currentTask + ? getTaskSessionState(input.ctx.directory, currentTask.key) + : null + await injectBoulderContinuation({ ctx: input.ctx, sessionID: input.sessionID, @@ -39,6 +53,8 @@ async function injectContinuation(input: { total: input.progress.total, agent: input.agent, worktreePath: input.worktreePath, + preferredTaskSessionId: preferredTaskSession?.session_id, + preferredTaskTitle: preferredTaskSession?.task_title, backgroundManager: input.options?.backgroundManager, sessionState: input.sessionState, }) @@ -62,7 +78,7 @@ function scheduleRetry(input: { sessionState.pendingRetryTimer = setTimeout(async () => { sessionState.pendingRetryTimer = undefined - if (sessionState.promptFailureCount >= 2) return + if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) return if (sessionState.waitingForFinalWaveApproval) return const currentBoulder = readBoulderState(ctx.directory) @@ -72,7 +88,6 @@ function scheduleRetry(input: { const currentProgress = getPlanProgress(currentBoulder.active_plan) if (currentProgress.isComplete) return if (options?.isContinuationStopped?.(sessionID)) return - if (options?.shouldSkipContinuation?.(sessionID)) return if (hasRunningBackgroundTasks(sessionID, options)) return await injectContinuation({ @@ -135,7 +150,7 @@ export async function handleAtlasSessionIdle(input: { return } - if (sessionState.promptFailureCount >= 2) { + if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) { const timeSinceLastFailure = sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY if (timeSinceLastFailure < FAILURE_BACKOFF_MS) { @@ -161,11 +176,6 @@ export async function handleAtlasSessionIdle(input: { return } - if (options?.shouldSkipContinuation?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID }) - return - } - if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) { scheduleRetry({ ctx, sessionID, sessionState, options }) log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 269d7928b..c3a16a90b 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -10,6 +10,7 @@ import { } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state" import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state" +import type { PendingTaskRef } from "./types" const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`) const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message") @@ -33,25 +34,40 @@ mock.module("../../shared/opencode-storage-detection", () => ({ })) const { createAtlasHook } = await import("./index") +const { createToolExecuteAfterHandler } = await import("./tool-execute-after") +const { createToolExecuteBeforeHandler } = await import("./tool-execute-before") const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector") describe("atlas hook", () => { let TEST_DIR: string let SISYPHUS_DIR: string - function createMockPluginInput(overrides?: { promptMock?: ReturnType }) { + function createMockPluginInput(overrides?: { + promptMock?: ReturnType + sessionGetMock?: ReturnType + }) { const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve()) + const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({ + data: { + id: path.id, + parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123", + }, + })) return { directory: TEST_DIR, client: { session: { - get: async () => ({ data: { parentID: "main-session-123" } }), + get: sessionGetMock, prompt: promptMock, promptAsync: promptMock, }, }, _promptMock: promptMock, - } as unknown as Parameters[0] & { _promptMock: ReturnType } + _sessionGetMock: sessionGetMock, + } as unknown as Parameters[0] & { + _promptMock: ReturnType + _sessionGetMock: ReturnType + } } function setupMessageStorage(sessionID: string, agent: string): void { @@ -404,12 +420,417 @@ describe("atlas hook", () => { // then - should include verification instructions expect(output.output).toContain("LYING") - expect(output.output).toContain("PHASE 1") - expect(output.output).toContain("PHASE 2") + expect(output.output).toContain("PHASE 1") + expect(output.output).toContain("PHASE 2") cleanupMessageStorage(sessionID) }) + test("should clean pending task refs when a task returns background launch output", async () => { + // given - direct handlers with shared pending maps + const sessionID = "session-bg-launch-cleanup-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "background-cleanup-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +`) + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "background-cleanup-plan", + }) + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const beforeHandler = createToolExecuteBeforeHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + }) + const afterHandler = createToolExecuteAfterHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }) + + // when - the task is captured before execution + await beforeHandler( + { tool: "task", sessionID, callID: "call-bg-launch" }, + { args: { prompt: "Implement auth flow" } } + ) + expect(pendingTaskRefs.size).toBe(1) + + // and the task returns a background launch result + await afterHandler( + { tool: "task", sessionID, callID: "call-bg-launch" }, + { + title: "Sisyphus Task", + output: "Background task launched.\n\nSession ID: ses_bg_12345", + metadata: {}, + } + ) + + // then - the pending task ref is still cleaned up + expect(pendingTaskRefs.size).toBe(0) + + cleanupMessageStorage(sessionID) + }) + + test("should persist preferred subagent session for the current top-level task", async () => { + // given - boulder state with a current top-level task, Atlas caller + const sessionID = "session-task-session-track-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "task-session-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow + - [ ] nested acceptance checkbox +`) + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "task-session-plan", + } + writeBoulderState(TEST_DIR, state) + + const hook = createAtlasHook(createMockPluginInput()) + const output = { + title: "Sisyphus Task", + output: `Task completed successfully + + +session_id: ses_auth_flow_123 +`, + metadata: { + agent: "sisyphus-junior", + category: "deep", + }, + } + + // when + await hook["tool.execute.after"]( + { tool: "task", sessionID }, + output + ) + + // then + const updatedState = readBoulderState(TEST_DIR) + expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123") + expect(updatedState?.task_sessions?.["todo:1"]?.task_title).toBe("Implement auth flow") + expect(updatedState?.task_sessions?.["todo:1"]?.agent).toBe("sisyphus-junior") + expect(updatedState?.task_sessions?.["todo:1"]?.category).toBe("deep") + + cleanupMessageStorage(sessionID) + }) + + test("should preserve the delegated task key even after the plan advances to the next task", async () => { + // given - Atlas caller starts task 1, then the plan advances before task output is processed + const sessionID = "session-stable-task-key-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "stable-task-key-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +- [ ] 2. Add API validation +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "stable-task-key-plan", + }) + + const hook = createAtlasHook(createMockPluginInput()) + + // when - Atlas delegates task 1 + await hook["tool.execute.before"]( + { tool: "task", sessionID, callID: "call-task-1" }, + { args: { prompt: "Implement auth flow" } } + ) + + // and the plan is advanced before the task output is processed + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Implement auth flow +- [ ] 2. Add API validation +`) + + await hook["tool.execute.after"]( + { tool: "task", sessionID, callID: "call-task-1" }, + { + title: "Sisyphus Task", + output: `Task completed successfully + + +session_id: ses_auth_flow_123 +`, + metadata: { + agent: "sisyphus-junior", + category: "deep", + }, + } + ) + + // then - the completed task session is still recorded against task 1, not task 2 + const updatedState = readBoulderState(TEST_DIR) + expect(updatedState?.task_sessions?.["todo:1"]?.session_id).toBe("ses_auth_flow_123") + expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined() + + cleanupMessageStorage(sessionID) + }) + + test("should not overwrite the current task mapping when task() explicitly resumes an older session", async () => { + // given - current plan is on task 2, but Atlas explicitly resumes an older session for a previous task + const sessionID = "session-cross-task-resume-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "cross-task-resume-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Implement auth flow +- [ ] 2. Add API validation +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "cross-task-resume-plan", + }) + + const hook = createAtlasHook(createMockPluginInput()) + + // when - Atlas resumes an explicit prior session + await hook["tool.execute.before"]( + { tool: "task", sessionID, callID: "call-resume-old-task" }, + { args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } } + ) + + const output = { + title: "Sisyphus Task", + output: `Task continued successfully + + +session_id: ses_old_task_111 +`, + metadata: { + agent: "sisyphus-junior", + category: "deep", + }, + } + await hook["tool.execute.after"]( + { tool: "task", sessionID, callID: "call-resume-old-task" }, + output + ) + + // then - Atlas does not poison task 2's preferred session mapping + const updatedState = readBoulderState(TEST_DIR) + expect(updatedState?.task_sessions?.["todo:2"]).toBeUndefined() + expect(output.output).not.toContain('task(session_id="ses_old_task_111"') + + cleanupMessageStorage(sessionID) + }) + + test("should not reuse an explicitly resumed session id in completion reminders", async () => { + // given - current plan is on task 2 with an existing tracked session + const sessionID = "session-explicit-resume-reminder-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "explicit-resume-reminder-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [x] 1. Implement auth flow +- [ ] 2. Add API validation +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "explicit-resume-reminder-plan", + task_sessions: { + "todo:2": { + task_key: "todo:2", + task_label: "2", + task_title: "Add API validation", + session_id: "ses_tracked_current_task", + updated_at: "2026-01-02T10:00:00Z", + }, + }, + }) + + const hook = createAtlasHook(createMockPluginInput()) + const output = { + title: "Sisyphus Task", + output: `Task continued successfully + + +session_id: ses_old_task_111 +`, + metadata: {}, + } + + // when + await hook["tool.execute.before"]( + { tool: "task", sessionID, callID: "call-explicit-resume-reminder" }, + { args: { prompt: "Follow up on previous task", session_id: "ses_old_task_111" } } + ) + await hook["tool.execute.after"]( + { tool: "task", sessionID, callID: "call-explicit-resume-reminder" }, + output + ) + + // then + expect(output.output).not.toContain('task(session_id="ses_old_task_111"') + expect(output.output).toContain("ses_tracked_current_task") + + cleanupMessageStorage(sessionID) + }) + + test("should skip persistence when multiple in-flight task calls claim the same top-level task", async () => { + // given + const sessionID = "session-parallel-task-collision-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "parallel-task-collision-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +- [ ] 2. Add API validation +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "parallel-task-collision-plan", + }) + + const pendingFilePaths = new Map() + const pendingTaskRefs = new Map() + const beforeHandler = createToolExecuteBeforeHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + }) + const afterHandler = createToolExecuteAfterHandler({ + ctx: createMockPluginInput(), + pendingFilePaths, + pendingTaskRefs, + autoCommit: true, + getState: () => ({ promptFailureCount: 0 }), + }) + + // when - two task() calls start before either one completes + await beforeHandler( + { tool: "task", sessionID, callID: "call-task-first" }, + { args: { prompt: "Implement auth flow part 1" } } + ) + await beforeHandler( + { tool: "task", sessionID, callID: "call-task-second" }, + { args: { prompt: "Implement auth flow part 2" } } + ) + + const secondPendingTaskRef = pendingTaskRefs.get("call-task-second") + + await afterHandler( + { tool: "task", sessionID, callID: "call-task-second" }, + { + title: "Sisyphus Task", + output: `Task completed successfully + + +session_id: ses_parallel_collision_222 +`, + metadata: {}, + } + ) + + // then + expect(secondPendingTaskRef).toEqual({ + kind: "skip", + reason: "ambiguous_task_key", + task: { + key: "todo:1", + label: "1", + title: "Implement auth flow", + }, + }) + const updatedState = readBoulderState(TEST_DIR) + expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined() + + cleanupMessageStorage(sessionID) + }) + + test("should ignore extracted session ids that are outside the active boulder lineage", async () => { + // given + const sessionID = "session-untrusted-session-id-test" + setupMessageStorage(sessionID, "atlas") + + const planPath = join(TEST_DIR, "untrusted-session-id-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["session-1"], + plan_name: "untrusted-session-id-plan", + }) + + const hook = createAtlasHook(createMockPluginInput({ + sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({ + data: { + id: path.id, + parentID: path.id === "ses_untrusted_999" ? "session-outside-lineage" : "main-session-123", + }, + })), + })) + const output = { + title: "Sisyphus Task", + output: `Task completed successfully + + +session_id: ses_untrusted_999 +`, + metadata: {}, + } + + // when + await hook["tool.execute.after"]( + { tool: "task", sessionID }, + output + ) + + // then + const updatedState = readBoulderState(TEST_DIR) + expect(updatedState?.task_sessions?.["todo:1"]).toBeUndefined() + expect(output.output).not.toContain('task(session_id="ses_untrusted_999"') + expect(output.output).toContain('task(session_id=""') + + cleanupMessageStorage(sessionID) + }) + describe("completion gate output ordering", () => { const COMPLETION_GATE_SESSION = "completion-gate-order-test" @@ -1043,37 +1464,6 @@ describe("atlas hook", () => { expect(mockInput._promptMock).not.toHaveBeenCalled() }) - test("should skip when another continuation hook already injected", async () => { - // given - boulder state with incomplete plan - const planPath = join(TEST_DIR, "test-plan.md") - writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") - - const state: BoulderState = { - active_plan: planPath, - started_at: "2026-01-02T10:00:00Z", - session_ids: [MAIN_SESSION_ID], - plan_name: "test-plan", - } - writeBoulderState(TEST_DIR, state) - - const mockInput = createMockPluginInput() - const hook = createAtlasHook(mockInput, { - directory: TEST_DIR, - shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID, - }) - - // when - await hook.handler({ - event: { - type: "session.idle", - properties: { sessionID: MAIN_SESSION_ID }, - }, - }) - - // then - should not call prompt because another continuation already handled it - expect(mockInput._promptMock).not.toHaveBeenCalled() - }) - test("should clear abort state on message.updated", async () => { // given - boulder with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") @@ -1147,6 +1537,48 @@ describe("atlas hook", () => { expect(callArgs.body.parts[0].text).toContain("2 remaining") }) + test("should include preferred reuse session in continuation prompt for current top-level task", async () => { + // given - boulder state with tracked preferred session + const planPath = join(TEST_DIR, "preferred-session-plan.md") + writeFileSync(planPath, `# Plan + +## TODOs +- [ ] 1. Implement auth flow +`) + + writeBoulderState(TEST_DIR, { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "preferred-session-plan", + task_sessions: { + "todo:1": { + task_key: "todo:1", + task_label: "1", + task_title: "Implement auth flow", + session_id: "ses_auth_flow_123", + updated_at: "2026-01-02T10:00:00Z", + }, + }, + }) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.body.parts[0].text).toContain("Preferred reuse session for current top-level plan task") + expect(callArgs.body.parts[0].text).toContain("ses_auth_flow_123") + }) + test("should inject when last agent is sisyphus and boulder targets atlas explicitly", async () => { // given - boulder explicitly set to atlas, but last agent is sisyphus (initial state after /start-work) const planPath = join(TEST_DIR, "test-plan.md") @@ -1283,7 +1715,7 @@ describe("atlas hook", () => { expect(mockInput._promptMock).toHaveBeenCalledTimes(1) }) - test("should stop continuation after 2 consecutive prompt failures (issue #1355)", async () => { + test("should stop continuation after 10 consecutive prompt failures (issue #1355)", async () => { //#given - boulder state with incomplete plan and prompt always fails const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") @@ -1296,7 +1728,7 @@ describe("atlas hook", () => { } writeBoulderState(TEST_DIR, state) - const promptMock = mock(() => Promise.reject(new Error("Bad Request"))) + const promptMock = mock((): Promise => Promise.reject(new Error("Bad Request"))) const mockInput = createMockPluginInput({ promptMock }) const hook = createAtlasHook(mockInput) @@ -1306,25 +1738,23 @@ describe("atlas hook", () => { try { //#when - idle fires repeatedly, past cooldown each time - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 - - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() - //#then - should attempt only twice, then disable continuation - expect(promptMock).toHaveBeenCalledTimes(2) + //#then - should attempt only 10 times, then disable continuation + expect(promptMock).toHaveBeenCalledTimes(10) } finally { Date.now = originalDateNow } }) - test("should reset prompt failure counter on success and only stop after 2 consecutive failures", async () => { + test("should reset prompt failure counter on success and only stop after 10 consecutive failures", async () => { //#given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") @@ -1337,11 +1767,9 @@ describe("atlas hook", () => { } writeBoulderState(TEST_DIR, state) - const promptMock = mock(() => Promise.resolve()) + const promptMock = mock((): Promise => Promise.reject(new Error("Bad Request"))) promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) promptMock.mockImplementationOnce(() => Promise.resolve()) - promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) - promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) const mockInput = createMockPluginInput({ promptMock }) const hook = createAtlasHook(mockInput) @@ -1351,21 +1779,21 @@ describe("atlas hook", () => { Date.now = () => now try { - //#when - fail, succeed (reset), then fail twice (disable), then attempt again - for (let i = 0; i < 5; i++) { + //#when - fail, succeed (reset), then fail 10 times (disable), then attempt again + for (let i = 0; i < 13; i++) { await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() now += 6000 } - //#then - 4 prompt attempts; 5th idle is skipped after 2 consecutive failures - expect(promptMock).toHaveBeenCalledTimes(4) + //#then - 12 prompt attempts; 13th idle is skipped after 10 consecutive failures + expect(promptMock).toHaveBeenCalledTimes(12) } finally { Date.now = originalDateNow } }) - test("should keep skipping continuation during 5-minute backoff after 2 consecutive failures", async () => { + test("should keep skipping continuation during 5-minute backoff after 10 consecutive failures", async () => { //#given - boulder state with incomplete plan and prompt always fails const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") @@ -1387,26 +1815,26 @@ describe("atlas hook", () => { Date.now = () => now try { - //#when - third idle occurs inside 5-minute backoff window - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + //#when - 11th idle occurs inside 5-minute backoff window + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() now += 60000 await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() - //#then - third attempt should still be skipped - expect(promptMock).toHaveBeenCalledTimes(2) + //#then - 11th attempt should still be skipped + expect(promptMock).toHaveBeenCalledTimes(10) } finally { Date.now = originalDateNow } }) - test("should retry continuation after 5-minute backoff expires following 2 consecutive failures", async () => { + test("should retry continuation after 5-minute backoff expires following 10 consecutive failures", async () => { //#given - boulder state with incomplete plan and prompt always fails const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") @@ -1428,20 +1856,20 @@ describe("atlas hook", () => { Date.now = () => now try { - //#when - third idle occurs after 5+ minutes - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + //#when - 11th idle occurs after 5+ minutes + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() now += 300000 await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() - //#then - third attempt should run after backoff expiration - expect(promptMock).toHaveBeenCalledTimes(3) + //#then - 11th attempt should run after backoff expiration + expect(promptMock).toHaveBeenCalledTimes(11) } finally { Date.now = originalDateNow } @@ -1461,8 +1889,9 @@ describe("atlas hook", () => { writeBoulderState(TEST_DIR, state) const promptMock = mock((): Promise => Promise.reject(new Error("Bad Request"))) - promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) - promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) + for (let i = 0; i < 10; i++) { + promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request"))) + } promptMock.mockImplementationOnce(() => Promise.resolve(undefined)) const mockInput = createMockPluginInput({ promptMock }) const hook = createAtlasHook(mockInput) @@ -1472,32 +1901,30 @@ describe("atlas hook", () => { Date.now = () => now try { - //#when - fail twice, recover after backoff with success, then fail twice again - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + //#when - fail 10 times, recover after backoff with success, then fail 10 times again + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() now += 300000 await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() now += 6000 - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 - - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() - //#then - success retry resets counter, so two additional failures are allowed before skip - expect(promptMock).toHaveBeenCalledTimes(5) + //#then - success retry resets counter, so 10 additional failures are allowed before skip + expect(promptMock).toHaveBeenCalledTimes(21) } finally { Date.now = originalDateNow } @@ -1525,14 +1952,12 @@ describe("atlas hook", () => { Date.now = () => now try { - //#when - two failures disables continuation, then compaction resets it - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 - - await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) - await flushMicrotasks() - now += 6000 + //#when - 10 failures disable continuation, then compaction resets it + for (let i = 0; i < 10; i++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) + await flushMicrotasks() + now += 6000 + } await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() @@ -1543,8 +1968,8 @@ describe("atlas hook", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } }) await flushMicrotasks() - //#then - 2 attempts + 1 after compaction (3 total) - expect(promptMock).toHaveBeenCalledTimes(3) + //#then - 10 attempts + 1 after compaction (11 total) + expect(promptMock).toHaveBeenCalledTimes(11) } finally { Date.now = originalDateNow } diff --git a/src/hooks/atlas/subagent-session-id.test.ts b/src/hooks/atlas/subagent-session-id.test.ts new file mode 100644 index 000000000..45f716f0f --- /dev/null +++ b/src/hooks/atlas/subagent-session-id.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" + +import { extractSessionIdFromOutput } from "./subagent-session-id" + +describe("extractSessionIdFromOutput", () => { + test("extracts Session ID blocks from background output", () => { + // given + const output = `Background task launched.\n\nSession ID: ses_bg_12345` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_bg_12345") + }) + + test("extracts session_id from task metadata blocks", () => { + // given + const output = `Task completed.\n\n\nsession_id: ses_sync_12345\n` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_sync_12345") + }) + + test("extracts hyphenated session IDs from task metadata blocks", () => { + // given + const output = `Task completed.\n\n\nsession_id: ses_auth-flow-123\n` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_auth-flow-123") + }) + + test("returns undefined when no session id is present", () => { + // given + const output = "Task completed without metadata" + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBeUndefined() + }) + + test("prefers the session id inside the trailing task_metadata block", () => { + // given + const output = `The previous attempt mentioned session_id: ses_wrong_body_123 but that was only context. + + +session_id: ses_real_metadata_456 +` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_real_metadata_456") + }) + + test("does not let task_metadata parsing bleed into incidental body text after the closing tag", () => { + // given + const output = ` +session_id: ses_real_metadata_456 + + +debug log: session_id: ses_wrong_body_789` + + // when + const result = extractSessionIdFromOutput(output) + + // then + expect(result).toBe("ses_real_metadata_456") + }) +}) diff --git a/src/hooks/atlas/subagent-session-id.ts b/src/hooks/atlas/subagent-session-id.ts index 12cf619b1..b316e5f68 100644 --- a/src/hooks/atlas/subagent-session-id.ts +++ b/src/hooks/atlas/subagent-session-id.ts @@ -1,4 +1,44 @@ -export function extractSessionIdFromOutput(output: string): string { - const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/) - return match?.[1] ?? "" +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import { isSessionInBoulderLineage } from "./boulder-session-lineage" +import { HOOK_NAME } from "./hook-name" + +export function extractSessionIdFromOutput(output: string): string | undefined { + const taskMetadataBlocks = [...output.matchAll(/([\s\S]*?)<\/task_metadata>/gi)] + const lastTaskMetadataBlock = taskMetadataBlocks.at(-1)?.[1] + if (lastTaskMetadataBlock) { + const taskMetadataSessionMatch = lastTaskMetadataBlock.match(/session_id:\s*(ses_[a-zA-Z0-9_-]+)/i) + if (taskMetadataSessionMatch) { + return taskMetadataSessionMatch[1] + } + } + + const explicitSessionMatches = [...output.matchAll(/Session ID:\s*(ses_[a-zA-Z0-9_-]+)/g)] + return explicitSessionMatches.at(-1)?.[1] +} + +export async function validateSubagentSessionId(input: { + client: PluginInput["client"] + sessionID?: string + lineageSessionIDs: string[] +}): Promise { + if (!input.sessionID || input.lineageSessionIDs.length === 0) { + return undefined + } + + const belongsToLineage = await isSessionInBoulderLineage({ + client: input.client, + sessionID: input.sessionID, + boulderSessionIDs: input.lineageSessionIDs, + }) + + if (!belongsToLineage) { + log(`[${HOOK_NAME}] Ignoring extracted session id outside active lineage`, { + sessionID: input.sessionID, + lineageSessionIDs: input.lineageSessionIDs, + }) + return undefined + } + + return input.sessionID } diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 55fb8ddd6..823cdf1b9 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -1,5 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { + appendSessionId, + getPlanProgress, + getTaskSessionState, + readBoulderState, + readCurrentTopLevelTask, + upsertTaskSessionState, +} from "../../features/boulder-state" import { log } from "../../shared/logger" import { isCallerOrchestrator } from "../../shared/session-utils" import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree" @@ -7,7 +14,7 @@ import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate" import { HOOK_NAME } from "./hook-name" import { DIRECT_WORK_REMINDER } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" -import { extractSessionIdFromOutput } from "./subagent-session-id" +import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id" import { buildCompletionGate, buildFinalWaveApprovalReminder, @@ -15,16 +22,60 @@ import { buildStandaloneVerificationReminder, } from "./verification-reminders" import { isWriteOrEditToolName } from "./write-edit-tool-policy" -import type { SessionState } from "./types" -import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" +import type { PendingTaskRef, SessionState } from "./types" +import type { ToolExecuteAfterInput, ToolExecuteAfterOutput, TrackedTopLevelTaskRef } from "./types" + +function resolvePreferredSessionId(currentSessionId?: string, trackedSessionId?: string): string { + return currentSessionId ?? trackedSessionId ?? "" +} + +function resolveTaskContext( + pendingTaskRef: PendingTaskRef | undefined, + planPath: string, +): { + currentTask: TrackedTopLevelTaskRef | null + shouldSkipTaskSessionUpdate: boolean + shouldIgnoreCurrentSessionId: boolean +} { + if (!pendingTaskRef) { + return { + currentTask: readCurrentTopLevelTask(planPath), + shouldSkipTaskSessionUpdate: false, + shouldIgnoreCurrentSessionId: false, + } + } + + if (pendingTaskRef.kind === "track") { + return { + currentTask: pendingTaskRef.task, + shouldSkipTaskSessionUpdate: false, + shouldIgnoreCurrentSessionId: false, + } + } + + if (pendingTaskRef.reason === "explicit_resume") { + return { + currentTask: readCurrentTopLevelTask(planPath), + shouldSkipTaskSessionUpdate: true, + shouldIgnoreCurrentSessionId: true, + } + } + + return { + currentTask: pendingTaskRef.task, + shouldSkipTaskSessionUpdate: true, + shouldIgnoreCurrentSessionId: true, + } +} export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map + pendingTaskRefs: Map autoCommit: boolean getState: (sessionID: string) => SessionState }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { - const { ctx, pendingFilePaths, autoCommit, getState } = input + const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) if (!toolOutput) { @@ -59,6 +110,10 @@ export function createToolExecuteAfterHandler(input: { } const outputStr = toolOutput.output && typeof toolOutput.output === "string" ? toolOutput.output : "" + const pendingTaskRef = toolInput.callID ? pendingTaskRefs.get(toolInput.callID) : undefined + if (toolInput.callID) { + pendingTaskRefs.delete(toolInput.callID) + } const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued") if (isBackgroundLaunch) { return @@ -67,11 +122,19 @@ export function createToolExecuteAfterHandler(input: { if (toolOutput.output && typeof toolOutput.output === "string") { const gitStats = collectGitDiffStats(ctx.directory) const fileChanges = formatFileChanges(gitStats) - const subagentSessionId = extractSessionIdFromOutput(toolOutput.output) + const extractedSessionId = extractSessionIdFromOutput(toolOutput.output) const boulderState = readBoulderState(ctx.directory) if (boulderState) { const progress = getPlanProgress(boulderState.active_plan) + const { + currentTask, + shouldSkipTaskSessionUpdate, + shouldIgnoreCurrentSessionId, + } = resolveTaskContext(pendingTaskRef, boulderState.active_plan) + const trackedTaskSession = currentTask + ? getTaskSessionState(ctx.directory, currentTask.key) + : null const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined if (toolInput.sessionID && !boulderState.session_ids?.includes(toolInput.sessionID)) { @@ -82,6 +145,31 @@ export function createToolExecuteAfterHandler(input: { }) } + const lineageSessionIDs = toolInput.sessionID && !boulderState.session_ids.includes(toolInput.sessionID) + ? [...boulderState.session_ids, toolInput.sessionID] + : boulderState.session_ids + const subagentSessionId = await validateSubagentSessionId({ + client: ctx.client, + sessionID: extractedSessionId, + lineageSessionIDs, + }) + + if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) { + upsertTaskSessionState(ctx.directory, { + taskKey: currentTask.key, + taskLabel: currentTask.label, + taskTitle: currentTask.title, + sessionId: subagentSessionId, + agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined, + category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined, + }) + } + + const preferredSessionId = resolvePreferredSessionId( + shouldIgnoreCurrentSessionId ? undefined : subagentSessionId, + trackedTaskSession?.session_id, + ) + // Preserve original subagent response - critical for debugging failed tasks const originalResponse = toolOutput.output const shouldPauseForApproval = sessionState @@ -102,11 +190,11 @@ export function createToolExecuteAfterHandler(input: { } const leadReminder = shouldPauseForApproval - ? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, subagentSessionId) - : buildCompletionGate(boulderState.plan_name, subagentSessionId) + ? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId) + : buildCompletionGate(boulderState.plan_name, preferredSessionId) const followupReminder = shouldPauseForApproval ? null - : buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit, false) + : buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false) toolOutput.output = ` @@ -132,10 +220,22 @@ ${ plan: boulderState.plan_name, progress: `${progress.completed}/${progress.total}`, fileCount: gitStats.length, + preferredSessionId, waitingForFinalWaveApproval: shouldPauseForApproval, }) } else { - toolOutput.output += `\n\n${buildStandaloneVerificationReminder(subagentSessionId)}\n` + const lineageSessionIDs = toolInput.sessionID ? [toolInput.sessionID] : [] + const subagentSessionId = await validateSubagentSessionId({ + client: ctx.client, + sessionID: extractedSessionId, + lineageSessionIDs, + }) + const preferredSessionId = pendingTaskRef?.kind === "skip" + ? undefined + : subagentSessionId + toolOutput.output += `\n\n${buildStandaloneVerificationReminder( + resolvePreferredSessionId(preferredSessionId), + )}\n` log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, { sessionID: toolInput.sessionID, diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index 51f670000..e00224d84 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -2,19 +2,26 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isCallerOrchestrator } from "../../shared/session-utils" import type { PluginInput } from "@opencode-ai/plugin" +import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state" import { HOOK_NAME } from "./hook-name" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" +import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types" import { isWriteOrEditToolName } from "./write-edit-tool-policy" export function createToolExecuteBeforeHandler(input: { ctx: PluginInput pendingFilePaths: Map + pendingTaskRefs: Map }): ( toolInput: { tool: string; sessionID?: string; callID?: string }, toolOutput: { args: Record; message?: string } ) => Promise { - const { ctx, pendingFilePaths } = input + const { ctx, pendingFilePaths, pendingTaskRefs } = input + + function trackTask(callID: string, task: TrackedTopLevelTaskRef): void { + pendingTaskRefs.set(callID, { kind: "track", task }) + } return async (toolInput, toolOutput): Promise => { if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) { @@ -43,6 +50,46 @@ export function createToolExecuteBeforeHandler(input: { // Check task - inject single-task directive if (toolInput.tool === "task") { + if (toolInput.callID) { + const requestedSessionId = toolOutput.args.session_id as string | undefined + if (requestedSessionId) { + pendingTaskRefs.set(toolInput.callID, { + kind: "skip", + reason: "explicit_resume", + }) + } else { + const boulderState = readBoulderState(ctx.directory) + const currentTask = boulderState + ? readCurrentTopLevelTask(boulderState.active_plan) + : null + if (currentTask) { + const task = { + key: currentTask.key, + label: currentTask.label, + title: currentTask.title, + } + const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => ( + pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key + )) + + if (hasExistingClaim) { + pendingTaskRefs.set(toolInput.callID, { + kind: "skip", + reason: "ambiguous_task_key", + task, + }) + log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, { + sessionID: toolInput.sessionID, + callID: toolInput.callID, + taskKey: task.key, + }) + } else { + trackTask(toolInput.callID, task) + } + } + } + } + const prompt = toolOutput.args.prompt as string | undefined if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { toolOutput.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index c3aa9bbc7..79a4c51dc 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -1,5 +1,6 @@ import type { AgentOverrides } from "../../config" import type { BackgroundManager } from "../../features/background-agent" +import type { TopLevelTaskRef } from "../../features/boulder-state" export type ModelInfo = { providerID: string; modelID: string } @@ -7,7 +8,6 @@ export interface AtlasHookOptions { directory: string backgroundManager?: BackgroundManager isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean agentOverrides?: AgentOverrides /** Enable auto-commit after each atomic task completion (default: true) */ autoCommit?: boolean @@ -25,6 +25,13 @@ export interface ToolExecuteAfterOutput { metadata: Record } +export type TrackedTopLevelTaskRef = Pick + +export type PendingTaskRef = + | { kind: "track"; task: TrackedTopLevelTaskRef } + | { kind: "skip"; reason: "explicit_resume" } + | { kind: "skip"; reason: "ambiguous_task_key"; task: TrackedTopLevelTaskRef } + export interface SessionState { lastEventWasAbortError?: boolean lastContinuationInjectedAt?: number diff --git a/src/hooks/atlas/verification-reminders.ts b/src/hooks/atlas/verification-reminders.ts index cb988984e..80217798d 100644 --- a/src/hooks/atlas/verification-reminders.ts +++ b/src/hooks/atlas/verification-reminders.ts @@ -1,5 +1,14 @@ import { VERIFICATION_REMINDER } from "./system-reminder-templates" +function buildReuseHint(sessionId: string): string { + return ` +**PREFERRED REUSE SESSION FOR THE CURRENT TOP-LEVEL PLAN TASK** + +- Reuse \`${sessionId}\` first if verification fails or the result needs follow-up. +- Start a fresh subagent session only when reuse is unavailable or would cross task boundaries. +` +} + export function buildCompletionGate(planName: string, sessionId: string): string { return ` **COMPLETION GATE — DO NOT PROCEED UNTIL THIS IS DONE** @@ -25,7 +34,8 @@ task(session_id="${sessionId}", prompt="fix: checkbox not recorded correctly") **Your completion is NOT tracked until the checkbox is marked in the plan file.** -**VERIFICATION_REMINDER**` +**VERIFICATION_REMINDER** +${buildReuseHint(sessionId)}` } function buildVerificationReminder(sessionId: string): string { @@ -38,7 +48,9 @@ ${VERIFICATION_REMINDER} **If ANY verification fails, use this immediately:** \`\`\` task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") -\`\`\`` +\`\`\` + +${buildReuseHint(sessionId)}` } export function buildOrchestratorReminder( diff --git a/src/hooks/gpt-permission-continuation/assistant-message.ts b/src/hooks/gpt-permission-continuation/assistant-message.ts deleted file mode 100644 index 6e1c2335e..000000000 --- a/src/hooks/gpt-permission-continuation/assistant-message.ts +++ /dev/null @@ -1,44 +0,0 @@ -type TextPart = { - type?: string - text?: string -} - -type MessageInfo = { - id?: string - role?: string - error?: unknown - model?: { - providerID?: string - modelID?: string - } - providerID?: string - modelID?: string -} - -export type SessionMessage = { - info?: MessageInfo - parts?: TextPart[] -} - -export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null { - for (let index = messages.length - 1; index >= 0; index--) { - if (messages[index].info?.role === "assistant") { - return messages[index] - } - } - - return null -} - -export function extractAssistantText(message: SessionMessage): string { - return (message.parts ?? []) - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text?.trim() ?? "") - .filter(Boolean) - .join("\n") -} - -export function isGptAssistantMessage(message: SessionMessage): boolean { - const modelID = message.info?.model?.modelID ?? message.info?.modelID - return typeof modelID === "string" && modelID.toLowerCase().includes("gpt") -} diff --git a/src/hooks/gpt-permission-continuation/constants.ts b/src/hooks/gpt-permission-continuation/constants.ts deleted file mode 100644 index 04eda9a72..000000000 --- a/src/hooks/gpt-permission-continuation/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const HOOK_NAME = "gpt-permission-continuation" -export const CONTINUATION_PROMPT = "continue" -export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3 - -export const DEFAULT_STALL_PATTERNS = [ - "if you want", - "would you like", - "shall i", - "do you want me to", - "let me know if", -] as const diff --git a/src/hooks/gpt-permission-continuation/detector.ts b/src/hooks/gpt-permission-continuation/detector.ts deleted file mode 100644 index a28894ec2..000000000 --- a/src/hooks/gpt-permission-continuation/detector.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DEFAULT_STALL_PATTERNS } from "./constants" - -function getTrailingSegment(text: string): string { - const normalized = text.trim().replace(/\s+/g, " ") - if (!normalized) return "" - - const sentenceParts = normalized.split(/(?<=[.!?])\s+/) - return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? "" -} - -export function detectStallPattern( - text: string, - patterns: readonly string[] = DEFAULT_STALL_PATTERNS, -): boolean { - if (!text.trim()) return false - - const tail = text.slice(-800) - const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean) - const hotZone = lines.slice(-3).join(" ") - const trailingSegment = getTrailingSegment(hotZone) - - return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase())) -} diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts deleted file mode 100644 index 98e2e2ae8..000000000 --- a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -/// - -import { createOpencodeClient } from "@opencode-ai/sdk" -import { describe, expect, it as test } from "bun:test" - -import { createGptPermissionContinuationHook } from "." - -type SessionMessage = { - info: { - id: string - role: "user" | "assistant" - model?: { - providerID?: string - modelID?: string - } - modelID?: string - } - parts?: Array<{ type: string; text?: string }> -} - -type GptPermissionContext = Parameters[0] - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function extractPromptText(input: unknown): string { - if (!isRecord(input)) return "" - - const body = input.body - if (!isRecord(body)) return "" - - const parts = body.parts - if (!Array.isArray(parts)) return "" - - const firstPart = parts[0] - if (!isRecord(firstPart)) return "" - - return typeof firstPart.text === "string" ? firstPart.text : "" -} - -function createMockPluginInput(messages: SessionMessage[]): { - ctx: GptPermissionContext - promptCalls: string[] -} { - const promptCalls: string[] = [] - const client = createOpencodeClient({ directory: "/tmp/test" }) - const shell = Object.assign( - () => { - throw new Error("$ is not used in this test") - }, - { - braces: () => [], - escape: (input: string) => input, - env() { - return shell - }, - cwd() { - return shell - }, - nothrow() { - return shell - }, - throws() { - return shell - }, - }, - ) - const request = new Request("http://localhost") - const response = new Response() - - Reflect.set(client.session, "messages", async () => ({ data: messages, error: undefined, request, response })) - Reflect.set(client.session, "prompt", async (input: unknown) => { - promptCalls.push(extractPromptText(input)) - return { data: undefined, error: undefined, request, response } - }) - Reflect.set(client.session, "promptAsync", async (input: unknown) => { - promptCalls.push(extractPromptText(input)) - return { data: undefined, error: undefined, request, response } - }) - - const ctx: GptPermissionContext = { - client, - project: { - id: "test-project", - worktree: "/tmp/test", - time: { created: Date.now() }, - }, - directory: "/tmp/test", - worktree: "/tmp/test", - serverUrl: new URL("http://localhost"), - $: shell, - } - - return { ctx, promptCalls } -} - -function createAssistantMessage(id: string, text: string): SessionMessage { - return { - info: { id, role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text }], - } -} - -function createUserMessage(id: string, text: string): SessionMessage { - return { - info: { id, role: "user" }, - parts: [{ type: "text", text }], - } -} - -describe("gpt-permission-continuation", () => { - test("injects continue when the last GPT assistant reply asks for permission", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue"]) - }) - - test("does not inject when the last assistant model is not GPT", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { - id: "msg-1", - role: "assistant", - model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, - }, - parts: [{ type: "text", text: "If you want, I can keep going." }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual([]) - }) - - test("does not inject when the last assistant reply is not a stall pattern", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "I completed the refactor and all tests pass." }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual([]) - }) - - test("does not inject when a permission phrase appears before the final sentence", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual([]) - }) - - test("does not inject when continuation is stopped for the session", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "If you want, I can continue with the fix." }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx, { - isContinuationStopped: (sessionID) => sessionID === "ses-1", - }) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual([]) - }) - - test("does not inject twice for the same assistant message", async () => { - // given - const { ctx, promptCalls } = createMockPluginInput([ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "Would you like me to continue with the fix?" }], - }, - ]) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue"]) - }) - - describe("#given repeated GPT permission tails in the same session", () => { - describe("#when the permission phrases keep changing", () => { - test("stops injecting after three consecutive auto-continues", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "continue")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue", "continue", "continue"]) - }) - }) - - describe("#when a real user message arrives between auto-continues", () => { - test("resets the consecutive auto-continue counter", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup.")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-8", "continue")) - messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-10", "continue")) - messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue", "continue", "continue", "continue", "continue"]) - }) - }) - - describe("#when the same permission phrase repeats after an auto-continue", () => { - test("stops immediately on stagnation", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue"]) - }) - }) - - describe("#when a user manually types continue after the cap is reached", () => { - test("resets the cap and allows another auto-continue", async () => { - // given - const messages: SessionMessage[] = [ - createUserMessage("msg-0", "Please continue the fix."), - createAssistantMessage("msg-1", "If you want, I can apply the patch next."), - ] - const { ctx, promptCalls } = createMockPluginInput(messages) - const hook = createGptPermissionContinuationHook(ctx) - - // when - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-2", "continue")) - messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-4", "continue")) - messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-6", "continue")) - messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - messages.push(createUserMessage("msg-8", "continue")) - messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish.")) - await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } }) - - // then - expect(promptCalls).toEqual(["continue", "continue", "continue", "continue"]) - }) - }) - }) -}) diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts deleted file mode 100644 index 27f28530f..000000000 --- a/src/hooks/gpt-permission-continuation/handler.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -import { normalizeSDKResponse } from "../../shared" -import { log } from "../../shared/logger" - -import { - extractAssistantText, - getLastAssistantMessage, - isGptAssistantMessage, - type SessionMessage, -} from "./assistant-message" -import { - CONTINUATION_PROMPT, - HOOK_NAME, - MAX_CONSECUTIVE_AUTO_CONTINUES, -} from "./constants" -import { detectStallPattern } from "./detector" -import type { SessionStateStore } from "./session-state" - -type SessionState = ReturnType - -async function promptContinuation( - ctx: PluginInput, - sessionID: string, -): Promise { - const payload = { - path: { id: sessionID }, - body: { - parts: [{ type: "text" as const, text: CONTINUATION_PROMPT }], - }, - query: { directory: ctx.directory }, - } - - if (typeof ctx.client.session.promptAsync === "function") { - await ctx.client.session.promptAsync(payload) - return - } - - await ctx.client.session.prompt(payload) -} - -function getLastUserMessageBefore( - messages: SessionMessage[], - lastAssistantIndex: number, -): SessionMessage | null { - for (let index = lastAssistantIndex - 1; index >= 0; index--) { - if (messages[index].info?.role === "user") { - return messages[index] - } - } - - return null -} - -function isAutoContinuationUserMessage(message: SessionMessage): boolean { - return extractAssistantText(message).trim().toLowerCase() === CONTINUATION_PROMPT -} - -function extractPermissionPhrase(text: string): string | null { - const tail = text.slice(-800) - const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean) - const hotZone = lines.slice(-3).join(" ") - const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/) - const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? "" - return trailingSegment || null -} - -function resetAutoContinuationState(state: SessionState): void { - state.consecutiveAutoContinueCount = 0 - state.awaitingAutoContinuationResponse = false - state.lastAutoContinuePermissionPhrase = undefined -} - -export function createGptPermissionContinuationHandler(args: { - ctx: PluginInput - sessionStateStore: SessionStateStore - isContinuationStopped?: (sessionID: string) => boolean -}): (input: { event: { type: string; properties?: unknown } }) => Promise { - const { ctx, sessionStateStore, isContinuationStopped } = args - - return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { - const properties = event.properties as Record | undefined - - if (event.type === "session.deleted") { - const sessionID = (properties?.info as { id?: string } | undefined)?.id - if (sessionID) { - sessionStateStore.cleanup(sessionID) - } - return - } - - if (event.type !== "session.idle") return - - const sessionID = properties?.sessionID as string | undefined - if (!sessionID) return - - if (isContinuationStopped?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) - return - } - - const state = sessionStateStore.getState(sessionID) - if (state.inFlight) { - log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID }) - return - } - - try { - const messagesResponse = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], { - preferResponseOnMissingData: true, - }) - const lastAssistantMessage = getLastAssistantMessage(messages) - if (!lastAssistantMessage) return - - const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage) - const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex) - const previousUserMessageWasAutoContinuation = - previousUserMessage !== null - && state.awaitingAutoContinuationResponse - && isAutoContinuationUserMessage(previousUserMessage) - - if (previousUserMessageWasAutoContinuation) { - state.awaitingAutoContinuationResponse = false - } else if (previousUserMessage) { - resetAutoContinuationState(state) - } else { - state.awaitingAutoContinuationResponse = false - } - - const messageID = lastAssistantMessage.info?.id - if (messageID && state.lastHandledMessageID === messageID) { - log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID }) - return - } - - if (lastAssistantMessage.info?.error) { - log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID }) - return - } - - if (!isGptAssistantMessage(lastAssistantMessage)) { - log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID }) - return - } - - const assistantText = extractAssistantText(lastAssistantMessage) - if (!detectStallPattern(assistantText)) { - return - } - - const permissionPhrase = extractPermissionPhrase(assistantText) - if (!permissionPhrase) { - return - } - - if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) { - state.lastHandledMessageID = messageID - log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, { - sessionID, - messageID, - consecutiveAutoContinueCount: state.consecutiveAutoContinueCount, - }) - return - } - - if ( - state.consecutiveAutoContinueCount >= 1 - && state.lastAutoContinuePermissionPhrase === permissionPhrase - ) { - state.lastHandledMessageID = messageID - log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, { - sessionID, - messageID, - permissionPhrase, - }) - return - } - - state.inFlight = true - await promptContinuation(ctx, sessionID) - state.lastHandledMessageID = messageID - state.consecutiveAutoContinueCount += 1 - state.awaitingAutoContinuationResponse = true - state.lastAutoContinuePermissionPhrase = permissionPhrase - state.lastInjectedAt = Date.now() - log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID }) - } catch (error) { - log(`[${HOOK_NAME}] Failed to inject continuation prompt`, { - sessionID, - error: String(error), - }) - } finally { - state.inFlight = false - } - } -} diff --git a/src/hooks/gpt-permission-continuation/index.ts b/src/hooks/gpt-permission-continuation/index.ts deleted file mode 100644 index a87295635..000000000 --- a/src/hooks/gpt-permission-continuation/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" - -import { createGptPermissionContinuationHandler } from "./handler" -import { createSessionStateStore } from "./session-state" - -export type GptPermissionContinuationHook = { - handler: (input: { event: { type: string; properties?: unknown } }) => Promise - wasRecentlyInjected: (sessionID: string) => boolean -} - -export function createGptPermissionContinuationHook( - ctx: PluginInput, - options?: { - isContinuationStopped?: (sessionID: string) => boolean - }, -): GptPermissionContinuationHook { - const sessionStateStore = createSessionStateStore() - - return { - handler: createGptPermissionContinuationHandler({ - ctx, - sessionStateStore, - isContinuationStopped: options?.isContinuationStopped, - }), - wasRecentlyInjected(sessionID: string): boolean { - return sessionStateStore.wasRecentlyInjected(sessionID, 5_000) - }, - } -} diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts deleted file mode 100644 index 9414692e4..000000000 --- a/src/hooks/gpt-permission-continuation/session-state.ts +++ /dev/null @@ -1,39 +0,0 @@ -type SessionState = { - inFlight: boolean - consecutiveAutoContinueCount: number - awaitingAutoContinuationResponse: boolean - lastHandledMessageID?: string - lastAutoContinuePermissionPhrase?: string - lastInjectedAt?: number -} - -export type SessionStateStore = ReturnType - -export function createSessionStateStore() { - const states = new Map() - - const getState = (sessionID: string): SessionState => { - const existing = states.get(sessionID) - if (existing) return existing - - const created: SessionState = { - inFlight: false, - consecutiveAutoContinueCount: 0, - awaitingAutoContinuationResponse: false, - } - states.set(sessionID, created) - return created - } - - return { - getState, - wasRecentlyInjected(sessionID: string, windowMs: number): boolean { - const state = states.get(sessionID) - if (!state?.lastInjectedAt) return false - return Date.now() - state.lastInjectedAt <= windowMs - }, - cleanup(sessionID: string): void { - states.delete(sessionID) - }, - } -} diff --git a/src/hooks/gpt-permission-continuation/todo-coordination.test.ts b/src/hooks/gpt-permission-continuation/todo-coordination.test.ts deleted file mode 100644 index dc32db7af..000000000 --- a/src/hooks/gpt-permission-continuation/todo-coordination.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, test } from "bun:test" - -import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer" -import { createGptPermissionContinuationHook } from "." - -describe("gpt-permission-continuation coordination", () => { - test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => { - // given - const promptCalls: string[] = [] - const toastCalls: string[] = [] - const sessionID = "ses-dual-continuation" - const ctx = { - directory: "/tmp/test", - client: { - session: { - messages: async () => ({ - data: [ - { - info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" }, - parts: [{ type: "text", text: "If you want, I can implement the fix next." }], - }, - ], - }), - todo: async () => ({ - data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], - }), - prompt: async (input: { body: { parts: Array<{ text: string }> } }) => { - promptCalls.push(input.body.parts[0]?.text ?? "") - return {} - }, - promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => { - promptCalls.push(input.body.parts[0]?.text ?? "") - return {} - }, - }, - tui: { - showToast: async (input: { body: { title: string } }) => { - toastCalls.push(input.body.title) - return {} - }, - }, - }, - } as any - - const gptPermissionContinuation = createGptPermissionContinuationHook(ctx) - const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, { - shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id), - }) - - // when - await gptPermissionContinuation.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - await todoContinuationEnforcer.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - // then - expect(promptCalls).toEqual(["continue"]) - expect(toastCalls).toEqual([]) - }) -}) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 73fbb652d..abbf79bb7 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -30,7 +30,6 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createNoSisyphusGptHook } from "./no-sisyphus-gpt"; export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt"; -export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation" export { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; diff --git a/src/hooks/openclaw.test.ts b/src/hooks/openclaw.test.ts new file mode 100644 index 000000000..db3b69a91 --- /dev/null +++ b/src/hooks/openclaw.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" + +const wakeOpenClawMock = mock(async () => null) + +mock.module("../openclaw", () => ({ + wakeOpenClaw: wakeOpenClawMock, +})) + +describe("createOpenClawHook", () => { + beforeEach(() => { + wakeOpenClawMock.mockClear() + }) + + test("maps session.created to session-start", async () => { + const { createOpenClawHook } = await import("./openclaw") + const hook = createOpenClawHook( + { directory: "/tmp/project" } as any, + { openclaw: { enabled: true } } as any, + ) + + await hook?.event?.({ + event: { + type: "session.created", + properties: { sessionID: "session-1" }, + }, + }) + + expect(wakeOpenClawMock).toHaveBeenCalledWith( + expect.anything(), + "session-start", + expect.objectContaining({ + projectPath: "/tmp/project", + sessionId: "session-1", + }), + ) + }) + + test("uses tool.execute.before for question tools", async () => { + const { createOpenClawHook } = await import("./openclaw") + const hook = createOpenClawHook( + { directory: "/tmp/project" } as any, + { openclaw: { enabled: true } } as any, + ) + + await hook?.["tool.execute.before"]?.( + { tool: "ask_user_question", sessionID: "session-2" }, + { args: { questions: [{ question: "Need approval?", options: [{ label: "Yes" }] }] } }, + ) + + expect(wakeOpenClawMock).toHaveBeenCalledWith( + expect.anything(), + "ask-user-question", + expect.objectContaining({ + projectPath: "/tmp/project", + question: "Need approval?", + sessionId: "session-2", + }), + ) + }) + + test("falls back to args.question string when questions array absent", async () => { + const { createOpenClawHook } = await import("./openclaw") + const hook = createOpenClawHook( + { directory: "/tmp/project" } as any, + { openclaw: { enabled: true } } as any, + ) + + await hook?.["tool.execute.before"]?.( + { tool: "question", sessionID: "session-3" }, + { args: { question: "Fallback?" } }, + ) + + expect(wakeOpenClawMock).toHaveBeenCalledWith( + expect.anything(), + "ask-user-question", + expect.objectContaining({ + question: "Fallback?", + sessionId: "session-3", + }), + ) + }) +}) diff --git a/src/hooks/openclaw.ts b/src/hooks/openclaw.ts new file mode 100644 index 000000000..00bce217e --- /dev/null +++ b/src/hooks/openclaw.ts @@ -0,0 +1,66 @@ +import type { PluginContext } from "../plugin/types" +import type { OhMyOpenCodeConfig } from "../config" +import { wakeOpenClaw } from "../openclaw" +import type { OpenClawContext } from "../openclaw/types" + +export function createOpenClawHook( + ctx: PluginContext, + pluginConfig: OhMyOpenCodeConfig, +) { + const config = pluginConfig.openclaw + if (!config?.enabled) return null + + const handleWake = async (event: string, context: OpenClawContext) => { + await wakeOpenClaw(config, event, context) + } + + return { + event: async (input: any) => { + const { event } = input + const props = event.properties || {} + const sessionID = props.sessionID || props.info?.id + + const context: OpenClawContext = { + sessionId: sessionID, + projectPath: ctx.directory, + } + + if (event.type === "session.created") { + await handleWake("session-start", context) + } else if (event.type === "session.deleted") { + await handleWake("session-end", context) + } else if (event.type === "session.idle") { + // Check if we are waiting for user input (ask-user-question) + // This is heuristic. If the last message was from assistant and ended with a question? + // Or if the system is idle. + await handleWake("session-idle", context) + } + }, + + "tool.execute.before": async ( + input: { tool: string; sessionID: string }, + output: { args: Record }, + ) => { + const normalizedToolName = input.tool.toLowerCase() + if ( + normalizedToolName !== "question" + && normalizedToolName !== "ask_user_question" + && normalizedToolName !== "askuserquestion" + ) { + return + } + + // question tool uses args.questions array, not args.question + const questions = Array.isArray(output.args.questions) ? output.args.questions : [] + const question = questions.length > 0 && typeof questions[0]?.question === "string" + ? questions[0].question + : typeof output.args.question === "string" ? output.args.question : undefined + const context: OpenClawContext = { + sessionId: input.sessionID, + projectPath: ctx.directory, + question, + } + await handleWake("ask-user-question", context) + }, + } +} diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts index 6e2dae816..b63640457 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.test.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts @@ -108,4 +108,80 @@ describe("detectCompletionInSessionMessages", () => { expect(detected).toBe(true) }) }) + + describe("#given promise appears in tool_result part (not text part)", () => { + test("#when Oracle returns VERIFIED via task() tool_result #then should detect completion", async () => { + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [ + { type: "text", text: "Consulting Oracle for verification." }, + { type: "tool_use", text: '{"subagent_type":"oracle"}' }, + ], + }, + { + info: { role: "assistant" }, + parts: [ + { type: "tool_result", text: 'Task completed.\n\nAgent: oracle\n\nVERIFIED\n\n\nsession_id: ses_abc123\n' }, + { type: "text", text: "Oracle verified the task." }, + ], + }, + ] + const ctx = createPluginInput(messages) + + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "VERIFIED", + apiTimeoutMs: 1000, + directory: "/tmp", + sinceMessageIndex: 0, + }) + + expect(detected).toBe(true) + }) + + test("#when DONE appears only in tool_result part #then should detect completion", async () => { + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [ + { type: "tool_result", text: 'Background task output DONE' }, + { type: "text", text: "Task completed successfully." }, + ], + }, + ] + const ctx = createPluginInput(messages) + + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "DONE", + apiTimeoutMs: 1000, + directory: "/tmp", + }) + + expect(detected).toBe(true) + }) + + test("#when promise appears in tool_use part (not tool_result) #then should NOT detect completion", async () => { + const messages: SessionMessage[] = [ + { + info: { role: "assistant" }, + parts: [ + { type: "tool_use", text: 'prompt containing VERIFIED as instruction' }, + { type: "text", text: "Calling Oracle." }, + ], + }, + ] + const ctx = createPluginInput(messages) + + const detected = await detectCompletionInSessionMessages(ctx, { + sessionID: "session-123", + promise: "VERIFIED", + apiTimeoutMs: 1000, + directory: "/tmp", + }) + + expect(detected).toBe(false) + }) + }) }) diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index 81f061bad..40e9e1af7 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -93,7 +93,7 @@ export async function detectCompletionInSessionMessages( let responseText = "" for (const part of assistant.parts) { - if (part.type !== "text") continue + if (part.type !== "text" && part.type !== "tool_result") continue responseText += `${responseText ? "\n" : ""}${part.text ?? ""}` } diff --git a/src/hooks/ralph-loop/continuation-prompt-builder.ts b/src/hooks/ralph-loop/continuation-prompt-builder.ts index 8d807fe39..e709caa23 100644 --- a/src/hooks/ralph-loop/continuation-prompt-builder.ts +++ b/src/hooks/ralph-loop/continuation-prompt-builder.ts @@ -25,6 +25,8 @@ You already emitted {{INITIAL_PROMISE}}. This does NOT finish REQUIRED NOW: - Call Oracle using task(subagent_type="oracle", load_skills=[], run_in_background=false, ...) - Ask Oracle to verify whether the original task is actually complete +- Include the original task in the Oracle request +- Explicitly tell Oracle to review skeptically and critically, and to look for reasons the task may still be incomplete or wrong - The system will inspect the Oracle session directly for the verification result - If Oracle does not verify, continue fixing the task and do not consider it complete @@ -40,6 +42,7 @@ REQUIRED NOW: - Oracle does not lie. Treat the verification result as ground truth - Do not claim completion early or argue with the failed verification - After fixing the remaining issues, request Oracle review again using task(subagent_type="oracle", load_skills=[], run_in_background=false, ...) +- Include the original task in the Oracle request and tell Oracle to review skeptically and critically - Only when the work is ready for review again, output: {{PROMISE}} Original task: diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 31dc7295c..00878ca91 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -25,7 +25,7 @@ function collectAssistantText(message: OpenCodeSessionMessage): string { let text = "" for (const part of message.parts) { - if (part.type !== "text") { + if (part.type !== "text" && part.type !== "tool_result") { continue } text += `${text ? "\n" : ""}${part.text ?? ""}` diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 73c6260be..0093e890a 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -87,7 +87,7 @@ export function createRalphLoopEventHandler( return } - const completionSessionID = verificationSessionID ?? (state.verification_pending ? undefined : sessionID) + const completionSessionID = verificationSessionID ?? sessionID const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined const completionViaTranscript = completionSessionID ? detectCompletionInTranscript( @@ -107,7 +107,13 @@ export function createRalphLoopEventHandler( sinceMessageIndex: undefined, }) : state.verification_pending - ? false + ? await detectCompletionInSessionMessages(ctx, { + sessionID, + promise: state.completion_promise, + apiTimeoutMs: options.apiTimeoutMs, + directory: options.directory, + sinceMessageIndex: state.message_count_at_start, + }) : await detectCompletionInSessionMessages(ctx, { sessionID, promise: state.completion_promise, diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index abed0ad76..8366c56d6 100644 --- a/src/hooks/ralph-loop/ulw-loop-verification.test.ts +++ b/src/hooks/ralph-loop/ulw-loop-verification.test.ts @@ -366,7 +366,7 @@ describe("ulw-loop verification", () => { expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP COMPLETE!")).toBe(false) }) - test("#given parent session emits VERIFIED #when oracle session is not tracked #then ulw loop continues instead of completing", async () => { + test("#given parent session emits VERIFIED #when oracle session is not tracked #then ulw loop completes from parent session evidence", async () => { const hook = createRalphLoopHook(createMockPluginInput(), { getTranscriptPath: (sessionID) => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, }) @@ -379,17 +379,13 @@ describe("ulw-loop verification", () => { await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) writeFileSync( parentTranscriptPath, - `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done DONE" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `bad parent leak ${ULTRAWORK_VERIFICATION_PROMISE}` } })}\n`, + `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done DONE" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `verified ${ULTRAWORK_VERIFICATION_PROMISE}` } })}\n`, ) await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) - expect(hook.getState()).not.toBeNull() - expect(hook.getState()?.iteration).toBe(2) - expect(hook.getState()?.completion_promise).toBe("DONE") - expect(hook.getState()?.verification_pending).toBeUndefined() - expect(promptCalls).toHaveLength(2) - expect(promptCalls[1]?.text).toContain("Verification failed") + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP COMPLETE!")).toBe(true) }) test("#given oracle verification fails #when loop restarts #then old oracle session is aborted", async () => { diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index 5829ea867..30244ce9a 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -10,7 +10,8 @@ Incomplete tasks remain in your todo list. Continue working on the next pending - Proceed without asking for permission - Mark each task complete when finished -- Do not stop until all tasks are done` +- Do not stop until all tasks are done +- If you believe all work is already complete, the system is questioning your completion claim. Critically re-examine each todo item from a skeptical perspective, verify the work was actually done correctly, and update the todo list accordingly.` export const COUNTDOWN_SECONDS = 2 export const TOAST_DURATION_MS = 900 diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index a3eb71bf7..716c1a2e8 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -17,7 +17,6 @@ export function createTodoContinuationHandler(args: { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean }): (input: { event: { type: string; properties?: unknown } }) => Promise { const { ctx, @@ -25,7 +24,6 @@ export function createTodoContinuationHandler(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = args return async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { @@ -58,7 +56,6 @@ export function createTodoContinuationHandler(args: { backgroundManager, skipAgents, isContinuationStopped, - shouldSkipContinuation, }) return } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index b8824e302..a4d7ea83a 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -30,7 +30,6 @@ export async function handleSessionIdle(args: { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean }): Promise { const { ctx, @@ -39,7 +38,6 @@ export async function handleSessionIdle(args: { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = args log(`[${HOOK_NAME}] session.idle`, { sessionID }) @@ -174,11 +172,6 @@ export async function handleSessionIdle(args: { return } - if (shouldSkipContinuation?.(sessionID)) { - log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID }) - return - } - const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos) if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return diff --git a/src/hooks/todo-continuation-enforcer/index.ts b/src/hooks/todo-continuation-enforcer/index.ts index edeba85b5..5fcda2495 100644 --- a/src/hooks/todo-continuation-enforcer/index.ts +++ b/src/hooks/todo-continuation-enforcer/index.ts @@ -17,7 +17,6 @@ export function createTodoContinuationEnforcer( backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped, - shouldSkipContinuation, } = options const sessionStateStore = createSessionStateStore() @@ -43,7 +42,6 @@ export function createTodoContinuationEnforcer( backgroundManager, skipAgents, isContinuationStopped, - shouldSkipContinuation, }) const cancelAllCountdowns = (): void => { diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index e22f7c629..508cef6a4 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1706,27 +1706,6 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) - test("should not inject when shouldSkipContinuation returns true", async () => { - // given - session already handled by another continuation hook - const sessionID = "main-skip-other-continuation" - setMainSession(sessionID) - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - shouldSkipContinuation: (id) => id === sessionID, - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await fakeTimers.advanceBy(3000) - - // then - no countdown toast or continuation injection - expect(toastCalls).toHaveLength(0) - expect(promptCalls).toHaveLength(0) - }) - test("should not inject when isContinuationStopped becomes true during countdown", async () => { // given - session where continuation is not stopped at idle time but stops during countdown const sessionID = "main-race-condition" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 0f40cec28..dbc79d4d7 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean - shouldSkipContinuation?: (sessionID: string) => boolean } export interface TodoContinuationEnforcer { diff --git a/src/openclaw/__tests__/config.test.ts b/src/openclaw/__tests__/config.test.ts new file mode 100644 index 000000000..62972f45a --- /dev/null +++ b/src/openclaw/__tests__/config.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import { resolveGateway, validateGatewayUrl, normalizeReplyListenerConfig } from "../config" +import type { OpenClawConfig } from "../types" +import { OpenClawConfigSchema } from "../../config/schema/openclaw" + +describe("OpenClaw Config", () => { + test("resolveGateway resolves HTTP gateway", () => { + const config: OpenClawConfig = { + enabled: true, + gateways: { + discord: { + type: "http", + url: "https://discord.com/api/webhooks/123", + }, + }, + hooks: { + "session-start": { + enabled: true, + gateway: "discord", + instruction: "Started session {{sessionId}}", + }, + }, + } as any + + const resolved = resolveGateway(config, "session-start") + expect(resolved).not.toBeNull() + expect(resolved?.gatewayName).toBe("discord") + expect(resolved?.gateway.url).toBe("https://discord.com/api/webhooks/123") + expect(resolved?.instruction).toBe("Started session {{sessionId}}") + }) + + test("resolveGateway returns null for disabled config", () => { + const config: OpenClawConfig = { + enabled: false, + gateways: {}, + hooks: {}, + } as any + expect(resolveGateway(config, "session-start")).toBeNull() + }) + + test("resolveGateway returns null for unknown hook", () => { + const config: OpenClawConfig = { + enabled: true, + gateways: {}, + hooks: {}, + } as any + expect(resolveGateway(config, "unknown")).toBeNull() + }) + + test("resolveGateway returns null for disabled hook", () => { + const config: OpenClawConfig = { + enabled: true, + gateways: { g: { type: "http", url: "https://example.com" } }, + hooks: { + event: { enabled: false, gateway: "g", instruction: "i" }, + }, + } as any + expect(resolveGateway(config, "event")).toBeNull() + }) + + test("validateGatewayUrl allows HTTPS", () => { + expect(validateGatewayUrl("https://example.com")).toBe(true) + }) + + test("validateGatewayUrl rejects HTTP remote", () => { + expect(validateGatewayUrl("http://example.com")).toBe(false) + }) + + test("validateGatewayUrl allows HTTP localhost", () => { + expect(validateGatewayUrl("http://localhost:3000")).toBe(true) + expect(validateGatewayUrl("http://127.0.0.1:3000")).toBe(true) + }) + + test("normalizeReplyListenerConfig normalizes nested reply listener fields", () => { + const config = normalizeReplyListenerConfig({ + enabled: true, + gateways: {}, + hooks: {}, + replyListener: { + discordBotToken: "discord-token", + discordChannelId: "channel-id", + authorizedDiscordUserIds: ["user-1", "", "user-2"], + pollIntervalMs: 100, + rateLimitPerMinute: 0, + maxMessageLength: 9000, + includePrefix: false, + }, + } as OpenClawConfig) + + expect(config.replyListener).toEqual({ + discordBotToken: "discord-token", + discordChannelId: "channel-id", + authorizedDiscordUserIds: ["user-1", "user-2"], + pollIntervalMs: 500, + rateLimitPerMinute: 1, + maxMessageLength: 4000, + includePrefix: false, + }) + }) + + test("gateway timeout remains optional so env fallback can apply", () => { + const parsed = OpenClawConfigSchema.parse({ + enabled: true, + gateways: { + command: { + type: "command", + command: "echo hi", + }, + }, + hooks: {}, + }) + + expect(parsed.gateways.command.timeout).toBeUndefined() + }) +}) diff --git a/src/openclaw/__tests__/dispatcher.test.ts b/src/openclaw/__tests__/dispatcher.test.ts new file mode 100644 index 000000000..43485ae1c --- /dev/null +++ b/src/openclaw/__tests__/dispatcher.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, mock, spyOn } from "bun:test" +import { + interpolateInstruction, + resolveCommandTimeoutMs, + shellEscapeArg, + wakeGateway, + wakeCommandGateway, +} from "../dispatcher" + +describe("OpenClaw Dispatcher", () => { + test("interpolateInstruction replaces variables", () => { + const template = "Hello {{name}}, welcome to {{place}}!" + const variables = { name: "World", place: "Bun" } + expect(interpolateInstruction(template, variables)).toBe( + "Hello World, welcome to Bun!", + ) + }) + + test("interpolateInstruction handles missing variables", () => { + const template = "Hello {{name}}!" + const variables = {} + expect(interpolateInstruction(template, variables)).toBe("Hello !") + }) + + test("shellEscapeArg escapes single quotes", () => { + expect(shellEscapeArg("foo'bar")).toBe("'foo'\\''bar'") + expect(shellEscapeArg("simple")).toBe("'simple'") + }) + + test("wakeGateway sends POST request", async () => { + const fetchSpy = spyOn(global, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ) + try { + const result = await wakeGateway( + "test", + { url: "https://example.com", method: "POST", timeout: 1000, type: "http" }, + { foo: "bar" }, + ) + + expect(result.success).toBe(true) + expect(fetchSpy).toHaveBeenCalled() + const call = fetchSpy.mock.calls.find(c => c[0] === "https://example.com") + expect(call[0]).toBe("https://example.com") + expect(call[1]?.method).toBe("POST") + expect(call[1]?.body).toBe('{"foo":"bar"}') + } finally { + fetchSpy.mockRestore() + } + }) + + test("wakeGateway fails on invalid URL", async () => { + const result = await wakeGateway("test", { url: "http://example.com", method: "POST", timeout: 1000, type: "http" }, {}) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid URL") + }) + + test("resolveCommandTimeoutMs reads OMO env fallback", () => { + const original = process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS + process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS = "4321" + + try { + // Call without explicit envTimeoutRaw so the function reads from process.env itself + expect(resolveCommandTimeoutMs(undefined)).toBe(4321) + } finally { + if (original === undefined) delete process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS + else process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS = original + } + }) +}) diff --git a/src/openclaw/__tests__/tmux.test.ts b/src/openclaw/__tests__/tmux.test.ts new file mode 100644 index 000000000..790a1bbe0 --- /dev/null +++ b/src/openclaw/__tests__/tmux.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { analyzePaneContent } from "../tmux" + +describe("openclaw tmux helpers", () => { + test("analyzePaneContent recognizes the opencode welcome prompt", () => { + const content = "opencode\nAsk anything...\nRun /help" + expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1) + }) + + test("analyzePaneContent returns zero confidence for empty content", () => { + expect(analyzePaneContent(null).confidence).toBe(0) + }) +}) diff --git a/src/openclaw/config.ts b/src/openclaw/config.ts new file mode 100644 index 000000000..946b11e69 --- /dev/null +++ b/src/openclaw/config.ts @@ -0,0 +1,120 @@ +import type { + OpenClawConfig, + OpenClawGateway, + OpenClawReplyListenerConfig, +} from "./types" + +const DEFAULT_REPLY_POLL_INTERVAL_MS = 3000 +const MIN_REPLY_POLL_INTERVAL_MS = 500 +const MAX_REPLY_POLL_INTERVAL_MS = 60000 +const DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE = 10 +const MIN_REPLY_RATE_LIMIT_PER_MINUTE = 1 +const DEFAULT_REPLY_MAX_MESSAGE_LENGTH = 500 +const MIN_REPLY_MAX_MESSAGE_LENGTH = 1 +const MAX_REPLY_MAX_MESSAGE_LENGTH = 4000 + +function normalizeInteger( + value: unknown, + fallback: number, + min: number, + max?: number, +): number { + const numeric = + typeof value === "number" + ? Math.trunc(value) + : typeof value === "string" && value.trim() + ? Number.parseInt(value, 10) + : Number.NaN + + if (!Number.isFinite(numeric)) return fallback + if (numeric < min) return min + if (max !== undefined && numeric > max) return max + return numeric +} + +export function normalizeReplyListenerConfig(config: OpenClawConfig): OpenClawConfig { + const replyListener = config.replyListener + if (!replyListener) return config + + const normalizedReplyListener: OpenClawReplyListenerConfig = { + ...replyListener, + discordBotToken: replyListener.discordBotToken, + discordChannelId: replyListener.discordChannelId, + telegramBotToken: replyListener.telegramBotToken, + telegramChatId: replyListener.telegramChatId, + pollIntervalMs: normalizeInteger( + replyListener.pollIntervalMs, + DEFAULT_REPLY_POLL_INTERVAL_MS, + MIN_REPLY_POLL_INTERVAL_MS, + MAX_REPLY_POLL_INTERVAL_MS, + ), + rateLimitPerMinute: normalizeInteger( + replyListener.rateLimitPerMinute, + DEFAULT_REPLY_RATE_LIMIT_PER_MINUTE, + MIN_REPLY_RATE_LIMIT_PER_MINUTE, + ), + maxMessageLength: normalizeInteger( + replyListener.maxMessageLength, + DEFAULT_REPLY_MAX_MESSAGE_LENGTH, + MIN_REPLY_MAX_MESSAGE_LENGTH, + MAX_REPLY_MAX_MESSAGE_LENGTH, + ), + includePrefix: replyListener.includePrefix !== false, + authorizedDiscordUserIds: Array.isArray(replyListener.authorizedDiscordUserIds) + ? replyListener.authorizedDiscordUserIds.filter( + (id) => typeof id === "string" && id.trim() !== "", + ) + : [], + } + + return { + ...config, + replyListener: normalizedReplyListener, + } +} + +export function resolveGateway( + config: OpenClawConfig, + event: string, +): { gatewayName: string; gateway: OpenClawGateway; instruction: string } | null { + if (!config.enabled) return null + + const mapping = config.hooks[event] + if (!mapping || !mapping.enabled) { + return null + } + + const gateway = config.gateways[mapping.gateway] + if (!gateway) { + return null + } + + // Validate based on gateway type + if (gateway.type === "command") { + if (!gateway.command) return null + } else { + // HTTP gateway + if (!gateway.url) return null + } + + return { gatewayName: mapping.gateway, gateway, instruction: mapping.instruction } +} + +export function validateGatewayUrl(url: string): boolean { + try { + const parsed = new URL(url) + if (parsed.protocol === "https:") return true + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1" || + parsed.hostname === "[::1]") + ) { + return true + } + return false + } catch { + return false + } +} diff --git a/src/openclaw/daemon.ts b/src/openclaw/daemon.ts new file mode 100644 index 000000000..b075903ab --- /dev/null +++ b/src/openclaw/daemon.ts @@ -0,0 +1,9 @@ +import { pollLoop, logReplyListenerMessage } from "./reply-listener" + +pollLoop().catch((err) => { + logReplyListenerMessage( + `FATAL: reply listener daemon crashed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`, + ) + console.error(err) + process.exit(1) +}) diff --git a/src/openclaw/dispatcher.ts b/src/openclaw/dispatcher.ts new file mode 100644 index 000000000..a965d7b47 --- /dev/null +++ b/src/openclaw/dispatcher.ts @@ -0,0 +1,180 @@ +import { spawn } from "bun" +import type { OpenClawGateway } from "./types" + +const DEFAULT_HTTP_TIMEOUT_MS = 10_000 +const DEFAULT_COMMAND_TIMEOUT_MS = 5_000 +const MIN_COMMAND_TIMEOUT_MS = 100 +const MAX_COMMAND_TIMEOUT_MS = 300_000 +const SHELL_METACHAR_RE = /[|&;><`$()]/ + +export function validateGatewayUrl(url: string): boolean { + try { + const parsed = new URL(url) + if (parsed.protocol === "https:") return true + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1" || + parsed.hostname === "[::1]") + ) { + return true + } + return false + } catch { + return false + } +} + +export function interpolateInstruction( + template: string, + variables: Record, +): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => { + return variables[key] ?? "" + }) +} + +export function shellEscapeArg(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'" +} + +export function resolveCommandTimeoutMs( + gatewayTimeout?: number, + envTimeoutRaw = + process.env.OMO_OPENCLAW_COMMAND_TIMEOUT_MS + ?? process.env.OMX_OPENCLAW_COMMAND_TIMEOUT_MS, +): number { + const parseFinite = (value: unknown): number | undefined => { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + return value + } + const parseEnv = (value?: string): number | undefined => { + if (!value) return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + + const rawTimeout = + parseFinite(gatewayTimeout) ?? + parseEnv(envTimeoutRaw) ?? + DEFAULT_COMMAND_TIMEOUT_MS + + return Math.min( + MAX_COMMAND_TIMEOUT_MS, + Math.max(MIN_COMMAND_TIMEOUT_MS, Math.trunc(rawTimeout)), + ) +} + +export async function wakeGateway( + gatewayName: string, + gatewayConfig: OpenClawGateway, + payload: unknown, +): Promise<{ gateway: string; success: boolean; error?: string; statusCode?: number }> { + if (!gatewayConfig.url || !validateGatewayUrl(gatewayConfig.url)) { + return { + gateway: gatewayName, + success: false, + error: "Invalid URL (HTTPS required)", + } + } + + try { + const headers = { + "Content-Type": "application/json", + ...gatewayConfig.headers, + } + + const timeout = gatewayConfig.timeout ?? DEFAULT_HTTP_TIMEOUT_MS + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + const response = await fetch(gatewayConfig.url, { + method: gatewayConfig.method || "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }).finally(() => { + clearTimeout(timeoutId) + }) + + if (!response.ok) { + return { + gateway: gatewayName, + success: false, + error: `HTTP ${response.status}`, + statusCode: response.status, + } + } + + return { gateway: gatewayName, success: true, statusCode: response.status } + } catch (error) { + return { + gateway: gatewayName, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} + +export async function wakeCommandGateway( + gatewayName: string, + gatewayConfig: OpenClawGateway, + variables: Record, +): Promise<{ gateway: string; success: boolean; error?: string }> { + if (!gatewayConfig.command) { + return { + gateway: gatewayName, + success: false, + error: "No command configured", + } + } + + try { + const timeout = resolveCommandTimeoutMs(gatewayConfig.timeout) + + // Interpolate variables with shell escaping + const interpolated = gatewayConfig.command.replace(/\{\{(\w+)\}\}/g, (_match, key) => { + const value = variables[key] + if (value === undefined) return _match + return shellEscapeArg(value) + }) + + // Always use sh -c to handle the shell command string correctly + const proc = spawn(["sh", "-c", interpolated], { + env: { ...process.env }, + stdout: "ignore", + stderr: "ignore", + }) + + // Handle timeout manually + let timeoutId: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + proc.kill() + reject(new Error("Command timed out")) + }, timeout) + }) + + try { + await Promise.race([proc.exited, timeoutPromise]) + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } + + if (proc.exitCode !== 0) { + throw new Error(`Command exited with code ${proc.exitCode}`) + } + + return { gateway: gatewayName, success: true } + } catch (error) { + return { + gateway: gatewayName, + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } +} diff --git a/src/openclaw/index.ts b/src/openclaw/index.ts new file mode 100644 index 000000000..5cbbe3362 --- /dev/null +++ b/src/openclaw/index.ts @@ -0,0 +1,141 @@ +import { basename } from "path" +import { resolveGateway } from "./config" +import { + wakeGateway, + wakeCommandGateway, + interpolateInstruction, +} from "./dispatcher" +import { getCurrentTmuxSession, captureTmuxPane } from "./tmux" +import { startReplyListener, stopReplyListener } from "./reply-listener" +import type { OpenClawConfig, OpenClawContext, OpenClawPayload, WakeResult } from "./types" + +const DEBUG = + process.env.OMO_OPENCLAW_DEBUG === "1" + || process.env.OMX_OPENCLAW_DEBUG === "1" + +function buildWhitelistedContext(context: OpenClawContext): OpenClawContext { + const result: OpenClawContext = {} + if (context.sessionId !== undefined) result.sessionId = context.sessionId + if (context.projectPath !== undefined) result.projectPath = context.projectPath + if (context.tmuxSession !== undefined) result.tmuxSession = context.tmuxSession + if (context.prompt !== undefined) result.prompt = context.prompt + if (context.contextSummary !== undefined) result.contextSummary = context.contextSummary + if (context.reasoning !== undefined) result.reasoning = context.reasoning + if (context.question !== undefined) result.question = context.question + if (context.tmuxTail !== undefined) result.tmuxTail = context.tmuxTail + if (context.replyChannel !== undefined) result.replyChannel = context.replyChannel + if (context.replyTarget !== undefined) result.replyTarget = context.replyTarget + if (context.replyThread !== undefined) result.replyThread = context.replyThread + return result +} + +export async function wakeOpenClaw( + config: OpenClawConfig, + event: string, + context: OpenClawContext, +): Promise { + try { + if (!config.enabled) return null + + const resolved = resolveGateway(config, event) + if (!resolved) return null + + const { gatewayName, gateway, instruction } = resolved + + const now = new Date().toISOString() + + const replyChannel = context.replyChannel ?? process.env.OPENCLAW_REPLY_CHANNEL + const replyTarget = context.replyTarget ?? process.env.OPENCLAW_REPLY_TARGET + const replyThread = context.replyThread ?? process.env.OPENCLAW_REPLY_THREAD + + const enrichedContext: OpenClawContext = { + ...context, + ...(replyChannel !== undefined && { replyChannel }), + ...(replyTarget !== undefined && { replyTarget }), + ...(replyThread !== undefined && { replyThread }), + } + + const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined + + let tmuxTail = enrichedContext.tmuxTail + if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) { + try { + const paneId = process.env.TMUX_PANE + if (paneId) { + tmuxTail = (await captureTmuxPane(paneId, 15)) ?? undefined + } + } catch (error) { + if (DEBUG) { + console.error( + "[openclaw] failed to capture tmux tail:", + error instanceof Error ? error.message : error, + ) + } + } + } + + const variables: Record = { + sessionId: enrichedContext.sessionId, + projectPath: enrichedContext.projectPath, + projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined, + tmuxSession, + prompt: enrichedContext.prompt, + contextSummary: enrichedContext.contextSummary, + reasoning: enrichedContext.reasoning, + question: enrichedContext.question, + tmuxTail, + event, + timestamp: now, + replyChannel, + replyTarget, + replyThread, + } + + const interpolatedInstruction = interpolateInstruction(instruction, variables) + variables.instruction = interpolatedInstruction + + let result: WakeResult + + if (gateway.type === "command") { + result = await wakeCommandGateway(gatewayName, gateway, variables) + } else { + const payload: OpenClawPayload = { + event, + instruction: interpolatedInstruction, + text: interpolatedInstruction, + timestamp: now, + sessionId: enrichedContext.sessionId, + projectPath: enrichedContext.projectPath, + projectName: enrichedContext.projectPath ? basename(enrichedContext.projectPath) : undefined, + tmuxSession, + tmuxTail, + ...(replyChannel !== undefined && { channel: replyChannel }), + ...(replyTarget !== undefined && { to: replyTarget }), + ...(replyThread !== undefined && { threadId: replyThread }), + context: buildWhitelistedContext(enrichedContext), + } + + result = await wakeGateway(gatewayName, gateway, payload) + } + + if (DEBUG) { + console.error(`[openclaw] wake ${event} -> ${gatewayName}: ${result.success ? "ok" : result.error}`) + } + + return result + } catch (error) { + if (DEBUG) { + console.error(`[openclaw] wakeOpenClaw error:`, error instanceof Error ? error.message : error) + } + return null + } +} + +export async function initializeOpenClaw(config: OpenClawConfig): Promise { + const replyListener = config.replyListener + if (config.enabled && (replyListener?.discordBotToken || replyListener?.telegramBotToken)) { + await startReplyListener(config) + } +} + +export { startReplyListener, stopReplyListener } diff --git a/src/openclaw/reply-listener.ts b/src/openclaw/reply-listener.ts new file mode 100644 index 000000000..f6c8e015b --- /dev/null +++ b/src/openclaw/reply-listener.ts @@ -0,0 +1,717 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + chmodSync, + statSync, + appendFileSync, + renameSync, +} from "fs" +import { join, dirname } from "path" +import { homedir } from "os" +import { spawn } from "bun" // Use bun spawn +import { captureTmuxPane, analyzePaneContent, sendToPane, isTmuxAvailable } from "./tmux" +import { lookupByMessageId, removeMessagesByPane, pruneStale } from "./session-registry" +import type { OpenClawConfig } from "./types" +import { normalizeReplyListenerConfig } from "./config" + +const SECURE_FILE_MODE = 0o600 +const MAX_LOG_SIZE_BYTES = 1 * 1024 * 1024 +const DAEMON_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USERPROFILE", + "USER", + "USERNAME", + "LOGNAME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMUX", + "TMUX_PANE", + "TMPDIR", + "TMP", + "TEMP", + "XDG_RUNTIME_DIR", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "SHELL", + "NODE_ENV", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + "SystemRoot", + "SYSTEMROOT", + "windir", + "COMSPEC", +] + +const DEFAULT_STATE_DIR = join(homedir(), ".omx", "state") +const PID_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener.pid") +const STATE_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener-state.json") +const CONFIG_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener-config.json") +const LOG_FILE_PATH = join(DEFAULT_STATE_DIR, "reply-listener.log") + +export const DAEMON_IDENTITY_MARKER = "--openclaw-reply-listener-daemon" + +function createMinimalDaemonEnv(): Record { + const env: Record = {} + for (const key of DAEMON_ENV_ALLOWLIST) { + if (process.env[key] !== undefined) { + env[key] = process.env[key] as string + } + } + return env +} + +function ensureStateDir(): void { + if (!existsSync(DEFAULT_STATE_DIR)) { + mkdirSync(DEFAULT_STATE_DIR, { recursive: true, mode: 0o700 }) + } +} + +function writeSecureFile(filePath: string, content: string): void { + ensureStateDir() + writeFileSync(filePath, content, { mode: SECURE_FILE_MODE }) + try { + chmodSync(filePath, SECURE_FILE_MODE) + } catch { + // Ignore + } +} + +function rotateLogIfNeeded(logPath: string): void { + try { + if (!existsSync(logPath)) return + const stats = statSync(logPath) + if (stats.size > MAX_LOG_SIZE_BYTES) { + const backupPath = `${logPath}.old` + if (existsSync(backupPath)) { + unlinkSync(backupPath) + } + renameSync(logPath, backupPath) + } + } catch { + // Ignore + } +} + +function log(message: string): void { + try { + ensureStateDir() + rotateLogIfNeeded(LOG_FILE_PATH) + const timestamp = new Date().toISOString() + const logLine = `[${timestamp}] ${message}\n` + appendFileSync(LOG_FILE_PATH, logLine, { mode: SECURE_FILE_MODE }) + } catch { + // Ignore + } +} + +export function logReplyListenerMessage(message: string): void { + log(message) +} + +interface DaemonState { + isRunning: boolean + pid: number | null + startedAt: string + lastPollAt: string | null + telegramLastUpdateId: number | null + discordLastMessageId: string | null + messagesInjected: number + errors: number + lastError?: string +} + +function readDaemonState(): DaemonState | null { + try { + if (!existsSync(STATE_FILE_PATH)) return null + const content = readFileSync(STATE_FILE_PATH, "utf-8") + return JSON.parse(content) + } catch { + return null + } +} + +function writeDaemonState(state: DaemonState): void { + writeSecureFile(STATE_FILE_PATH, JSON.stringify(state, null, 2)) +} + +function readDaemonConfig(): OpenClawConfig | null { + try { + if (!existsSync(CONFIG_FILE_PATH)) return null + const content = readFileSync(CONFIG_FILE_PATH, "utf-8") + return JSON.parse(content) + } catch { + return null + } +} + +function writeDaemonConfig(config: OpenClawConfig): void { + writeSecureFile(CONFIG_FILE_PATH, JSON.stringify(config, null, 2)) +} + +function readPidFile(): number | null { + try { + if (!existsSync(PID_FILE_PATH)) return null + const content = readFileSync(PID_FILE_PATH, "utf-8") + const pid = parseInt(content.trim(), 10) + if (Number.isNaN(pid)) return null + return pid + } catch { + return null + } +} + +function writePidFile(pid: number): void { + writeSecureFile(PID_FILE_PATH, String(pid)) +} + +function removePidFile(): void { + if (existsSync(PID_FILE_PATH)) { + unlinkSync(PID_FILE_PATH) + } +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export async function isReplyListenerProcess(pid: number): Promise { + try { + if (process.platform === "linux") { + const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8") + return cmdline.includes(DAEMON_IDENTITY_MARKER) + } + // macOS + const proc = spawn(["ps", "-p", String(pid), "-o", "args="], { + stdout: "pipe", + stderr: "ignore", + }) + const stdout = await new Response(proc.stdout).text() + if (proc.exitCode !== 0) return false + return stdout.includes(DAEMON_IDENTITY_MARKER) + } catch { + return false + } +} + +export async function isDaemonRunning(): Promise { + const pid = readPidFile() + if (pid === null) return false + if (!isProcessRunning(pid)) { + removePidFile() + return false + } + if (!(await isReplyListenerProcess(pid))) { + removePidFile() + return false + } + return true +} + +// Input Sanitization +export function sanitizeReplyInput(text: string): string { + return text + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "") + .replace(/\r?\n/g, " ") + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$\(/g, "\\$(") + .replace(/\$\{/g, "\\${") + .trim() +} + +class RateLimiter { + maxPerMinute: number + timestamps: number[] = [] + windowMs = 60 * 1000 + + constructor(maxPerMinute: number) { + this.maxPerMinute = maxPerMinute + } + + canProceed(): boolean { + const now = Date.now() + this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs) + if (this.timestamps.length >= this.maxPerMinute) return false + this.timestamps.push(now) + return true + } +} + +async function injectReply( + paneId: string, + text: string, + platform: string, + config: OpenClawConfig, +): Promise { + const replyListener = config.replyListener + const content = await captureTmuxPane(paneId, 15) + const analysis = analyzePaneContent(content) + + if (analysis.confidence < 0.3) { // Lower threshold for simple check + log( + `WARN: Pane ${paneId} does not appear to be running OpenCode CLI (confidence: ${analysis.confidence}). Skipping injection, removing stale mapping.`, + ) + removeMessagesByPane(paneId) + return false + } + + const prefix = replyListener?.includePrefix === false ? "" : `[reply:${platform}] ` + const sanitized = sanitizeReplyInput(prefix + text) + const truncated = sanitized.slice(0, replyListener?.maxMessageLength ?? 500) + const success = await sendToPane(paneId, truncated, true) + + if (success) { + log( + `Injected reply from ${platform} into pane ${paneId}: "${truncated.slice(0, 50)}${truncated.length > 50 ? "..." : ""}"`, + ) + } else { + log(`ERROR: Failed to inject reply into pane ${paneId}`) + } + return success +} + +let discordBackoffUntil = 0 + +async function pollDiscord( + config: OpenClawConfig, + state: DaemonState, + rateLimiter: RateLimiter, +): Promise { + const replyListener = config.replyListener + if (!replyListener?.discordBotToken || !replyListener.discordChannelId) return + if ( + !replyListener.authorizedDiscordUserIds + || replyListener.authorizedDiscordUserIds.length === 0 + ) { + return + } + if (Date.now() < discordBackoffUntil) return + + try { + const after = state.discordLastMessageId + ? `?after=${state.discordLastMessageId}&limit=10` + : "?limit=10" + const url = `https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages${after}` + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10000) + + const response = await fetch(url, { + method: "GET", + headers: { Authorization: `Bot ${replyListener.discordBotToken}` }, + signal: controller.signal, + }) + + clearTimeout(timeout) + + const remaining = response.headers.get("x-ratelimit-remaining") + const reset = response.headers.get("x-ratelimit-reset") + + if (remaining !== null && parseInt(remaining, 10) < 2) { + const parsed = reset ? parseFloat(reset) : Number.NaN + const resetTime = Number.isFinite(parsed) ? parsed * 1000 : Date.now() + 10000 + discordBackoffUntil = resetTime + log( + `WARN: Discord rate limit low (remaining: ${remaining}), backing off until ${new Date(resetTime).toISOString()}`, + ) + } + + if (!response.ok) { + log(`Discord API error: HTTP ${response.status}`) + return + } + + const messages = await response.json() + if (!Array.isArray(messages) || messages.length === 0) return + + const sorted = [...messages].reverse() + + for (const msg of sorted) { + if (!msg.message_reference?.message_id) { + state.discordLastMessageId = msg.id + writeDaemonState(state) + continue + } + + if (!replyListener.authorizedDiscordUserIds.includes(msg.author.id)) { + state.discordLastMessageId = msg.id + writeDaemonState(state) + continue + } + + const mapping = lookupByMessageId("discord-bot", msg.message_reference.message_id) + if (!mapping) { + state.discordLastMessageId = msg.id + writeDaemonState(state) + continue + } + + if (!rateLimiter.canProceed()) { + log(`WARN: Rate limit exceeded, dropping Discord message ${msg.id}`) + state.discordLastMessageId = msg.id + writeDaemonState(state) + state.errors++ + continue + } + + state.discordLastMessageId = msg.id + writeDaemonState(state) + + const success = await injectReply(mapping.tmuxPaneId, msg.content, "discord", config) + + if (success) { + state.messagesInjected++ + // Add reaction + try { + await fetch( + `https://discord.com/api/v10/channels/${replyListener.discordChannelId}/messages/${msg.id}/reactions/%E2%9C%85/@me`, + { + method: "PUT", + headers: { Authorization: `Bot ${replyListener.discordBotToken}` }, + }, + ) + } catch { + // Ignore + } + } else { + state.errors++ + } + } + } catch (error) { + state.errors++ + state.lastError = error instanceof Error ? error.message : String(error) + log(`Discord polling error: ${state.lastError}`) + } +} + +async function pollTelegram( + config: OpenClawConfig, + state: DaemonState, + rateLimiter: RateLimiter, +): Promise { + const replyListener = config.replyListener + if (!replyListener?.telegramBotToken || !replyListener.telegramChatId) return + + try { + const offset = state.telegramLastUpdateId ? state.telegramLastUpdateId + 1 : 0 + const url = `https://api.telegram.org/bot${replyListener.telegramBotToken}/getUpdates?offset=${offset}&timeout=0` + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10000) + + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + }) + + clearTimeout(timeout) + + if (!response.ok) { + log(`Telegram API error: HTTP ${response.status}`) + return + } + + const body = await response.json() as any + const updates = body.result || [] + + for (const update of updates) { + const msg = update.message + if (!msg) { + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + continue + } + + if (!msg.reply_to_message?.message_id) { + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + continue + } + + if (String(msg.chat.id) !== replyListener.telegramChatId) { + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + continue + } + + const mapping = lookupByMessageId("telegram", String(msg.reply_to_message.message_id)) + if (!mapping) { + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + continue + } + + const text = msg.text || "" + if (!text) { + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + continue + } + + if (!rateLimiter.canProceed()) { + log(`WARN: Rate limit exceeded, dropping Telegram message ${msg.message_id}`) + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + state.errors++ + continue + } + + state.telegramLastUpdateId = update.update_id + writeDaemonState(state) + + const success = await injectReply(mapping.tmuxPaneId, text, "telegram", config) + + if (success) { + state.messagesInjected++ + try { + await fetch( + `https://api.telegram.org/bot${replyListener.telegramBotToken}/sendMessage`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: replyListener.telegramChatId, + text: "Injected into Codex CLI session.", + reply_to_message_id: msg.message_id, + }), + }, + ) + } catch { + // Ignore + } + } else { + state.errors++ + } + } + } catch (error) { + state.errors++ + state.lastError = error instanceof Error ? error.message : String(error) + log(`Telegram polling error: ${state.lastError}`) + } +} + +const PRUNE_INTERVAL_MS = 60 * 60 * 1000 + +export async function pollLoop(): Promise { + log("Reply listener daemon starting poll loop") + const config = readDaemonConfig() + if (!config) { + log("ERROR: No daemon config found, exiting") + process.exit(1) + } + + const state = readDaemonState() || { + isRunning: true, + pid: process.pid, + startedAt: new Date().toISOString(), + lastPollAt: null, + telegramLastUpdateId: null, + discordLastMessageId: null, + messagesInjected: 0, + errors: 0, + } + + state.isRunning = true + state.pid = process.pid + + const rateLimiter = new RateLimiter(config.replyListener?.rateLimitPerMinute || 10) + let lastPruneAt = Date.now() + + const shutdown = (): void => { + log("Shutdown signal received") + state.isRunning = false + writeDaemonState(state) + removePidFile() + process.exit(0) + } + + process.on("SIGTERM", shutdown) + process.on("SIGINT", shutdown) + + try { + pruneStale() + log("Pruned stale registry entries") + } catch (e) { + log(`WARN: Failed to prune stale entries: ${e}`) + } + + while (state.isRunning) { + try { + state.lastPollAt = new Date().toISOString() + await pollDiscord(config, state, rateLimiter) + await pollTelegram(config, state, rateLimiter) + + if (Date.now() - lastPruneAt > PRUNE_INTERVAL_MS) { + try { + pruneStale() + lastPruneAt = Date.now() + log("Pruned stale registry entries") + } catch (e) { + log(`WARN: Prune failed: ${e instanceof Error ? e.message : String(e)}`) + } + } + + writeDaemonState(state) + await new Promise((resolve) => + setTimeout(resolve, config.replyListener?.pollIntervalMs || 3000), + ) + } catch (error) { + state.errors++ + state.lastError = error instanceof Error ? error.message : String(error) + log(`Poll error: ${state.lastError}`) + writeDaemonState(state) + await new Promise((resolve) => + setTimeout(resolve, (config.replyListener?.pollIntervalMs || 3000) * 2), + ) + } + } + log("Poll loop ended") +} + +export async function startReplyListener(config: OpenClawConfig): Promise<{ success: boolean; message: string; state?: DaemonState; error?: string }> { + if (await isDaemonRunning()) { + const state = readDaemonState() + return { + success: true, + message: "Reply listener daemon is already running", + state: state || undefined, + } + } + + if (!(await isTmuxAvailable())) { + return { + success: false, + message: "tmux not available - reply injection requires tmux", + } + } + + const normalizedConfig = normalizeReplyListenerConfig(config) + const replyListener = normalizedConfig.replyListener + if (!replyListener?.discordBotToken && !replyListener?.telegramBotToken) { + // Only warn if no platforms enabled, but user might just want outbound + // Actually, instructions say: "Fire-and-forget for outbound, daemon process for inbound" + // So if no inbound config, we shouldn't start daemon. + return { + success: false, + message: "No enabled reply listener platforms configured (missing bot tokens/channels)", + } + } + + writeDaemonConfig(normalizedConfig) + ensureStateDir() + + const currentFile = import.meta.url + const isTs = currentFile.endsWith(".ts") + const daemonScript = isTs + ? join(dirname(new URL(currentFile).pathname), "daemon.ts") + : join(dirname(new URL(currentFile).pathname), "daemon.js") + + try { + const proc = spawn(["bun", "run", daemonScript, DAEMON_IDENTITY_MARKER], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + cwd: process.cwd(), + env: createMinimalDaemonEnv(), + }) + + proc.unref() + const pid = proc.pid + + if (pid) { + writePidFile(pid) + const state: DaemonState = { + isRunning: true, + pid, + startedAt: new Date().toISOString(), + lastPollAt: null, + telegramLastUpdateId: null, + discordLastMessageId: null, + messagesInjected: 0, + errors: 0, + } + writeDaemonState(state) + log(`Reply listener daemon started with PID ${pid}`) + return { + success: true, + message: `Reply listener daemon started with PID ${pid}`, + state, + } + } + + return { + success: false, + message: "Failed to start daemon process", + } + } catch (error) { + return { + success: false, + message: "Failed to start daemon", + error: error instanceof Error ? error.message : String(error), + } + } +} + +export async function stopReplyListener(): Promise<{ success: boolean; message: string; state?: DaemonState; error?: string }> { + const pid = readPidFile() + if (pid === null) { + return { + success: true, + message: "Reply listener daemon is not running", + } + } + + if (!isProcessRunning(pid)) { + removePidFile() + return { + success: true, + message: "Reply listener daemon was not running (cleaned up stale PID file)", + } + } + + if (!(await isReplyListenerProcess(pid))) { + removePidFile() + return { + success: false, + message: `Refusing to kill PID ${pid}: process identity does not match the reply listener daemon (stale or reused PID - removed PID file)`, + } + } + + try { + process.kill(pid, "SIGTERM") + removePidFile() + const state = readDaemonState() + if (state) { + state.isRunning = false + state.pid = null + writeDaemonState(state) + } + log(`Reply listener daemon stopped (PID ${pid})`) + return { + success: true, + message: `Reply listener daemon stopped (PID ${pid})`, + state: state || undefined, + } + } catch (error) { + return { + success: false, + message: "Failed to stop daemon", + error: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/src/openclaw/session-registry.ts b/src/openclaw/session-registry.ts new file mode 100644 index 000000000..4f0b37979 --- /dev/null +++ b/src/openclaw/session-registry.ts @@ -0,0 +1,340 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + openSync, + closeSync, + writeSync, + unlinkSync, + statSync, + constants, +} from "fs" +import { join, dirname } from "path" +import { randomUUID } from "crypto" +import { getOpenCodeStorageDir } from "../shared/data-path" + +const OPENCLAW_STORAGE_DIR = join(getOpenCodeStorageDir(), "openclaw") +const REGISTRY_PATH = join(OPENCLAW_STORAGE_DIR, "reply-session-registry.jsonl") +const REGISTRY_LOCK_PATH = join(OPENCLAW_STORAGE_DIR, "reply-session-registry.lock") +const SECURE_FILE_MODE = 0o600 +const MAX_AGE_MS = 24 * 60 * 60 * 1000 +const LOCK_TIMEOUT_MS = 2000 +const LOCK_WAIT_TIMEOUT_MS = 4000 +const LOCK_RETRY_MS = 20 +const LOCK_STALE_MS = 10000 + +export interface SessionMapping { + sessionId: string + tmuxSession: string + tmuxPaneId: string + projectPath: string + platform: string + messageId: string + channelId?: string + threadId?: string + createdAt: string +} + +function ensureRegistryDir(): void { + const registryDir = dirname(REGISTRY_PATH) + if (!existsSync(registryDir)) { + mkdirSync(registryDir, { recursive: true, mode: 0o700 }) + } +} + +function sleepMs(ms: number): void { + // Use Atomics.wait for synchronous sleep + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +function isPidAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +interface LockSnapshot { + raw: string + pid: number | null + token: string | null +} + +function readLockSnapshot(): LockSnapshot | null { + try { + if (!existsSync(REGISTRY_LOCK_PATH)) return null + const raw = readFileSync(REGISTRY_LOCK_PATH, "utf-8") + const trimmed = raw.trim() + if (!trimmed) return { raw, pid: null, token: null } + + try { + const parsed = JSON.parse(trimmed) + const pid = + typeof parsed.pid === "number" && Number.isFinite(parsed.pid) ? parsed.pid : null + const token = + typeof parsed.token === "string" && parsed.token.length > 0 ? parsed.token : null + return { raw, pid, token } + } catch { + // Legacy format or plain PID + const [pidStr] = trimmed.split(":") + const parsedPid = Number.parseInt(pidStr ?? "", 10) + return { + raw, + pid: Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : null, + token: null, + } + } + } catch { + return null + } +} + +function removeLockIfUnchanged(snapshot: LockSnapshot): boolean { + try { + if (!existsSync(REGISTRY_LOCK_PATH)) return false + const currentRaw = readFileSync(REGISTRY_LOCK_PATH, "utf-8") + if (currentRaw !== snapshot.raw) return false + unlinkSync(REGISTRY_LOCK_PATH) + return true + } catch { + return false + } +} + +interface LockHandle { + fd: number + token: string +} + +function acquireRegistryLock(): LockHandle | null { + ensureRegistryDir() + const started = Date.now() + while (Date.now() - started < LOCK_TIMEOUT_MS) { + try { + const token = randomUUID() + const fd = openSync( + REGISTRY_LOCK_PATH, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + SECURE_FILE_MODE, + ) + try { + const lockPayload = JSON.stringify({ + pid: process.pid, + acquiredAt: Date.now(), + token, + }) + writeSync(fd, lockPayload) + } catch (writeError) { + try { + closeSync(fd) + } catch { + // Ignore + } + try { + unlinkSync(REGISTRY_LOCK_PATH) + } catch { + // Ignore + } + throw writeError + } + return { fd, token } + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code !== "EEXIST") throw error + + try { + const stats = statSync(REGISTRY_LOCK_PATH) + const lockAgeMs = Date.now() - stats.mtimeMs + if (lockAgeMs > LOCK_STALE_MS) { + const snapshot = readLockSnapshot() + if (!snapshot) { + sleepMs(LOCK_RETRY_MS) + continue + } + if (snapshot.pid !== null && isPidAlive(snapshot.pid)) { + sleepMs(LOCK_RETRY_MS) + continue + } + if (removeLockIfUnchanged(snapshot)) { + continue + } + } + } catch { + // Ignore errors + } + sleepMs(LOCK_RETRY_MS) + } + } + return null +} + +function acquireRegistryLockOrWait(maxWaitMs = LOCK_WAIT_TIMEOUT_MS): LockHandle | null { + const started = Date.now() + while (Date.now() - started < maxWaitMs) { + const lock = acquireRegistryLock() + if (lock !== null) return lock + if (Date.now() - started < maxWaitMs) { + sleepMs(LOCK_RETRY_MS) + } + } + return null +} + +function releaseRegistryLock(lock: LockHandle): void { + try { + closeSync(lock.fd) + } catch { + // Ignore + } + const snapshot = readLockSnapshot() + if (!snapshot || snapshot.token !== lock.token) return + removeLockIfUnchanged(snapshot) +} + +function withRegistryLockOrWait( + onLocked: () => T, + onLockUnavailable: () => T, +): T { + const lock = acquireRegistryLockOrWait() + if (lock === null) return onLockUnavailable() + try { + return onLocked() + } finally { + releaseRegistryLock(lock) + } +} + +function withRegistryLock(onLocked: () => void, onLockUnavailable: () => void): void { + const lock = acquireRegistryLock() + if (lock === null) { + onLockUnavailable() + return + } + try { + onLocked() + } finally { + releaseRegistryLock(lock) + } +} + +function readAllMappingsUnsafe(): SessionMapping[] { + if (!existsSync(REGISTRY_PATH)) return [] + try { + const content = readFileSync(REGISTRY_PATH, "utf-8") + return content + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line) as SessionMapping + } catch { + return null + } + }) + .filter((m): m is SessionMapping => m !== null) + } catch { + return [] + } +} + +function rewriteRegistryUnsafe(mappings: SessionMapping[]): void { + ensureRegistryDir() + if (mappings.length === 0) { + writeFileSync(REGISTRY_PATH, "", { mode: SECURE_FILE_MODE }) + return + } + const content = mappings.map((m) => JSON.stringify(m)).join("\n") + "\n" + writeFileSync(REGISTRY_PATH, content, { mode: SECURE_FILE_MODE }) +} + +export function registerMessage(mapping: SessionMapping): boolean { + return withRegistryLockOrWait( + () => { + ensureRegistryDir() + const line = JSON.stringify(mapping) + "\n" + const fd = openSync( + REGISTRY_PATH, + constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT, + SECURE_FILE_MODE, + ) + try { + writeSync(fd, line) + } finally { + closeSync(fd) + } + return true + }, + () => { + console.warn( + "[notifications] session registry lock unavailable; skipping reply correlation write", + ) + return false + }, + ) +} + +export function loadAllMappings(): SessionMapping[] { + return withRegistryLockOrWait( + () => readAllMappingsUnsafe(), + () => [], + ) +} + +export function lookupByMessageId(platform: string, messageId: string): SessionMapping | null { + const mappings = loadAllMappings() + return mappings.find((m) => m.platform === platform && m.messageId === messageId) || null +} + +export function removeSession(sessionId: string): void { + withRegistryLock( + () => { + const mappings = readAllMappingsUnsafe() + const filtered = mappings.filter((m) => m.sessionId !== sessionId) + if (filtered.length === mappings.length) return + rewriteRegistryUnsafe(filtered) + }, + () => { + // Best-effort + }, + ) +} + +export function removeMessagesByPane(paneId: string): void { + withRegistryLock( + () => { + const mappings = readAllMappingsUnsafe() + const filtered = mappings.filter((m) => m.tmuxPaneId !== paneId) + if (filtered.length === mappings.length) return + rewriteRegistryUnsafe(filtered) + }, + () => { + // Best-effort + }, + ) +} + +export function pruneStale(): void { + withRegistryLock( + () => { + const now = Date.now() + const mappings = readAllMappingsUnsafe() + const filtered = mappings.filter((m) => { + try { + const age = now - new Date(m.createdAt).getTime() + return age < MAX_AGE_MS + } catch { + return false + } + }) + if (filtered.length === mappings.length) return + rewriteRegistryUnsafe(filtered) + }, + () => { + // Best-effort + }, + ) +} diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts new file mode 100644 index 000000000..6b575e662 --- /dev/null +++ b/src/openclaw/tmux.ts @@ -0,0 +1,91 @@ +import { spawn } from "bun" + +export function getCurrentTmuxSession(): string | null { + const env = process.env.TMUX + if (!env) return null + const match = env.match(/(\d+)$/) + return match ? `session-${match[1]}` : null // Wait, TMUX env is /tmp/tmux-501/default,1234,0 + // Reference tmux.js gets session name via `tmux display-message -p '#S'` +} + +export async function getTmuxSessionName(): Promise { + try { + const proc = spawn(["tmux", "display-message", "-p", "#S"], { + stdout: "pipe", + stderr: "ignore", + }) + const outputPromise = new Response(proc.stdout).text() + await proc.exited + const output = await outputPromise + // Await proc.exited ensures exitCode is set; avoid race condition + if (proc.exitCode !== 0) return null + return output.trim() || null + } catch { + return null + } +} + +export async function captureTmuxPane(paneId: string, lines = 15): Promise { + try { + const proc = spawn( + ["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], + { + stdout: "pipe", + stderr: "ignore", + }, + ) + const outputPromise = new Response(proc.stdout).text() + await proc.exited + const output = await outputPromise + if (proc.exitCode !== 0) return null + return output.trim() || null + } catch { + return null + } +} + +export async function sendToPane(paneId: string, text: string, confirm = true): Promise { + try { + const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], { + stdout: "ignore", + stderr: "ignore", + }) + await literalProc.exited + if (literalProc.exitCode !== 0) return false + + if (!confirm) return true + + const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], { + stdout: "ignore", + stderr: "ignore", + }) + await enterProc.exited + return enterProc.exitCode === 0 + } catch { + return false + } +} + +export async function isTmuxAvailable(): Promise { + try { + const proc = spawn(["tmux", "-V"], { + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited + return proc.exitCode === 0 + } catch { + return false + } +} + +export function analyzePaneContent(content: string | null): { confidence: number } { + if (!content) return { confidence: 0 } + + let confidence = 0 + if (content.includes("opencode")) confidence += 0.3 + if (content.includes("Ask anything...")) confidence += 0.5 + if (content.includes("Run /help")) confidence += 0.2 + + return { confidence: Math.min(1, confidence) } +} diff --git a/src/openclaw/types.ts b/src/openclaw/types.ts new file mode 100644 index 000000000..b05325da2 --- /dev/null +++ b/src/openclaw/types.ts @@ -0,0 +1,52 @@ +import type { + OpenClawConfig, + OpenClawGateway, + OpenClawHook, + OpenClawReplyListenerConfig, +} from "../config/schema/openclaw" + +export type { + OpenClawConfig, + OpenClawGateway, + OpenClawHook, + OpenClawReplyListenerConfig, +} + +export interface OpenClawContext { + sessionId?: string + projectPath?: string + projectName?: string + tmuxSession?: string + prompt?: string + contextSummary?: string + reasoning?: string + question?: string + tmuxTail?: string + replyChannel?: string + replyTarget?: string + replyThread?: string + [key: string]: string | undefined +} + +export interface OpenClawPayload { + event: string + instruction: string + text: string + timestamp: string + sessionId?: string + projectPath?: string + projectName?: string + tmuxSession?: string + tmuxTail?: string + channel?: string + to?: string + threadId?: string + context: OpenClawContext +} + +export interface WakeResult { + gateway: string + success: boolean + error?: string + statusCode?: number +} diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md index d2a67a2ae..805a8f621 100644 --- a/src/plugin/AGENTS.md +++ b/src/plugin/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 46 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type. +Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and composing 48 hooks into the PluginInterface. Every handler file corresponds to one OpenCode hook type. ## HANDLER FILES @@ -25,9 +25,9 @@ Core glue layer. 20 source files assembling the 8 OpenCode hook handlers and com | File | Tier | Count | |------|------|-------| | `create-session-hooks.ts` | Session | 23 | -| `create-tool-guard-hooks.ts` | Tool Guard | 10 | +| `create-tool-guard-hooks.ts` | Tool Guard | 12 | | `create-skill-hooks.ts` | Skill | 2 | -| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 37 | +| `create-core-hooks.ts` | Aggregator | Session + Guard + Transform = 39 | ## SUPPORT FILES diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 45765c025..d55783477 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -195,7 +195,6 @@ export function createEventHandler(args: { await Promise.resolve(hooks.claudeCodeHooks?.event?.(input)); await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input)); await Promise.resolve(hooks.sessionNotification?.(input)); - await Promise.resolve(hooks.gptPermissionContinuation?.handler?.(input)); await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input)); await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input)); await Promise.resolve(hooks.contextWindowMonitor?.event?.(input)); diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index 5dee1724c..c44247af9 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -3,7 +3,6 @@ import type { BackgroundManager } from "../../features/background-agent" import type { PluginContext } from "../types" import { - createGptPermissionContinuationHook, createTodoContinuationEnforcer, createBackgroundNotificationHook, createStopContinuationGuardHook, @@ -15,7 +14,6 @@ import { safeCreateHook } from "../../shared/safe-create-hook" import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter" export type ContinuationHooks = { - gptPermissionContinuation: ReturnType | null stopContinuationGuard: ReturnType | null compactionContextInjector: ReturnType | null compactionTodoPreserver: ReturnType | null @@ -57,13 +55,6 @@ export function createContinuationHooks(args: { })) : null - const gptPermissionContinuation = isHookEnabled("gpt-permission-continuation") - ? safeHook("gpt-permission-continuation", () => - createGptPermissionContinuationHook(ctx, { - isContinuationStopped: stopContinuationGuard?.isStopped, - })) - : null - const compactionContextInjector = isHookEnabled("compaction-context-injector") ? safeHook("compaction-context-injector", () => createCompactionContextInjector({ ctx, backgroundManager })) @@ -78,8 +69,6 @@ export function createContinuationHooks(args: { createTodoContinuationEnforcer(ctx, { backgroundManager, isContinuationStopped: stopContinuationGuard?.isStopped, - shouldSkipContinuation: (sessionID: string) => - gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false, })) : null @@ -122,15 +111,12 @@ export function createContinuationHooks(args: { backgroundManager, isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, - shouldSkipContinuation: (sessionID: string) => - gptPermissionContinuation?.wasRecentlyInjected(sessionID) ?? false, agentOverrides: pluginConfig.agents, autoCommit: pluginConfig.start_work?.auto_commit, })) : null return { - gptPermissionContinuation, stopContinuationGuard, compactionContextInjector, compactionTodoPreserver, diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 6d62d6130..0cc986b7d 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -6,6 +6,17 @@ import { readState, writeState } from "../hooks/ralph-loop/storage" const VERIFICATION_ATTEMPT_PATTERN = /(.*?)<\/ulw_verification_attempt_id>/i +function getMetadataString(metadata: Record | undefined, keys: string[]): string | undefined { + for (const key of keys) { + const value = metadata?.[key] + if (typeof value === "string") { + return value + } + } + + return undefined +} + function getPluginDirectory(ctx: PluginContext): string | null { if (typeof ctx === "object" && ctx !== null && "directory" in ctx && typeof ctx.directory === "string") { return ctx.directory @@ -43,9 +54,9 @@ export function createToolExecuteAfterHandler(args: { if (input.tool === "task") { const directory = getPluginDirectory(ctx) - const sessionId = typeof output.metadata?.sessionId === "string" ? output.metadata.sessionId : undefined - const agent = typeof output.metadata?.agent === "string" ? output.metadata.agent : undefined - const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined + const sessionId = getMetadataString(output.metadata, ["sessionId", "sessionID", "session_id"]) + const agent = getMetadataString(output.metadata, ["agent"]) + const prompt = getMetadataString(output.metadata, ["prompt"]) const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim() const loopState = directory ? readState(directory) : null const isVerificationContext = diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 866512995..5d4f2c86e 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -20,6 +20,26 @@ export function createToolExecuteBeforeHandler(args: { ) => Promise { const { ctx, hooks } = args + function buildUltraworkOracleVerificationPrompt(prompt: string, originalTask: string, verificationAttemptId: string): string { + const verificationPrompt = [ + "You are verifying the active ULTRAWORK loop result for this session.", + "", + "Original task:", + originalTask, + "", + "Review the work skeptically and critically.", + "Assume it may be incomplete, misleading, or subtly broken until the evidence proves otherwise.", + "Look for missing scope, weak verification, process violations, hidden regressions, and any reason the task should NOT be considered complete.", + "", + `If the work is fully complete, end your response with ${ULTRAWORK_VERIFICATION_PROMISE}.`, + "If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.", + "", + `${verificationAttemptId}`, + ].join("\n") + + return `${prompt ? `${prompt}\n\n` : ""}${verificationPrompt}` + } + return async (input, output): Promise => { await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output) await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) @@ -91,7 +111,11 @@ export function createToolExecuteBeforeHandler(args: { verification_session_id: undefined, }) argsObject.run_in_background = false - argsObject.prompt = `${prompt ? `${prompt}\n\n` : ""}You are verifying the active ULTRAWORK loop result for this session. Review whether the original task is truly complete: ${loopState.prompt}\n\nIf the work is fully complete, end your response with ${ULTRAWORK_VERIFICATION_PROMISE}. If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.\n\n${verificationAttemptId}` + argsObject.prompt = buildUltraworkOracleVerificationPrompt( + prompt, + loopState.prompt, + verificationAttemptId, + ) } } diff --git a/src/plugin/tool-execute-before.ulw-loop.test.ts b/src/plugin/tool-execute-before.ulw-loop.test.ts index 74cd5cf8f..50e29ca05 100644 --- a/src/plugin/tool-execute-before.ulw-loop.test.ts +++ b/src/plugin/tool-execute-before.ulw-loop.test.ts @@ -65,7 +65,9 @@ describe("tool.execute.before ultrawork oracle verification", () => { expect(readState(directory)?.verification_attempt_id).toBeTruthy() expect(output.args.run_in_background).toBe(false) + expect(output.args.prompt).toContain("Original task:") expect(output.args.prompt).toContain("Ship feature") + expect(output.args.prompt).toContain("Review the work skeptically and critically") expect(output.args.prompt).toContain(`${ULTRAWORK_VERIFICATION_PROMISE}`) clearState(directory) @@ -171,6 +173,45 @@ describe("tool.execute.before ultrawork oracle verification", () => { rmSync(directory, { recursive: true, force: true }) }) + test("#given ulw loop is awaiting verification #when oracle metadata uses sessionID #then oracle session id is stored", async () => { + const directory = join(tmpdir(), `tool-after-ulw-sessionid-${Date.now()}`) + mkdirSync(directory, { recursive: true }) + writeState(directory, { + active: true, + iteration: 3, + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + initial_completion_promise: "DONE", + started_at: new Date().toISOString(), + prompt: "Ship feature", + session_id: "ses-main", + ultrawork: true, + verification_pending: true, + }) + + const handler = createToolExecuteAfterHandler({ + ctx: createCtx(directory) as unknown as Parameters[0]["ctx"], + hooks: {} as Parameters[0]["hooks"], + }) + + await handler( + { tool: "task", sessionID: "ses-main", callID: "call-1" }, + { + title: "oracle task", + output: "done", + metadata: { + agent: "oracle", + sessionID: "ses-oracle-alt", + sync: true, + }, + }, + ) + + expect(readState(directory)?.verification_session_id).toBe("ses-oracle-alt") + + clearState(directory) + rmSync(directory, { recursive: true, force: true }) + }) + test("#given newer oracle attempt exists #when older oracle task finishes #then old session does not overwrite active verification", async () => { const directory = join(tmpdir(), `tool-race-ulw-${Date.now()}`) mkdirSync(directory, { recursive: true }) diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index 1d88861b7..e02fa4356 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -289,6 +289,19 @@ describe("migrateHookNames", () => { expect(removed).toHaveLength(1) }) + test("removes gpt-permission-continuation from disabled hooks", () => { + // given: Config with removed GPT permission continuation hook + const hooks = ["gpt-permission-continuation", "comment-checker"] + + // when: Migrate hook names + const { migrated, changed, removed } = migrateHookNames(hooks) + + // then: Removed hook should be filtered out + expect(changed).toBe(true) + expect(migrated).toEqual(["comment-checker"]) + expect(removed).toEqual(["gpt-permission-continuation"]) + }) + test("handles mixed migration and removal", () => { // given: Config with both legacy rename and removed hooks const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"] @@ -413,6 +426,20 @@ describe("migrateConfigFile", () => { expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) }) + test("removes gpt-permission-continuation from disabled_hooks", () => { + // given: Config with removed GPT permission continuation hook + const rawConfig: Record = { + disabled_hooks: ["gpt-permission-continuation", "comment-checker"], + } + + // when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // then: Removed hook should be filtered out + expect(needsWrite).toBe(true) + expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) + }) + test("does not write if no migration needed", () => { // given: Config with current names const rawConfig: Record = { diff --git a/src/shared/migration/hook-names.ts b/src/shared/migration/hook-names.ts index 342206049..09dde113a 100644 --- a/src/shared/migration/hook-names.ts +++ b/src/shared/migration/hook-names.ts @@ -10,6 +10,7 @@ export const HOOK_NAME_MAP: Record = { // Removed hooks (v3.0.0) - will be filtered out and user warned "empty-message-sanitizer": null, "delegate-task-english-directive": null, + "gpt-permission-continuation": null, } export function migrateHookNames( diff --git a/src/shared/model-requirements.test.ts b/src/shared/model-requirements.test.ts index 63278e57f..d69de0ef5 100644 --- a/src/shared/model-requirements.test.ts +++ b/src/shared/model-requirements.test.ts @@ -361,19 +361,23 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => { expect(fifth.model).toBe("k2p5") }) - test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => { + test("quick has valid fallbackChain with gpt-5.4-mini as primary and claude-haiku-4-5 as secondary", () => { // given - quick category requirement const quick = CATEGORY_MODEL_REQUIREMENTS["quick"] // when - accessing quick requirement - // then - fallbackChain exists with claude-haiku-4-5 as first entry + // then - fallbackChain exists with gpt-5.4-mini as first entry, haiku as second expect(quick).toBeDefined() expect(quick.fallbackChain).toBeArray() - expect(quick.fallbackChain.length).toBeGreaterThan(0) + expect(quick.fallbackChain.length).toBeGreaterThan(1) const primary = quick.fallbackChain[0] - expect(primary.model).toBe("claude-haiku-4-5") - expect(primary.providers[0]).toBe("anthropic") + expect(primary.model).toBe("gpt-5.4-mini") + expect(primary.providers).toContain("openai") + + const secondary = quick.fallbackChain[1] + expect(secondary.model).toBe("claude-haiku-4-5") + expect(secondary.providers).toContain("anthropic") }) test("unspecified-low has valid fallbackChain with claude-sonnet-4-6 as primary", () => { diff --git a/src/shared/model-requirements.ts b/src/shared/model-requirements.ts index 56863e658..16f7e78c9 100644 --- a/src/shared/model-requirements.ts +++ b/src/shared/model-requirements.ts @@ -251,6 +251,10 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record = { }, quick: { fallbackChain: [ + { + providers: ["openai", "github-copilot", "opencode"], + model: "gpt-5.4-mini", + }, { providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5", diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index d7b0e840c..c9df2e9d5 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -95,7 +95,7 @@ | ultrabrain | gpt-5.4 xhigh | Hard logic | | deep | gpt-5.3-codex medium | Autonomous problem-solving | | artistry | gemini-3.1-pro high | Creative approaches | -| quick | claude-haiku-4-5 | Trivial tasks | +| quick | gpt-5.4-mini | Trivial tasks | | unspecified-low | claude-sonnet-4-6 | Moderate effort | | unspecified-high | claude-opus-4-6 max | High effort | | writing | kimi-k2p5 | Documentation | diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index b3336a066..6ecebb4fb 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -149,9 +149,9 @@ Approach: -THIS CATEGORY USES A LESS CAPABLE MODEL (claude-haiku-4-5). +THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini). -The model executing this task has LIMITED reasoning capacity. Your prompt MUST be: +The model executing this task is optimized for speed over depth. Your prompt MUST be: **EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation: 1. MUST DO: List every required action as atomic, numbered steps @@ -159,10 +159,9 @@ The model executing this task has LIMITED reasoning capacity. Your prompt MUST b 3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples **WHY THIS MATTERS:** -- Less capable models WILL deviate without explicit guardrails -- Vague instructions → unpredictable results -- Implicit expectations → missed requirements - +- Smaller models benefit from explicit guardrails +- Vague instructions may lead to unpredictable results +- Implicit expectations may be missed **PROMPT STRUCTURE (MANDATORY):** \`\`\` TASK: [One-sentence goal] @@ -287,7 +286,7 @@ export const DEFAULT_CATEGORIES: Record = { ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" }, deep: { model: "openai/gpt-5.3-codex", variant: "medium" }, artistry: { model: "google/gemini-3.1-pro", variant: "high" }, - quick: { model: "anthropic/claude-haiku-4-5" }, + quick: { model: "openai/gpt-5.4-mini" }, "unspecified-low": { model: "anthropic/claude-sonnet-4-6" }, "unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" }, writing: { model: "kimi-for-coding/k2p5" },