b30a131690
- Rename delegate_task tool to task across codebase (100 files) - Update model references: claude-opus-4-6 → 4-5, gpt-5.3-codex → 5.2-codex - Add tool-metadata-store to restore metadata overwritten by fromPlugin() - Add session ID polling for BackgroundManager task sessions - Await async ctx.metadata() calls in tool executors - Add ses_ prefix guard to getMessageDir for performance - Harden BackgroundManager with idle deferral and error handling - Fix duplicate task key in sisyphus-junior test object literals - Fix unawaited showOutputToUser in ast_grep_replace - Fix background=true → run_in_background=true in ultrawork prompt - Fix duplicate task/task references in docs and comments
187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import { existsSync, readdirSync } from "node:fs"
|
|
import { join, resolve, relative, isAbsolute } from "node:path"
|
|
import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
|
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
|
import { readBoulderState } from "../../features/boulder-state"
|
|
import { log } from "../../shared/logger"
|
|
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
|
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
|
|
|
export * from "./constants"
|
|
|
|
/**
|
|
* Cross-platform path validator for Prometheus file writes.
|
|
* Uses path.resolve/relative instead of string matching to handle:
|
|
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
|
|
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
|
|
* - Case-insensitive directory/extension matching
|
|
* - Workspace confinement (blocks paths outside root or via traversal)
|
|
* - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent)
|
|
*/
|
|
function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
|
|
// 1. Resolve to absolute path
|
|
const resolved = resolve(workspaceRoot, filePath)
|
|
|
|
// 2. Get relative path from workspace root
|
|
const rel = relative(workspaceRoot, resolved)
|
|
|
|
// 3. Reject if escapes root (starts with ".." or is absolute)
|
|
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
return false
|
|
}
|
|
|
|
// 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive)
|
|
// This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md)
|
|
if (!/\.sisyphus[/\\]/i.test(rel)) {
|
|
return false
|
|
}
|
|
|
|
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
|
|
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
|
|
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
|
|
)
|
|
if (!hasAllowedExtension) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function getMessageDir(sessionID: string): string | null {
|
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
|
|
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
|
if (existsSync(directPath)) return directPath
|
|
|
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
|
if (existsSync(sessionPath)) return sessionPath
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const TASK_TOOLS = ["task", "call_omo_agent"]
|
|
|
|
function getAgentFromMessageFiles(sessionID: string): string | undefined {
|
|
const messageDir = getMessageDir(sessionID)
|
|
if (!messageDir) return undefined
|
|
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
|
|
}
|
|
|
|
/**
|
|
* Get the effective agent for the session.
|
|
* Priority order:
|
|
* 1. In-memory session agent (most recent, set by /start-work)
|
|
* 2. Boulder state agent (persisted across restarts, fixes #927)
|
|
* 3. Message files (fallback for sessions without boulder state)
|
|
*
|
|
* This fixes issue #927 where after interruption:
|
|
* - In-memory map is cleared (process restart)
|
|
* - Message files return "prometheus" (oldest message from /plan)
|
|
* - But boulder.json has agent: "atlas" (set by /start-work)
|
|
*/
|
|
function getAgentFromSession(sessionID: string, directory: string): string | undefined {
|
|
// Check in-memory first (current session)
|
|
const memoryAgent = getSessionAgent(sessionID)
|
|
if (memoryAgent) return memoryAgent
|
|
|
|
// Check boulder state (persisted across restarts) - fixes #927
|
|
const boulderState = readBoulderState(directory)
|
|
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
|
|
return boulderState.agent
|
|
}
|
|
|
|
// Fallback to message files
|
|
return getAgentFromMessageFiles(sessionID)
|
|
}
|
|
|
|
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
|
return {
|
|
"tool.execute.before": async (
|
|
input: { tool: string; sessionID: string; callID: string },
|
|
output: { args: Record<string, unknown>; message?: string }
|
|
): Promise<void> => {
|
|
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
|
|
|
if (agentName !== PROMETHEUS_AGENT) {
|
|
return
|
|
}
|
|
|
|
const toolName = input.tool
|
|
|
|
// Inject read-only warning for task tools called by Prometheus
|
|
if (TASK_TOOLS.includes(toolName)) {
|
|
const prompt = output.args.prompt as string | undefined
|
|
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
|
output.args.prompt = PLANNING_CONSULT_WARNING + prompt
|
|
log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, {
|
|
sessionID: input.sessionID,
|
|
tool: toolName,
|
|
agent: agentName,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!BLOCKED_TOOLS.includes(toolName)) {
|
|
return
|
|
}
|
|
|
|
// Block bash commands completely - Prometheus is read-only
|
|
if (toolName === "bash") {
|
|
log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, {
|
|
sessionID: input.sessionID,
|
|
tool: toolName,
|
|
agent: agentName,
|
|
})
|
|
throw new Error(
|
|
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` +
|
|
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
|
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
|
)
|
|
}
|
|
|
|
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
|
if (!filePath) {
|
|
return
|
|
}
|
|
|
|
if (!isAllowedFile(filePath, ctx.directory)) {
|
|
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
|
sessionID: input.sessionID,
|
|
tool: toolName,
|
|
filePath,
|
|
agent: agentName,
|
|
})
|
|
throw new Error(
|
|
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
|
|
`Attempted to modify: ${filePath}. ` +
|
|
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
|
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
|
)
|
|
}
|
|
|
|
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
|
|
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {
|
|
log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, {
|
|
sessionID: input.sessionID,
|
|
tool: toolName,
|
|
filePath,
|
|
agent: agentName,
|
|
})
|
|
output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER
|
|
}
|
|
|
|
log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, {
|
|
sessionID: input.sessionID,
|
|
tool: toolName,
|
|
filePath,
|
|
agent: agentName,
|
|
})
|
|
},
|
|
}
|
|
}
|