Merge branch 'code-yeongyu:dev' into fix/oauth-discovery-root-fallback

This commit is contained in:
Philippe Oscar Sanoy
2026-03-23 09:45:54 +08:00
committed by GitHub
87 changed files with 3909 additions and 1039 deletions
+1
View File
@@ -36,3 +36,4 @@ test-injection/
notepad.md
oauth-success.html
*.bun-build
.omx/
+42 -24
View File
@@ -79,47 +79,65 @@ Pass `REPO`, `REPORT_DIR`, and `COMMIT_SHA` to every subagent.
---
## Phase 1: Fetch All Open Items
---
<fetch>
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"
```
</fetch>
**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.
---
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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 |
+1
View File
@@ -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**:
+1 -1
View File
@@ -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 |
+5 -5
View File
@@ -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" },
+4 -5
View File
@@ -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`.
+2 -6
View File
@@ -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
+6 -4
View File
@@ -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);
});
}
+56
View File
@@ -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
}
]
}
+4 -4
View File
@@ -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
```
@@ -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",
+2 -2
View File
@@ -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" })
})
})
+1 -1
View File
@@ -7,7 +7,7 @@ const OPENAI_ONLY_AGENT_OVERRIDES: Record<string, AgentConfig> = {
const OPENAI_ONLY_CATEGORY_OVERRIDES: Record<string, CategoryConfig> = {
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" },
}
+36
View File
@@ -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
+1
View File
@@ -31,6 +31,7 @@ export async function waitForEventProcessorShutdown(
export async function run(options: RunOptions): Promise<number> {
process.env.OPENCODE_CLI_RUN_MODE = "true"
process.env.OPENCODE_CLIENT = "run"
const startTime = Date.now()
const {
+1 -1
View File
@@ -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)
-1
View File
@@ -1,7 +1,6 @@
import { z } from "zod"
export const HookNameSchema = z.enum([
"gpt-permission-continuation",
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
@@ -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(),
+50
View File
@@ -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<typeof OpenClawConfigSchema>
export type OpenClawGateway = z.infer<typeof OpenClawGatewaySchema>
export type OpenClawHook = z.infer<typeof OpenClawHookSchema>
export type OpenClawReplyListenerConfig = z.infer<typeof OpenClawReplyListenerConfigSchema>
+1
View File
@@ -1,3 +1,4 @@
export * from "./types"
export * from "./constants"
export * from "./storage"
export * from "./top-level-task"
+130
View File
@@ -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
+54 -1
View File
@@ -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
@@ -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")
})
})
@@ -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
}
}
+30
View File
@@ -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<string, TaskSessionState>
}
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
}
+3 -3
View File
@@ -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 |
|------|-------|---------|
+4 -3
View File
@@ -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<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
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 }),
}
}
@@ -15,6 +15,8 @@ export async function injectBoulderContinuation(input: {
total: number
agent?: string
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
sessionState: SessionState
}): Promise<void> {
@@ -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 {
@@ -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"],
@@ -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(),
}
})
+19 -9
View File
@@ -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`, {
+525 -100
View File
@@ -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<typeof mock> }) {
function createMockPluginInput(overrides?: {
promptMock?: ReturnType<typeof mock>
sessionGetMock?: ReturnType<typeof mock>
}) {
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<typeof createAtlasHook>[0] & { _promptMock: ReturnType<typeof mock> }
_sessionGetMock: sessionGetMock,
} as unknown as Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
}
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<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
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
<task_metadata>
session_id: ses_auth_flow_123
</task_metadata>`,
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
<task_metadata>
session_id: ses_auth_flow_123
</task_metadata>`,
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
<task_metadata>
session_id: ses_old_task_111
</task_metadata>`,
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
<task_metadata>
session_id: ses_old_task_111
</task_metadata>`,
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<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
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
<task_metadata>
session_id: ses_parallel_collision_222
</task_metadata>`,
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
<task_metadata>
session_id: ses_untrusted_999
</task_metadata>`,
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="<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<void> => 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<void> => 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<void> => 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
}
@@ -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<task_metadata>\nsession_id: ses_sync_12345\n</task_metadata>`
// 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<task_metadata>\nsession_id: ses_auth-flow-123\n</task_metadata>`
// 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.
<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>`
// 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 = `<task_metadata>
session_id: ses_real_metadata_456
</task_metadata>
debug log: session_id: ses_wrong_body_789`
// when
const result = extractSessionIdFromOutput(output)
// then
expect(result).toBe("ses_real_metadata_456")
})
})
+43 -3
View File
@@ -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] ?? "<session_id>"
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(/<task_metadata>([\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<string | undefined> {
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
}
+110 -10
View File
@@ -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 ?? "<session_id>"
}
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<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
const { ctx, pendingFilePaths, autoCommit, getState } = input
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
return async (toolInput, toolOutput): Promise<void> => {
// 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 = `
<system-reminder>
@@ -132,10 +220,22 @@ ${
plan: boulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
preferredSessionId,
waitingForFinalWaveApproval: shouldPauseForApproval,
})
} else {
toolOutput.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
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<system-reminder>\n${buildStandaloneVerificationReminder(
resolvePreferredSessionId(preferredSessionId),
)}\n</system-reminder>`
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
sessionID: toolInput.sessionID,
+48 -1
View File
@@ -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<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
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<void> => {
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 = `<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>\n` + prompt
+8 -1
View File
@@ -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<string, unknown>
}
export type TrackedTopLevelTaskRef = Pick<TopLevelTaskRef, "key" | "label" | "title">
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
+14 -2
View File
@@ -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(
@@ -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")
}
@@ -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
@@ -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()))
}
@@ -1,334 +0,0 @@
/// <reference path="../../../bun-test.d.ts" />
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<typeof createGptPermissionContinuationHook>[0]
function isRecord(value: unknown): value is Record<string, unknown> {
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"])
})
})
})
})
@@ -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<SessionStateStore["getState"]>
async function promptContinuation(
ctx: PluginInput,
sessionID: string,
): Promise<void> {
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<void> {
const { ctx, sessionStateStore, isContinuationStopped } = args
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const properties = event.properties as Record<string, unknown> | 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
}
}
}
@@ -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<void>
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)
},
}
}
@@ -1,39 +0,0 @@
type SessionState = {
inFlight: boolean
consecutiveAutoContinueCount: number
awaitingAutoContinuationResponse: boolean
lastHandledMessageID?: string
lastAutoContinuePermissionPhrase?: string
lastInjectedAt?: number
}
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
export function createSessionStateStore() {
const states = new Map<string, SessionState>()
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)
},
}
}
@@ -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([])
})
})
-1
View File
@@ -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";
+82
View File
@@ -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",
}),
)
})
})
+66
View File
@@ -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<string, unknown> },
) => {
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)
},
}
}
@@ -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\n<promise>VERIFIED</promise>\n\n<task_metadata>\nsession_id: ses_abc123\n</task_metadata>' },
{ 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 <promise>DONE</promise>' },
{ 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 <promise>VERIFIED</promise> 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)
})
})
})
@@ -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 ?? ""}`
}
@@ -25,6 +25,8 @@ You already emitted <promise>{{INITIAL_PROMISE}}</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>{{PROMISE}}</promise>
Original task:
@@ -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 ?? ""}`
@@ -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,
@@ -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 <promise>DONE</promise>" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `bad parent leak <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>` } })}\n`,
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "done <promise>DONE</promise>" } })}\n${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: `verified <promise>${ULTRAWORK_VERIFICATION_PROMISE}</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 () => {
@@ -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
@@ -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<void> {
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<void> => {
@@ -58,7 +56,6 @@ export function createTodoContinuationHandler(args: {
backgroundManager,
skipAgents,
isContinuationStopped,
shouldSkipContinuation,
})
return
}
@@ -30,7 +30,6 @@ export async function handleSessionIdle(args: {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
}): Promise<void> {
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
@@ -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 => {
@@ -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"
@@ -5,7 +5,6 @@ export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
shouldSkipContinuation?: (sessionID: string) => boolean
}
export interface TodoContinuationEnforcer {
+115
View File
@@ -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()
})
})
+70
View File
@@ -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
}
})
})
+13
View File
@@ -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)
})
})
+120
View File
@@ -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
}
}
+9
View File
@@ -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)
})
+180
View File
@@ -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, string | undefined>,
): 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<string, string | undefined>,
): 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<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, 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",
}
}
}
+141
View File
@@ -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<WakeResult | null> {
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<string, string | undefined> = {
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<void> {
const replyListener = config.replyListener
if (config.enabled && (replyListener?.discordBotToken || replyListener?.telegramBotToken)) {
await startReplyListener(config)
}
}
export { startReplyListener, stopReplyListener }
+717
View File
@@ -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<string, string> {
const env: Record<string, string> = {}
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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),
}
}
}
+340
View File
@@ -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<T>(
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
},
)
}
+91
View File
@@ -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<string | null> {
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<string | null> {
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<boolean> {
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<boolean> {
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) }
}
+52
View File
@@ -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
}
+3 -3
View File
@@ -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
-1
View File
@@ -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));
@@ -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<typeof createGptPermissionContinuationHook> | null
stopContinuationGuard: ReturnType<typeof createStopContinuationGuardHook> | null
compactionContextInjector: ReturnType<typeof createCompactionContextInjector> | null
compactionTodoPreserver: ReturnType<typeof createCompactionTodoPreserverHook> | 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,
+14 -3
View File
@@ -6,6 +6,17 @@ import { readState, writeState } from "../hooks/ralph-loop/storage"
const VERIFICATION_ATTEMPT_PATTERN = /<ulw_verification_attempt_id>(.*?)<\/ulw_verification_attempt_id>/i
function getMetadataString(metadata: Record<string, unknown> | 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 =
+25 -1
View File
@@ -20,6 +20,26 @@ export function createToolExecuteBeforeHandler(args: {
) => Promise<void> {
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 <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>.`,
"If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.",
"",
`<ulw_verification_attempt_id>${verificationAttemptId}</ulw_verification_attempt_id>`,
].join("\n")
return `${prompt ? `${prompt}\n\n` : ""}${verificationPrompt}`
}
return async (input, output): Promise<void> => {
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 <promise>${ULTRAWORK_VERIFICATION_PROMISE}</promise>. If the work is not complete, explain the blocking issues clearly and DO NOT emit that promise.\n\n<ulw_verification_attempt_id>${verificationAttemptId}</ulw_verification_attempt_id>`
argsObject.prompt = buildUltraworkOracleVerificationPrompt(
prompt,
loopState.prompt,
verificationAttemptId,
)
}
}
@@ -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(`<promise>${ULTRAWORK_VERIFICATION_PROMISE}</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<typeof createToolExecuteAfterHandler>[0]["ctx"],
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[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 })
+27
View File
@@ -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<string, unknown> = {
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<string, unknown> = {
+1
View File
@@ -10,6 +10,7 @@ export const HOOK_NAME_MAP: Record<string, string | null> = {
// 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(
+9 -5
View File
@@ -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", () => {
+4
View File
@@ -251,6 +251,10 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
quick: {
fallbackChain: [
{
providers: ["openai", "github-copilot", "opencode"],
model: "gpt-5.4-mini",
},
{
providers: ["anthropic", "github-copilot", "opencode"],
model: "claude-haiku-4-5",
+1 -1
View File
@@ -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 |
+6 -7
View File
@@ -149,9 +149,9 @@ Approach:
</Category_Context>
<Caller_Warning>
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<string, CategoryConfig> = {
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" },