feat(features): add TaskToastManager for consolidated task notifications

- 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
This commit is contained in:
YeonGyu-Kim
2026-01-06 13:40:27 +09:00
parent 110dbd665a
commit 9eafe6b5f9
9 changed files with 371 additions and 48 deletions

View File

@@ -23,7 +23,25 @@ export const PROMETHEUS_SYSTEM_PROMPT = `<system-reminder>
**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.**

View File

@@ -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.`

View File

@@ -0,0 +1,2 @@
export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager"
export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types"

View File

@@ -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<string, TrackedTask> = 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
}

View File

@@ -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
}

View File

@@ -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<string, unknown>; message?: string }
): Promise<void> => {
const agentName = input.agent
const agentName = getAgentFromSession(input.sessionID)
if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) {
return

View File

@@ -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<string, unknown>
}
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<void> => {
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)

View File

@@ -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;

View File

@@ -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}`
}