Merge remote-tracking branch 'origin/dev' into fix/sync-package-json-to-opencode-intent

This commit is contained in:
acamq
2026-03-11 08:04:16 -06:00
185 changed files with 8236 additions and 1041 deletions
+106
View File
@@ -0,0 +1,106 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { buildAntiDuplicationSection } from "./dynamic-agent-prompt-builder"
import { METIS_SYSTEM_PROMPT } from "./metis"
describe("buildAntiDuplicationSection", () => {
it("#given no arguments #when building anti-duplication section #then returns comprehensive rule section", () => {
//#given: no special configuration needed
//#when: building the anti-duplication section
const result = buildAntiDuplicationSection()
//#then: should contain the anti-duplication rule with all key concepts
expect(result).toContain("Anti-Duplication Rule")
expect(result).toContain("CRITICAL")
expect(result).toContain("DO NOT perform the same search yourself")
})
it("#given no arguments #when building #then explicitly forbids manual re-search after delegation", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explicitly list forbidden behaviors
expect(result).toContain("FORBIDDEN")
expect(result).toContain("manually grep/search for the same information")
expect(result).toContain("Re-doing the research")
})
it("#given no arguments #when building #then allows non-overlapping work", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explicitly allow non-overlapping work
expect(result).toContain("ALLOWED")
expect(result).toContain("non-overlapping work")
expect(result).toContain("work that doesn't depend on the delegated research")
})
it("#given no arguments #when building #then includes wait-for-results instructions", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should include instructions for waiting properly
expect(result).toContain("Wait for Results Properly")
expect(result).toContain("End your response")
expect(result).toContain("Wait for the completion notification")
expect(result).toContain("background_output")
})
it("#given no arguments #when building #then explains why this matters", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explain the purpose
expect(result).toContain("Why This Matters")
expect(result).toContain("Wasted tokens")
expect(result).toContain("Confusion")
expect(result).toContain("Efficiency")
})
it("#given no arguments #when building #then provides code examples", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should include examples
expect(result).toContain("Example")
expect(result).toContain("WRONG")
expect(result).toContain("CORRECT")
expect(result).toContain("task(subagent_type=")
})
it("#given no arguments #when building #then uses proper markdown formatting", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should be wrapped in Anti_Duplication tag
expect(result).toContain("<Anti_Duplication>")
expect(result).toContain("</Anti_Duplication>")
})
})
describe("METIS_SYSTEM_PROMPT anti-duplication coverage", () => {
it("#given the system prompt #when reading delegated exploration rules #then includes anti-duplication guidance", () => {
// given
const prompt = METIS_SYSTEM_PROMPT
// when / then
expect(prompt).toContain("<Anti_Duplication>")
expect(prompt).toContain("Anti-Duplication Rule")
expect(prompt).toContain("DO NOT perform the same search yourself")
expect(prompt).toContain("non-overlapping work")
})
})
+133
View File
@@ -0,0 +1,133 @@
import { describe, test, expect } from "bun:test"
import { ATLAS_SYSTEM_PROMPT } from "./default"
import { ATLAS_GPT_SYSTEM_PROMPT } from "./gpt"
import { ATLAS_GEMINI_SYSTEM_PROMPT } from "./gemini"
describe("Atlas prompts auto-continue policy", () => {
test("default variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("gpt variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_GPT_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("gemini variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("all variants should require immediate continuation after verification passes", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
const lowerPrompt = prompt.toLowerCase()
expect(lowerPrompt).toMatch(/auto-continue immediately after verification/)
expect(lowerPrompt).toMatch(/immediately delegate next task/)
}
})
test("all variants should define when user interaction is actually needed", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
const lowerPrompt = prompt.toLowerCase()
expect(lowerPrompt).toMatch(/only pause.*truly blocked/)
expect(lowerPrompt).toMatch(/plan needs clarification|blocked by external/)
}
})
})
describe("Atlas prompts anti-duplication coverage", () => {
test("all variants should include anti-duplication rules for delegated exploration", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
expect(prompt).toContain("<Anti_Duplication>")
expect(prompt).toContain("Anti-Duplication Rule")
expect(prompt).toContain("DO NOT perform the same search yourself")
expect(prompt).toContain("non-overlapping work")
}
})
})
describe("Atlas prompts plan path consistency", () => {
test("default variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/{plan-name}.yaml")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("gpt variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_GPT_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("gemini variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("all variants should read plan file after verification", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
expect(prompt).toMatch(/read[\s\S]*?\.sisyphus\/plans\//)
}
})
})
+27
View File
@@ -8,6 +8,8 @@
* - Extended reasoning sections
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export const ATLAS_SYSTEM_PROMPT = `
<identity>
You are Atlas - the Master Orchestrator from OhMyOpenCode.
@@ -24,6 +26,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
One task per delegation. Parallel when independent. Verify everything.
</mission>
${buildAntiDuplicationSection()}
<delegation_system>
## How to Delegate
@@ -100,6 +104,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**If your prompt is under 30 lines, it's TOO SHORT.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
+27
View File
@@ -8,6 +8,8 @@
* - Consequence-driven framing (Gemini ignores soft warnings)
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export const ATLAS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Atlas - Master Orchestrator from OhMyOpenCode.
@@ -51,6 +53,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
- **Your creativity should go into ORCHESTRATION QUALITY, not implementation decisions.**
</scope_and_design_constraints>
${buildAntiDuplicationSection()}
<delegation_system>
## How to Delegate
@@ -117,6 +121,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
+29 -1
View File
@@ -8,6 +8,8 @@
* - Scope discipline (no extra features)
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export const ATLAS_GPT_SYSTEM_PROMPT = `
<identity>
You are Atlas - Master Orchestrator from OhMyOpenCode.
@@ -40,9 +42,10 @@ Implementation tasks are the means. Final Wave approval is the goal.
</scope_and_design_constraints>
<uncertainty_and_ambiguity>
- If a task is ambiguous or underspecified:
- During initial plan analysis, if a task is ambiguous or underspecified:
- Ask 1-3 precise clarifying questions, OR
- State your interpretation explicitly and proceed with the simplest approach.
- Once execution has started, do NOT stop to ask for continuation or approval between steps.
- Never fabricate task details, file paths, or requirements.
- Prefer language like "Based on the plan..." instead of absolute claims.
- When unsure about parallelization, default to sequential execution.
@@ -60,6 +63,8 @@ Implementation tasks are the means. Final Wave approval is the goal.
3. \`Read\` for changed files
</tool_usage_rules>
${buildAntiDuplicationSection()}
<delegation_system>
## Delegation API
@@ -126,6 +131,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**Minimum 30 lines per delegation prompt.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
import { createSisyphusAgent } from "./sisyphus"
import { createHephaestusAgent } from "./hephaestus"
import { buildSisyphusJuniorPrompt } from "./sisyphus-junior/agent"
import {
buildAntiDuplicationSection,
buildExploreSection,
type AvailableAgent,
} from "./dynamic-agent-prompt-builder"
const exploreAgent = {
name: "explore",
description: "Contextual grep specialist",
metadata: {
category: "advisor",
cost: "FREE",
promptAlias: "Explore",
triggers: [],
useWhen: ["Multiple search angles needed"],
avoidWhen: ["Single keyword search is enough"],
},
} satisfies AvailableAgent
describe("delegation trust prompt rules", () => {
test("buildAntiDuplicationSection explains overlap is forbidden", () => {
// given
const section = buildAntiDuplicationSection()
// when / then
expect(section).toContain("DO NOT perform the same search yourself")
expect(section).toContain("non-overlapping work")
expect(section).toContain("End your response")
})
test("buildExploreSection includes delegation trust rule", () => {
// given
const agents = [exploreAgent]
// when
const section = buildExploreSection(agents)
// then
expect(section).toContain("Delegation Trust Rule")
expect(section).toContain("do **not** manually perform that same search yourself")
})
test("Sisyphus prompt forbids duplicate delegated exploration", () => {
// given
const agent = createSisyphusAgent("anthropic/claude-sonnet-4-6", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("Continue only with non-overlapping work")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.2", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.4", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.3-codex", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.2", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const agent = createSisyphusAgent("openai/gpt-5.4", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("do only non-overlapping work simultaneously")
expect(prompt).toContain("Continue only with non-overlapping work")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.4", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.3-codex", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior Gemini prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("google/gemini-3.1-pro", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
})
@@ -118,6 +118,8 @@ export function buildExploreSection(agents: AvailableAgent[]): string {
Use it as a **peer tool**, not a fallback. Fire liberally for discovery, not for files you already know.
**Delegation Trust Rule:** Once you fire an explore agent for a search, do **not** manually perform that same search yourself. Use direct tools only for non-overlapping work or when you intentionally skipped delegation.
**Use Direct Tools when:**
${avoidWhen.map((w) => `- ${w}`).join("\n")}
@@ -335,6 +337,7 @@ export function buildAntiPatternsSection(): string {
"- **Search**: Firing agents for single-line typos or obvious syntax errors",
"- **Debugging**: Shotgun debugging, random changes",
"- **Background Tasks**: Polling `background_output` on running tasks — end response and wait for notification",
"- **Delegation Duplication**: Delegating exploration to explore/librarian and then manually doing the same search yourself",
"- **Oracle**: Delivering answer without collecting Oracle results",
]
@@ -470,3 +473,52 @@ export function buildUltraworkSection(
return lines.join("\n")
}
// Anti-duplication section for agent prompts
export function buildAntiDuplicationSection(): string {
return `<Anti_Duplication>
## Anti-Duplication Rule (CRITICAL)
Once you delegate exploration to explore/librarian agents, **DO NOT perform the same search yourself**.
### What this means:
**FORBIDDEN:**
- After firing explore/librarian, manually grep/search for the same information
- Re-doing the research the agents were just tasked with
- "Just quickly checking" the same files the background agents are checking
**ALLOWED:**
- Continue with **non-overlapping work** — work that doesn't depend on the delegated research
- Work on unrelated parts of the codebase
- Preparation work (e.g., setting up files, configs) that can proceed independently
### Wait for Results Properly:
When you need the delegated results but they're not ready:
1. **End your response** — do NOT continue with work that depends on those results
2. **Wait for the completion notification** — the system will trigger your next turn
3. **Then** collect results via \`background_output(task_id="...")\`
4. **Do NOT** impatiently re-search the same topics while waiting
### Why This Matters:
- **Wasted tokens**: Duplicate exploration wastes your context budget
- **Confusion**: You might contradict the agent's findings
- **Efficiency**: The whole point of delegation is parallel throughput
### Example:
\`\`\`typescript
// WRONG: After delegating, re-doing the search
task(subagent_type="explore", run_in_background=true, ...)
// Then immediately grep for the same thing yourself — FORBIDDEN
// CORRECT: Continue non-overlapping work
task(subagent_type="explore", run_in_background=true, ...)
// Work on a different, unrelated file while they search
// End your response and wait for the notification
\`\`\`
</Anti_Duplication>`
}
+5 -2
View File
@@ -18,6 +18,7 @@ import {
buildHardBlocksSection,
buildAntiPatternsSection,
buildToolCallFormatSection,
buildAntiDuplicationSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
const MODE: AgentMode = "all";
@@ -156,7 +157,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
@@ -292,11 +293,13 @@ Prompt structure for each agent:
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+5 -2
View File
@@ -16,6 +16,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
} from "../dynamic-agent-prompt-builder";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
@@ -115,7 +116,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
@@ -241,11 +242,13 @@ Prompt structure for each agent:
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when you have enough context, the same information keeps appearing, 2 search iterations yielded nothing new, or a direct answer was found. Do not over-explore.
+5 -2
View File
@@ -16,6 +16,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
} from "../dynamic-agent-prompt-builder";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
@@ -109,7 +110,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
## Hard Constraints
@@ -194,11 +195,13 @@ task(subagent_type="librarian", run_in_background=true, load_skills=[], descript
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually
- **NEVER use \`background_cancel(all=true)\`**
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+3
View File
@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { buildAntiDuplicationSection } from "./dynamic-agent-prompt-builder"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
@@ -25,6 +26,8 @@ export const METIS_SYSTEM_PROMPT = `# Metis - Pre-Planning Consultant
- **READ-ONLY**: You analyze, question, advise. You do NOT implement or modify files.
- **OUTPUT**: Your analysis feeds into Prometheus (planner). Be actionable.
${buildAntiDuplicationSection()}
---
## PHASE 0: INTENT CLASSIFICATION (MANDATORY FIRST STEP)
+21
View File
@@ -1,5 +1,7 @@
import { describe, test, expect } from "bun:test"
import { PROMETHEUS_SYSTEM_PROMPT } from "./prometheus"
import { PROMETHEUS_GPT_SYSTEM_PROMPT } from "./prometheus/gpt"
import { PROMETHEUS_GEMINI_SYSTEM_PROMPT } from "./prometheus/gemini"
describe("PROMETHEUS_SYSTEM_PROMPT Momus invocation policy", () => {
test("should direct providing ONLY the file path string when invoking Momus", () => {
@@ -82,3 +84,22 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
expect(lowerPrompt).toMatch(/zero acceptance criteria require human/)
})
})
describe("Prometheus prompts anti-duplication coverage", () => {
test("all variants should include anti-duplication rules for delegated exploration", () => {
// given
const prompts = [
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_GPT_SYSTEM_PROMPT,
PROMETHEUS_GEMINI_SYSTEM_PROMPT,
]
// when / then
for (const prompt of prompts) {
expect(prompt).toContain("<Anti_Duplication>")
expect(prompt).toContain("Anti-Duplication Rule")
expect(prompt).toContain("DO NOT perform the same search yourself")
expect(prompt).toContain("non-overlapping work")
}
})
})
+5 -1
View File
@@ -9,6 +9,8 @@
* - Tool-call mandate for every phase transition
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export const PROMETHEUS_GEMINI_SYSTEM_PROMPT = `
<identity>
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
@@ -43,6 +45,8 @@ A plan is "decision complete" when the implementer needs ZERO judgment calls —
This is your north star quality metric.
</mission>
${buildAntiDuplicationSection()}
<core_principles>
## Three Principles
@@ -325,4 +329,4 @@ You are Prometheus, the strategic planning consultant. You bring foresight and s
export function getGeminiPrometheusPrompt(): string {
return PROMETHEUS_GEMINI_SYSTEM_PROMPT
}
}
+4
View File
@@ -8,6 +8,8 @@
* - Principle-driven: Decision Complete, Explore Before Asking, Two Kinds of Unknowns
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
export const PROMETHEUS_GPT_SYSTEM_PROMPT = `
<identity>
You are Prometheus - Strategic Planning Consultant from OhMyOpenCode.
@@ -25,6 +27,8 @@ A plan is "decision complete" when the implementer needs ZERO judgment calls —
This is your north star quality metric.
</mission>
${buildAntiDuplicationSection()}
<core_principles>
## Three Principles (Read First)
+4
View File
@@ -5,6 +5,8 @@
* Includes intent classification, research patterns, and anti-patterns.
*/
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export const PROMETHEUS_INTERVIEW_MODE = `# PHASE 1: INTERVIEW MODE (DEFAULT)
## Step 0: Intent Classification (EVERY request)
@@ -29,6 +31,8 @@ Before diving into consultation, classify the work intent. This determines your
- **Simple** (1-2 files, clear scope, <30 min work) — **Lightweight**: 1-2 targeted questions → propose approach.
- **Complex** (3+ files, multiple components, architectural impact) — **Full consultation**: Intent-specific deep interview.
${buildAntiDuplicationSection()}
---
## Intent-Specific Interview Strategies
+3
View File
@@ -8,6 +8,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildDefaultSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -23,6 +24,8 @@ Sisyphus-Junior - Focused executor from OhMyOpenCode.
Execute tasks directly.
</Role>
${buildAntiDuplicationSection()}
${todoDiscipline}
<Verification>
+5 -2
View File
@@ -9,6 +9,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGeminiSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -58,7 +59,7 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -77,13 +78,15 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -7,6 +7,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGpt53CodexSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -40,7 +41,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -58,12 +59,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -10,6 +10,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri";
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
export function buildGpt54SisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -43,7 +44,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -62,12 +63,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -8,6 +8,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGptSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -41,7 +42,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -59,12 +60,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+10 -5
View File
@@ -37,6 +37,7 @@ import {
buildAntiPatternsSection,
buildParallelDelegationSection,
buildNonClaudePlannerSection,
buildAntiDuplicationSection,
categorizeTools,
} from "./dynamic-agent-prompt-builder";
@@ -225,17 +226,21 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
// WRONG: Sequential or blocking
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
\`\`\`
### Background Result Collection:
1. Launch parallel agents \u2192 receive task_ids
2. If you have DIFFERENT independent work \u2192 do it now
3. Otherwise \u2192 **END YOUR RESPONSE.**
4. System sends \`<system-reminder>\` on completion \u2192 triggers your next turn
5. Collect via \`background_output(task_id="...")\`
6. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work \u2192 do it now
- Otherwise \u2192 **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on each task completion — then call \`background_output(task_id="...")\`
4. Need results not yet ready? **End your response.** The notification will trigger your next turn.
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
### Search Stop Conditions
+10 -6
View File
@@ -21,6 +21,7 @@ import {
buildAntiPatternsSection,
buildParallelDelegationSection,
buildNonClaudePlannerSection,
buildAntiDuplicationSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
@@ -319,7 +320,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue working immediately. System notifies on completion — collect with background_output then.
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
// WRONG: Sequential or blocking
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
@@ -327,11 +328,14 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
### Background Result Collection:
1. Launch parallel agents → receive task_ids
2. If you have DIFFERENT independent work → do it now
3. Otherwise → **END YOUR RESPONSE.**
4. System sends \`<system-reminder>\` on completion → triggers your next turn
5. Collect via \`background_output(task_id="...")\`
6. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work → do it now
- Otherwise → **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on completion → triggers your next turn
4. Collect via \`background_output(task_id="...")\`
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
### Search Stop Conditions
+10 -6
View File
@@ -37,6 +37,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
buildNonClaudePlannerSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
@@ -233,7 +234,7 @@ ${librarianSection}
<tool_method>
- Fire 2-5 explore/librarian agents in parallel for any non-trivial codebase question.
- Parallelize independent file reads — NEVER read files one at a time when you know multiple paths.
- When delegating AND doing direct work: do both simultaneously.
- When delegating AND doing direct work: do only non-overlapping work simultaneously.
</tool_method>
Explore and Librarian agents are background grep — always \`run_in_background=true\`, always parallel.
@@ -246,11 +247,14 @@ Each agent prompt should include:
Background result collection:
1. Launch parallel agents → receive task_ids
2. If you have DIFFERENT independent work → do it now
3. Otherwise → **END YOUR RESPONSE.**
4. System sends \`<system-reminder>\` on completion → triggers your next turn
5. Collect via \`background_output(task_id="...")\`
6. Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work → do it now
- Otherwise → **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on completion → triggers your next turn
4. Collect via \`background_output(task_id="...")\`
5. Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
Stop searching when: you have enough context, same info repeating, 2 iterations with no new data, or direct answer found.
</explore>`;
+4
View File
@@ -69,6 +69,7 @@ program
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-m, --model <provider/model>", "Model override (e.g., anthropic/claude-sonnet-4)")
.option("-d, --directory <path>", "Working directory")
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
@@ -86,6 +87,8 @@ Examples:
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
$ bunx oh-my-opencode run --model anthropic/claude-sonnet-4 "Fix the bug"
$ bunx oh-my-opencode run --agent Sisyphus --model openai/gpt-5.4 "Implement feature X"
Agent resolution order:
1) --agent flag
@@ -108,6 +111,7 @@ Unlike 'opencode run', this command waits until:
const runOptions: RunOptions = {
message,
agent: options.agent,
model: options.model,
directory: options.directory,
port: options.port,
attach: options.attach,
+141 -14
View File
@@ -1,10 +1,37 @@
import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test"
/// <reference types="bun-types" />
import * as fs from "node:fs"
import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from "bun:test"
import * as dataPath from "../../shared/data-path"
import * as logger from "../../shared/logger"
import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
import type { BunInstallResult } from "./bun-install"
import { runBunInstallWithDetails } from "./bun-install"
type CreateProcOptions = {
exitCode?: number | null
exited?: Promise<number>
kill?: () => void
output?: {
stdout?: string
stderr?: string
}
}
function createProc(options: CreateProcOptions = {}): ReturnType<typeof spawnHelpers.spawnWithWindowsHide> {
const exitCode = options.exitCode ?? 0
return {
exited: options.exited ?? Promise.resolve(exitCode),
exitCode,
stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined,
stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined,
kill: options.kill ?? (() => {}),
} satisfies ReturnType<typeof spawnHelpers.spawnWithWindowsHide>
}
describe("runBunInstallWithDetails", () => {
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
@@ -14,11 +41,7 @@ describe("runBunInstallWithDetails", () => {
beforeEach(() => {
getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache")
logSpy = spyOn(logger, "log").mockImplementation(() => {})
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
kill: () => {},
} as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>)
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue(createProc())
existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true)
})
@@ -29,15 +52,119 @@ describe("runBunInstallWithDetails", () => {
existsSyncSpy.mockRestore()
})
it("runs bun install in the OpenCode cache directory", async () => {
const result = await runBunInstallWithDetails()
describe("#given the cache workspace exists", () => {
describe("#when bun install uses inherited output", () => {
it("#then runs bun install in the cache directory", async () => {
// given
expect(result).toEqual({ success: true })
expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1)
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
cwd: "/tmp/opencode-cache",
stdout: "inherit",
stderr: "inherit",
// when
const result = await runBunInstallWithDetails()
// then
expect(result).toEqual({ success: true })
expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1)
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
cwd: "/tmp/opencode-cache",
stdout: "inherit",
stderr: "inherit",
})
})
})
describe("#when bun install uses piped output", () => {
it("#then passes pipe mode to the spawned process", async () => {
// given
// when
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
// then
expect(result).toEqual({ success: true })
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
cwd: "/tmp/opencode-cache",
stdout: "pipe",
stderr: "pipe",
})
})
})
describe("#when piped bun install fails", () => {
it("#then logs captured stdout and stderr", async () => {
// given
spawnWithWindowsHideSpy.mockReturnValue(
createProc({
exitCode: 1,
output: {
stdout: "resolved 10 packages",
stderr: "network error",
},
})
)
// when
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
// then
expect(result).toEqual({
success: false,
error: "bun install failed with exit code 1",
})
expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", {
stdout: "resolved 10 packages",
stderr: "network error",
})
})
})
describe("#when the install times out and proc.exited never resolves", () => {
it("#then returns timedOut true without hanging", async () => {
// given
jest.useFakeTimers()
let killCallCount = 0
spawnWithWindowsHideSpy.mockReturnValue(
createProc({
exitCode: null,
exited: new Promise<number>(() => {}),
kill: () => {
killCallCount += 1
},
})
)
try {
// when
const resultPromise = runBunInstallWithDetails({ outputMode: "pipe" })
jest.advanceTimersByTime(60_000)
jest.runOnlyPendingTimers()
await Promise.resolve()
const outcome = await Promise.race([
resultPromise.then((result) => ({
status: "resolved" as const,
result,
})),
new Promise<{ status: "pending" }>((resolve) => {
queueMicrotask(() => resolve({ status: "pending" }))
}),
])
// then
if (outcome.status === "pending") {
throw new Error("runBunInstallWithDetails did not resolve after timing out")
}
expect(outcome.result).toEqual({
success: false,
timedOut: true,
error: 'bun install timed out after 60 seconds. Try running manually: cd "/tmp/opencode-cache" && bun i',
} satisfies BunInstallResult)
expect(killCallCount).toBe(1)
} finally {
jest.clearAllTimers()
jest.useRealTimers()
}
})
})
})
})
+73 -5
View File
@@ -1,4 +1,5 @@
import { existsSync } from "node:fs"
import { getOpenCodeCacheDir } from "../../shared/data-path"
import { log } from "../../shared/logger"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
@@ -6,6 +7,26 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
type BunInstallOutputMode = "inherit" | "pipe"
interface RunBunInstallOptions {
outputMode?: BunInstallOutputMode
}
interface BunInstallOutput {
stdout: string
stderr: string
}
declare function setTimeout(callback: () => void, delay?: number): number
declare function clearTimeout(timeout: number): void
type ProcessOutputStream = ReturnType<typeof spawnWithWindowsHide>["stdout"]
declare const Bun: {
readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string>
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
@@ -17,7 +38,33 @@ export async function runBunInstall(): Promise<boolean> {
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
function readProcessOutput(stream: ProcessOutputStream): Promise<string> {
if (!stream) {
return Promise.resolve("")
}
return Bun.readableStreamToText(stream)
}
function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void {
if (outputMode !== "pipe") {
return
}
const stdout = output.stdout.trim()
const stderr = output.stderr.trim()
if (!stdout && !stderr) {
return
}
log("[bun-install] Captured output from failed bun install", {
stdout,
stderr,
})
}
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
const outputMode = options?.outputMode ?? "inherit"
const cacheDir = getOpenCodeCacheDir()
const packageJsonPath = `${cacheDir}/package.json`
@@ -31,17 +78,23 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: cacheDir,
stdout: "inherit",
stderr: "inherit",
stdout: outputMode,
stderr: outputMode,
})
let timeoutId: ReturnType<typeof setTimeout>
const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then(
([stdout, stderr]) => ({ stdout, stderr })
)
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
})
const exitPromise = proc.exited.then(() => "completed" as const)
const result = await Promise.race([exitPromise, timeoutPromise])
clearTimeout(timeoutId!)
if (timeoutId) {
clearTimeout(timeoutId)
}
if (result === "timeout") {
try {
@@ -49,6 +102,17 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
} catch (err) {
log("[cli/install] Failed to kill timed out bun install process:", err)
}
if (outputMode === "pipe") {
void outputPromise
.then((output) => {
logCapturedOutputOnFailure(outputMode, output)
})
.catch((err) => {
log("[bun-install] Failed to read captured output after timeout:", err)
})
}
return {
success: false,
timedOut: true,
@@ -56,7 +120,11 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
}
}
const output = await outputPromise
if (proc.exitCode !== 0) {
logCapturedOutputOnFailure(outputMode, output)
return {
success: false,
error: `bun install failed with exit code ${proc.exitCode}`,
@@ -1,8 +1,111 @@
import { describe, expect, it } from "bun:test"
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { getSuggestedInstallTag } from "./system-loaded-version"
import { PACKAGE_NAME } from "../constants"
const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test"
const { getLoadedPluginVersion, getSuggestedInstallTag }: typeof import("./system-loaded-version") =
await import(systemLoadedVersionModulePath)
const originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const originalXdgCacheHome = process.env.XDG_CACHE_HOME
const temporaryDirectories: string[] = []
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix))
temporaryDirectories.push(directory)
return directory
}
function writeJson(filePath: string, value: Record<string, string | Record<string, string>>): void {
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify(value), "utf-8")
}
afterEach(() => {
if (originalOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
}
if (originalXdgCacheHome === undefined) {
delete process.env.XDG_CACHE_HOME
} else {
process.env.XDG_CACHE_HOME = originalXdgCacheHome
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe("system loaded version", () => {
describe("getLoadedPluginVersion", () => {
it("prefers the config directory when both installs exist", () => {
//#given
const configDir = createTemporaryDirectory("omo-config-")
const cacheHome = createTemporaryDirectory("omo-cache-")
const cacheDir = join(cacheHome, "opencode")
process.env.OPENCODE_CONFIG_DIR = configDir
process.env.XDG_CACHE_HOME = cacheHome
writeJson(join(configDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "1.2.3" },
})
writeJson(join(configDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "1.2.3",
})
writeJson(join(cacheDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "9.9.9" },
})
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "9.9.9",
})
//#when
const loadedVersion = getLoadedPluginVersion()
//#then
expect(loadedVersion.cacheDir).toBe(configDir)
expect(loadedVersion.cachePackagePath).toBe(join(configDir, "package.json"))
expect(loadedVersion.installedPackagePath).toBe(join(configDir, "node_modules", PACKAGE_NAME, "package.json"))
expect(loadedVersion.expectedVersion).toBe("1.2.3")
expect(loadedVersion.loadedVersion).toBe("1.2.3")
})
it("falls back to the cache directory for legacy installs", () => {
//#given
const configDir = createTemporaryDirectory("omo-config-")
const cacheHome = createTemporaryDirectory("omo-cache-")
const cacheDir = join(cacheHome, "opencode")
process.env.OPENCODE_CONFIG_DIR = configDir
process.env.XDG_CACHE_HOME = cacheHome
writeJson(join(cacheDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "2.3.4" },
})
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "2.3.4",
})
//#when
const loadedVersion = getLoadedPluginVersion()
//#then
expect(loadedVersion.cacheDir).toBe(cacheDir)
expect(loadedVersion.cachePackagePath).toBe(join(cacheDir, "package.json"))
expect(loadedVersion.installedPackagePath).toBe(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"))
expect(loadedVersion.expectedVersion).toBe("2.3.4")
expect(loadedVersion.loadedVersion).toBe("2.3.4")
})
})
describe("getSuggestedInstallTag", () => {
it("returns prerelease channel when current version is prerelease", () => {
//#given
+19 -4
View File
@@ -5,7 +5,7 @@ import { join } from "node:path"
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
import { extractChannel } from "../../../hooks/auto-update-checker"
import { PACKAGE_NAME } from "../constants"
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
import { getOpenCodeCacheDir, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
interface PackageJsonShape {
version?: string
@@ -54,9 +54,24 @@ function normalizeVersion(value: string | undefined): string | null {
}
export function getLoadedPluginVersion(): LoadedVersionInfo {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = resolveOpenCodeCacheDir()
const cachePackagePath = join(cacheDir, "package.json")
const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
const candidates = [
{
cacheDir: configPaths.configDir,
cachePackagePath: configPaths.packageJson,
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
},
{
cacheDir,
cachePackagePath: join(cacheDir, "package.json"),
installedPackagePath: join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"),
},
]
const selectedCandidate = candidates.find((candidate) => existsSync(candidate.installedPackagePath)) ?? candidates[0]
const { cacheDir: selectedDir, cachePackagePath, installedPackagePath } = selectedCandidate
const cachePackage = readPackageJson(cachePackagePath)
const installedPackage = readPackageJson(installedPackagePath)
@@ -65,7 +80,7 @@ export function getLoadedPluginVersion(): LoadedVersionInfo {
const loadedVersion = normalizeVersion(installedPackage?.version)
return {
cacheDir,
cacheDir: selectedDir,
cachePackagePath,
installedPackagePath,
expectedVersion,
+1
View File
@@ -1,5 +1,6 @@
export { run } from "./runner"
export { resolveRunAgent } from "./agent-resolver"
export { resolveRunModel } from "./model-resolver"
export { createServerConnection } from "./server-connection"
export { resolveSession } from "./session-resolver"
export { createJsonOutputManager } from "./json-output"
+83
View File
@@ -0,0 +1,83 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { resolveRunModel } from "./model-resolver"
describe("resolveRunModel", () => {
it("given no model string, when resolved, then returns undefined", () => {
// given
const modelString = undefined
// when
const result = resolveRunModel(modelString)
// then
expect(result).toBeUndefined()
})
it("given empty string, when resolved, then throws Error", () => {
// given
const modelString = ""
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given valid 'anthropic/claude-sonnet-4', when resolved, then returns correct object", () => {
// given
const modelString = "anthropic/claude-sonnet-4"
// when
const result = resolveRunModel(modelString)
// then
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" })
})
it("given nested slashes 'openai/gpt-5.3/preview', when resolved, then modelID is 'gpt-5.3/preview'", () => {
// given
const modelString = "openai/gpt-5.3/preview"
// when
const result = resolveRunModel(modelString)
// then
expect(result).toEqual({ providerID: "openai", modelID: "gpt-5.3/preview" })
})
it("given no slash 'claude-sonnet-4', when resolved, then throws Error", () => {
// given
const modelString = "claude-sonnet-4"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given empty provider '/claude-sonnet-4', when resolved, then throws Error", () => {
// given
const modelString = "/claude-sonnet-4"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given trailing slash 'anthropic/', when resolved, then throws Error", () => {
// given
const modelString = "anthropic/"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
})
+29
View File
@@ -0,0 +1,29 @@
export function resolveRunModel(
modelString?: string
): { providerID: string; modelID: string } | undefined {
if (modelString === undefined) {
return undefined
}
const trimmed = modelString.trim()
if (trimmed.length === 0) {
throw new Error("Model string cannot be empty")
}
const parts = trimmed.split("/")
if (parts.length < 2) {
throw new Error("Model string must be in 'provider/model' format")
}
const providerID = parts[0]
if (providerID.length === 0) {
throw new Error("Provider cannot be empty")
}
const modelID = parts.slice(1).join("/")
if (modelID.length === 0) {
throw new Error("Model ID cannot be empty")
}
return { providerID, modelID }
}
+24 -35
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { createEventState } from "./events"
import { pollForCompletion } from "./poll-for-completion"
@@ -30,11 +30,26 @@ const createMockContext = (overrides: {
}
}
let consoleLogSpy: ReturnType<typeof spyOn>
let consoleErrorSpy: ReturnType<typeof spyOn>
function abortAfter(abortController: AbortController, delayMs: number): void {
setTimeout(() => abortController.abort(), delayMs)
}
beforeEach(() => {
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
consoleLogSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
describe("pollForCompletion", () => {
it("requires consecutive stability checks before exiting - not immediate", async () => {
//#given - 0 todos, 0 children, session idle, meaningful work done
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -56,8 +71,6 @@ describe("pollForCompletion", () => {
it("does not check completion during stabilization period after first meaningful work", async () => {
//#given - session idle, meaningful work done, but stabilization period not elapsed
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -65,7 +78,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort after 50ms (within the 60ms stabilization period)
setTimeout(() => abortController.abort(), 50)
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -80,8 +93,6 @@ describe("pollForCompletion", () => {
it("does not exit when currentTool is set - resets consecutive counter", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -90,7 +101,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort after enough time to verify it didn't exit
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -105,8 +116,6 @@ describe("pollForCompletion", () => {
it("resets consecutive counter when session becomes busy between checks", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -147,8 +156,6 @@ describe("pollForCompletion", () => {
it("returns 1 on session error", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -169,14 +176,12 @@ describe("pollForCompletion", () => {
it("returns 130 when aborted", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 50)
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -188,8 +193,6 @@ describe("pollForCompletion", () => {
it("does not check completion when hasReceivedMeaningfulWork is false", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -197,7 +200,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -211,8 +214,6 @@ describe("pollForCompletion", () => {
it("falls back to session.status API when idle event is missing", async () => {
//#given - mainSessionIdle not set by events, but status API says idle
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext({
statuses: {
"test-session": { type: "idle" },
@@ -236,8 +237,6 @@ describe("pollForCompletion", () => {
it("allows silent completion after stabilization when no meaningful work is received", async () => {
//#given - session is idle and stable but no assistant message/tool event arrived
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -257,8 +256,6 @@ describe("pollForCompletion", () => {
it("uses default stabilization to avoid indefinite wait when no meaningful work arrives", async () => {
//#given - idle with no meaningful work and no explicit minStabilization override
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -277,8 +274,6 @@ describe("pollForCompletion", () => {
it("coerces non-positive stabilization values to default stabilization", async () => {
//#given - explicit zero stabilization should still wait for default window
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -286,7 +281,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort before default 1s window elapses
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 1,
@@ -299,8 +294,6 @@ describe("pollForCompletion", () => {
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -323,7 +316,7 @@ describe("pollForCompletion", () => {
)
//#when - abort after tool stays in-flight
setTimeout(() => abortController.abort(), 200)
abortAfter(abortController, 200)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -335,8 +328,6 @@ describe("pollForCompletion", () => {
it("returns 1 when session errors while not idle (error not masked by idle gate)", async () => {
//#given - mainSessionIdle=false, mainSessionError=true, lastError="crash"
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = false
@@ -359,8 +350,6 @@ describe("pollForCompletion", () => {
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
//#given - mainSessionIdle=true, currentTool="bash", mainSessionError=true
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
+7
View File
@@ -7,6 +7,7 @@ import { resolveSession } from "./session-resolver"
import { createJsonOutputManager } from "./json-output"
import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { resolveRunModel } from "./model-resolver"
import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
import { suppressRunInput } from "./stdin-suppression"
@@ -46,6 +47,7 @@ export async function run(options: RunOptions): Promise<number> {
const pluginConfig = loadPluginConfig(directory, { command: "run" })
const resolvedAgent = resolveRunAgent(options, pluginConfig)
const resolvedModel = resolveRunModel(options.model)
const abortController = new AbortController()
try {
@@ -78,6 +80,10 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.dim(`Session: ${sessionID}`))
if (resolvedModel) {
console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
}
const ctx: RunContext = {
client,
sessionID,
@@ -96,6 +102,7 @@ export async function run(options: RunOptions): Promise<number> {
path: { id: sessionID },
body: {
agent: resolvedAgent,
...(resolvedModel ? { model: resolvedModel } : {}),
tools: {
question: false,
},
+1
View File
@@ -4,6 +4,7 @@ export type { OpencodeClient }
export interface RunOptions {
message: string
agent?: string
model?: string
timestamp?: boolean
verbose?: boolean
directory?: string
+19
View File
@@ -884,6 +884,25 @@ describe("GitMasterConfigSchema", () => {
//#then
expect(result.success).toBe(false)
})
test("accepts shell-safe git_env_prefix", () => {
const config = { git_env_prefix: "MY_HOOK=active" }
const result = GitMasterConfigSchema.safeParse(config)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.git_env_prefix).toBe("MY_HOOK=active")
}
})
test("rejects git_env_prefix with shell metacharacters", () => {
const config = { git_env_prefix: "A=1; rm -rf /" }
const result = GitMasterConfigSchema.safeParse(config)
expect(result.success).toBe(false)
})
})
describe("skills schema", () => {
+1
View File
@@ -10,6 +10,7 @@ export * from "./schema/commands"
export * from "./schema/dynamic-context-pruning"
export * from "./schema/experimental"
export * from "./schema/fallback-models"
export * from "./schema/git-env-prefix"
export * from "./schema/git-master"
export * from "./schema/hooks"
export * from "./schema/notification"
+48
View File
@@ -3,6 +3,54 @@ import { ZodError } from "zod/v4"
import { BackgroundTaskConfigSchema } from "./background-task"
describe("BackgroundTaskConfigSchema", () => {
describe("maxDepth", () => {
describe("#given valid maxDepth (3)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ maxDepth: 3 })
expect(result.maxDepth).toBe(3)
})
})
describe("#given maxDepth below minimum (0)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ maxDepth: 0 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
describe("maxDescendants", () => {
describe("#given valid maxDescendants (50)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 })
expect(result.maxDescendants).toBe(50)
})
})
describe("#given maxDescendants below minimum (0)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ maxDescendants: 0 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
describe("syncPollTimeoutMs", () => {
describe("#given valid syncPollTimeoutMs (120000)", () => {
test("#when parsed #then returns correct value", () => {
+2
View File
@@ -4,6 +4,8 @@ export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
maxDepth: z.number().int().min(1).optional(),
maxDescendants: z.number().int().min(1).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod"
const GIT_ENV_ASSIGNMENT_PATTERN =
/^(?:[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)(?: [A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)*$/
export const GIT_ENV_PREFIX_VALIDATION_MESSAGE =
'git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"'
export function isValidGitEnvPrefix(value: string): boolean {
if (value === "") {
return true
}
return GIT_ENV_ASSIGNMENT_PATTERN.test(value)
}
export function assertValidGitEnvPrefix(value: string): string {
if (!isValidGitEnvPrefix(value)) {
throw new Error(GIT_ENV_PREFIX_VALIDATION_MESSAGE)
}
return value
}
export const GitEnvPrefixSchema = z
.string()
.refine(isValidGitEnvPrefix, { message: GIT_ENV_PREFIX_VALIDATION_MESSAGE })
.default("GIT_MASTER=1")
+4
View File
@@ -1,10 +1,14 @@
import { z } from "zod"
import { GitEnvPrefixSchema } from "./git-env-prefix"
export const GitMasterConfigSchema = z.object({
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */
commit_footer: z.union([z.boolean(), z.string()]).default(true),
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
include_co_authored_by: z.boolean().default(true),
/** Environment variable prefix for all git commands (default: "GIT_MASTER=1"). Set to "" to disable. Allows custom git hooks to detect git-master skill usage. */
git_env_prefix: GitEnvPrefixSchema,
})
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
+1
View File
@@ -1,6 +1,7 @@
import { z } from "zod"
export const HookNameSchema = z.enum([
"gpt-permission-continuation",
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
+2 -1
View File
@@ -11,6 +11,7 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
import { BuiltinCommandNameSchema } from "./commands"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { HookNameSchema } from "./hooks"
import { NotificationConfigSchema } from "./notification"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
@@ -30,7 +31,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(z.string()).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(z.string()).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),
@@ -2,7 +2,15 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import {
isCompactionAgent,
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
} from "./compaction-aware-message-resolver"
import {
clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
describe("isCompactionAgent", () => {
describe("#given agent name variations", () => {
@@ -65,6 +73,7 @@ describe("findNearestMessageExcludingCompaction", () => {
afterEach(() => {
rmSync(tempDir, { force: true, recursive: true })
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
})
describe("#given directory with messages", () => {
@@ -186,5 +195,65 @@ describe("findNearestMessageExcludingCompaction", () => {
expect(result).not.toBeNull()
expect(result?.agent).toBe("newer")
})
test("merges partial metadata from multiple recent messages", () => {
// given
writeFileSync(
join(tempDir, "003.json"),
JSON.stringify({ model: { providerID: "anthropic", modelID: "claude-opus-4-1" } }),
)
writeFileSync(join(tempDir, "002.json"), JSON.stringify({ agent: "atlas" }))
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))
// when
const result = findNearestMessageExcludingCompaction(tempDir)
// then
expect(result).toEqual({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
tools: { bash: true },
})
})
test("fills missing metadata from compaction checkpoint", () => {
// given
setCompactionAgentConfigCheckpoint("ses_checkpoint", {
agent: "sisyphus",
model: { providerID: "openai", modelID: "gpt-5" },
})
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))
// when
const result = findNearestMessageExcludingCompaction(tempDir, "ses_checkpoint")
// then
expect(result).toEqual({
agent: "sisyphus",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
})
})
})
describe("resolvePromptContextFromSessionMessages", () => {
test("merges partial prompt context from recent SDK messages", () => {
// given
const messages = [
{ info: { agent: "atlas" } },
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
{ info: { tools: { bash: true } } },
]
// when
const result = resolvePromptContextFromSessionMessages(messages)
// then
expect(result).toEqual({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
tools: { bash: true },
})
})
})
@@ -1,6 +1,21 @@
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { StoredMessage } from "../hook-message-injector"
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
type SessionMessage = {
info?: {
agent?: string
model?: {
providerID?: string
modelID?: string
variant?: string
}
providerID?: string
modelID?: string
tools?: StoredMessage["tools"]
}
}
export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
@@ -16,42 +31,121 @@ function hasFullAgentAndModel(message: StoredMessage): boolean {
function hasPartialAgentOrModel(message: StoredMessage): boolean {
const hasAgent = !!message.agent && !isCompactionAgent(message.agent)
const hasModel = !!message.model?.providerID && !!message.model?.modelID
return hasAgent || hasModel
return hasAgent || hasModel || !!message.tools
}
export function findNearestMessageExcludingCompaction(messageDir: string): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((name) => name.endsWith(".json"))
.sort()
.reverse()
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const parsed = JSON.parse(content) as StoredMessage
if (hasFullAgentAndModel(parsed)) {
return parsed
}
} catch {
continue
}
}
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const parsed = JSON.parse(content) as StoredMessage
if (hasPartialAgentOrModel(parsed)) {
return parsed
}
} catch {
continue
}
}
} catch {
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
const info = message.info
if (!info) {
return null
}
return null
const providerID = info.model?.providerID ?? info.providerID
const modelID = info.model?.modelID ?? info.modelID
return {
...(info.agent ? { agent: info.agent } : {}),
...(providerID && modelID
? {
model: {
providerID,
modelID,
...(info.model?.variant ? { variant: info.model.variant } : {}),
},
}
: {}),
...(info.tools ? { tools: info.tools } : {}),
}
}
function mergeStoredMessages(
messages: Array<StoredMessage | null>,
sessionID?: string,
): StoredMessage | null {
const merged: StoredMessage = {}
for (const message of messages) {
if (!message || isCompactionAgent(message.agent)) {
continue
}
if (!merged.agent && message.agent) {
merged.agent = message.agent
}
if (!merged.model?.providerID && message.model?.providerID && message.model.modelID) {
merged.model = {
providerID: message.model.providerID,
modelID: message.model.modelID,
...(message.model.variant ? { variant: message.model.variant } : {}),
}
}
if (!merged.tools && message.tools) {
merged.tools = message.tools
}
if (hasFullAgentAndModel(merged) && merged.tools) {
break
}
}
const checkpoint = sessionID
? getCompactionAgentConfigCheckpoint(sessionID)
: undefined
if (!merged.agent && checkpoint?.agent) {
merged.agent = checkpoint.agent
}
if (!merged.model && checkpoint?.model) {
merged.model = {
providerID: checkpoint.model.providerID,
modelID: checkpoint.model.modelID,
}
}
if (!merged.tools && checkpoint?.tools) {
merged.tools = checkpoint.tools
}
return hasPartialAgentOrModel(merged) ? merged : null
}
export function resolvePromptContextFromSessionMessages(
messages: SessionMessage[],
sessionID?: string,
): StoredMessage | null {
const convertedMessages = messages
.map(convertSessionMessageToStoredMessage)
.reverse()
return mergeStoredMessages(convertedMessages, sessionID)
}
export function findNearestMessageExcludingCompaction(
messageDir: string,
sessionID?: string,
): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((name: string) => name.endsWith(".json"))
.sort()
.reverse()
const messages: Array<StoredMessage | null> = []
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content) as StoredMessage)
} catch {
continue
}
}
return mergeStoredMessages(messages, sessionID)
} catch {
return null
}
}
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
describe("BackgroundManager session permission", () => {
test("passes explicit session permission rules to child session creation", async () => {
// given
const createCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (input: Record<string, unknown>) => {
createCalls.push(input)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
// when
await manager.launch({
description: "Test task",
prompt: "Do something",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
await new Promise(resolve => setTimeout(resolve, 50))
manager.shutdown()
// then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body).toEqual({
parentID: "ses_parent",
title: "Test task (@explore subagent)",
permission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
})
})
+264 -16
View File
@@ -224,6 +224,12 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
async function flushBackgroundNotifications(): Promise<void> {
for (let i = 0; i < 6; i++) {
await Promise.resolve()
}
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager({
@@ -1306,11 +1312,20 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(abortedSessionIDs).toEqual(["session-1"])
})
test("should clean pendingByParent even when notifyParentSession throws", async () => {
test("should clean pendingByParent even when promptAsync notification fails", async () => {
// given
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {
throw new Error("notify failed")
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
throw new Error("notify failed")
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-pending-cleanup",
@@ -1518,7 +1533,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
// then
expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2)
expect(promptBodies.some((b) => b.noReply === false)).toBe(true)
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
})
})
@@ -1731,6 +1746,32 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}
}
function createMockClientWithSessionChain(
sessions: Record<string, { directory: string; parentID?: string }>,
options?: { sessionLookupError?: Error }
) {
return {
session: {
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async ({ path }: { path: { id: string } }) => {
if (options?.sessionLookupError) {
throw options.sessionLookupError
}
return {
data: sessions[path.id] ?? { directory: "/test/dir" },
}
},
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
}
beforeEach(() => {
// given
mockClient = createMockClient()
@@ -1925,6 +1966,151 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime())
}
})
test("should track rootSessionID and spawnDepth from the parent chain", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-2",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// then
expect(task.rootSessionID).toBe("session-root")
expect(task.spawnDepth).toBe(3)
})
test("should block launches that exceed maxDepth", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" },
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-3",
parentMessageID: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDepth=3")
})
test("should block launches when maxDescendants is reached", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
await manager.launch(input)
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
})
test("should consume descendant quota for reserved sync spawns", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
await manager.reserveSubagentSpawn("session-root")
// when
const result = manager.assertCanSpawn("session-root")
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
})
test("should fail closed when session lineage lookup fails", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain(
{
"session-root": { directory: "/test/dir" },
},
{ sessionLookupError: new Error("session lookup failed") }
),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
})
})
describe("pending task can be cancelled", () => {
@@ -2026,7 +2212,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
test("should cancel running task and release concurrency", async () => {
// given
const manager = createBackgroundManager()
stubNotifyParentSession(manager)
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
@@ -2984,7 +3169,7 @@ describe("BackgroundManager.shutdown session abort", () => {
})
describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
test("should cancel descendant tasks when parent session is deleted", () => {
test("should cancel descendant tasks and keep them until delayed cleanup", async () => {
// given
const manager = createBackgroundManager()
const parentSessionID = "session-parent"
@@ -3031,21 +3216,26 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
// then
expect(taskMap.has(childTask.id)).toBe(false)
expect(taskMap.has(siblingTask.id)).toBe(false)
expect(taskMap.has(grandchildTask.id)).toBe(false)
expect(taskMap.has(childTask.id)).toBe(true)
expect(taskMap.has(siblingTask.id)).toBe(true)
expect(taskMap.has(grandchildTask.id)).toBe(true)
expect(taskMap.has(unrelatedTask.id)).toBe(true)
expect(childTask.status).toBe("cancelled")
expect(siblingTask.status).toBe("cancelled")
expect(grandchildTask.status).toBe("cancelled")
expect(pendingByParent.get(parentSessionID)).toBeUndefined()
expect(pendingByParent.get("session-child")).toBeUndefined()
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(siblingTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
})
test("should remove tasks from toast manager when session is deleted", () => {
test("should remove cancelled tasks from toast manager while preserving delayed cleanup", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -3074,9 +3264,13 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(childTask.id)
expect(removeTaskCalls).toContain(grandchildTask.id)
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
resetToastManager()
@@ -3139,7 +3333,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
return task
}
test("sets task to error, releases concurrency, and cleans up", async () => {
test("sets task to error, releases concurrency, and keeps it until delayed cleanup", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
@@ -3172,18 +3366,21 @@ describe("BackgroundManager.handleEvent - session.error", () => {
},
})
await flushBackgroundNotifications()
//#then
expect(task.status).toBe("error")
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
expect(task.completedAt).toBeInstanceOf(Date)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("removes errored task from toast manager", () => {
test("should remove errored task from toast manager while preserving delayed cleanup", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -3205,8 +3402,11 @@ describe("BackgroundManager.handleEvent - session.error", () => {
},
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(task.id)
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
resetToastManager()
@@ -3489,7 +3689,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
})
test("removes stale task from toast manager", () => {
test("removes stale task from toast manager", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -3504,6 +3704,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(staleTask.id)
@@ -3511,6 +3712,53 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
resetToastManager()
})
test("keeps stale task until notification cleanup after notifying parent", async () => {
//#given
const notifications: string[] = []
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const client = {
session: {
prompt: async () => ({}),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> & { noReply?: boolean; parts?: unknown[] } }) => {
const firstPart = args.body.parts?.[0]
if (firstPart && typeof firstPart === "object" && "text" in firstPart && typeof firstPart.text === "string") {
notifications.push(firstPart.text)
}
return {}
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const staleTask = createMockTask({
id: "task-stale-notify-cleanup",
sessionID: "session-stale-notify-cleanup",
parentSessionID: "parent-stale-notify-cleanup",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
getPendingByParent(manager).set(staleTask.parentSessionID, new Set([staleTask.id]))
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
const retainedTask = getTaskMap(manager).get(staleTask.id)
expect(retainedTask?.status).toBe("error")
expect(getTaskMap(manager).has(staleTask.id)).toBe(true)
expect(notifications).toHaveLength(1)
expect(notifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
expect(notifications[0]).toContain(staleTask.description)
expect(getCompletionTimers(manager).has(staleTask.id)).toBe(true)
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
@@ -3614,7 +3862,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
expect(completionTimers.size).toBe(0)
})
test("should cancel timer when task is deleted via session.deleted", () => {
test("should preserve cleanup timer when terminal task session is deleted", () => {
// given
const manager = createBackgroundManager()
const task: BackgroundTask = {
@@ -3643,7 +3891,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
})
// then
expect(completionTimers.has(task.id)).toBe(false)
expect(completionTimers.has(task.id)).toBe(true)
manager.shutdown()
})
+221 -134
View File
@@ -41,13 +41,24 @@ import {
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import {
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
} from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import {
createSubagentDepthLimitError,
createSubagentDescendantLimitError,
getMaxRootSessionSpawnBudget,
getMaxSubagentDepth,
resolveSubagentSpawnContext,
type SubagentSpawnContext,
} from "./subagent-spawn-limits"
type OpencodeClient = PluginInput["client"]
@@ -112,6 +123,7 @@ export class BackgroundManager {
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private rootDescendantCounts: Map<string, number>
private enableParentSessionNotifications: boolean
readonly taskHistory = new TaskHistory()
@@ -136,10 +148,77 @@ export class BackgroundManager {
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
this.onShutdown = options?.onShutdown
this.rootDescendantCounts = new Map()
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
this.registerProcessCleanup()
}
async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID)
const maxDepth = getMaxSubagentDepth(this.config)
if (spawnContext.childDepth > maxDepth) {
throw createSubagentDepthLimitError({
childDepth: spawnContext.childDepth,
maxDepth,
parentSessionID,
rootSessionID: spawnContext.rootSessionID,
})
}
const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
if (descendantCount >= maxRootSessionSpawnBudget) {
throw createSubagentDescendantLimitError({
rootSessionID: spawnContext.rootSessionID,
descendantCount,
maxDescendants: maxRootSessionSpawnBudget,
})
}
return spawnContext
}
async reserveSubagentSpawn(parentSessionID: string): Promise<{
spawnContext: SubagentSpawnContext
descendantCount: number
commit: () => number
rollback: () => void
}> {
const spawnContext = await this.assertCanSpawn(parentSessionID)
const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
let settled = false
return {
spawnContext,
descendantCount,
commit: () => {
settled = true
return descendantCount
},
rollback: () => {
if (settled) return
settled = true
this.unregisterRootDescendant(spawnContext.rootSessionID)
},
}
}
private registerRootDescendant(rootSessionID: string): number {
const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
this.rootDescendantCounts.set(rootSessionID, nextCount)
return nextCount
}
private unregisterRootDescendant(rootSessionID: string): void {
const currentCount = this.rootDescendantCounts.get(rootSessionID) ?? 0
if (currentCount <= 1) {
this.rootDescendantCounts.delete(rootSessionID)
return
}
this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
log("[background-agent] launch() called with:", {
agent: input.agent,
@@ -152,61 +231,79 @@ export class BackgroundManager {
throw new Error("Agent parameter is required")
}
// Create task immediately with status="pending"
const task: BackgroundTask = {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
status: "pending",
queuedAt: new Date(),
// Do NOT set startedAt - will be set when running
// Do NOT set sessionID - will be set when running
description: input.description,
prompt: input.prompt,
agent: input.agent,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
fallbackChain: input.fallbackChain,
attemptCount: 0,
category: input.category,
}
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
this.tasks.set(task.id, task)
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
// Track for batched notifications immediately (pending state)
if (input.parentSessionID) {
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
pending.add(task.id)
this.pendingByParent.set(input.parentSessionID, pending)
}
// Add to queue
const key = this.getConcurrencyKeyFromInput(input)
const queue = this.queuesByKey.get(key) ?? []
queue.push({ task, input })
this.queuesByKey.set(key, queue)
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.addTask({
id: task.id,
description: input.description,
agent: input.agent,
isBackground: true,
status: "queued",
skills: input.skills,
try {
log("[background-agent] spawn guard passed", {
parentSessionID: input.parentSessionID,
rootSessionID: spawnReservation.spawnContext.rootSessionID,
childDepth: spawnReservation.spawnContext.childDepth,
descendantCount: spawnReservation.descendantCount,
})
// Create task immediately with status="pending"
const task: BackgroundTask = {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
status: "pending",
queuedAt: new Date(),
rootSessionID: spawnReservation.spawnContext.rootSessionID,
// Do NOT set startedAt - will be set when running
// Do NOT set sessionID - will be set when running
description: input.description,
prompt: input.prompt,
agent: input.agent,
spawnDepth: spawnReservation.spawnContext.childDepth,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
fallbackChain: input.fallbackChain,
attemptCount: 0,
category: input.category,
}
this.tasks.set(task.id, task)
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
// Track for batched notifications immediately (pending state)
if (input.parentSessionID) {
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
pending.add(task.id)
this.pendingByParent.set(input.parentSessionID, pending)
}
// Add to queue
const key = this.getConcurrencyKeyFromInput(input)
const queue = this.queuesByKey.get(key) ?? []
queue.push({ task, input })
this.queuesByKey.set(key, queue)
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.addTask({
id: task.id,
description: input.description,
agent: input.agent,
isBackground: true,
status: "queued",
skills: input.skills,
})
}
spawnReservation.commit()
// Trigger processing (fire-and-forget)
this.processKey(key)
return { ...task }
} catch (error) {
spawnReservation.rollback()
throw error
}
// Trigger processing (fire-and-forget)
this.processKey(key)
return task
}
private async processKey(key: string): Promise<void> {
@@ -272,6 +369,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -394,7 +492,6 @@ export class BackgroundManager {
}).catch(() => {})
this.markForNotification(existingTask)
this.cleanupPendingByParent(existingTask)
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
log("[background-agent] Failed to notify on error:", err)
})
@@ -667,7 +764,6 @@ export class BackgroundManager {
}
this.markForNotification(existingTask)
this.cleanupPendingByParent(existingTask)
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
log("[background-agent] Failed to notify on resume error:", err)
})
@@ -810,16 +906,14 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
})
}
if (event.type === "session.deleted") {
@@ -840,47 +934,32 @@ export class BackgroundManager {
if (tasksToCancel.size === 0) return
const deletedSessionIDs = new Set<string>([sessionID])
for (const task of tasksToCancel.values()) {
if (task.sessionID) {
deletedSessionIDs.add(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.status === "running" || task.status === "pending") {
void this.cancelTask(task.id, {
source: "session.deleted",
reason: "Session deleted",
skipNotification: true,
}).then(() => {
if (deletedSessionIDs.has(task.parentSessionID)) {
this.pendingNotifications.delete(task.parentSessionID)
}
}).catch(err => {
if (deletedSessionIDs.has(task.parentSessionID)) {
this.pendingNotifications.delete(task.parentSessionID)
}
log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err })
})
}
const existingTimer = this.completionTimers.get(task.id)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(task.id)
}
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.parentSessionID) {
this.pendingNotifications.delete(task.parentSessionID)
}
}
this.rootDescendantCounts.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
@@ -1100,8 +1179,6 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
if (abortSession && task.sessionID) {
this.client.session.abort({
path: { id: task.sessionID },
@@ -1208,9 +1285,6 @@ export class BackgroundManager {
this.markForNotification(task)
// Ensure pending tracking is cleaned up even if notification fails
this.cleanupPendingByParent(task)
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
@@ -1266,7 +1340,10 @@ export class BackgroundManager {
this.pendingByParent.delete(task.parentSessionID)
}
} else {
allComplete = true
remainingCount = Array.from(this.tasks.values())
.filter(t => t.parentSessionID === task.parentSessionID && t.id !== task.id && (t.status === "running" || t.status === "pending"))
.length
allComplete = remainingCount === 0
}
const completedTasks = allComplete
@@ -1274,7 +1351,13 @@ export class BackgroundManager {
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
: []
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const statusText = task.status === "completed"
? "COMPLETED"
: task.status === "interrupt"
? "INTERRUPTED"
: task.status === "error"
? "ERROR"
: "CANCELLED"
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
let notification: string
@@ -1322,20 +1405,20 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (isCompactionAgent(info?.agent)) {
continue
}
const normalizedTools = isRecord(info?.tools)
? normalizePromptTools(info.tools as Record<string, boolean | "allow" | "deny" | "ask">)
const promptContext = resolvePromptContextFromSessionMessages(
messages,
task.parentSessionID,
)
const normalizedTools = isRecord(promptContext?.tools)
? normalizePromptTools(promptContext.tools)
: undefined
if (promptContext?.agent || promptContext?.model || normalizedTools) {
agent = promptContext?.agent ?? task.parentAgent
model = promptContext?.model?.providerID && promptContext.model.modelID
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
: undefined
if (info?.agent || info?.model || (info?.modelID && info?.providerID) || normalizedTools) {
agent = info?.agent ?? task.parentAgent
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
tools = normalizedTools ?? tools
break
}
tools = normalizedTools ?? tools
}
} catch (error) {
if (isAbortedSessionError(error)) {
@@ -1345,7 +1428,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
}
const messageDir = join(MESSAGE_STORAGE, task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageExcludingCompaction(messageDir) : null
const currentMessage = messageDir
? findNearestMessageExcludingCompaction(messageDir, task.parentSessionID)
: null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
@@ -1405,8 +1490,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
if (this.tasks.has(taskId)) {
const taskToRemove = this.tasks.get(taskId)
if (taskToRemove) {
this.clearNotificationsForTask(taskId)
if (taskToRemove.sessionID) {
subagentSessions.delete(taskToRemove.sessionID)
SessionCategoryRegistry.remove(taskToRemove.sessionID)
}
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
@@ -1416,14 +1506,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private formatDuration(start: Date, end?: Date): string {
return formatDuration(start, end)
}
private isAbortedSessionError(error: unknown): boolean {
return isAbortedSessionError(error)
}
private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
@@ -1441,12 +1523,22 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.status = "error"
task.error = errorMessage
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
removeTaskToastTracking(task.id)
this.cleanupPendingByParent(task)
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(taskId)
}
const idleTimer = this.idleDeferralTimers.get(taskId)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(taskId)
}
if (wasPending) {
const key = task.model
? `${task.model.providerID}/${task.model.modelID}`
@@ -1462,16 +1554,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
}
this.clearNotificationsForTask(taskId)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
})
},
})
}
@@ -1619,6 +1705,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.notificationQueueByParent.clear()
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
this.unregisterProcessCleanup()
@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner.startTask", () => {
test("does not override parent session permission rules when creating child session", async () => {
test("applies explicit child session permission rules when creating child session", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
@@ -41,6 +41,9 @@ describe("background-agent spawner.startTask", () => {
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
},
}
@@ -57,6 +60,8 @@ describe("background-agent spawner.startTask", () => {
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
})
})
+1
View File
@@ -61,6 +61,7 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "./constants"
import { resolveSubagentSpawnContext } from "./subagent-spawn-limits"
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
return {
session: {
get: sessionGet,
},
} as OpencodeClient
}
describe("resolveSubagentSpawnContext", () => {
describe("#given session.get returns an SDK error response", () => {
test("throws a fail-closed spawn blocked error", async () => {
// given
const client = createMockClient(async () => ({
error: "lookup failed",
data: undefined,
}))
// when
const result = resolveSubagentSpawnContext(client, "parent-session")
// then
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*lookup failed/)
})
})
describe("#given session.get returns no session data", () => {
test("throws a fail-closed spawn blocked error", async () => {
// given
const client = createMockClient(async () => ({
data: undefined,
}))
// when
const result = resolveSubagentSpawnContext(client, "parent-session")
// then
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/)
})
})
})
@@ -0,0 +1,95 @@
import type { BackgroundTaskConfig } from "../../config/schema"
import type { OpencodeClient } from "./constants"
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
export const DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET = 50
export interface SubagentSpawnContext {
rootSessionID: string
parentDepth: number
childDepth: number
}
export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
}
export function getMaxRootSessionSpawnBudget(config?: BackgroundTaskConfig): number {
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET
}
export async function resolveSubagentSpawnContext(
client: OpencodeClient,
parentSessionID: string
): Promise<SubagentSpawnContext> {
const visitedSessionIDs = new Set<string>()
let rootSessionID = parentSessionID
let currentSessionID = parentSessionID
let parentDepth = 0
while (true) {
if (visitedSessionIDs.has(currentSessionID)) {
throw new Error(`Detected a session parent cycle while resolving ${parentSessionID}`)
}
visitedSessionIDs.add(currentSessionID)
let nextParentSessionID: string | undefined
try {
const response = await client.session.get({
path: { id: currentSessionID },
})
if (response.error) {
throw new Error(String(response.error))
}
if (!response.data) {
throw new Error("No session data returned")
}
nextParentSessionID = response.data.parentID
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDescendants cannot be enforced safely. ${reason}`
)
}
if (!nextParentSessionID) {
rootSessionID = currentSessionID
break
}
currentSessionID = nextParentSessionID
parentDepth += 1
}
return {
rootSessionID,
parentDepth,
childDepth: parentDepth + 1,
}
}
export function createSubagentDepthLimitError(input: {
childDepth: number
maxDepth: number
parentSessionID: string
rootSessionID: string
}): Error {
const { childDepth, maxDepth, parentSessionID, rootSessionID } = input
return new Error(
`Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.`
)
}
export function createSubagentDescendantLimitError(input: {
rootSessionID: string
descendantCount: number
maxDescendants: number
}): Error {
const { rootSessionID, descendantCount, maxDescendants } = input
return new Error(
`Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.`
)
}
@@ -1,4 +1,5 @@
import { describe, it, expect, mock } from "bun:test"
declare const require: (name: string) => any
const { describe, it, expect, mock } = require("bun:test")
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
import type { BackgroundTask } from "./types"
@@ -419,6 +420,21 @@ describe("checkAndInterruptStaleTasks", () => {
})
describe("pruneStaleTasksAndNotifications", () => {
function createTerminalTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "terminal-task",
parentSessionID: "parent",
parentMessageID: "msg",
description: "terminal",
prompt: "terminal",
agent: "explore",
status: "completed",
startedAt: new Date(Date.now() - 40 * 60 * 1000),
completedAt: new Date(Date.now() - 31 * 60 * 1000),
...overrides,
}
}
it("should prune tasks that exceeded TTL", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
@@ -447,4 +463,52 @@ describe("pruneStaleTasksAndNotifications", () => {
//#then
expect(pruned).toContain("old-task")
})
it("should prune terminal tasks when completion time exceeds terminal TTL", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
const terminalStatuses: BackgroundTask["status"][] = ["completed", "error", "cancelled", "interrupt"]
for (const status of terminalStatuses) {
tasks.set(status, createTerminalTask({
id: status,
description: status,
prompt: status,
status,
}))
}
const pruned: string[] = []
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications: new Map<string, BackgroundTask[]>(),
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toEqual([])
expect(Array.from(tasks.keys())).toEqual([])
})
it("should keep terminal tasks with pending notifications until notification cleanup", () => {
//#given
const task = createTerminalTask()
const tasks = new Map<string, BackgroundTask>([[task.id, task]])
const notifications = new Map<string, BackgroundTask[]>([[task.parentSessionID, [task]]])
const pruned: string[] = []
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications,
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toEqual([])
expect(tasks.has(task.id)).toBe(true)
expect(notifications.has(task.parentSessionID)).toBe(false)
})
})
@@ -13,6 +13,15 @@ import {
} from "./constants"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
"cancelled",
"interrupt",
])
export function pruneStaleTasksAndNotifications(args: {
tasks: Map<string, BackgroundTask>
notifications: Map<string, BackgroundTask[]>
@@ -20,8 +29,29 @@ export function pruneStaleTasksAndNotifications(args: {
}): void {
const { tasks, notifications, onTaskPruned } = args
const now = Date.now()
const tasksWithPendingNotifications = new Set<string>()
for (const queued of notifications.values()) {
for (const task of queued) {
tasksWithPendingNotifications.add(task.id)
}
}
for (const [taskId, task] of tasks.entries()) {
if (TERMINAL_TASK_STATUSES.has(task.status)) {
if (tasksWithPendingNotifications.has(taskId)) continue
const completedAt = task.completedAt?.getTime()
if (!completedAt) continue
const age = now - completedAt
if (age <= TERMINAL_TASK_TTL_MS) continue
removeTaskToastTracking(taskId)
tasks.delete(taskId)
continue
}
const timestamp = task.status === "pending"
? task.queuedAt?.getTime()
: task.startedAt?.getTime()
+4
View File
@@ -1,4 +1,5 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
export type BackgroundTaskStatus =
| "pending"
@@ -19,11 +20,13 @@ export interface TaskProgress {
export interface BackgroundTask {
id: string
sessionID?: string
rootSessionID?: string
parentSessionID: string
parentMessageID: string
description: string
prompt: string
agent: string
spawnDepth?: number
status: BackgroundTaskStatus
queuedAt?: Date
startedAt?: Date
@@ -72,6 +75,7 @@ export interface LaunchInput {
skills?: string[]
skillContent?: string
category?: string
sessionPermission?: SessionPermissionRule[]
}
export interface ResumeInput {
@@ -0,0 +1,108 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
describe("mapClaudeModelToOpenCode", () => {
describe("#given undefined or empty input", () => {
it("#when called with undefined #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(undefined)).toBeUndefined()
})
it("#when called with empty string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("")).toBeUndefined()
})
it("#when called with whitespace-only string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(" ")).toBeUndefined()
})
})
describe("#given Claude Code alias", () => {
it("#when called with sonnet #then maps to anthropic claude-sonnet-4-6 object", () => {
expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => {
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})
it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => {
expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" })
})
it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => {
expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})
describe("#given inherit", () => {
it("#when called with inherit #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("inherit")).toBeUndefined()
})
})
describe("#given bare Claude model name", () => {
it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" })
})
it("#when called with claude-opus-4-6 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})
it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5-20251001" })
})
it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})
describe("#given model with dot version numbers", () => {
it("#when called with claude-3.5-sonnet #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
})
it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})
describe("#given model already in provider/model format", () => {
it("#when called with anthropic/claude-sonnet-4-6 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
it("#when called with openai/gpt-5.2 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
})
})
describe("#given non-Claude bare model", () => {
it("#when called with gpt-5.2 #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gpt-5.2")).toBeUndefined()
})
it("#when called with gemini-3-flash #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBeUndefined()
})
})
describe("#given prototype property name", () => {
it("#when called with constructor #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("constructor")).toBeUndefined()
})
it("#when called with toString #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("toString")).toBeUndefined()
})
})
describe("#given model with leading/trailing whitespace", () => {
it("#when called with padded string #then trims before returning object format", () => {
expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})
})
@@ -0,0 +1,39 @@
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { normalizeModelID } from "../../shared/model-normalization"
const ANTHROPIC_PREFIX = "anthropic/"
const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`],
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`],
["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`],
])
function mapClaudeModelString(model: string | undefined): string | undefined {
if (!model) return undefined
const trimmed = model.trim()
if (trimmed.length === 0) return undefined
if (trimmed === "inherit") return undefined
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
if (aliasResult) return aliasResult
if (trimmed.includes("/")) return trimmed
const normalized = normalizeModelID(trimmed)
if (normalized.startsWith("claude-")) {
return `${ANTHROPIC_PREFIX}${normalized}`
}
return undefined
}
export function mapClaudeModelToOpenCode(
model: string | undefined
): { providerID: string; modelID: string } | undefined {
const mappedModel = mapClaudeModelString(model)
return mappedModel ? normalizeModelFormat(mappedModel) : undefined
}
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
const formattedDescription = `(${scope}) ${originalDescription}`
const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: data.mode || "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
}
const toolsConfig = parseToolsConfig(data.tools)
@@ -67,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
return agents
}
export function loadUserAgents(): Record<string, AgentConfig> {
export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadProjectAgents(directory?: string): Record<string, AgentConfig> {
export function loadProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -2,6 +2,10 @@ import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentScope = "user" | "project"
export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
model?: string | { providerID: string; modelID: string }
}
export interface AgentFrontmatter {
name?: string
description?: string
@@ -13,6 +17,6 @@ export interface AgentFrontmatter {
export interface LoadedAgent {
name: string
path: string
config: AgentConfig
config: ClaudeCodeAgentConfig
scope: AgentScope
}
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { basename, join } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { log } from "../../shared/logger"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper"
import type { LoadedPlugin } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
@@ -24,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefine
return result
}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
const agents: Record<string, ClaudeCodeAgentConfig> = {}
for (const plugin of plugins) {
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
@@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
}
const toolsConfig = parseToolsConfig(data.tools)
@@ -1,7 +1,7 @@
import { log } from "../../shared/logger"
import type { AgentConfig } from "@opencode-ai/sdk"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
import type { ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types"
import { discoverInstalledPlugins } from "./discovery"
import { loadPluginCommands } from "./command-loader"
@@ -20,7 +20,7 @@ export { loadPluginHooksConfigs } from "./hook-loader"
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
agents: Record<string, ClaudeCodeAgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
@@ -0,0 +1,155 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { injectGitMasterConfig } from "./git-master-template-injection"
const SAMPLE_TEMPLATE = [
"# Git Master Agent",
"",
"## MODE DETECTION (FIRST STEP)",
"",
"Analyze the request.",
"",
"```bash",
"git status",
"git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null",
"MERGE_BASE=$(git merge-base HEAD main)",
"GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $MERGE_BASE",
"```",
"",
"```",
"</execution>",
].join("\n")
describe("#given git_env_prefix config", () => {
describe("#when default config (GIT_MASTER=1)", () => {
it("#then injects env prefix section before MODE DETECTION", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("GIT_MASTER=1 git push")
expect(result).toContain("EVERY git command MUST be prefixed with `GIT_MASTER=1`")
const prefixIndex = result.indexOf("## GIT COMMAND PREFIX")
const modeIndex = result.indexOf("## MODE DETECTION")
expect(prefixIndex).toBeLessThan(modeIndex)
})
})
describe("#when git_env_prefix is empty string", () => {
it("#then does NOT inject env prefix section", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "",
})
expect(result).not.toContain("## GIT COMMAND PREFIX")
expect(result).not.toContain("GIT_MASTER=1")
expect(result).not.toContain("git_env_prefix")
})
})
describe("#when git_env_prefix is custom value", () => {
it("#then injects custom prefix in section", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "MY_HOOK=active",
})
expect(result).toContain("MY_HOOK=active git status")
expect(result).toContain("MY_HOOK=active git commit")
expect(result).not.toContain("GIT_MASTER=1")
})
})
describe("#when git_env_prefix contains shell metacharacters", () => {
it("#then rejects the malicious value", () => {
expect(() =>
injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "A=1; rm -rf /",
})
).toThrow('git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"')
})
})
describe("#when no config provided", () => {
it("#then uses default GIT_MASTER=1 prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE)
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
})
})
})
describe("#given git_env_prefix with commit footer", () => {
describe("#when both env prefix and footer are enabled", () => {
it("#then commit examples include the env prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("Ultraworked with [Sisyphus]")
})
})
describe("#when the template already contains bare git commands in bash blocks", () => {
it("#then prefixes every git invocation in the final output", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain(
"GIT_MASTER=1 git merge-base HEAD main 2>/dev/null || GIT_MASTER=1 git merge-base HEAD master 2>/dev/null"
)
expect(result).toContain("MERGE_BASE=$(GIT_MASTER=1 git merge-base HEAD main)")
expect(result).toContain(
"GIT_SEQUENCE_EDITOR=: GIT_MASTER=1 git rebase -i --autosquash $MERGE_BASE"
)
})
})
describe("#when env prefix disabled but footer enabled", () => {
it("#then commit examples have no env prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "",
})
expect(result).not.toContain("GIT_MASTER=1 git commit")
expect(result).toContain("git commit -m")
expect(result).toContain("Ultraworked with [Sisyphus]")
})
})
describe("#when both env prefix and co-author are enabled", () => {
it("#then commit example includes prefix, footer, and co-author", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("Ultraworked with [Sisyphus]")
expect(result).toContain("Co-authored-by: Sisyphus")
})
})
})
@@ -1,14 +1,88 @@
import type { GitMasterConfig } from "../../config/schema"
import { assertValidGitEnvPrefix, type GitMasterConfig } from "../../config/schema"
const BASH_CODE_BLOCK_PATTERN = /```bash\r?\n([\s\S]*?)```/g
const LEADING_GIT_COMMAND_PATTERN = /^([ \t]*(?:[A-Za-z_][A-Za-z0-9_]*=[^ \t]+\s+)*)git(?=[ \t]|$)/gm
const INLINE_GIT_COMMAND_PATTERN = /([;&|()][ \t]*)git(?=[ \t]|$)/g
export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
const commitFooter = config?.commit_footer ?? true
const includeCoAuthoredBy = config?.include_co_authored_by ?? true
const gitEnvPrefix = assertValidGitEnvPrefix(config?.git_env_prefix ?? "GIT_MASTER=1")
if (!commitFooter && !includeCoAuthoredBy) {
return template
let result = gitEnvPrefix ? injectGitEnvPrefix(template, gitEnvPrefix) : template
if (commitFooter || includeCoAuthoredBy) {
const injection = buildCommitFooterInjection(commitFooter, includeCoAuthoredBy, gitEnvPrefix)
const insertionPoint = result.indexOf("```\n</execution>")
result =
insertionPoint !== -1
? result.slice(0, insertionPoint) +
"```\n\n" +
injection +
"\n</execution>" +
result.slice(insertionPoint + "```\n</execution>".length)
: result + "\n\n" + injection
}
return gitEnvPrefix ? prefixGitCommandsInBashCodeBlocks(result, gitEnvPrefix) : result
}
function injectGitEnvPrefix(template: string, prefix: string): string {
const envPrefixSection = [
"## GIT COMMAND PREFIX (MANDATORY)",
"",
`<git_env_prefix>`,
`**EVERY git command MUST be prefixed with \`${prefix}\`.**`,
"",
"This allows custom git hooks to detect when git-master skill is active.",
"",
"```bash",
`${prefix} git status`,
`${prefix} git add <files>`,
`${prefix} git commit -m "message"`,
`${prefix} git push`,
`${prefix} git rebase ...`,
`${prefix} git log ...`,
"```",
"",
"**NO EXCEPTIONS. Every `git` invocation must include this prefix.**",
`</git_env_prefix>`,
].join("\n")
const modeDetectionMarker = "## MODE DETECTION (FIRST STEP)"
const markerIndex = template.indexOf(modeDetectionMarker)
if (markerIndex !== -1) {
return (
template.slice(0, markerIndex) +
envPrefixSection +
"\n\n---\n\n" +
template.slice(markerIndex)
)
}
return envPrefixSection + "\n\n---\n\n" + template
}
function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): string {
return template.replace(BASH_CODE_BLOCK_PATTERN, (block, codeBlock: string) => {
return block.replace(codeBlock, prefixGitCommandsInCodeBlock(codeBlock, prefix))
})
}
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
return codeBlock
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
}
function buildCommitFooterInjection(
commitFooter: boolean | string,
includeCoAuthoredBy: boolean,
gitEnvPrefix: string,
): string {
const sections: string[] = []
const cmdPrefix = gitEnvPrefix ? `${gitEnvPrefix} ` : ""
sections.push("### 5.5 Commit Footer & Co-Author")
sections.push("")
@@ -43,7 +117,7 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
sections.push("**Example (both enabled):**")
sections.push("```bash")
sections.push(
`git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
)
sections.push("```")
} else if (commitFooter) {
@@ -53,29 +127,16 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)"
sections.push("**Example:**")
sections.push("```bash")
sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`)
sections.push(`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}"`)
sections.push("```")
} else if (includeCoAuthoredBy) {
sections.push("**Example:**")
sections.push("```bash")
sections.push(
"git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>\""
`${cmdPrefix}git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
)
sections.push("```")
}
const injection = sections.join("\n")
const insertionPoint = template.indexOf("```\n</execution>")
if (insertionPoint !== -1) {
return (
template.slice(0, insertionPoint) +
"```\n\n" +
injection +
"\n</execution>" +
template.slice(insertionPoint + "```\n</execution>".length)
)
}
return template + "\n\n" + injection
return sections.join("\n")
}
@@ -228,6 +228,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -249,6 +250,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -269,6 +271,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -302,6 +305,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: false,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -322,6 +326,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: customFooter,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -341,6 +346,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
+1
View File
@@ -10,6 +10,7 @@ export * from "./session-status-parser"
export * from "./session-message-count"
export * from "./session-ready-waiter"
export * from "./types"
export * from "./pane-state-parser"
export * from "./pane-state-querier"
export * from "./decision-engine"
export * from "./action-executor"
@@ -0,0 +1,72 @@
/// <reference path="../../../bun-test.d.ts" />
import { describe, expect, it } from "bun:test"
import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => {
it("rejects malformed integer fields", () => {
// given
const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects negative integer fields", () => {
// given
const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects empty integer fields", () => {
// given
const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects non-binary active flags", () => {
// given
const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects numeric active flags other than zero or one", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
it("rejects empty active flags", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).toBe(null)
})
})
@@ -0,0 +1,135 @@
import type { TmuxPaneInfo } from "./types"
const MANDATORY_PANE_FIELD_COUNT = 8
type ParsedPaneState = {
windowWidth: number
windowHeight: number
panes: TmuxPaneInfo[]
}
type ParsedPaneLine = {
pane: TmuxPaneInfo
windowWidth: number
windowHeight: number
}
type MandatoryPaneFields = [
paneId: string,
widthString: string,
heightString: string,
leftString: string,
topString: string,
activeString: string,
windowWidthString: string,
windowHeightString: string,
]
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
const lines = stdout
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.length > 0)
if (lines.length === 0) return null
const parsedPaneLines = lines
.map(parsePaneLine)
.filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null)
if (parsedPaneLines.length === 0) return null
const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1]
if (!latestPaneLine) return null
return {
windowWidth: latestPaneLine.windowWidth,
windowHeight: latestPaneLine.windowHeight,
panes: parsedPaneLines.map(({ pane }) => pane),
}
}
function parsePaneLine(line: string): ParsedPaneLine | null {
const fields = line.split("\t")
const mandatoryFields = getMandatoryPaneFields(fields)
if (!mandatoryFields) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
const width = parseInteger(widthString)
const height = parseInteger(heightString)
const left = parseInteger(leftString)
const top = parseInteger(topString)
const isActive = parseActiveValue(activeString)
const windowWidth = parseInteger(windowWidthString)
const windowHeight = parseInteger(windowHeightString)
if (
width === null ||
height === null ||
left === null ||
top === null ||
isActive === null ||
windowWidth === null ||
windowHeight === null
) {
return null
}
return {
pane: {
paneId,
width,
height,
left,
top,
title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"),
isActive,
},
windowWidth,
windowHeight,
}
}
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
if (
paneId === undefined ||
widthString === undefined ||
heightString === undefined ||
leftString === undefined ||
topString === undefined ||
activeString === undefined ||
windowWidthString === undefined ||
windowHeightString === undefined
) {
return null
}
return [
paneId,
widthString,
heightString,
leftString,
topString,
activeString,
windowWidthString,
windowHeightString,
]
}
function parseInteger(value: string): number | null {
if (!/^\d+$/.test(value)) return null
const parsedValue = Number.parseInt(value, 10)
return Number.isNaN(parsedValue) ? null : parsedValue
}
function parseActiveValue(value: string): boolean | null {
if (value === "1") return true
if (value === "0") return false
return null
}
@@ -0,0 +1,75 @@
/// <reference types="bun-types/test" />
import { describe, expect, it } from "bun:test"
import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => {
it("accepts a single pane when tmux omits the empty trailing title field", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result).toEqual({
windowWidth: 120,
windowHeight: 40,
panes: [
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
],
})
})
it("handles CRLF line endings without dropping panes", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result?.panes).toEqual([
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
{
paneId: "%1",
width: 60,
height: 40,
left: 60,
top: 0,
title: "agent",
isActive: false,
},
])
})
it("preserves tabs inside pane titles", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
})
})
@@ -1,5 +1,6 @@
import { spawn } from "bun"
import type { WindowState, TmuxPaneInfo } from "./types"
import { parsePaneStateOutput } from "./pane-state-parser"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { log } from "../../shared"
@@ -27,32 +28,18 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
return null
}
const lines = stdout.trim().replace(/\r/g, "").split("\n").filter(Boolean)
if (lines.length === 0) return null
let windowWidth = 0
let windowHeight = 0
const panes: TmuxPaneInfo[] = []
for (const line of lines) {
const fields = line.split("\t")
if (fields.length < 8) continue
const [paneId, widthStr, heightStr, leftStr, topStr, activeStr, windowWidthStr, windowHeightStr] = fields
const title = fields.length > 8 ? fields.slice(8).join("\t") : ""
const width = parseInt(widthStr, 10)
const height = parseInt(heightStr, 10)
const left = parseInt(leftStr, 10)
const top = parseInt(topStr, 10)
const isActive = activeStr === "1"
windowWidth = parseInt(windowWidthStr, 10)
windowHeight = parseInt(windowHeightStr, 10)
if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
panes.push({ paneId, width, height, left, top, title, isActive })
}
const parsedPaneState = parsePaneStateOutput(stdout)
if (!parsedPaneState) {
log("[pane-state-querier] failed to parse pane state output", {
sourcePaneId,
})
return null
}
const { panes } = parsedPaneState
const windowWidth = parsedPaneState.windowWidth
const windowHeight = parsedPaneState.windowHeight
panes.sort((a, b) => a.left - b.left || a.top - b.top)
const mainPane = panes.reduce<TmuxPaneInfo | null>((selected, pane) => {
+6
View File
@@ -110,6 +110,7 @@ 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({
@@ -192,6 +193,11 @@ 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`, {
+31
View File
@@ -1042,6 +1042,37 @@ 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")
+1
View File
@@ -7,6 +7,7 @@ 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
+1 -1
View File
@@ -3,7 +3,7 @@ export { getLocalDevVersion } from "./checker/local-dev-version"
export { findPluginEntry } from "./checker/plugin-entry"
export type { PluginEntryInfo } from "./checker/plugin-entry"
export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
export { updatePinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
@@ -10,11 +10,8 @@ interface CachePackageJson {
}
export interface SyncResult {
/** Whether the package.json was successfully synced/updated */
synced: boolean
/** Whether there was an error during sync (null if no error) */
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
/** Human-readable message describing what happened */
message?: string
}
@@ -28,35 +25,13 @@ function safeUnlink(filePath: string): void {
}
}
/**
* Determine the version specifier to use in cache package.json based on opencode.json intent.
*
* - "oh-my-opencode" (no version) → "latest"
* - "oh-my-opencode@latest" → "latest"
* - "oh-my-opencode@next" → "next"
* - "oh-my-opencode@3.10.0" → "3.10.0" (pinned, use as-is)
*/
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
if (!pluginInfo.pinnedVersion) {
// No version specified in opencode.json, default to latest
return "latest"
}
return pluginInfo.pinnedVersion
}
/**
* Sync the cache package.json to match the opencode.json plugin intent.
*
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
* This causes issues when users switch from pinned to tag in opencode.json:
* - User changes opencode.json from "oh-my-opencode@3.10.0" to "oh-my-opencode@latest"
* - Cache package.json still has "3.10.0"
* - bun install reinstalls 3.10.0 instead of resolving @latest
*
* This function updates cache package.json to match the user's intent before bun install.
*
* @returns SyncResult with synced status and any error information
*/
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
@@ -95,9 +70,6 @@ export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncR
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
}
// Check if this is a meaningful change:
// - If intent is a tag (latest, next, beta) and current is semver, we need to update
// - If both are semver but different, user explicitly changed versions
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
const currentIsSemver = EXACT_SEMVER_REGEX.test(currentVersion.trim())
+34 -44
View File
@@ -54,6 +54,26 @@ function createPluginInput() {
} as never
}
async function flushScheduledWork(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
await Promise.resolve()
await Promise.resolve()
}
function runSessionCreatedEvent(
hook: ReturnType<HookFactory>,
properties?: { info?: { parentID?: string } }
): void {
hook.event({
event: {
type: "session.created",
properties,
},
})
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
@@ -85,13 +105,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: undefined } })
await flushScheduledWork()
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -108,12 +123,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives on primary session
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -129,13 +140,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event contains parentID
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: "parent-123" } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -152,17 +158,9 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event is fired twice
hook.event({
event: {
type: "session.created",
},
})
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -179,12 +177,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -206,7 +200,7 @@ describe("createAutoUpdateCheckerHook", () => {
type: "session.deleted",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -225,12 +219,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup toast includes sisyphus wording
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
@@ -0,0 +1,56 @@
import {
createSystemDirective,
SystemDirectiveTypes,
} from "../../shared/system-directive"
export const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
When summarizing this session, you MUST include the following sections in your summary:
## 1. User Requests (As-Is)
- List all original user requests exactly as they were stated
- Preserve the user's exact wording and intent
## 2. Final Goal
- What the user ultimately wanted to achieve
- The end result or deliverable expected
## 3. Work Completed
- What has been done so far
- Files created/modified
- Features implemented
- Problems solved
## 4. Remaining Tasks
- What still needs to be done
- Pending items from the original request
- Follow-up tasks identified during the work
## 5. Active Working Context (For Seamless Continuation)
- **Files**: Paths of files currently being edited or frequently referenced
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
- **External References**: Documentation URLs, library APIs, or external resources being consulted
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
## 6. Explicit Constraints (Verbatim Only)
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
- Quote constraints verbatim (do not paraphrase)
- Do NOT invent, add, or modify constraints
- If no explicit constraints exist, write "None"
## 7. Agent Verification State (Critical for Reviewers)
- **Current Agent**: What agent is running (momus, oracle, etc.)
- **Verification Progress**: Files already verified/validated
- **Pending Verifications**: Files still needing verification
- **Previous Rejections**: If reviewer agent, what was rejected and why
- **Acceptance Status**: Current state of review process
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
## 8. Delegated Agent Sessions
- List ALL background agent tasks spawned during this session
- For each: agent name, category, status, description, and **session_id**
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
This context is critical for maintaining continuity after compaction.
`
@@ -0,0 +1,5 @@
export const HOOK_NAME = "compaction-context-injector"
export const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
export const NO_TEXT_TAIL_THRESHOLD = 5
export const RECOVERY_COOLDOWN_MS = 60_000
export const RECENT_COMPACTION_WINDOW_MS = 10 * 60 * 1000
+142 -50
View File
@@ -1,63 +1,60 @@
import type { BackgroundManager } from "../../features/background-agent"
import {
createSystemDirective,
SystemDirectiveTypes,
} from "../../shared/system-directive"
clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { log } from "../../shared/logger"
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
import { finalizeTrackedAssistantMessage, shouldTreatAssistantPartAsOutput, trackAssistantOutput, type TailMonitorState } from "./tail-monitor"
import { resolveSessionID } from "./session-id"
import type { CompactionContextClient, CompactionContextInjector } from "./types"
import { createRecoveryLogic } from "./recovery"
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
export function createCompactionContextInjector(options?: {
ctx?: CompactionContextClient
backgroundManager?: BackgroundManager
}): CompactionContextInjector {
const ctx = options?.ctx
const backgroundManager = options?.backgroundManager
const tailStates = new Map<string, TailMonitorState>()
When summarizing this session, you MUST include the following sections in your summary:
const getTailState = (sessionID: string): TailMonitorState => {
const existing = tailStates.get(sessionID)
if (existing) {
return existing
}
## 1. User Requests (As-Is)
- List all original user requests exactly as they were stated
- Preserve the user's exact wording and intent
const created: TailMonitorState = {
currentHasOutput: false,
consecutiveNoTextMessages: 0,
}
tailStates.set(sessionID, created)
return created
}
## 2. Final Goal
- What the user ultimately wanted to achieve
- The end result or deliverable expected
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
## 3. Work Completed
- What has been done so far
- Files created/modified
- Features implemented
- Problems solved
const capture = async (sessionID: string): Promise<void> => {
if (!ctx || !sessionID) {
return
}
## 4. Remaining Tasks
- What still needs to be done
- Pending items from the original request
- Follow-up tasks identified during the work
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
if (!promptConfig.agent && !promptConfig.model && !promptConfig.tools) {
return
}
## 5. Active Working Context (For Seamless Continuation)
- **Files**: Paths of files currently being edited or frequently referenced
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
- **External References**: Documentation URLs, library APIs, or external resources being consulted
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
setCompactionAgentConfigCheckpoint(sessionID, promptConfig)
log(`[compaction-context-injector] Captured agent checkpoint before compaction`, {
sessionID,
agent: promptConfig.agent,
model: promptConfig.model,
hasTools: !!promptConfig.tools,
})
}
## 6. Explicit Constraints (Verbatim Only)
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
- Quote constraints verbatim (do not paraphrase)
- Do NOT invent, add, or modify constraints
- If no explicit constraints exist, write "None"
## 7. Agent Verification State (Critical for Reviewers)
- **Current Agent**: What agent is running (momus, oracle, etc.)
- **Verification Progress**: Files already verified/validated
- **Pending Verifications**: Files still needing verification
- **Previous Rejections**: If reviewer agent, what was rejected and why
- **Acceptance Status**: Current state of review process
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
## 8. Delegated Agent Sessions
- List ALL background agent tasks spawned during this session
- For each: agent name, category, status, description, and **session_id**
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
This context is critical for maintaining continuity after compaction.
`
export function createCompactionContextInjector(backgroundManager?: BackgroundManager) {
return (sessionID?: string): string => {
const inject = (sessionID?: string): string => {
let prompt = COMPACTION_CONTEXT_PROMPT
if (backgroundManager && sessionID) {
@@ -69,4 +66,99 @@ export function createCompactionContextInjector(backgroundManager?: BackgroundMa
return prompt
}
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionID = resolveSessionID(props)
if (sessionID) {
clearCompactionAgentConfigCheckpoint(sessionID)
tailStates.delete(sessionID)
}
return
}
if (event.type === "session.idle") {
const sessionID = resolveSessionID(props)
if (!sessionID) {
return
}
const noTextCount = finalizeTrackedAssistantMessage(getTailState(sessionID))
if (noTextCount > 0) {
await maybeWarnAboutNoTextTail(sessionID)
}
return
}
if (event.type === "session.compacted") {
const sessionID = resolveSessionID(props)
if (!sessionID) {
return
}
const tailState = getTailState(sessionID)
finalizeTrackedAssistantMessage(tailState)
tailState.lastCompactedAt = Date.now()
await maybeWarnAboutNoTextTail(sessionID)
await recoverCheckpointedAgentConfig(sessionID, "session.compacted")
return
}
if (event.type === "message.updated") {
const info = props?.info as {
id?: string
role?: string
sessionID?: string
} | undefined
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
return
}
const tailState = getTailState(info.sessionID)
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
finalizeTrackedAssistantMessage(tailState)
await maybeWarnAboutNoTextTail(info.sessionID)
}
if (tailState.currentMessageID !== info.id) {
tailState.currentMessageID = info.id
tailState.currentHasOutput = false
}
return
}
if (event.type === "message.part.delta") {
const sessionID = props?.sessionID as string | undefined
const messageID = props?.messageID as string | undefined
const field = props?.field as string | undefined
const delta = props?.delta as string | undefined
if (!sessionID || field !== "text" || !delta?.trim()) {
return
}
trackAssistantOutput(getTailState(sessionID), messageID)
return
}
if (event.type === "message.part.updated") {
const part = props?.part as {
messageID?: string
sessionID?: string
type?: string
text?: string
} | undefined
if (!part?.sessionID || !shouldTreatAssistantPartAsOutput(part)) {
return
}
trackAssistantOutput(getTailState(part.sessionID), part.messageID)
}
}
return { capture, inject, event }
}
@@ -17,6 +17,27 @@ mock.module("../../shared/system-directive", () => ({
import { createCompactionContextInjector } from "./index"
import { TaskHistory } from "../../features/background-agent/task-history"
function createMockContext(
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
promptAsyncMock = mock(async () => ({})),
) {
let callIndex = 0
return {
client: {
session: {
messages: mock(async () => {
const response = messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
callIndex += 1
return { data: response }
}),
promptAsync: promptAsyncMock,
},
},
directory: "/tmp/test",
}
}
describe("createCompactionContextInjector", () => {
describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => {
@@ -24,7 +45,7 @@ describe("createCompactionContextInjector", () => {
const injector = createCompactionContextInjector()
//#when
const prompt = injector()
const prompt = injector.inject()
//#then
expect(prompt).toContain("Agent Verification State")
@@ -37,7 +58,7 @@ describe("createCompactionContextInjector", () => {
const injector = createCompactionContextInjector()
//#when
const prompt = injector()
const prompt = injector.inject()
//#then
expect(prompt).toContain("Previous Rejections")
@@ -50,7 +71,7 @@ describe("createCompactionContextInjector", () => {
const injector = createCompactionContextInjector()
//#when
const prompt = injector()
const prompt = injector.inject()
//#then
expect(prompt).toContain("Pending Verifications")
@@ -63,7 +84,7 @@ describe("createCompactionContextInjector", () => {
const injector = createCompactionContextInjector()
//#when
const prompt = injector()
const prompt = injector.inject()
//#then
expect(prompt).toContain("Explicit Constraints (Verbatim Only)")
@@ -77,7 +98,7 @@ describe("createCompactionContextInjector", () => {
const injector = createCompactionContextInjector()
//#when
const prompt = injector()
const prompt = injector.inject()
//#then
expect(prompt).toContain("Delegated Agent Sessions")
@@ -89,10 +110,10 @@ describe("createCompactionContextInjector", () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" })
const injector = createCompactionContextInjector(mockManager)
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
//#when
const prompt = injector("ses_parent")
const prompt = injector.inject("ses_parent")
//#then
expect(prompt).toContain("Active/Recent Delegated Sessions")
@@ -104,13 +125,152 @@ describe("createCompactionContextInjector", () => {
it("does not inject task history section when no entries exist", async () => {
//#given
const mockManager = { taskHistory: new TaskHistory() } as any
const injector = createCompactionContextInjector(mockManager)
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
//#when
const prompt = injector("ses_empty")
const prompt = injector.inject("ses_empty")
//#then
expect(prompt).not.toContain("Active/Recent Delegated Sessions")
})
})
describe("agent checkpoint recovery", () => {
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: "allow" },
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
],
promptAsyncMock,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_checkpoint")
await injector.event({
event: { type: "session.compacted", properties: { sessionID: "ses_checkpoint" } },
})
//#then
expect(promptAsyncMock).toHaveBeenCalledWith({
path: { id: "ses_checkpoint" },
body: {
noReply: true,
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
parts: [
{
type: "text",
text: expect.stringContaining("restore checkpointed session agent configuration"),
},
],
},
query: { directory: "/tmp/test" },
})
})
it("recovers after five consecutive assistant messages with no text", async () => {
//#given
const promptAsyncMock = mock(async () => ({}))
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
],
promptAsyncMock,
)
const injector = createCompactionContextInjector({ ctx })
await injector.capture("ses_no_text_tail")
await injector.event({
event: { type: "session.compacted", properties: { sessionID: "ses_no_text_tail" } },
})
//#when
for (let index = 1; index <= 5; index++) {
await injector.event({
event: {
type: "message.updated",
properties: {
info: {
id: `msg_${index}`,
role: "assistant",
sessionID: "ses_no_text_tail",
},
},
},
})
}
await injector.event({
event: { type: "session.idle", properties: { sessionID: "ses_no_text_tail" } },
})
//#then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(promptAsyncMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: "ses_no_text_tail" },
body: expect.objectContaining({
noReply: true,
agent: "atlas",
}),
}),
)
})
})
})
@@ -0,0 +1,76 @@
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
export type RecoveryPromptConfig = CompactionAgentConfigCheckpoint & {
agent: string
}
function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
}
function matchesExpectedModel(
actualModel: CompactionAgentConfigCheckpoint["model"],
expectedModel: CompactionAgentConfigCheckpoint["model"],
): boolean {
if (!expectedModel) {
return true
}
return (
actualModel?.providerID === expectedModel.providerID &&
actualModel.modelID === expectedModel.modelID
)
}
function matchesExpectedTools(
actualTools: CompactionAgentConfigCheckpoint["tools"],
expectedTools: CompactionAgentConfigCheckpoint["tools"],
): boolean {
if (!expectedTools) {
return true
}
if (!actualTools) {
return false
}
const expectedEntries = Object.entries(expectedTools)
if (expectedEntries.length !== Object.keys(actualTools).length) {
return false
}
return expectedEntries.every(
([toolName, isAllowed]) => actualTools[toolName] === isAllowed,
)
}
export function createExpectedRecoveryPromptConfig(
checkpoint: Pick<RecoveryPromptConfig, "agent"> & CompactionAgentConfigCheckpoint,
currentPromptConfig: CompactionAgentConfigCheckpoint,
): RecoveryPromptConfig {
const model = checkpoint.model ?? currentPromptConfig.model
const tools = checkpoint.tools ?? currentPromptConfig.tools
return {
agent: checkpoint.agent,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
}
}
export function isPromptConfigRecovered(
actualPromptConfig: CompactionAgentConfigCheckpoint,
expectedPromptConfig: RecoveryPromptConfig,
): boolean {
const actualAgent = actualPromptConfig.agent
const agentMatches =
typeof actualAgent === "string" &&
!isCompactionAgent(actualAgent) &&
actualAgent.toLowerCase() === expectedPromptConfig.agent.toLowerCase()
return (
agentMatches &&
matchesExpectedModel(actualPromptConfig.model, expectedPromptConfig.model) &&
matchesExpectedTools(actualPromptConfig.tools, expectedPromptConfig.tools)
)
}
@@ -0,0 +1,360 @@
/// <reference path="../../../bun-test.d.ts" />
import { describe, expect, it } from "bun:test"
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import { createCompactionContextInjector } from "./index"
type SessionMessageResponse = Array<{
info?: Record<string, unknown>
}>
type PromptAsyncInput = {
path: { id: string }
body: {
noReply?: boolean
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean>
parts: Array<{ type: "text"; text: string }>
}
query?: { directory: string }
}
function createPromptAsyncRecorder(): {
calls: PromptAsyncInput[]
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>
} {
const calls: PromptAsyncInput[] = []
return {
calls,
promptAsync: async (input: PromptAsyncInput) => {
calls.push(input)
return {}
},
}
}
function createMockContext(
messageResponses: SessionMessageResponse[],
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>,
) {
let callIndex = 0
return {
client: {
session: {
messages: async () => {
const response =
messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
callIndex += 1
return { data: response }
},
promptAsync,
},
},
directory: "/tmp/test",
}
}
function createAssistantMessageUpdatedEvent(sessionID: string, messageID: string) {
return {
event: {
type: "message.updated",
properties: {
info: {
id: messageID,
role: "assistant",
sessionID,
},
},
},
} as const
}
function createMeaningfulPartUpdatedEvent(
sessionID: string,
messageID: string,
type: "reasoning" | "tool_use",
) {
return {
event: {
type: "message.part.updated",
properties: {
part: {
messageID,
sessionID,
type,
...(type === "reasoning" ? { text: "thinking" } : {}),
},
},
},
} as const
}
describe("createCompactionContextInjector recovery", () => {
it("re-injects after compaction when agent and model match but tools are missing", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_missing_tools")
await injector.event({
event: { type: "session.compacted", properties: { sessionID: "ses_missing_tools" } },
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(1)
expect(promptAsyncRecorder.calls[0]?.body.agent).toBe("atlas")
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
})
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const mismatchResponse = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-4.1" },
},
},
]
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
mismatchResponse,
mismatchResponse,
mismatchResponse,
mismatchResponse,
mismatchResponse,
mismatchResponse,
],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.capture("ses_retry_incomplete_recovery")
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID: "ses_retry_incomplete_recovery" },
},
})
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID: "ses_retry_incomplete_recovery" },
},
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(2)
})
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const matchingPromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
]
const ctx = createMockContext(
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
const sessionID = "ses_reasoning_tail"
await injector.capture(sessionID)
await injector.event({
event: { type: "session.compacted", properties: { sessionID } },
})
//#when
for (let index = 1; index <= 5; index++) {
const messageID = `msg_reasoning_${index}`
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
await injector.event(
createMeaningfulPartUpdatedEvent(sessionID, messageID, "reasoning"),
)
await injector.event({
event: { type: "session.idle", properties: { sessionID } },
})
}
//#then
expect(promptAsyncRecorder.calls.length).toBe(0)
})
it("does not treat tool_use-only assistant messages as a no-text tail", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const matchingPromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
]
const ctx = createMockContext(
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
const sessionID = "ses_tool_use_tail"
await injector.capture(sessionID)
await injector.event({
event: { type: "session.compacted", properties: { sessionID } },
})
//#when
for (let index = 1; index <= 5; index++) {
const messageID = `msg_tool_use_${index}`
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
await injector.event(
createMeaningfulPartUpdatedEvent(sessionID, messageID, "tool_use"),
)
await injector.event({
event: { type: "session.idle", properties: { sessionID } },
})
}
//#then
expect(promptAsyncRecorder.calls.length).toBe(0)
})
it("falls back to the current non-compaction model when a checkpoint model is poisoned", async () => {
//#given
const sessionID = "ses_poisoned_checkpoint_model"
const promptAsyncRecorder = createPromptAsyncRecorder()
setCompactionAgentConfigCheckpoint(sessionID, {
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
tools: { bash: true },
})
const ctx = createMockContext(
[
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
],
[
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
},
},
],
],
promptAsyncRecorder.promptAsync,
)
const injector = createCompactionContextInjector({ ctx })
//#when
await injector.event({
event: { type: "session.compacted", properties: { sessionID } },
})
//#then
expect(promptAsyncRecorder.calls.length).toBe(1)
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
})
})
@@ -0,0 +1,159 @@
import { updateSessionAgent } from "../../features/claude-code-session-state"
import {
getCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { setSessionModel } from "../../shared/session-model-state"
import { setSessionTools } from "../../shared/session-tools-store"
import {
createExpectedRecoveryPromptConfig,
isPromptConfigRecovered,
} from "./recovery-prompt-config"
import { validateCheckpointModel } from "./validated-model"
import {
resolveLatestSessionPromptConfig,
resolveSessionPromptConfig,
} from "./session-prompt-config-resolver"
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
import type { CompactionContextClient } from "./types"
import type { TailMonitorState } from "./tail-monitor"
export function createRecoveryLogic(
ctx: CompactionContextClient | undefined,
getTailState: (sessionID: string) => TailMonitorState,
) {
const recoverCheckpointedAgentConfig = async (
sessionID: string,
reason: "session.compacted" | "no-text-tail",
): Promise<boolean> => {
if (!ctx) {
return false
}
const checkpoint = getCompactionAgentConfigCheckpoint(sessionID)
if (!checkpoint?.agent) {
return false
}
const tailState = getTailState(sessionID)
const now = Date.now()
if (tailState.lastRecoveryAt && now - tailState.lastRecoveryAt < RECOVERY_COOLDOWN_MS) {
return false
}
const currentPromptConfig = await resolveSessionPromptConfig(ctx, sessionID)
const validatedCheckpointModel = validateCheckpointModel(
checkpoint.model,
currentPromptConfig.model,
)
const { model: checkpointModel, ...checkpointWithoutModel } = checkpoint
const checkpointWithAgent = {
...checkpointWithoutModel,
agent: checkpoint.agent,
...(validatedCheckpointModel ? { model: validatedCheckpointModel } : {}),
}
if (checkpointModel && !validatedCheckpointModel) {
log(`[compaction-context-injector] Ignoring checkpoint model that disagrees with current prompt config`, {
sessionID,
checkpointModel,
currentModel: currentPromptConfig.model,
})
}
const expectedPromptConfig = createExpectedRecoveryPromptConfig(
checkpointWithAgent,
currentPromptConfig,
)
const model = expectedPromptConfig.model
const tools = expectedPromptConfig.tools
if (reason === "session.compacted") {
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
return false
}
}
try {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: true,
agent: expectedPromptConfig.agent,
...(model ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
},
query: { directory: ctx.directory },
})
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
log(`[compaction-context-injector] Re-injected agent config but recovery is still incomplete`, {
sessionID,
reason,
agent: expectedPromptConfig.agent,
model,
hasTools: !!tools,
recoveredPromptConfig,
})
return false
}
updateSessionAgent(sessionID, expectedPromptConfig.agent)
if (model) {
setSessionModel(sessionID, model)
}
if (tools) {
setSessionTools(sessionID, tools)
}
tailState.lastRecoveryAt = now
tailState.consecutiveNoTextMessages = 0
log(`[compaction-context-injector] Re-injected checkpointed agent config`, {
sessionID,
reason,
agent: expectedPromptConfig.agent,
model,
})
return true
} catch (error) {
log(`[compaction-context-injector] Failed to re-inject checkpointed agent config`, {
sessionID,
reason,
error: String(error),
})
return false
}
}
const maybeWarnAboutNoTextTail = async (sessionID: string): Promise<void> => {
const tailState = getTailState(sessionID)
if (tailState.consecutiveNoTextMessages < NO_TEXT_TAIL_THRESHOLD) {
return
}
const recentlyCompacted =
tailState.lastCompactedAt !== undefined &&
Date.now() - tailState.lastCompactedAt < RECENT_COMPACTION_WINDOW_MS
log(`[compaction-context-injector] Detected consecutive assistant messages with no text`, {
sessionID,
consecutiveNoTextMessages: tailState.consecutiveNoTextMessages,
recentlyCompacted,
})
if (recentlyCompacted) {
await recoverCheckpointedAgentConfig(sessionID, "no-text-tail")
}
}
return {
recoverCheckpointedAgentConfig,
maybeWarnAboutNoTextTail,
}
}
@@ -0,0 +1,8 @@
export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
}
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
return (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined
}
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "bun:test"
import { _resetForTesting } from "../../features/claude-code-session-state"
import { clearSessionModel, setSessionModel } from "../../shared/session-model-state"
import { clearSessionTools } from "../../shared/session-tools-store"
import {
resolveLatestSessionPromptConfig,
resolveSessionPromptConfig,
} from "./session-prompt-config-resolver"
type SessionMessage = {
info?: {
agent?: string
model?: {
providerID?: string
modelID?: string
}
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}
function createMockContext(messages: SessionMessage[]) {
return {
client: {
session: {
messages: async () => ({ data: messages }),
},
},
directory: "/tmp/test",
}
}
describe("session prompt config resolver", () => {
const sessionID = "ses_compaction_model_validation"
afterEach(() => {
_resetForTesting()
clearSessionModel(sessionID)
clearSessionTools()
})
it("prefers the latest non-compaction model over poisoned session state", async () => {
// given
setSessionModel(sessionID, {
providerID: "anthropic",
modelID: "claude-opus-4-1",
})
const ctx = createMockContext([
{
info: {
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: "allow" },
},
},
{
info: {
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
])
// when
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
// then
expect(promptConfig).toEqual({
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
})
it("omits a compaction model from the latest prompt config", async () => {
// given
const ctx = createMockContext([
{
info: {
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
{
info: {
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
},
},
])
// when
const promptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
// then
expect(promptConfig).toEqual({ agent: "compaction" })
})
})
@@ -0,0 +1,120 @@
import { getSessionAgent } from "../../features/claude-code-session-state"
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared/normalize-sdk-response"
import { normalizePromptTools } from "../../shared/prompt-tools"
import { getSessionModel } from "../../shared/session-model-state"
import { getSessionTools } from "../../shared/session-tools-store"
import { isCompactionAgent } from "./session-id"
import { resolveValidatedModel } from "./validated-model"
type SessionMessage = {
info?: {
agent?: string
model?: {
providerID?: string
modelID?: string
}
providerID?: string
modelID?: string
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}
type ResolverContext = {
client: {
session: {
messages: (input: { path: { id: string } }) => Promise<unknown>
}
}
directory: string
}
export async function resolveSessionPromptConfig(
ctx: ResolverContext,
sessionID: string,
): Promise<CompactionAgentConfigCheckpoint> {
const storedModel = getSessionModel(sessionID)
const promptConfig: CompactionAgentConfigCheckpoint = {
agent: getSessionAgent(sessionID),
tools: getSessionTools(sessionID),
}
try {
const response = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
preferResponseOnMissingData: true,
})
for (let index = messages.length - 1; index >= 0; index--) {
const info = messages[index].info
if (!promptConfig.agent && info?.agent && !isCompactionAgent(info.agent)) {
promptConfig.agent = info.agent
}
if (!promptConfig.model) {
const model = resolveValidatedModel(info)
if (model) {
promptConfig.model = model
}
}
if (!promptConfig.tools) {
const tools = normalizePromptTools(info?.tools)
if (tools) {
promptConfig.tools = tools
}
}
if (promptConfig.agent && promptConfig.model && promptConfig.tools) {
break
}
}
} catch (error) {
log("[compaction-context-injector] Failed to resolve prompt config from messages", {
sessionID,
directory: ctx.directory,
error: String(error),
})
}
if (!promptConfig.model && storedModel) {
promptConfig.model = storedModel
}
return promptConfig
}
export async function resolveLatestSessionPromptConfig(
ctx: ResolverContext,
sessionID: string,
): Promise<CompactionAgentConfigCheckpoint> {
try {
const response = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
preferResponseOnMissingData: true,
})
const latestInfo = messages.at(-1)?.info
if (!latestInfo) {
return {}
}
const model = resolveValidatedModel(latestInfo)
const tools = normalizePromptTools(latestInfo.tools)
return {
...(latestInfo.agent ? { agent: latestInfo.agent } : {}),
...(model ? { model } : {}),
...(tools ? { tools } : {}),
}
} catch (error) {
log("[compaction-context-injector] Failed to resolve latest prompt config", {
sessionID,
directory: ctx.directory,
error: String(error),
})
return {}
}
}
@@ -0,0 +1,52 @@
const MEANINGFUL_ASSISTANT_PART_TYPES = new Set([
"reasoning",
"tool",
"tool_use",
])
export type TailMonitorState = {
currentMessageID?: string
currentHasOutput: boolean
consecutiveNoTextMessages: number
lastCompactedAt?: number
lastRecoveryAt?: number
}
export function finalizeTrackedAssistantMessage(
state: TailMonitorState,
): number {
if (!state.currentMessageID) {
return state.consecutiveNoTextMessages
}
state.consecutiveNoTextMessages = state.currentHasOutput
? 0
: state.consecutiveNoTextMessages + 1
state.currentMessageID = undefined
state.currentHasOutput = false
return state.consecutiveNoTextMessages
}
export function shouldTreatAssistantPartAsOutput(part: {
type?: string
text?: string
}): boolean {
if (part.type === "text") {
return !!part.text?.trim()
}
return typeof part.type === "string" && MEANINGFUL_ASSISTANT_PART_TYPES.has(part.type)
}
export function trackAssistantOutput(
state: TailMonitorState,
messageID?: string,
): void {
if (messageID && !state.currentMessageID) {
state.currentMessageID = messageID
}
state.currentHasOutput = true
state.consecutiveNoTextMessages = 0
}
@@ -0,0 +1,25 @@
export interface CompactionContextInjector {
capture: (sessionID: string) => Promise<void>
inject: (sessionID?: string) => string
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
}
export type CompactionContextClient = {
client: {
session: {
messages: (input: { path: { id: string } }) => Promise<unknown>
promptAsync: (input: {
path: { id: string }
body: {
noReply?: boolean
agent?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean>
parts: Array<{ type: "text"; text: string }>
}
query?: { directory: string }
}) => Promise<unknown>
}
}
directory: string
}
@@ -0,0 +1,47 @@
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import { isCompactionAgent } from "./session-id"
type PromptConfigInfo = {
agent?: string
model?: {
providerID?: string
modelID?: string
}
providerID?: string
modelID?: string
}
export function resolveValidatedModel(
info: PromptConfigInfo | undefined,
): CompactionAgentConfigCheckpoint["model"] | undefined {
if (isCompactionAgent(info?.agent)) {
return undefined
}
const providerID = info?.model?.providerID ?? info?.providerID
const modelID = info?.model?.modelID ?? info?.modelID
if (!providerID || !modelID) {
return undefined
}
return { providerID, modelID }
}
export function validateCheckpointModel(
checkpointModel: CompactionAgentConfigCheckpoint["model"],
currentModel: CompactionAgentConfigCheckpoint["model"],
): CompactionAgentConfigCheckpoint["model"] | undefined {
if (!checkpointModel) {
return undefined
}
if (!currentModel) {
return checkpointModel
}
return checkpointModel.providerID === currentModel.providerID &&
checkpointModel.modelID === currentModel.modelID
? checkpointModel
: undefined
}
@@ -0,0 +1,184 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { createContextWindowMonitorHook } from "./context-window-monitor"
function createOutput() {
return { title: "", output: "original", metadata: null }
}
describe("context-window-monitor modelContextLimitsCache", () => {
it("does not append reminder below cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_below_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 150000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toBe("original")
})
it("appends reminder above cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_above_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 180000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toContain("context remaining")
expect(output.output).toContain("262,144-token context window")
expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]")
expect(output.output).not.toContain("1,000,000")
})
describe("#given Anthropic provider with cached context limit and 1M mode enabled", () => {
describe("#when cached usage would exceed 200K but stay below 1M", () => {
it("#then should ignore the cached limit and skip the reminder", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200000)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: true,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_1m_overrides_cached_limit"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
finish: true,
tokens: {
input: 300000,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toBe("original")
})
})
})
describe("#given Anthropic provider with cached context limit and 1M mode disabled", () => {
describe("#when cached usage exceeds the Anthropic default limit", () => {
it("#then should ignore the cached limit and append the reminder from the default Anthropic limit", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_default_overrides_cached_limit"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
finish: true,
tokens: {
input: 150000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toContain("context remaining")
expect(output.output).toContain("200,000-token context window")
expect(output.output).not.toContain("500,000-token context window")
expect(output.output).not.toContain("1,000,000-token context window")
})
})
})
})
+26 -29
View File
@@ -1,27 +1,21 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const CONTEXT_WARNING_THRESHOLD = 0.70
type ModelCacheStateLike = {
anthropicContext1MEnabled: boolean
}
function createContextReminder(actualLimit: number): string {
const limitTokens = actualLimit.toLocaleString()
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
You are using Anthropic Claude with 1M context window.
You have plenty of context remaining - do NOT rush or skip tasks.
You are using a ${limitTokens}-token context window.
You still have context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
}
interface TokenInfo {
input: number
@@ -32,16 +26,13 @@ interface TokenInfo {
interface CachedTokenState {
providerID: string
modelID: string
tokens: TokenInfo
}
function isAnthropicProvider(providerID: string): boolean {
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
}
export function createContextWindowMonitorHook(
_ctx: PluginInput,
modelCacheState?: ModelCacheStateLike,
modelCacheState?: ContextLimitModelCacheState,
) {
const remindedSessions = new Set<string>()
const tokenCache = new Map<string, CachedTokenState>()
@@ -57,25 +48,29 @@ export function createContextWindowMonitorHook(
const cached = tokenCache.get(sessionID)
if (!cached) return
if (!isAnthropicProvider(cached.providerID)) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (!actualLimit) return
const lastTokens = cached.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const actualUsagePercentage =
totalInputTokens / getAnthropicActualLimit(modelCacheState)
const actualUsagePercentage = totalInputTokens / actualLimit
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
remindedSessions.add(sessionID)
const displayUsagePercentage = totalInputTokens / ANTHROPIC_DISPLAY_LIMIT
const usedPct = (displayUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - displayUsagePercentage) * 100).toFixed(1)
const usedPct = (actualUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
const usedTokens = totalInputTokens.toLocaleString()
const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString()
const limitTokens = actualLimit.toLocaleString()
output.output += `\n\n${CONTEXT_REMINDER}
output.output += `\n\n${createContextReminder(actualLimit)}
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
}
@@ -95,6 +90,7 @@ export function createContextWindowMonitorHook(
role?: string
sessionID?: string
providerID?: string
modelID?: string
finish?: boolean
tokens?: TokenInfo
} | undefined
@@ -104,6 +100,7 @@ export function createContextWindowMonitorHook(
tokenCache.set(info.sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
})
}
@@ -0,0 +1,44 @@
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")
}
@@ -0,0 +1,10 @@
export const HOOK_NAME = "gpt-permission-continuation"
export const CONTINUATION_PROMPT = "continue"
export const DEFAULT_STALL_PATTERNS = [
"if you want",
"would you like",
"shall i",
"do you want me to",
"let me know if",
] as const
@@ -0,0 +1,23 @@
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()))
}
@@ -0,0 +1,150 @@
import { describe, expect, 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 }>
}
function createMockPluginInput(messages: SessionMessage[]) {
const promptCalls: string[] = []
const ctx = {
directory: "/tmp/test",
client: {
session: {
messages: async () => ({ data: messages }),
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 {}
},
},
},
} as any
return { ctx, promptCalls }
}
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"])
})
})
@@ -0,0 +1,116 @@
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 } from "./constants"
import { detectStallPattern } from "./detector"
import type { SessionStateStore } from "./session-state"
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)
}
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 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
}
state.inFlight = true
await promptContinuation(ctx, sessionID)
state.lastHandledMessageID = messageID
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
}
}
}
@@ -0,0 +1,29 @@
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)
},
}
}
@@ -0,0 +1,34 @@
type SessionState = {
inFlight: boolean
lastHandledMessageID?: 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,
}
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)
},
}
}
@@ -0,0 +1,62 @@
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,6 +30,7 @@ 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";
+18 -3
View File
@@ -14,6 +14,14 @@ import {
import type { ContextCollector } from "../../features/context-injector"
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
if (typeof message["variant"] === "string") {
return message["variant"]
}
return typeof input.variant === "string" ? input.variant : undefined
}
return {
"chat.message": async (
input: {
@@ -21,6 +29,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
variant?: string
},
output: {
message: Record<string, unknown>
@@ -72,15 +81,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
if (hasUltrawork) {
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
const runtimeVariant = getRuntimeVariant(input, output.message)
const isRuntimeMax = runtimeVariant === "max"
output.message.variant = "max"
log(`[keyword-detector] Ultrawork mode activated`, {
sessionID: input.sessionID,
runtimeVariant,
})
ctx.client.tui
.showToast({
body: {
title: "Ultrawork Mode Activated",
message: "Maximum precision engaged. All agents at your disposal.",
message: isRuntimeMax
? "Maximum precision engaged. All agents at your disposal."
: "Runtime variant preserved. All agents at your disposal.",
variant: "success" as const,
duration: 3000,
},
+9 -9
View File
@@ -169,8 +169,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should still work (variant set to max)
expect(output.message.variant).toBe("max")
// then - ultrawork should still work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -214,12 +214,12 @@ describe("keyword-detector session filtering", () => {
output
)
// then - all keywords should work
expect(output.message.variant).toBe("max")
// then - all keywords should work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should override existing variant when ultrawork keyword is used", async () => {
test("should preserve existing runtime variant when ultrawork keyword is used", async () => {
// given - main session set with pre-existing variant from TUI
setMainSession("main-123")
@@ -236,8 +236,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should override TUI variant to max
expect(output.message.variant).toBe("max")
// then - ultrawork should preserve the already resolved runtime variant
expect(output.message.variant).toBe("low")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
})
@@ -311,8 +311,8 @@ describe("keyword-detector word boundary", () => {
output
)
// then - ultrawork should be triggered
expect(output.message.variant).toBe("max")
// then - ultrawork should be triggered without forcing max
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})

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