Skip to content
Merged
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
40 changes: 35 additions & 5 deletions .fork-features/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,35 @@
"packages/opencode/src/tool/task.ts",
"packages/opencode/src/tool/task.txt",
"packages/opencode/src/tool/check_task.ts",
"packages/opencode/src/tool/check_task.txt"
"packages/opencode/src/tool/check_task.txt",
"packages/opencode/src/tool/list_tasks.ts",
"packages/opencode/src/tool/list_tasks.txt"
],
"modifiedFiles": [
"packages/opencode/src/session/index.ts",
"packages/opencode/src/session/prompt.ts",
"packages/opencode/src/tool/registry.ts"
],
"modifiedFiles": ["packages/opencode/src/session/index.ts", "packages/opencode/src/session/prompt.ts"],
"criticalCode": [
"backgroundTaskResults",
"trackBackgroundTask",
"cleanupSessionMaps",
"formatCompletedTasksForInjection",
"TaskTool",
"CheckTaskTool",
"Agent.get(task.agent)",
"ListTasksTool",
"parent_session_id",
"session ownership",
"filter to caller's session",
"sync",
"sync mode execution",
"parameters.sync"
],
"tests": ["packages/opencode/test/tool/check_task.test.ts", "packages/opencode/test/session/async-tasks.test.ts"],
"tests": [
"packages/opencode/test/tool/check_task.test.ts",
"packages/opencode/test/tool/list_tasks.test.ts",
"packages/opencode/test/session/async-tasks.test.ts"
],
"upstreamTracking": {
"relatedPRs": ["anomalyco/opencode#7206"],
"relatedIssues": ["anomalyco/opencode#5887"],
Expand Down Expand Up @@ -129,7 +141,7 @@
"modifiedFiles": [],
"criticalCode": [],
"tests": [],
"upstreamTracking": {
"upstream": {
"absorptionSignals": []
}
},
Expand Down Expand Up @@ -189,6 +201,24 @@
"task.*delegation.*restrict"
]
}
},
"list-tasks": {
"status": "active",
"description": "New tool to list all background tasks with their status. Exposes the internal listBackgroundTasks() function as a user-facing tool. Supports filtering by limit and including/excluding completed tasks.",
"issue": "https://github.com/randomm/opencode/issues/184",
"newFiles": [
"packages/opencode/src/tool/list_tasks.ts",
"packages/opencode/src/tool/list_tasks.txt",
"packages/opencode/test/tool/list_tasks.test.ts"
],
"modifiedFiles": ["packages/opencode/src/tool/registry.ts"],
"criticalCode": ["ListTasksTool", "listBackgroundTasks", "list_tasks"],
"tests": ["packages/opencode/test/tool/list_tasks.test.ts"],
"upstreamTracking": {
"relatedPRs": [],
"relatedIssues": [],
"absorptionSignals": ["list.*tasks", "ListTasksTool", "listBackgroundTasks"]
}
}
}
}
129 changes: 129 additions & 0 deletions packages/opencode/src/tool/list_tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Tool } from "./tool"
import DESCRIPTION from "./list_tasks.txt"
import z from "zod"
import { listBackgroundTasks, getBackgroundTaskResult, getBackgroundTaskMetadata } from "../session/async-tasks"

interface TaskInfo {
task_id: string
status: "running" | "completed" | "failed"
agent?: string
description?: string
started_at?: string
completed_at?: string
}

interface ListTasksResult {
pending: TaskInfo[]
completed: TaskInfo[]
total_count: number
}

// Helper: Timestamp validation (>= 2000-01-01, sane range)
function isValidTimestamp(timestamp: number): boolean {
if (!timestamp || !Number.isFinite(timestamp)) {
return false
}
const date = new Date(timestamp)
return date.getFullYear() >= 2000 && date.getFullYear() <= 2100
}

export const ListTasksTool = Tool.define("list_tasks", {
description: DESCRIPTION,
parameters: z.object({
limit: z.number().optional().describe("Maximum number of tasks to return (default: 50)"),
include_completed: z.boolean().optional().describe("Include completed tasks (default: true)"),
}),
async execute(params, ctx) {
// CRITICAL #1: Session ownership filtering - only return tasks owned by caller's session
const callerSessionId = ctx.sessionID!

const tasks = listBackgroundTasks()
const limit = params.limit ?? 50

// Filter and map pending tasks
const pending: TaskInfo[] = []
for (const id of tasks.pending.slice(0, limit)) {
const metadata = getBackgroundTaskMetadata(id)
const result = getBackgroundTaskResult(id)

// Ownership check: filter to caller's session
const ownedByCaller =
metadata?.parent_session_id === callerSessionId || result?.metadata?.parent_session_id === callerSessionId

if (!ownedByCaller) {
continue
}

// CRITICAL #2: Timestamp validation before Date() calls
let started_at: string | undefined
if (metadata?.start_time && isValidTimestamp(metadata.start_time)) {
started_at = new Date(metadata.start_time).toISOString()
} else if (result?.time.started && isValidTimestamp(result.time.started)) {
started_at = new Date(result.time.started).toISOString()
}

pending.push({
task_id: id,
status: "running",
agent: metadata?.agent_type,
description: metadata?.description,
started_at,
})

if (pending.length >= limit) {
break
}
}

// Filter and map completed tasks
const completed: TaskInfo[] = []
if (params.include_completed !== false) {
// CRITICAL #4: Clamp slice end to prevent negative values (limit=0 case)
const completedLimit = Math.max(0, limit - pending.length)

// CRITICAL #3: Guard against undefined values from Object.entries
for (const [id, result] of Object.entries(tasks.results)) {
if (!result) {
continue
}

// Ownership check: only return tasks owned by caller's session
if (result.metadata?.parent_session_id !== callerSessionId) {
continue
}

// CRITICAL #5: Timestamp validation before Date() calls
const has_valid_started = isValidTimestamp(result.time.started)
const has_valid_completed = result.time.completed && isValidTimestamp(result.time.completed)

completed.push({
task_id: id,
status: result.status,
agent: result.metadata?.agent_type,
description: result.metadata?.description,
started_at: has_valid_started ? new Date(result.time.started).toISOString() : undefined,
completed_at: has_valid_completed ? new Date(result.time.completed).toISOString() : undefined,
})

if (completed.length >= completedLimit) {
break
}
}
}

const taskResult: ListTasksResult = {
pending,
completed,
total_count: pending.length + completed.length,
}

return {
title: `List tasks: ${taskResult.total_count} total`,
output: JSON.stringify(taskResult, null, 2),
metadata: {
pending_count: pending.length,
completed_count: completed.length,
},
}
},
})
17 changes: 17 additions & 0 deletions packages/opencode/src/tool/list_tasks.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
List all background tasks with their status.

Use this tool to get an overview of all running and completed tasks without needing to know their individual IDs.

Parameters:
- limit: Maximum number of tasks to return (default: 50)
- include_completed: Whether to include completed tasks (default: true)

Returns:
- pending: Array of running tasks with task_id, status, agent, description, started_at
- completed: Array of completed/failed tasks with task_id, status, agent, description, started_at, completed_at
- total_count: Total number of tasks returned

Example usage:
List all tasks: /list_tasks
List only pending tasks: /list_tasks {"include_completed": false}
List up to 10 tasks: /list_tasks {"limit": 10}
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
import { CheckTaskTool } from "./check_task"
import { ListTasksTool } from "./list_tasks"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -109,6 +110,7 @@ export namespace ToolRegistry {
WriteTool,
TaskTool,
CheckTaskTool,
ListTasksTool,
WebFetchTool,
TodoWriteTool,
// TodoReadTool,
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/tool/list_tasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { ListTasksTool } from "../../src/tool/list_tasks"
import { Instance } from "../../src/project/instance"

describe("tool.list_tasks", () => {
const ctx = {
sessionID: "test-session",
messageID: "",
callID: "",
agent: "test",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
extra: { bypassAgentCheck: true },
}

test("returns empty list when no tasks", async () => {
await Instance.provide({
directory: "/tmp/test",
fn: async () => {
const tool = await ListTasksTool.init()
const result = await tool.execute({}, ctx)

const output = JSON.parse(result.output)
expect(output.pending).toEqual([])
expect(output.completed).toEqual([])
expect(output.total_count).toBe(0)
},
})
})

test("respects limit parameter", async () => {
await Instance.provide({
directory: "/tmp/test",
fn: async () => {
const tool = await ListTasksTool.init()
const result = await tool.execute({ limit: 5 }, ctx)

const output = JSON.parse(result.output)
expect(output.total_count).toBeLessThanOrEqual(5)
},
})
})

test("can exclude completed tasks", async () => {
await Instance.provide({
directory: "/tmp/test",
fn: async () => {
const tool = await ListTasksTool.init()
const result = await tool.execute({ include_completed: false }, ctx)

const output = JSON.parse(result.output)
expect(output.completed).toEqual([])
},
})
})
})