From 9eafe6b5f935520870e5e66bc928d63443ae0859 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 6 Jan 2026 13:40:27 +0900 Subject: [PATCH] feat(features): add TaskToastManager for consolidated task notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create task-toast-manager feature with singleton pattern - Show running task list (newest first) when new task starts - Track queued tasks status from ConcurrencyManager - Integrate with BackgroundManager and sisyphus-task tool πŸ€– Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance --- src/agents/prometheus-prompt.ts | 40 ++++- src/features/background-agent/manager.ts | 38 ++--- src/features/task-toast-manager/index.ts | 2 + src/features/task-toast-manager/manager.ts | 190 +++++++++++++++++++++ src/features/task-toast-manager/types.ts | 17 ++ src/hooks/prometheus-md-only/index.ts | 27 ++- src/hooks/sisyphus-orchestrator/index.ts | 68 +++++++- src/index.ts | 3 + src/tools/sisyphus-task/tools.ts | 34 ++-- 9 files changed, 371 insertions(+), 48 deletions(-) create mode 100644 src/features/task-toast-manager/index.ts create mode 100644 src/features/task-toast-manager/manager.ts create mode 100644 src/features/task-toast-manager/types.ts diff --git a/src/agents/prometheus-prompt.ts b/src/agents/prometheus-prompt.ts index 77e448a65..52a1e536d 100644 --- a/src/agents/prometheus-prompt.ts +++ b/src/agents/prometheus-prompt.ts @@ -23,7 +23,25 @@ export const PROMETHEUS_SYSTEM_PROMPT = ` **YOU ARE A PLANNER. YOU ARE NOT AN IMPLEMENTER. YOU DO NOT WRITE CODE. YOU DO NOT EXECUTE TASKS.** -This is not a suggestion. This is your fundamental identity constraint: +This is not a suggestion. This is your fundamental identity constraint. + +### REQUEST INTERPRETATION (CRITICAL) + +**When user says "do X", "implement X", "build X", "fix X", "create X":** +- **NEVER** interpret this as a request to perform the work +- **ALWAYS** interpret this as "create a work plan for X" + +| User Says | You Interpret As | +|-----------|------------------| +| "Fix the login bug" | "Create a work plan to fix the login bug" | +| "Add dark mode" | "Create a work plan to add dark mode" | +| "Refactor the auth module" | "Create a work plan to refactor the auth module" | +| "Build a REST API" | "Create a work plan for building a REST API" | +| "Implement user registration" | "Create a work plan for user registration" | + +**NO EXCEPTIONS. EVER. Under ANY circumstances.** + +### Identity Constraints | What You ARE | What You ARE NOT | |--------------|------------------| @@ -45,8 +63,24 @@ This is not a suggestion. This is your fundamental identity constraint: - Work plans saved to \`.sisyphus/plans/*.md\` - Drafts saved to \`.sisyphus/drafts/*.md\` -If user asks you to implement something: **REFUSE AND REDIRECT.** -Say: "I'm a planner, not an implementer. Let me create a work plan for this. Run \`/start-work\` after I'm done to execute." +### When User Seems to Want Direct Work + +If user says things like "just do it", "don't plan, just implement", "skip the planning": + +**STILL REFUSE. Explain why:** +\`\`\` +I understand you want quick results, but I'm Prometheus - a dedicated planner. + +Here's why planning matters: +1. Reduces bugs and rework by catching issues upfront +2. Creates a clear audit trail of what was done +3. Enables parallel work and delegation +4. Ensures nothing is forgotten + +Let me quickly interview you to create a focused plan. Then run \`/start-work\` and Sisyphus will execute it immediately. + +This takes 2-3 minutes but saves hours of debugging. +\`\`\` **REMEMBER: PLANNING β‰  DOING. YOU PLAN. SOMEONE ELSE DOES.** diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index cb0d5a9e6..282d239be 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -13,6 +13,7 @@ import { MESSAGE_STORAGE, } from "../hook-message-injector" import { subagentSessions } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" const TASK_TTL_MS = 30 * 60 * 1000 @@ -122,17 +123,14 @@ export class BackgroundManager { log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tuiClient = this.client as any - if (tuiClient.tui?.showToast) { - tuiClient.tui.showToast({ - body: { - title: "Background Task Started", - message: `"${input.description}" running with ${input.agent}`, - variant: "info", - duration: 3000, - }, - }).catch(() => {}) + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.addTask({ + id: task.id, + description: input.description, + agent: input.agent, + isBackground: true, + }) } this.client.session.promptAsync({ @@ -378,17 +376,13 @@ export class BackgroundManager { log("[background-agent] notifyParentSession called for task:", task.id) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tuiClient = this.client as any - if (tuiClient.tui?.showToast) { - tuiClient.tui.showToast({ - body: { - title: "Background Task Completed", - message: `Task "${task.description}" finished in ${duration}.`, - variant: "success", - duration: 5000, - }, - }).catch(() => {}) + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.showCompletionToast({ + id: task.id, + description: task.description, + duration, + }) } const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.` diff --git a/src/features/task-toast-manager/index.ts b/src/features/task-toast-manager/index.ts new file mode 100644 index 000000000..f779eee8c --- /dev/null +++ b/src/features/task-toast-manager/index.ts @@ -0,0 +1,2 @@ +export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager" +export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types" diff --git a/src/features/task-toast-manager/manager.ts b/src/features/task-toast-manager/manager.ts new file mode 100644 index 000000000..127916974 --- /dev/null +++ b/src/features/task-toast-manager/manager.ts @@ -0,0 +1,190 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { TrackedTask, TaskStatus } from "./types" +import type { ConcurrencyManager } from "../background-agent/concurrency" + +type OpencodeClient = PluginInput["client"] + +export class TaskToastManager { + private tasks: Map = new Map() + private client: OpencodeClient + private concurrencyManager?: ConcurrencyManager + + constructor(client: OpencodeClient, concurrencyManager?: ConcurrencyManager) { + this.client = client + this.concurrencyManager = concurrencyManager + } + + setConcurrencyManager(manager: ConcurrencyManager): void { + this.concurrencyManager = manager + } + + /** + * Register a new task and show consolidated toast + */ + addTask(task: { + id: string + description: string + agent: string + isBackground: boolean + status?: TaskStatus + }): void { + const trackedTask: TrackedTask = { + id: task.id, + description: task.description, + agent: task.agent, + status: task.status ?? "running", + startedAt: new Date(), + isBackground: task.isBackground, + } + + this.tasks.set(task.id, trackedTask) + this.showTaskListToast(trackedTask) + } + + /** + * Update task status + */ + updateTask(id: string, status: TaskStatus): void { + const task = this.tasks.get(id) + if (task) { + task.status = status + } + } + + /** + * Remove completed/error task + */ + removeTask(id: string): void { + this.tasks.delete(id) + } + + /** + * Get all running tasks (newest first) + */ + getRunningTasks(): TrackedTask[] { + const running = Array.from(this.tasks.values()) + .filter((t) => t.status === "running") + .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime()) + return running + } + + /** + * Get all queued tasks + */ + getQueuedTasks(): TrackedTask[] { + return Array.from(this.tasks.values()) + .filter((t) => t.status === "queued") + .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()) + } + + /** + * Format duration since task started + */ + private formatDuration(startedAt: Date): string { + const seconds = Math.floor((Date.now() - startedAt.getTime()) / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` + } + + /** + * Build task list message + */ + private buildTaskListMessage(newTask: TrackedTask): string { + const running = this.getRunningTasks() + const queued = this.getQueuedTasks() + + const lines: string[] = [] + + if (running.length > 0) { + lines.push(`Running (${running.length}):`) + for (const task of running) { + const duration = this.formatDuration(task.startedAt) + const bgIcon = task.isBackground ? "⚑" : "πŸ”„" + const isNew = task.id === newTask.id ? " ← NEW" : "" + lines.push(`${bgIcon} ${task.description} (${task.agent}) - ${duration}${isNew}`) + } + } + + if (queued.length > 0) { + if (lines.length > 0) lines.push("") + lines.push(`Queued (${queued.length}):`) + for (const task of queued) { + const bgIcon = task.isBackground ? "⏳" : "⏸️" + lines.push(`${bgIcon} ${task.description} (${task.agent})`) + } + } + + return lines.join("\n") + } + + /** + * Show consolidated toast with all running/queued tasks + */ + private showTaskListToast(newTask: TrackedTask): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tuiClient = this.client as any + if (!tuiClient.tui?.showToast) return + + const message = this.buildTaskListMessage(newTask) + const running = this.getRunningTasks() + const queued = this.getQueuedTasks() + + const title = newTask.isBackground + ? `⚑ New Background Task` + : `πŸ”„ New Task Executed` + + tuiClient.tui.showToast({ + body: { + title, + message: message || `${newTask.description} (${newTask.agent})`, + variant: "info", + duration: running.length + queued.length > 2 ? 5000 : 3000, + }, + }).catch(() => {}) + } + + /** + * Show task completion toast + */ + showCompletionToast(task: { id: string; description: string; duration: string }): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tuiClient = this.client as any + if (!tuiClient.tui?.showToast) return + + this.removeTask(task.id) + + const remaining = this.getRunningTasks() + const queued = this.getQueuedTasks() + + let message = `βœ… "${task.description}" finished in ${task.duration}` + if (remaining.length > 0 || queued.length > 0) { + message += `\n\nStill running: ${remaining.length} | Queued: ${queued.length}` + } + + tuiClient.tui.showToast({ + body: { + title: "Task Completed", + message, + variant: "success", + duration: 5000, + }, + }).catch(() => {}) + } +} + +let instance: TaskToastManager | null = null + +export function getTaskToastManager(): TaskToastManager | null { + return instance +} + +export function initTaskToastManager( + client: OpencodeClient, + concurrencyManager?: ConcurrencyManager +): TaskToastManager { + instance = new TaskToastManager(client, concurrencyManager) + return instance +} diff --git a/src/features/task-toast-manager/types.ts b/src/features/task-toast-manager/types.ts new file mode 100644 index 000000000..2e6ba01b3 --- /dev/null +++ b/src/features/task-toast-manager/types.ts @@ -0,0 +1,17 @@ +export type TaskStatus = "running" | "queued" | "completed" | "error" + +export interface TrackedTask { + id: string + description: string + agent: string + status: TaskStatus + startedAt: Date + isBackground: boolean +} + +export interface TaskToastOptions { + title: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number +} diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts index 2ff8c71bb..b0d9c45cc 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -1,5 +1,8 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { log } from "../../shared/logger" export * from "./constants" @@ -10,15 +13,35 @@ function isAllowedFile(filePath: string): boolean { return hasAllowedExtension && isInAllowedPath } +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 = ["sisyphus_task", "task", "call_omo_agent"] +function getAgentFromSession(sessionID: string): string | undefined { + const messageDir = getMessageDir(sessionID) + if (!messageDir) return undefined + return findNearestMessageWithFields(messageDir)?.agent +} + export function createPrometheusMdOnlyHook(_ctx: PluginInput) { return { "tool.execute.before": async ( - input: { tool: string; sessionID: string; callID: string; agent?: string }, + input: { tool: string; sessionID: string; callID: string }, output: { args: Record; message?: string } ): Promise => { - const agentName = input.agent + const agentName = getAgentFromSession(input.sessionID) if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) { return diff --git a/src/hooks/sisyphus-orchestrator/index.ts b/src/hooks/sisyphus-orchestrator/index.ts index ec12d1208..5be7b0371 100644 --- a/src/hooks/sisyphus-orchestrator/index.ts +++ b/src/hooks/sisyphus-orchestrator/index.ts @@ -14,6 +14,37 @@ import type { BackgroundManager } from "../../features/background-agent" export const HOOK_NAME = "sisyphus-orchestrator" +const ALLOWED_PATH_PREFIX = ".sisyphus/" +const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] + +const DIRECT_WORK_REMINDER = ` + +--- + +[SYSTEM REMINDER - DELEGATION REQUIRED] + +You just performed direct file modifications outside \`.sisyphus/\`. + +**Sisyphus is an ORCHESTRATOR, not an IMPLEMENTER.** + +As an orchestrator, you should: +- **DELEGATE** implementation work to subagents via \`sisyphus_task\` +- **VERIFY** the work done by subagents +- **COORDINATE** multiple tasks and ensure completion + +You should NOT: +- Write code directly (except for \`.sisyphus/\` files like plans and notepads) +- Make direct file edits outside \`.sisyphus/\` +- Implement features yourself + +**If you need to make changes:** +1. Use \`sisyphus_task\` to delegate to an appropriate subagent +2. Provide clear instructions in the prompt +3. Verify the subagent's work after completion + +--- +` + const BOULDER_CONTINUATION_PROMPT = `[SYSTEM REMINDER - BOULDER CONTINUATION] You have an active work plan with incomplete tasks. Continue working. @@ -208,12 +239,20 @@ interface ToolExecuteInput { agent?: string } -interface ToolExecuteOutput { +interface ToolExecuteAfterInput { + tool: string + sessionID?: string + callID?: string +} + +interface ToolExecuteAfterOutput { title: string output: string - metadata: unknown + metadata: Record } +type ToolExecuteOutput = ToolExecuteAfterOutput + function getMessageDir(sessionID: string): string | null { if (!existsSync(MESSAGE_STORAGE)) return null @@ -427,9 +466,26 @@ export function createSisyphusOrchestratorHook( }, "tool.execute.after": async ( - input: ToolExecuteInput, - output: ToolExecuteOutput + input: ToolExecuteAfterInput, + output: ToolExecuteAfterOutput ): Promise => { + if (!isCallerOrchestrator(input.sessionID)) { + return + } + + if (WRITE_EDIT_TOOLS.includes(input.tool)) { + const filePath = output.metadata?.filePath as string | undefined + if (filePath && !filePath.includes(ALLOWED_PATH_PREFIX)) { + output.output = (output.output || "") + DIRECT_WORK_REMINDER + log(`[${HOOK_NAME}] Direct work reminder appended`, { + sessionID: input.sessionID, + tool: input.tool, + filePath, + }) + } + return + } + if (input.tool !== "sisyphus_task") { return } @@ -440,10 +496,6 @@ export function createSisyphusOrchestratorHook( if (isBackgroundLaunch) { return } - - if (!isCallerOrchestrator(input.sessionID)) { - return - } if (output.output && typeof output.output === "string") { const gitStats = getGitDiffStats(ctx.directory) diff --git a/src/index.ts b/src/index.ts index 86d9b9ca9..8400742d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ import { } from "./tools"; import { BackgroundManager } from "./features/background-agent"; import { SkillMcpManager } from "./features/skill-mcp-manager"; +import { initTaskToastManager } from "./features/task-toast-manager"; import { type HookName } from "./config"; import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared"; import { loadPluginConfig } from "./plugin-config"; @@ -207,6 +208,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const backgroundManager = new BackgroundManager(ctx); + initTaskToastManager(ctx.client); + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") ? createTodoContinuationEnforcer(ctx, { backgroundManager }) : null; diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index ea798a2a8..0f99d2e13 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -8,6 +8,7 @@ import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../../features/builtin-skills/skills" +import { getTaskToastManager } from "../../features/task-toast-manager" type OpencodeClient = PluginInput["client"] @@ -242,20 +243,10 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id } } - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tuiClient = client as any - if (tuiClient.tui?.showToast) { - tuiClient.tui.showToast({ - body: { - title: "Task Started", - message: `"${args.description}" running with ${agentToUse}`, - variant: "info", - duration: 3000, - }, - }).catch(() => {}) - } + const toastManager = getTaskToastManager() + let taskId: string | undefined + try { const createResult = await client.session.create({ body: { parentID: ctx.sessionID, @@ -268,8 +259,18 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id } const sessionID = createResult.data.id + taskId = `sync_${sessionID.slice(0, 8)}` const startTime = new Date() + if (toastManager) { + toastManager.addTask({ + id: taskId, + description: args.description, + agent: agentToUse, + isBackground: false, + }) + } + ctx.metadata?.({ title: args.description, metadata: { sessionId: sessionID, category: args.category, sync: true }, @@ -308,6 +309,10 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id const duration = formatDuration(startTime) + if (toastManager) { + toastManager.removeTask(taskId) + } + return `Task completed in ${duration}. Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""} @@ -317,6 +322,9 @@ Session ID: ${sessionID} ${textContent || "(No text output)"}` } catch (error) { + if (toastManager && taskId !== undefined) { + toastManager.removeTask(taskId) + } const message = error instanceof Error ? error.message : String(error) return `❌ Task failed: ${message}` }