Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dist"
],
"scripts": {
"test": "bun test",
"build": "bun build src/index.ts --outdir dist --target bun --format esm && bun build src/cli/index.ts --outdir dist/cli --target node --format esm && tsc --emitDeclarationOnly",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",
Expand Down
42 changes: 36 additions & 6 deletions src/background/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { TaskToastManager } from "../features/task-toast"

export class BackgroundManager {
private tasks = new Map<string, BackgroundTask>()
private detachedParentSessions = new Set<string>()
private mainSessionID: string | undefined
private toastManager: TaskToastManager | undefined

Expand All @@ -41,6 +42,10 @@ export class BackgroundManager {
client: OpencodeClient,
input: LaunchInput
): Promise<BackgroundTask> {
if (!input.parentSessionID) {
throw new Error("Background task launch requires parentSessionID")
}

// Store main session ID for notifications
if (!this.mainSessionID) {
this.mainSessionID = input.parentSessionID
Expand All @@ -65,6 +70,7 @@ export class BackgroundManager {
const task: BackgroundTask = {
id: this.generateTaskId(),
sessionID,
parentSessionID: input.parentSessionID,
agent: input.agent,
description: input.description,
prompt: input.prompt,
Expand Down Expand Up @@ -239,19 +245,32 @@ export class BackgroundManager {
task.status = "completed"
task.completedAt = new Date()

// Parent session is gone - silently keep lifecycle local and cleanup task
if (this.detachedParentSessions.has(task.parentSessionID)) {
this.tasks.delete(task.id)
return null
}

// Show completion toast
if (this.toastManager) {
this.toastManager.showCompletionToast(task.id).catch(() => {})
}

// Generate notification
return this.getCompletionStatus()
// Generate notification for the task's parent session only
return this.getCompletionStatusForSession(task.parentSessionID)
}

getCompletionStatus(): CompletionNotification {
const allTasks = [...this.tasks.values()]
const runningTasks = allTasks.filter((t) => t.status === "running")
const completedTasks = allTasks.filter(
getCompletionStatusForSession(parentSessionID: string): CompletionNotification | null {
const sessionTasks = [...this.tasks.values()].filter(
(t) => t.parentSessionID === parentSessionID
)

if (sessionTasks.length === 0) {
return null
}

const runningTasks = sessionTasks.filter((t) => t.status === "running")
const completedTasks = sessionTasks.filter(
(t) => t.status === "completed" || t.status === "failed"
)

Expand Down Expand Up @@ -289,6 +308,7 @@ ${runningTasks.length} task(s) still running. Continue working.
message,
completedTasks,
runningCount: runningTasks.length,
parentSessionID,
parentAgent,
parentModel,
}
Expand All @@ -309,4 +329,14 @@ ${runningTasks.length} task(s) still running. Continue working.
}
}
}

detachParentSession(sessionID: string): void {
this.detachedParentSessions.add(sessionID)

for (const [id, task] of this.tasks) {
if (task.parentSessionID === sessionID && task.status !== "running") {
this.tasks.delete(id)
}
}
}
}
2 changes: 2 additions & 0 deletions src/background/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ParentModel {
export interface BackgroundTask {
id: string
sessionID: string
parentSessionID: string
agent: string
description: string
prompt: string
Expand All @@ -38,6 +39,7 @@ export interface CompletionNotification {
message: string
completedTasks: BackgroundTask[]
runningCount: number
parentSessionID: string
parentAgent?: string // Agent to return notification to (preserves mode)
parentModel?: ParentModel // Model to return notification to (preserves model)
}
11 changes: 8 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ const ZenoxPlugin: Plugin = async (ctx) => {
// Clear session agent tracking
clearSessionAgent(sessionID)

// Clear background tasks tied to this session
if (sessionID) {
backgroundManager.detachParentSession(sessionID)
}

// Clear main session if this was it
if (sessionID && sessionID === backgroundManager.getMainSession()) {
backgroundManager.setMainSession(undefined)
Expand All @@ -187,13 +192,13 @@ const ZenoxPlugin: Plugin = async (ctx) => {

// If a background task completed, notify the main session
if (notification) {
const mainSessionID = backgroundManager.getMainSession()
if (mainSessionID) {
const targetSessionID = notification.parentSessionID
if (targetSessionID) {
// Send notification with retry logic for undefined agent/model errors
const sendNotification = async (omitContext = false) => {
try {
await ctx.client.session.prompt({
path: { id: mainSessionID },
path: { id: targetSessionID },
body: {
// noReply: true = silent (don't trigger response)
// noReply: false = loud (trigger response)
Expand Down
110 changes: 110 additions & 0 deletions tests/background-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk"
import { BackgroundManager } from "../src/background/manager"

function createMockClient(): OpencodeClient {
let counter = 0

const client = {
session: {
create: async () => ({ data: { id: `child_${++counter}` } }),
prompt: async () => ({ data: {} }),
abort: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}

return client as unknown as OpencodeClient
}

describe("BackgroundManager session scoping", () => {
test("keeps completion notifications scoped to parent session", async () => {
const manager = new BackgroundManager()
const client = createMockClient()

const taskA = await manager.launch(client, {
agent: "explorer",
description: "task a",
prompt: "prompt a",
parentSessionID: "parent_a",
})

const taskB = await manager.launch(client, {
agent: "explorer",
description: "task b",
prompt: "prompt b",
parentSessionID: "parent_b",
})

const notification = manager.handleSessionIdle(taskA.sessionID)

expect(notification).not.toBeNull()
expect(notification?.parentSessionID).toBe("parent_a")
expect(notification?.allComplete).toBe(true)
expect(notification?.completedTasks.map((t) => t.id)).toEqual([taskA.id])
expect(notification?.message.includes(taskA.id)).toBe(true)
expect(notification?.message.includes(taskB.id)).toBe(false)
})

test("tracks running count per parent session", async () => {
const manager = new BackgroundManager()
const client = createMockClient()

const taskA1 = await manager.launch(client, {
agent: "explorer",
description: "task a1",
prompt: "prompt a1",
parentSessionID: "parent_a",
})

const taskA2 = await manager.launch(client, {
agent: "explorer",
description: "task a2",
prompt: "prompt a2",
parentSessionID: "parent_a",
})

const taskB = await manager.launch(client, {
agent: "explorer",
description: "task b",
prompt: "prompt b",
parentSessionID: "parent_b",
})

const first = manager.handleSessionIdle(taskA1.sessionID)
expect(first).not.toBeNull()
expect(first?.parentSessionID).toBe("parent_a")
expect(first?.allComplete).toBe(false)
expect(first?.runningCount).toBe(1)
expect(first?.completedTasks.map((t) => t.id)).toEqual([taskA1.id])

const second = manager.handleSessionIdle(taskA2.sessionID)
expect(second).not.toBeNull()
expect(second?.parentSessionID).toBe("parent_a")
expect(second?.allComplete).toBe(true)
expect(second?.completedTasks.map((t) => t.id).sort()).toEqual(
[taskA1.id, taskA2.id].sort()
)
expect(second?.completedTasks.map((t) => t.id).includes(taskB.id)).toBe(false)
})

test("detached parent session suppresses notifications and keeps running tasks until completion", async () => {
const manager = new BackgroundManager()
const client = createMockClient()

const task = await manager.launch(client, {
agent: "explorer",
description: "task",
prompt: "prompt",
parentSessionID: "parent_a",
})

manager.detachParentSession("parent_a")

expect(manager.listAllTasks().some((t) => t.id === task.id)).toBe(true)

const notification = manager.handleSessionIdle(task.sessionID)
expect(notification).toBeNull()
expect(manager.listAllTasks().some((t) => t.id === task.id)).toBe(false)
})
})