diff --git a/src/features/team-mode/tools/query.test.ts b/src/features/team-mode/tools/query.test.ts
new file mode 100644
index 000000000..23ddc90b1
--- /dev/null
+++ b/src/features/team-mode/tools/query.test.ts
@@ -0,0 +1,118 @@
+///
+
+import { describe, expect, mock, test } from "bun:test"
+
+import type { ToolContext } from "@opencode-ai/plugin/tool"
+import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
+import type { OpencodeClient } from "../../../tools/delegate-task/types"
+
+const mockClient = {} as OpencodeClient
+
+let aggregateStatusImplementation: typeof import("../team-runtime/status").aggregateStatus = async () => {
+ throw new Error("aggregateStatusImplementation not set")
+}
+
+let discoverTeamSpecsImplementation: typeof import("../team-registry/paths").discoverTeamSpecs = async () => {
+ throw new Error("discoverTeamSpecsImplementation not set")
+}
+
+let loadTeamSpecImplementation: typeof import("../team-registry/loader").loadTeamSpec = async () => {
+ throw new Error("loadTeamSpecImplementation not set")
+}
+
+let listActiveTeamsImplementation: typeof import("../team-state-store/store").listActiveTeams = async () => {
+ throw new Error("listActiveTeamsImplementation not set")
+}
+
+mock.module("../team-runtime/status", () => ({
+ aggregateStatus: (...args: Parameters) => aggregateStatusImplementation(...args),
+}))
+
+mock.module("../team-registry/paths", () => ({
+ discoverTeamSpecs: (...args: Parameters) => discoverTeamSpecsImplementation(...args),
+}))
+
+mock.module("../team-registry/loader", () => ({
+ loadTeamSpec: (...args: Parameters) => loadTeamSpecImplementation(...args),
+}))
+
+mock.module("../team-state-store/store", () => ({
+ listActiveTeams: (...args: Parameters) => listActiveTeamsImplementation(...args),
+}))
+
+import { createTeamListTool, createTeamStatusTool } from "./query"
+
+function createMockContext(): ToolContext {
+ return {
+ sessionID: "session",
+ messageID: "message",
+ agent: "agent",
+ directory: "/tmp/team-mode",
+ worktree: "/tmp/team-mode",
+ abort: new AbortController().signal,
+ metadata: mock(() => {}),
+ ask: async () => undefined,
+ } satisfies ToolContext
+}
+
+describe("query tools", () => {
+ test("team_status returns aggregated team status", async () => {
+ // given
+ const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/team-mode" })
+ const expectedStatus = {
+ teamRunId: "team-run-1",
+ teamName: "team-alpha",
+ status: "active",
+ createdAt: 1,
+ members: [{ name: "worker", unreadMessages: 0 }],
+ tasks: { pending: 0, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 0 },
+ shutdownRequests: [],
+ concurrency: { runningOnSameModel: 0, queuedOnSameModel: 0 },
+ bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
+ staleLocks: [],
+ } satisfies Awaited>
+ aggregateStatusImplementation = async (teamRunId, passedConfig) => {
+ expect(teamRunId).toBe("team-run-1")
+ expect(passedConfig).toBe(config)
+ return expectedStatus
+ }
+ const tool = createTeamStatusTool(config, mockClient)
+
+ // when
+ const result = JSON.parse(await tool.execute({ teamRunId: "team-run-1" }, createMockContext()))
+
+ // then
+ expect(result).toEqual(expectedStatus)
+ })
+
+ test("team_list includes declared-only teams", async () => {
+ // given
+ const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/team-mode" })
+ discoverTeamSpecsImplementation = async () => [
+ { name: "foo", scope: "project", path: "/tmp/project/foo/config.json" },
+ ]
+ loadTeamSpecImplementation = async (teamName) => {
+ expect(teamName).toBe("foo")
+ return {
+ version: 1,
+ name: "foo",
+ createdAt: 1,
+ leadAgentId: "lead",
+ members: [{ kind: "category", name: "member-a", category: "agent", prompt: "do", backendType: "in-process", isActive: true }],
+ }
+ }
+ listActiveTeamsImplementation = async () => [
+ { teamRunId: "run-1", teamName: "bar", status: "active", memberCount: 3, scope: "user" },
+ ]
+ const tool = createTeamListTool(config, mockClient)
+
+ // when
+ const result = JSON.parse(await tool.execute({}, createMockContext()))
+
+ // then
+ expect(result).toEqual([
+ { name: "foo", scope: "project", status: "not-started", teamRunId: undefined, memberCount: 1 },
+ { name: "bar", scope: "user", status: "active", teamRunId: "run-1", memberCount: 3 },
+ ])
+ })
+})
diff --git a/src/features/team-mode/tools/query.ts b/src/features/team-mode/tools/query.ts
new file mode 100644
index 000000000..7718a4231
--- /dev/null
+++ b/src/features/team-mode/tools/query.ts
@@ -0,0 +1,96 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
+
+import type { TeamModeConfig } from "../../../config/schema/team-mode"
+import type { OpencodeClient } from "../../../tools/delegate-task/types"
+import { loadTeamSpec } from "../team-registry/loader"
+import { aggregateStatus } from "../team-runtime/status"
+import { discoverTeamSpecs } from "../team-registry/paths"
+import { listActiveTeams } from "../team-state-store/store"
+
+type TeamListScope = "user" | "project" | "all"
+
+type TeamListEntry = {
+ name: string
+ scope: "user" | "project"
+ status: string
+ teamRunId?: string
+ memberCount: number
+}
+
+export function createTeamStatusTool(
+ config: TeamModeConfig,
+ client: OpencodeClient,
+ backgroundManager?: Parameters[2],
+): ToolDefinition {
+ void client
+
+ return tool({
+ description: "Return full status for a team run.",
+ args: {
+ teamRunId: tool.schema.string().describe("Team run ID"),
+ },
+ execute: async (args: { teamRunId: string }) => JSON.stringify(await aggregateStatus(args.teamRunId, config, backgroundManager)),
+ })
+}
+
+export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
+ void client
+
+ return tool({
+ description: "List declared and active teams.",
+ args: {
+ scope: tool.schema.union([
+ tool.schema.literal("user"),
+ tool.schema.literal("project"),
+ tool.schema.literal("all"),
+ ]).optional().describe("Team scope filter"),
+ },
+ execute: async (args: { scope?: TeamListScope }) => {
+ const scope = args.scope ?? "all"
+ const projectRoot = process.cwd()
+ const declaredTeamSpecs = await discoverTeamSpecs(config, projectRoot)
+ const activeTeams = await listActiveTeams(config)
+
+ const filteredDeclaredTeamSpecs = scope === "all"
+ ? declaredTeamSpecs
+ : declaredTeamSpecs.filter((teamSpec) => teamSpec.scope === scope)
+
+ const declaredTeamSpecsByName = new Map(
+ await Promise.all(filteredDeclaredTeamSpecs.map(async (teamSpec) => {
+ const loadedTeamSpec = await loadTeamSpec(teamSpec.name, config, projectRoot)
+ return [teamSpec.name, loadedTeamSpec.members.length] as const
+ })),
+ )
+
+ const activeTeamsByName = new Map(activeTeams.map((team) => [team.teamName, team]))
+
+ const teamEntries: TeamListEntry[] = []
+
+ for (const declaredTeamSpec of filteredDeclaredTeamSpecs) {
+ const activeTeam = activeTeamsByName.get(declaredTeamSpec.name)
+ const declaredTeamSpecMemberCount = declaredTeamSpecsByName.get(declaredTeamSpec.name)
+ teamEntries.push({
+ name: declaredTeamSpec.name,
+ scope: declaredTeamSpec.scope,
+ status: activeTeam?.status ?? "not-started",
+ teamRunId: activeTeam?.teamRunId,
+ memberCount: activeTeam?.memberCount ?? declaredTeamSpecMemberCount ?? 0,
+ })
+ }
+
+ for (const activeTeam of activeTeams) {
+ if (declaredTeamSpecsByName.has(activeTeam.teamName)) continue
+
+ teamEntries.push({
+ name: activeTeam.teamName,
+ scope: activeTeam.scope,
+ status: activeTeam.status,
+ teamRunId: activeTeam.teamRunId,
+ memberCount: activeTeam.memberCount,
+ })
+ }
+
+ return JSON.stringify(teamEntries)
+ },
+ })
+}