Compare commits

..

16 Commits

Author SHA1 Message Date
YeonGyu-Kim
bb45b0ecee fix: add iteration limits to prevent infinite loops (#1349) 2026-02-08 15:49:48 +09:00
YeonGyu-Kim
d8e7e4f170 refactor: extract git worktree parser from atlas hook 2026-02-08 14:01:31 +09:00
YeonGyu-Kim
2db9accfc7 Merge pull request #1655 from code-yeongyu/fix/sync-continuation-variant-loss
fix: preserve variant in sync continuation to maintain thinking budget
2026-02-08 14:00:56 +09:00
YeonGyu-Kim
6b4e149881 test: assert variant forwarded in sync continuation 2026-02-08 13:57:13 +09:00
YeonGyu-Kim
7f4338b6ed fix: preserve variant in sync continuation to maintain thinking budget 2026-02-08 13:55:35 +09:00
YeonGyu-Kim
24a013b867 Merge pull request #1653 from code-yeongyu/fix/plan-prometheus-decoupling
fix(delegation): decouple plan from prometheus and fix sync task responses
2026-02-08 13:46:40 +09:00
YeonGyu-Kim
d769b95869 fix(delegation): use blocking prompt for sync tasks instead of polling
Replace promptAsync + manual polling loop with promptSyncWithModelSuggestionRetry
(session.prompt) which blocks until the LLM response completes. This matches
OpenCode's native task tool behavior and fixes empty/broken responses that
occurred when polling declared stability prematurely.

Applied to both executeSyncTask and executeSyncContinuation paths.
2026-02-08 13:42:23 +09:00
YeonGyu-Kim
72cf908738 fix(delegation): decouple plan agent from prometheus - remove aliasing
Remove 'prometheus' from PLAN_AGENT_NAMES so isPlanAgent() no longer
matches prometheus. The only remaining connection is model inheritance
via buildPlanDemoteConfig() in plan-model-inheritance.ts.

- Remove 'prometheus' from PLAN_AGENT_NAMES array
- Update self-delegation error message to say 'plan agent' not 'prometheus'
- Update tests: prometheus is no longer treated as a plan agent
- Update task permission: only plan agents get task tool, not prometheus
2026-02-08 13:42:15 +09:00
YeonGyu-Kim
b88a868173 fix(config): plan agent inherits model settings from prometheus when not explicitly configured
Previously, demoted plan agent only received { mode: 'subagent' } with no
model settings, causing fallback to step-3.5-flash. Now inherits all
model-related settings (model, variant, temperature, top_p, maxTokens,
thinking, reasoningEffort, textVerbosity, providerOptions) from the
resolved prometheus config. User overrides via agents.plan.* take priority.

Prompt, permission, description, and color are intentionally NOT inherited.
2026-02-08 13:22:56 +09:00
YeonGyu-Kim
d0bdf521c3 Merge pull request #1649 from code-yeongyu/feat/anthropic-prefill-recovery
feat: auto-recover from Anthropic assistant message prefill errors
2026-02-08 13:19:38 +09:00
YeonGyu-Kim
7abefcca1f feat: auto-recover from Anthropic assistant message prefill errors
When Anthropic models reject requests with 'This model does not support
assistant message prefill', detect this as a recoverable error type and
automatically send 'Continue' once to resume the conversation.

Extends session-recovery hook with new 'assistant_prefill_unsupported'
error type. The existing session.error handler in index.ts already sends
'continue' after successful recovery, so no additional logic needed.
2026-02-08 13:16:16 +09:00
github-actions[bot]
139f392d76 release: v3.3.2 2026-02-08 03:38:39 +00:00
YeonGyu-Kim
71ac54c33e Merge pull request #1622 from itsnebulalol/dev 2026-02-08 11:44:40 +09:00
github-actions[bot]
cbeeee4053 @QiRaining has signed the CLA in code-yeongyu/oh-my-opencode#1641 2026-02-08 02:34:48 +00:00
github-actions[bot]
737bda680c @quantmind-br has signed the CLA in code-yeongyu/oh-my-opencode#1634 2026-02-07 18:38:33 +00:00
Dominic Frye
0cbbdd566e fix(cli): enable positional options on parent command for passThroughOptions 2026-02-07 10:06:13 -05:00
35 changed files with 1017 additions and 336 deletions

3
.gitignore vendored
View File

@@ -35,3 +35,6 @@ test-injection/
notepad.md
oauth-success.html
*.bun-build
# Local test sandbox
.test-home/

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.3.1",
"version": "3.3.2",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.3.1",
"oh-my-opencode-darwin-x64": "3.3.1",
"oh-my-opencode-linux-arm64": "3.3.1",
"oh-my-opencode-linux-arm64-musl": "3.3.1",
"oh-my-opencode-linux-x64": "3.3.1",
"oh-my-opencode-linux-x64-musl": "3.3.1",
"oh-my-opencode-windows-x64": "3.3.1"
"oh-my-opencode-darwin-arm64": "3.3.2",
"oh-my-opencode-darwin-x64": "3.3.2",
"oh-my-opencode-linux-arm64": "3.3.2",
"oh-my-opencode-linux-arm64-musl": "3.3.2",
"oh-my-opencode-linux-x64": "3.3.2",
"oh-my-opencode-linux-x64-musl": "3.3.2",
"oh-my-opencode-windows-x64": "3.3.2"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.3.1",
"version": "3.3.2",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1223,6 +1223,22 @@
"created_at": "2026-02-07T16:54:36Z",
"repoId": 1108837393,
"pullRequestNo": 1629
},
{
"name": "quantmind-br",
"id": 170503374,
"comment_id": 3865064441,
"created_at": "2026-02-07T18:38:24Z",
"repoId": 1108837393,
"pullRequestNo": 1634
},
{
"name": "QiRaining",
"id": 13825001,
"comment_id": 3865979224,
"created_at": "2026-02-08T02:34:46Z",
"repoId": 1108837393,
"pullRequestNo": 1641
}
]
}

View File

@@ -19,6 +19,7 @@ program
.name("oh-my-opencode")
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
.version(VERSION, "-v, --version", "Show version number")
.enablePositionalOptions()
program
.command("install")

View File

@@ -9,6 +9,7 @@ export {
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TodoContinuationConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
} from "./schema"
@@ -25,6 +26,7 @@ export type {
ExperimentalConfig,
DynamicContextPruningConfig,
RalphLoopConfig,
TodoContinuationConfig,
TmuxConfig,
TmuxLayout,
SisyphusConfig,

View File

@@ -322,6 +322,13 @@ export const RalphLoopConfigSchema = z.object({
state_dir: z.string().optional(),
})
export const TodoContinuationConfigSchema = z.object({
/** Max continuation injections per session before stopping (default: 8) */
max_injections: z.number().min(1).max(1000).default(8),
/** Max consecutive injections with no todo progress before stopping (default: 3) */
max_stale_injections: z.number().min(0).max(1000).default(3),
})
export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
@@ -419,6 +426,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
auto_update: z.boolean().optional(),
skills: SkillsConfigSchema.optional(),
ralph_loop: RalphLoopConfigSchema.optional(),
todo_continuation: TodoContinuationConfigSchema.optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
@@ -446,6 +454,7 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export type TodoContinuationConfig = z.infer<typeof TodoContinuationConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>

View File

@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { execSync } from "node:child_process"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import {
@@ -12,6 +11,7 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/ho
import { log } from "../../shared/logger"
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
import type { BackgroundManager } from "../../features/background-agent"
export const HOOK_NAME = "atlas"
@@ -269,113 +269,6 @@ function extractSessionIdFromOutput(output: string): string {
return match?.[1] ?? "<session_id>"
}
interface GitFileStat {
path: string
added: number
removed: number
status: "modified" | "added" | "deleted"
}
function getGitDiffStats(directory: string): GitFileStat[] {
try {
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!output) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "A" || status === "??") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
} catch {
return []
}
}
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}
interface ToolExecuteAfterInput {
tool: string
sessionID?: string
@@ -750,8 +643,8 @@ export function createAtlasHook(
}
if (output.output && typeof output.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(output.output)
const boulderState = readBoulderState(ctx.directory)

View File

@@ -129,6 +129,63 @@ describe("detectErrorType", () => {
})
})
describe("assistant_prefill_unsupported errors", () => {
it("should detect assistant message prefill error from direct message", () => {
//#given an error about assistant message prefill not being supported
const error = {
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect assistant message prefill error from nested error object", () => {
//#given an Anthropic API error with nested structure matching the real error format
const error = {
error: {
type: "invalid_request_error",
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
},
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect error with only 'conversation must end with a user message' fragment", () => {
//#given an error containing only the user message requirement
const error = {
message: "The conversation must end with a user message.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
it("should detect error with only 'assistant message prefill' fragment", () => {
//#given an error containing only the prefill mention
const error = {
message: "This model does not support assistant message prefill.",
}
//#when detectErrorType is called
const result = detectErrorType(error)
//#then should return assistant_prefill_unsupported
expect(result).toBe("assistant_prefill_unsupported")
})
})
describe("unrecognized errors", () => {
it("should return null for unrecognized error patterns", () => {
// given an unrelated error

View File

@@ -28,6 +28,7 @@ type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| "assistant_prefill_unsupported"
| null
interface MessageInfo {
@@ -126,6 +127,13 @@ function extractMessageIndex(error: unknown): number | null {
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (
message.includes("assistant message prefill") ||
message.includes("conversation must end with a user message")
) {
return "assistant_prefill_unsupported"
}
// IMPORTANT: Check thinking_block_order BEFORE tool_result_missing
// because Anthropic's extended thinking error messages contain "tool_use" and "tool_result"
// in the documentation URL, which would incorrectly match tool_result_missing
@@ -375,11 +383,13 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
assistant_prefill_unsupported: "Prefill Error Recovery",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
assistant_prefill_unsupported: "Sending 'Continue' to recover...",
}
await ctx.client.tui
@@ -411,6 +421,8 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
const resumeConfig = extractResumeConfig(lastUser, sessionID)
await resumeSession(ctx.client, resumeConfig)
}
} else if (errorType === "assistant_prefill_unsupported") {
success = true
}
return success

View File

@@ -1313,4 +1313,64 @@ describe("todo-continuation-enforcer", () => {
// then - no continuation injected (all countdowns cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should stop injecting after max injections reached", async () => {
// given - session with incomplete todos and low injection cap
const sessionID = "main-max-injections"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
config: { max_injections: 2, max_stale_injections: 100 },
})
// when - idle cycles happen repeatedly
for (let i = 0; i < 3; i++) {
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
}
// then - only 2 injections occur
expect(promptCalls).toHaveLength(2)
expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true)
}, { timeout: 15000 })
test("should stop injecting when stale injections exceed limit and reset on progress", async () => {
// given - session with a progress drop after first injection
const sessionID = "main-stale-breaker"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => {
// before first injection: 2 pending, after: 1 pending
return {
data: promptCalls.length === 0
? [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "pending", priority: "high" },
]
: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
],
}
}
const hook = createTodoContinuationEnforcer(mockInput, {
config: { max_injections: 100, max_stale_injections: 1 },
})
// when - three idle cycles happen
for (let i = 0; i < 3; i++) {
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
}
// then - progress allows a second injection, but the third is blocked as stale
expect(promptCalls).toHaveLength(2)
expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true)
}, { timeout: 15000 })
})

View File

@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { TodoContinuationConfig } from "../config"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager } from "../features/background-agent"
@@ -19,6 +20,7 @@ export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
config?: TodoContinuationConfig
}
export interface TodoContinuationEnforcer {
@@ -41,6 +43,10 @@ interface SessionState {
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
injectionCount?: number
staleInjectionCount?: number
lastIncompleteCount?: number
circuitBroken?: boolean
}
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
@@ -97,9 +103,17 @@ export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options
const {
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
isContinuationStopped,
config,
} = options
const sessions = new Map<string, SessionState>()
const maxInjections = config?.max_injections ?? 8
const maxStaleInjections = config?.max_stale_injections ?? 3
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
@@ -129,6 +143,45 @@ export function createTodoContinuationEnforcer(
sessions.delete(sessionID)
}
function resetCircuitBreaker(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
state.injectionCount = 0
state.staleInjectionCount = 0
state.lastIncompleteCount = undefined
state.circuitBroken = false
}
async function tripCircuitBreaker(
sessionID: string,
reason: string,
incompleteCount: number
): Promise<void> {
const state = getState(sessionID)
if (state.circuitBroken) return
state.circuitBroken = true
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Circuit breaker tripped`, {
sessionID,
reason,
injectionCount: state.injectionCount,
staleInjectionCount: state.staleInjectionCount,
incompleteCount,
maxInjections,
maxStaleInjections,
})
await ctx.client.tui.showToast({
body: {
title: "Todo Continuation Stopped",
message: reason,
variant: "warning" as const,
duration: 5000,
},
}).catch(() => {})
}
const markRecovering = (sessionID: string): void => {
const state = getState(sessionID)
state.isRecovering = true
@@ -169,6 +222,20 @@ export function createTodoContinuationEnforcer(
): Promise<void> {
const state = sessions.get(sessionID)
if (state?.circuitBroken) {
log(`[${HOOK_NAME}] Skipped injection: circuit breaker active`, { sessionID })
return
}
if ((state?.injectionCount ?? 0) >= maxInjections) {
await tripCircuitBreaker(
sessionID,
`Max injections (${maxInjections}) reached without todo completion progress`,
incompleteCount
)
return
}
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
@@ -198,6 +265,25 @@ export function createTodoContinuationEnforcer(
return
}
const currentState = getState(sessionID)
if (typeof currentState.lastIncompleteCount === "number") {
if (freshIncompleteCount < currentState.lastIncompleteCount) {
currentState.staleInjectionCount = 0
} else {
currentState.staleInjectionCount = (currentState.staleInjectionCount ?? 0) + 1
}
}
currentState.lastIncompleteCount = freshIncompleteCount
if (maxStaleInjections > 0 && (currentState.staleInjectionCount ?? 0) >= maxStaleInjections) {
await tripCircuitBreaker(
sessionID,
`No todo progress detected for ${maxStaleInjections} consecutive continuation(s); stopping to prevent infinite loop`,
freshIncompleteCount
)
return
}
let agentName = resolvedInfo?.agent
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
@@ -245,6 +331,9 @@ ${todoList}`
try {
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
const nextCount = (currentState.injectionCount ?? 0) + 1
currentState.injectionCount = nextCount
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
@@ -325,6 +414,11 @@ ${todoList}`
const state = getState(sessionID)
if (state.circuitBroken) {
log(`[${HOOK_NAME}] Skipped: circuit breaker active`, { sessionID })
return
}
if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
@@ -448,6 +542,7 @@ ${todoList}`
if (!sessionID) return
if (role === "user") {
resetCircuitBreaker(sessionID)
const state = sessions.get(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt

View File

@@ -2,7 +2,7 @@ import { _resetForTesting, setMainSession } from "../../features/claude-code-ses
import type { BackgroundTask } from "../../features/background-agent"
import { createUnstableAgentBabysitterHook } from "./index"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const projectDir = "/tmp/fix-1349"
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
@@ -21,6 +21,9 @@ function createMockPluginInput(options: {
prompt: async (input: unknown) => {
promptCalls.push({ input })
},
promptAsync: async (input: unknown) => {
promptCalls.push({ input })
},
},
},
}

View File

@@ -365,6 +365,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, {
backgroundManager,
isContinuationStopped: stopContinuationGuard?.isStopped,
config: pluginConfig.todo_continuation,
}), { enabled: safeHookEnabled })
: null;

View File

@@ -600,6 +600,187 @@ describe("Prometheus direct override priority over category", () => {
})
})
describe("Plan agent model inheritance from prometheus", () => {
test("plan agent inherits all model-related settings from resolved prometheus config", async () => {
//#given - prometheus resolves to claude-opus-4-6 with model settings
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
model: "anthropic/claude-opus-4-6",
provenance: "provider-fallback",
variant: "max",
})
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
replace_plan: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {
plan: {
name: "plan",
mode: "primary",
prompt: "original plan prompt",
},
},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
//#when
await handler(config)
//#then - plan inherits model and variant from prometheus, but NOT prompt
const agents = config.agent as Record<string, { mode?: string; model?: string; variant?: string; prompt?: string }>
expect(agents.plan).toBeDefined()
expect(agents.plan.mode).toBe("subagent")
expect(agents.plan.model).toBe("anthropic/claude-opus-4-6")
expect(agents.plan.variant).toBe("max")
expect(agents.plan.prompt).toBeUndefined()
})
test("plan agent inherits temperature, reasoningEffort, and other model settings from prometheus", async () => {
//#given - prometheus configured with category that has temperature and reasoningEffort
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
model: "openai/gpt-5.2",
provenance: "override",
variant: "high",
})
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
replace_plan: true,
},
agents: {
prometheus: {
model: "openai/gpt-5.2",
variant: "high",
temperature: 0.3,
top_p: 0.9,
maxTokens: 16000,
reasoningEffort: "high",
textVerbosity: "medium",
thinking: { type: "enabled", budgetTokens: 8000 },
},
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
//#when
await handler(config)
//#then - plan inherits ALL model-related settings from resolved prometheus
const agents = config.agent as Record<string, Record<string, unknown>>
expect(agents.plan).toBeDefined()
expect(agents.plan.mode).toBe("subagent")
expect(agents.plan.model).toBe("openai/gpt-5.2")
expect(agents.plan.variant).toBe("high")
expect(agents.plan.temperature).toBe(0.3)
expect(agents.plan.top_p).toBe(0.9)
expect(agents.plan.maxTokens).toBe(16000)
expect(agents.plan.reasoningEffort).toBe("high")
expect(agents.plan.textVerbosity).toBe("medium")
expect(agents.plan.thinking).toEqual({ type: "enabled", budgetTokens: 8000 })
})
test("plan agent user override takes priority over prometheus inherited settings", async () => {
//#given - prometheus resolves to opus, but user has plan override for gpt-5.2
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
model: "anthropic/claude-opus-4-6",
provenance: "provider-fallback",
variant: "max",
})
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
replace_plan: true,
},
agents: {
plan: {
model: "openai/gpt-5.2",
variant: "high",
temperature: 0.5,
},
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
//#when
await handler(config)
//#then - plan uses its own override, not prometheus settings
const agents = config.agent as Record<string, Record<string, unknown>>
expect(agents.plan.model).toBe("openai/gpt-5.2")
expect(agents.plan.variant).toBe("high")
expect(agents.plan.temperature).toBe(0.5)
})
test("plan agent does NOT inherit prompt, description, or color from prometheus", async () => {
//#given
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
model: "anthropic/claude-opus-4-6",
provenance: "provider-fallback",
variant: "max",
})
const pluginConfig: OhMyOpenCodeConfig = {
sisyphus_agent: {
planner_enabled: true,
replace_plan: true,
},
}
const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-6",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
//#when
await handler(config)
//#then - plan has model settings but NOT prompt/description/color
const agents = config.agent as Record<string, Record<string, unknown>>
expect(agents.plan.model).toBe("anthropic/claude-opus-4-6")
expect(agents.plan.prompt).toBeUndefined()
expect(agents.plan.description).toBeUndefined()
expect(agents.plan.color).toBeUndefined()
})
})
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => {
// given - This test ensures we don't regress on issue #1301

View File

@@ -32,6 +32,7 @@ import { AGENT_NAME_MAP } from "../shared/migration";
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus";
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
import type { ModelCacheState } from "../plugin-state";
import type { CategoryConfig } from "../config/schema";
@@ -385,8 +386,10 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {};
const planDemoteConfig = shouldDemotePlan
? { mode: "subagent" as const
}
? buildPlanDemoteConfig(
agentConfig["prometheus"] as Record<string, unknown> | undefined,
pluginConfig.agents?.plan as Record<string, unknown> | undefined,
)
: undefined;
config.agent = {

View File

@@ -0,0 +1,118 @@
import { describe, test, expect } from "bun:test"
import { buildPlanDemoteConfig } from "./plan-model-inheritance"
describe("buildPlanDemoteConfig", () => {
test("returns only mode when prometheus and plan override are both undefined", () => {
//#given
const prometheusConfig = undefined
const planOverride = undefined
//#when
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
//#then
expect(result).toEqual({ mode: "subagent" })
})
test("extracts all model settings from prometheus config", () => {
//#given
const prometheusConfig = {
name: "prometheus",
model: "anthropic/claude-opus-4-6",
variant: "max",
mode: "all",
prompt: "You are Prometheus...",
permission: { edit: "allow" },
description: "Plan agent (Prometheus)",
color: "#FF5722",
temperature: 0.1,
top_p: 0.95,
maxTokens: 32000,
thinking: { type: "enabled", budgetTokens: 10000 },
reasoningEffort: "high",
textVerbosity: "medium",
providerOptions: { key: "value" },
}
//#when
const result = buildPlanDemoteConfig(prometheusConfig, undefined)
//#then - picks model settings, NOT prompt/permission/description/color/name/mode
expect(result.mode).toBe("subagent")
expect(result.model).toBe("anthropic/claude-opus-4-6")
expect(result.variant).toBe("max")
expect(result.temperature).toBe(0.1)
expect(result.top_p).toBe(0.95)
expect(result.maxTokens).toBe(32000)
expect(result.thinking).toEqual({ type: "enabled", budgetTokens: 10000 })
expect(result.reasoningEffort).toBe("high")
expect(result.textVerbosity).toBe("medium")
expect(result.providerOptions).toEqual({ key: "value" })
expect(result.prompt).toBeUndefined()
expect(result.permission).toBeUndefined()
expect(result.description).toBeUndefined()
expect(result.color).toBeUndefined()
expect(result.name).toBeUndefined()
})
test("plan override takes priority over prometheus for all model settings", () => {
//#given
const prometheusConfig = {
model: "anthropic/claude-opus-4-6",
variant: "max",
temperature: 0.1,
reasoningEffort: "high",
}
const planOverride = {
model: "openai/gpt-5.2",
variant: "high",
temperature: 0.5,
reasoningEffort: "low",
}
//#when
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
//#then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.variant).toBe("high")
expect(result.temperature).toBe(0.5)
expect(result.reasoningEffort).toBe("low")
})
test("falls back to prometheus when plan override has partial settings", () => {
//#given
const prometheusConfig = {
model: "anthropic/claude-opus-4-6",
variant: "max",
temperature: 0.1,
reasoningEffort: "high",
}
const planOverride = {
model: "openai/gpt-5.2",
}
//#when
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
//#then - plan model wins, rest inherits from prometheus
expect(result.model).toBe("openai/gpt-5.2")
expect(result.variant).toBe("max")
expect(result.temperature).toBe(0.1)
expect(result.reasoningEffort).toBe("high")
})
test("skips undefined values from both sources", () => {
//#given
const prometheusConfig = {
model: "anthropic/claude-opus-4-6",
}
//#when
const result = buildPlanDemoteConfig(prometheusConfig, undefined)
//#then
expect(result).toEqual({ mode: "subagent", model: "anthropic/claude-opus-4-6" })
expect(Object.keys(result)).toEqual(["mode", "model"])
})
})

View File

@@ -0,0 +1,27 @@
const MODEL_SETTINGS_KEYS = [
"model",
"variant",
"temperature",
"top_p",
"maxTokens",
"thinking",
"reasoningEffort",
"textVerbosity",
"providerOptions",
] as const
export function buildPlanDemoteConfig(
prometheusConfig: Record<string, unknown> | undefined,
planOverride: Record<string, unknown> | undefined,
): Record<string, unknown> {
const modelSettings: Record<string, unknown> = {}
for (const key of MODEL_SETTINGS_KEYS) {
const value = planOverride?.[key] ?? prometheusConfig?.[key]
if (value !== undefined) {
modelSettings[key] = value
}
}
return { mode: "subagent" as const, ...modelSettings }
}

View File

@@ -0,0 +1,29 @@
import { execSync } from "node:child_process"
import { parseGitStatusPorcelain } from "./parse-status-porcelain"
import { parseGitDiffNumstat } from "./parse-diff-numstat"
import type { GitFileStat } from "./types"
export function collectGitDiffStats(directory: string): GitFileStat[] {
try {
const diffOutput = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!diffOutput) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = parseGitStatusPorcelain(statusOutput)
return parseGitDiffNumstat(diffOutput, statusMap)
} catch {
return []
}
}

View File

@@ -0,0 +1,46 @@
import type { GitFileStat } from "./types"
export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,51 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { formatFileChanges, parseGitDiffNumstat, parseGitStatusPorcelain } from "./index"
describe("git-worktree", () => {
test("#given status porcelain output #when parsing #then maps paths to statuses", () => {
const porcelain = [
" M src/a.ts",
"A src/b.ts",
"?? src/c.ts",
"D src/d.ts",
].join("\n")
const map = parseGitStatusPorcelain(porcelain)
expect(map.get("src/a.ts")).toBe("modified")
expect(map.get("src/b.ts")).toBe("added")
expect(map.get("src/c.ts")).toBe("added")
expect(map.get("src/d.ts")).toBe("deleted")
})
test("#given diff numstat and status map #when parsing #then returns typed stats", () => {
const porcelain = [" M src/a.ts", "A src/b.ts"].join("\n")
const statusMap = parseGitStatusPorcelain(porcelain)
const numstat = ["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts", "-\t-\tbin.dat"].join("\n")
const stats = parseGitDiffNumstat(numstat, statusMap)
expect(stats).toEqual([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "bin.dat", added: 0, removed: 0, status: "modified" },
])
})
test("#given git file stats #when formatting #then produces grouped summary", () => {
const summary = formatFileChanges([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "src/c.ts", added: 0, removed: 4, status: "deleted" },
])
expect(summary).toContain("[FILE CHANGES SUMMARY]")
expect(summary).toContain("Modified files:")
expect(summary).toContain("Created files:")
expect(summary).toContain("Deleted files:")
expect(summary).toContain("src/a.ts")
expect(summary).toContain("src/b.ts")
expect(summary).toContain("src/c.ts")
})
})

View File

@@ -0,0 +1,5 @@
export type { GitFileStatus, GitFileStat } from "./types"
export { parseGitStatusPorcelain } from "./parse-status-porcelain"
export { parseGitDiffNumstat } from "./parse-diff-numstat"
export { collectGitDiffStats } from "./collect-git-diff-stats"
export { formatFileChanges } from "./format-file-changes"

View File

@@ -0,0 +1,27 @@
import type { GitFileStat, GitFileStatus } from "./types"
export function parseGitDiffNumstat(
output: string,
statusMap: Map<string, GitFileStatus>
): GitFileStat[] {
if (!output) return []
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
}

View File

@@ -0,0 +1,25 @@
import type { GitFileStatus } from "./types"
export function parseGitStatusPorcelain(output: string): Map<string, GitFileStatus> {
const map = new Map<string, GitFileStatus>()
if (!output) return map
for (const line of output.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (!filePath) continue
if (status === "A" || status === "??") {
map.set(filePath, "added")
} else if (status === "D") {
map.set(filePath, "deleted")
} else {
map.set(filePath, "modified")
}
}
return map
}

View File

@@ -0,0 +1,8 @@
export type GitFileStatus = "modified" | "added" | "deleted"
export interface GitFileStat {
path: string
added: number
removed: number
status: GitFileStatus
}

View File

@@ -41,5 +41,6 @@ export * from "./tmux"
export * from "./model-suggestion-retry"
export * from "./opencode-server-auth"
export * from "./port-utils"
export * from "./git-worktree"
export * from "./safe-create-hook"
export * from "./truncate-description"

View File

@@ -538,7 +538,7 @@ export function buildPlanAgentSystemPrepend(
* List of agent names that should be treated as plan agents.
* Case-insensitive matching is used.
*/
export const PLAN_AGENT_NAMES = ["plan", "prometheus", "planner"]
export const PLAN_AGENT_NAMES = ["plan", "planner"]
/**
* Check if the given agent name is a plan agent.

View File

@@ -12,7 +12,7 @@ import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader
import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry } from "../../shared"
import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry, promptSyncWithModelSuggestionRetry } from "../../shared"
import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
@@ -39,7 +39,7 @@ export interface ParentContext {
}
interface SessionMessage {
info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string; variant?: string }
parts?: Array<{ type?: string; text?: string }>
}
@@ -190,6 +190,7 @@ export async function executeSyncContinuation(
try {
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
let resumeVariant: string | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
@@ -199,6 +200,7 @@ export async function executeSyncContinuation(
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resumeAgent = info.agent
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
resumeVariant = info.variant
break
}
}
@@ -209,22 +211,24 @@ export async function executeSyncContinuation(
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
resumeVariant = resumeMessage?.model?.variant
}
await (client.session as any).promptAsync({
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
await promptSyncWithModelSuggestionRetry(client, {
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
...(resumeVariant !== undefined ? { variant: resumeVariant } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (promptError) {
if (toastManager) {
toastManager.removeTask(taskId)
@@ -233,30 +237,6 @@ export async function executeSyncContinuation(
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
}
const timing = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.session_id! } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.session_id! },
})
@@ -621,7 +601,7 @@ export async function executeSyncTask(
try {
const allowTask = isPlanAgent(agentToUse)
await promptWithModelSuggestionRetry(client, {
await promptSyncWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
agent: agentToUse,
@@ -659,70 +639,6 @@ export async function executeSyncTask(
})
}
const syncTiming = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
let pollCount = 0
log("[task] Starting poll loop", { sessionID, agentToUse })
while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
log("[task] Aborted by user", { sessionID })
if (toastManager && taskId) toastManager.removeTask(taskId)
return `Task aborted.\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS))
pollCount++
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
if (pollCount % 10 === 0) {
log("[task] Poll status", {
sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
stablePolls,
lastMsgCount,
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
const elapsed = Date.now() - pollStart
if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) {
continue
}
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) {
log("[task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
break
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
log("[task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
}
const messagesResult = await client.session.messages({
path: { id: sessionID },
})
@@ -963,7 +879,7 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
return {
agentToUse: "",
categoryModel: undefined,
error: `You are prometheus. You cannot delegate to prometheus via task.
error: `You are the plan agent. You cannot delegate to plan via task.
Create the work plan directly - that's your job as the planning agent.`,
}

View File

@@ -1,5 +1,5 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
@@ -135,12 +135,12 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for 'prometheus'", () => {
// given / #when
test("returns false for 'prometheus' (decoupled from plan)", () => {
//#given / #when
const result = isPlanAgent("prometheus")
// then
expect(result).toBe(true)
//#then - prometheus is NOT a plan agent
expect(result).toBe(false)
})
test("returns true for 'planner'", () => {
@@ -159,12 +159,12 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for case-insensitive match 'Prometheus'", () => {
// given / #when
test("returns false for case-insensitive match 'Prometheus' (decoupled from plan)", () => {
//#given / #when
const result = isPlanAgent("Prometheus")
// then
expect(result).toBe(true)
//#then - Prometheus is NOT a plan agent
expect(result).toBe(false)
})
test("returns false for 'oracle'", () => {
@@ -199,11 +199,11 @@ describe("sisyphus-task", () => {
expect(result).toBe(false)
})
test("PLAN_AGENT_NAMES contains expected values", () => {
// given / #when / #then
test("PLAN_AGENT_NAMES contains only plan and planner (not prometheus)", () => {
//#given / #when / #then
expect(PLAN_AGENT_NAMES).toContain("plan")
expect(PLAN_AGENT_NAMES).toContain("prometheus")
expect(PLAN_AGENT_NAMES).toContain("planner")
expect(PLAN_AGENT_NAMES).not.toContain("prometheus")
})
})
@@ -1055,6 +1055,75 @@ describe("sisyphus-task", () => {
expect(result).not.toContain("Background task continued")
}, { timeout: 10000 })
test("sync continuation preserves variant from previous session message", async () => {
//#given a session with a previous message that has variant "max"
const { createDelegateTask } = require("./tools")
const promptMock = mock(async (input: any) => {
return { data: {} }
})
const mockClient = {
session: {
prompt: promptMock,
promptAsync: async () => ({ data: {} }),
messages: async () => ({
data: [
{
info: {
role: "user",
agent: "sisyphus-junior",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
variant: "max",
time: { created: Date.now() },
},
parts: [{ type: "text", text: "previous message" }],
},
{
info: { role: "assistant", time: { created: Date.now() + 1 } },
parts: [{ type: "text", text: "Completed." }],
},
],
}),
},
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
app: {
agents: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({
manager: { resume: async () => ({ id: "task-var", sessionID: "ses_var_test", description: "Variant test", agent: "sisyphus-junior", status: "running" }) },
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
//#when continuing the session
await tool.execute(
{
description: "Continue with variant",
prompt: "Continue the task",
session_id: "ses_var_test",
run_in_background: false,
load_skills: [],
},
toolContext
)
//#then prompt should include variant from previous message
expect(promptMock).toHaveBeenCalled()
const callArgs = promptMock.mock.calls[0][0]
expect(callArgs.body.variant).toBe("max")
expect(callArgs.body.agent).toBe("sisyphus-junior")
expect(callArgs.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
}, { timeout: 10000 })
test("session_id with background=true should return immediately without waiting", async () => {
// given
const { createDelegateTask } = require("./tools")
@@ -2258,68 +2327,36 @@ describe("sisyphus-task", () => {
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
})
test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
// given
test("does not prepend plan agent prompt for prometheus agent", () => {
//#given - prometheus is NOT a plan agent (decoupled)
const { buildSystemContent } = require("./tools")
const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a strategic planner"
const availableCategories = [
{
name: "ultrabrain",
description: "Complex architecture, deep logical reasoning",
model: "openai/gpt-5.3-codex",
},
]
const availableSkills = [
{
name: "git-master",
description: "Atomic commits, git operations.",
location: "plugin",
},
]
// when
//#when
const result = buildSystemContent({
skillContent,
agentName: "prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
//#then - prometheus should NOT get plan agent system prepend
expect(result).toBe(skillContent)
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
})
test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
// given
test("does not prepend plan agent prompt for Prometheus (case insensitive)", () => {
//#given - Prometheus (capitalized) is NOT a plan agent
const { buildSystemContent } = require("./tools")
const { buildPlanAgentSystemPrepend } = require("./constants")
const skillContent = "You are a strategic planner"
const availableCategories = [
{
name: "quick",
description: "Trivial tasks",
model: "anthropic/claude-haiku-4-5",
},
]
const availableSkills = [
{
name: "dev-browser",
description: "Persistent browser state automation.",
location: "plugin",
},
]
// when
//#when
const result = buildSystemContent({
skillContent,
agentName: "Prometheus",
availableCategories,
availableSkills,
})
// then
expect(result).toContain("<system>")
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
//#then
expect(result).toBe(skillContent)
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
})
test("combines plan agent prepend with skill content", () => {
@@ -2565,14 +2602,14 @@ describe("sisyphus-task", () => {
})
})
describe("prometheus self-delegation block", () => {
test("prometheus cannot delegate to prometheus - returns error with guidance", async () => {
// given - current agent is prometheus
describe("plan agent self-delegation block", () => {
test("plan agent cannot delegate to plan - returns error with guidance", async () => {
//#given - current agent is plan
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
@@ -2592,44 +2629,44 @@ describe("sisyphus-task", () => {
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "prometheus",
agent: "plan",
abort: new AbortController().signal,
}
// when - prometheus tries to delegate to prometheus
//#when - plan agent tries to delegate to plan
const result = await tool.execute(
{
description: "Test self-delegation block",
prompt: "Create a plan",
subagent_type: "prometheus",
subagent_type: "plan",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - should return error telling prometheus to create plan directly
expect(result).toContain("prometheus")
//#then - should return error telling plan agent to create plan directly
expect(result).toContain("plan agent")
expect(result).toContain("directly")
})
test("non-prometheus agent CAN delegate to prometheus - proceeds normally", async () => {
// given - current agent is sisyphus
test("prometheus is NOT a plan agent - can delegate to plan normally", async () => {
//#given - current agent is prometheus (no longer treated as plan agent)
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_allowed" } }),
create: async () => ({ data: { id: "ses_plan_from_prometheus" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created successfully" }] }]
}),
status: async () => ({ data: { "ses_prometheus_allowed": { type: "idle" } } }),
status: async () => ({ data: { "ses_plan_from_prometheus": { type: "idle" } } }),
},
}
@@ -2641,34 +2678,34 @@ describe("sisyphus-task", () => {
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
agent: "prometheus",
abort: new AbortController().signal,
}
// when - sisyphus delegates to prometheus
//#when - prometheus delegates to plan (should work now)
const result = await tool.execute(
{
description: "Test prometheus delegation from non-prometheus agent",
description: "Test plan delegation from prometheus",
prompt: "Create a plan",
subagent_type: "prometheus",
subagent_type: "plan",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - should proceed normally
//#then - should proceed normally (prometheus is not plan agent)
expect(result).not.toContain("Cannot delegate")
expect(result).toContain("Plan created successfully")
}, { timeout: 20000 })
test("case-insensitive: Prometheus (capitalized) cannot delegate to prometheus", async () => {
// given - current agent is Prometheus (capitalized)
test("planner agent self-delegation is also blocked", async () => {
//#given - current agent is planner
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
app: { agents: async () => ({ data: [{ name: "planner", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
@@ -2688,24 +2725,24 @@ describe("sisyphus-task", () => {
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "Prometheus",
agent: "planner",
abort: new AbortController().signal,
}
// when - Prometheus tries to delegate to prometheus
//#when - planner tries to delegate to plan
const result = await tool.execute(
{
description: "Test case-insensitive block",
description: "Test planner self-delegation block",
prompt: "Create a plan",
subagent_type: "prometheus",
subagent_type: "plan",
run_in_background: false,
load_skills: [],
},
toolContext
)
// then - should still return error
expect(result).toContain("prometheus")
//#then - should return error (planner is a plan agent alias)
expect(result).toContain("plan agent")
expect(result).toContain("directly")
})
})
@@ -2903,9 +2940,9 @@ describe("sisyphus-task", () => {
}, { timeout: 20000 })
})
describe("prometheus subagent task permission", () => {
test("prometheus subagent should have task permission enabled", async () => {
// given - sisyphus delegates to prometheus
describe("subagent task permission", () => {
test("plan subagent should have task permission enabled", async () => {
//#given - sisyphus delegates to plan agent
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -2917,17 +2954,17 @@ describe("sisyphus-task", () => {
}
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_delegate" } }),
create: async () => ({ data: { id: "ses_plan_delegate" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
}),
status: async () => ({ data: { "ses_prometheus_delegate": { type: "idle" } } }),
status: async () => ({ data: { "ses_plan_delegate": { type: "idle" } } }),
},
}
@@ -2943,10 +2980,65 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// when - sisyphus delegates to prometheus
//#when - sisyphus delegates to plan
await tool.execute(
{
description: "Test prometheus task permission",
description: "Test plan task permission",
prompt: "Create a plan",
subagent_type: "plan",
run_in_background: false,
load_skills: [],
},
toolContext
)
//#then - plan agent should have task permission
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("prometheus subagent should NOT have task permission (decoupled from plan)", async () => {
//#given - sisyphus delegates to prometheus (no longer a plan agent)
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const promptMock = async (input: any) => {
promptBody = input.body
return { data: {} }
}
const mockClient = {
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prometheus_no_task" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
}),
status: async () => ({ data: { "ses_prometheus_no_task": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
//#when - sisyphus delegates to prometheus
await tool.execute(
{
description: "Test prometheus no task permission",
prompt: "Create a plan",
subagent_type: "prometheus",
run_in_background: false,
@@ -2955,12 +3047,12 @@ describe("sisyphus-task", () => {
toolContext
)
// then - prometheus should have task permission
expect(promptBody.tools.task).toBe(true)
//#then - prometheus should NOT have task permission (it's not a plan agent)
expect(promptBody.tools.task).toBe(false)
}, { timeout: 20000 })
test("non-prometheus subagent should NOT have task permission", async () => {
// given - sisyphus delegates to oracle (non-prometheus)
test("non-plan subagent should NOT have task permission", async () => {
//#given - sisyphus delegates to oracle (non-plan)
const { createDelegateTask } = require("./tools")
let promptBody: any