diff --git a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts
new file mode 100644
index 000000000..0d707922c
--- /dev/null
+++ b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts
@@ -0,0 +1,302 @@
+///
+
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
+import { randomUUID } from "node:crypto"
+import { tmpdir } from "node:os"
+import path from "node:path"
+
+import type { ToolContext } from "@opencode-ai/plugin/tool"
+
+import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
+import type { RuntimeState, TeamSpec } from "../types"
+
+const runtimes = new Map()
+let nextTeamRunNumber = 1
+
+const lifecycleSpecifier = import.meta.resolve("./lifecycle")
+const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create")
+
+function clone(value: TValue): TValue {
+ return structuredClone(value)
+}
+
+function createToolContext(sessionID: string, agent = "test-agent"): ToolContext {
+ return {
+ sessionID,
+ messageID: randomUUID(),
+ agent,
+ directory: "/project",
+ worktree: "/project",
+ abort: new AbortController().signal,
+ metadata: () => {},
+ ask: async () => undefined,
+ }
+}
+
+function createRuntimeState(spec: TeamSpec, leadSessionId: string, teamRunId: string): RuntimeState {
+ return {
+ version: 1,
+ teamRunId,
+ teamName: spec.name,
+ specSource: "project",
+ createdAt: 1,
+ status: "active",
+ leadSessionId,
+ shutdownRequests: [],
+ bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
+ members: spec.members.map((member) => ({
+ name: member.name,
+ sessionId: member.name === spec.leadAgentId ? undefined : `${member.name}-session`,
+ tmuxPaneId: undefined,
+ agentType: member.name === spec.leadAgentId ? "leader" : "general-purpose",
+ status: "running",
+ color: member.color,
+ worktreePath: member.worktreePath,
+ lastInjectedTurnMarker: `turn:${member.name}`,
+ pendingInjectedMessageIds: [`msg:${member.name}`],
+ })),
+ }
+}
+
+const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) => {
+ const teamRunId = `team-run-${nextTeamRunNumber++}`
+ const runtimeState = createRuntimeState(spec, leadSessionId, teamRunId)
+ runtimes.set(teamRunId, runtimeState)
+ return clone(runtimeState)
+})
+
+function registerModuleMocks(): void {
+ mock.module(teamRuntimeCreateSpecifier, () => ({ createTeamRun: createTeamRunMock }))
+}
+
+async function loadCreateTeamCreateTool(): Promise {
+ const module = await import(`${lifecycleSpecifier}?test=${randomUUID()}`)
+ return module.createTeamCreateTool
+}
+
+function createConfig() {
+ return TeamModeConfigSchema.parse({
+ enabled: true,
+ base_dir: path.join(tmpdir(), `team-mode-inline-spec-${randomUUID()}`),
+ })
+}
+
+describe("createTeamCreateTool inline_spec normalization", () => {
+ afterEach(() => {
+ mock.restore()
+ })
+
+ beforeEach(() => {
+ mock.restore()
+ registerModuleMocks()
+ runtimes.clear()
+ nextTeamRunNumber = 1
+ createTeamRunMock.mockClear()
+ })
+
+ test("accepts inline_spec objects and auto-assigns missing member names", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+ const inlineSpec = {
+ name: "alpha-team",
+ lead: { kind: "subagent_type", subagent_type: "sisyphus" },
+ members: [
+ { kind: "category", category: "quick", prompt: "Quick scout the workspace for entrypoints." },
+ { kind: "subagent_type", subagent_type: "atlas" },
+ ],
+ }
+
+ // when
+ const result = JSON.parse(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session")))
+ const firstCall = createTeamRunMock.mock.calls[0]
+
+ // then
+ expect(firstCall?.[0]).toMatchObject({
+ leadAgentId: "lead",
+ members: [
+ { name: "lead", kind: "subagent_type", subagent_type: "sisyphus" },
+ { name: "quick-1", kind: "category", category: "quick" },
+ { name: "atlas-1", kind: "subagent_type", subagent_type: "atlas" },
+ ],
+ })
+ expect(firstCall?.[1]).toBe("lead-session")
+ expect(result.runtimeState.members.map((member: { name: string }) => member.name)).toEqual(["lead", "quick-1", "atlas-1"])
+ })
+
+ test("accepts stringified inline_spec values from tool calling", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+ const inlineSpec = JSON.stringify({
+ name: "ccapi-explorers-v2",
+ lead: { kind: "subagent_type", subagent_type: "sisyphus" },
+ members: [
+ { kind: "category", category: "quick", prompt: "Quick scout: survey ccapi workspace structure." },
+ { kind: "category", category: "deep", prompt: "Deep dive ccapi-cf." },
+ { kind: "category", category: "deep", prompt: "Deep dive ccapi-cf-proxy." },
+ ],
+ })
+
+ // when
+ const result = JSON.parse(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session")))
+
+ // then
+ expect(result.runtimeState.members.map((member: { name: string }) => member.name)).toEqual(["lead", "quick-1", "deep-1", "deep-2"])
+ expect(result.runtimeState.teamName).toBe("ccapi-explorers-v2")
+ })
+
+ test("accepts category members written with natural inline prompt fields", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+ const inlineSpec = {
+ name: "project-analysis-team",
+ description: "Analyze the codebase from structure, core logic, and quality angles.",
+ members: [
+ {
+ name: "structure-analyst",
+ category: "quick",
+ loadSkills: [],
+ systemPrompt: "Focus on directory layouts, module boundaries, and architectural organization.",
+ },
+ {
+ name: "core-logic-analyst",
+ category: "quick",
+ loadSkills: [],
+ systemPrompt: "Focus on initialization flows, plugin architecture, hooks, tools, and MCP integration.",
+ },
+ {
+ name: "quality-analyst",
+ category: "quick",
+ loadSkills: [],
+ systemPrompt: "Focus on tests, CI/CD, build scripts, conventions, and anti-pattern enforcement.",
+ },
+ ],
+ }
+
+ // when
+ await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus"))
+ const firstCall = createTeamRunMock.mock.calls[0]
+
+ // then
+ expect(firstCall?.[0]).toMatchObject({
+ leadAgentId: "lead",
+ members: [
+ { name: "lead", kind: "subagent_type" },
+ { name: "structure-analyst", kind: "category", category: "quick", prompt: "Focus on directory layouts, module boundaries, and architectural organization." },
+ { name: "core-logic-analyst", kind: "category", category: "quick", prompt: "Focus on initialization flows, plugin architecture, hooks, tools, and MCP integration." },
+ { name: "quality-analyst", kind: "category", category: "quick", prompt: "Focus on tests, CI/CD, build scripts, conventions, and anti-pattern enforcement." },
+ ],
+ })
+ })
+
+ test("explains how to call team_create when arguments are empty", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+
+ // when
+ const result = teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus"))
+
+ // then
+ await expect(result).rejects.toThrow("team_create requires exactly one of teamName or inline_spec")
+ await expect(result).rejects.toThrow("team_create({ inline_spec: { name:")
+ })
+
+ test("explains how to shape inline_spec when members are missing", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+
+ // when
+ const result = teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus"))
+
+ // then
+ await expect(result).rejects.toThrow("Invalid inline_spec for team_create")
+ await expect(result).rejects.toThrow("members array")
+ })
+
+ test("accepts natural team and member names in inline_spec", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never)
+ const inlineSpec = {
+ name: "Project Analysis Team",
+ members: [
+ { name: "Agent 1: Structure Analyst", category: "quick", prompt: "Analyze project structure and report concrete files." },
+ { name: "Agent 2: Core Logic Analyst", category: "quick", prompt: "Analyze initialization flow and report concrete functions." },
+ { name: "Agent 3: Quality/Process Analyst", category: "quick", prompt: "Analyze tests, builds, CI, and conventions." },
+ ],
+ }
+
+ // when
+ await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus"))
+ const firstCall = createTeamRunMock.mock.calls[0]
+
+ // then
+ expect(firstCall?.[0]).toMatchObject({
+ name: "project-analysis-team",
+ members: [
+ { name: "lead", kind: "subagent_type" },
+ { name: "agent-1-structure-analyst", kind: "category", category: "quick" },
+ { name: "agent-2-core-logic-analyst", kind: "category", category: "quick" },
+ { name: "agent-3-quality-process-analyst", kind: "category", category: "quick" },
+ ],
+ })
+ })
+
+ test("accepts role and capabilities style members with the configured fallback category", async () => {
+ // given
+ const createTeamCreateTool = await loadCreateTeamCreateTool()
+ const config = createConfig()
+ const teamCreateTool = createTeamCreateTool(config, {} as never, undefined as never, undefined, {
+ userCategories: {
+ analysis: {},
+ },
+ })
+ const inlineSpec = {
+ name: "Project Analysis Team",
+ members: [
+ {
+ name: "Agent 1: Structure Analyst",
+ kind: "agent",
+ role: "Structure Analyst",
+ capabilities: ["directory layouts", "module boundaries"],
+ },
+ {
+ name: "Agent 2: Core Logic Analyst",
+ kind: "quick",
+ role: "Core Logic Analyst",
+ description: "Analyze initialization flow and plugin architecture.",
+ },
+ {
+ name: "Agent 3: Quality/Process Analyst",
+ role: "Quality/Process Analyst",
+ responsibilities: ["tests", "builds", "CI/CD"],
+ },
+ ],
+ }
+
+ // when
+ await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus"))
+ const firstCall = createTeamRunMock.mock.calls[0]
+
+ // then
+ expect(firstCall?.[0]).toMatchObject({
+ name: "project-analysis-team",
+ members: [
+ { name: "lead", kind: "subagent_type" },
+ { name: "agent-1-structure-analyst", kind: "category", category: "analysis", prompt: "Role: Structure Analyst\ndirectory layouts, module boundaries" },
+ { name: "agent-2-core-logic-analyst", kind: "category", category: "quick", prompt: "Role: Core Logic Analyst\nAnalyze initialization flow and plugin architecture." },
+ { name: "agent-3-quality-process-analyst", kind: "category", category: "analysis", prompt: "Role: Quality/Process Analyst\ntests, builds, CI/CD" },
+ ],
+ })
+ })
+})
diff --git a/src/features/team-mode/tools/lifecycle-test-fixture.ts b/src/features/team-mode/tools/lifecycle-test-fixture.ts
new file mode 100644
index 000000000..5f608df58
--- /dev/null
+++ b/src/features/team-mode/tools/lifecycle-test-fixture.ts
@@ -0,0 +1,170 @@
+///
+
+import { mock } from "bun:test"
+import { randomUUID } from "node:crypto"
+
+import type { ToolContext } from "@opencode-ai/plugin/tool"
+
+import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
+import type { OpencodeClient } from "../../../tools/delegate-task/types"
+import type { BackgroundManager } from "../../background-agent/manager"
+import type { RuntimeState, TeamSpec } from "../types"
+
+const runtimes = new Map()
+const teamRuns = new Map()
+let nextTeamRunNumber = 1
+
+function clone(value: TValue): TValue {
+ return structuredClone(value)
+}
+
+export function parseToolResult(value: string): TValue {
+ return JSON.parse(value) as TValue
+}
+
+export function createToolContext(sessionID: string): ToolContext {
+ return {
+ sessionID,
+ messageID: randomUUID(),
+ agent: "test-agent",
+ directory: "/project",
+ worktree: "/project",
+ abort: new AbortController().signal,
+ metadata: () => {},
+ ask: async () => undefined,
+ }
+}
+
+export function getLatestShutdownRequest(
+ runtimeState: RuntimeState,
+ memberName: string,
+): RuntimeState["shutdownRequests"][number] | undefined {
+ for (let index = runtimeState.shutdownRequests.length - 1; index >= 0; index -= 1) {
+ const shutdownRequest = runtimeState.shutdownRequests[index]
+ if (shutdownRequest?.memberId === memberName) {
+ return shutdownRequest
+ }
+ }
+}
+
+export function createSpec(): TeamSpec {
+ return {
+ version: 1,
+ name: "alpha-team",
+ createdAt: 1,
+ leadAgentId: "lead",
+ members: [
+ { kind: "category", name: "lead", category: "deep", prompt: "Lead the assigned work", backendType: "in-process", isActive: true },
+ { kind: "category", name: "member-a", category: "quick", prompt: "Do the assigned work", backendType: "in-process", isActive: true },
+ ],
+ }
+}
+
+function createRuntimeState(spec: TeamSpec, leadSessionId: string, teamRunId: string): RuntimeState {
+ return {
+ version: 1,
+ teamRunId,
+ teamName: spec.name,
+ specSource: "project",
+ createdAt: 1,
+ status: "active",
+ leadSessionId,
+ shutdownRequests: [],
+ bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
+ members: spec.members.map((member) => ({
+ name: member.name,
+ sessionId: member.name === spec.leadAgentId ? undefined : `${member.name}-session`,
+ tmuxPaneId: undefined,
+ agentType: member.name === spec.leadAgentId ? "leader" : "general-purpose",
+ status: "running",
+ color: member.color,
+ worktreePath: member.worktreePath,
+ lastInjectedTurnMarker: `turn:${member.name}`,
+ pendingInjectedMessageIds: [`msg:${member.name}`],
+ })),
+ }
+}
+
+export function requireRuntime(teamRunId: string): RuntimeState {
+ const runtimeState = runtimes.get(teamRunId)
+ if (!runtimeState) throw new Error(`missing runtime ${teamRunId}`)
+ return runtimeState
+}
+
+export const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) => {
+ const key = `${spec.name}:${leadSessionId}`
+ const existingTeamRunId = teamRuns.get(key)
+ if (existingTeamRunId) return clone(requireRuntime(existingTeamRunId))
+ const teamRunId = `team-run-${nextTeamRunNumber++}`
+ teamRuns.set(key, teamRunId)
+ const runtimeState = createRuntimeState(spec, leadSessionId, teamRunId)
+ runtimes.set(teamRunId, runtimeState)
+ return clone(runtimeState)
+})
+export const deleteTeamMock = mock(async (
+ teamRunId: string,
+ _config?: unknown,
+ _tmuxMgr?: unknown,
+ _bgMgr?: unknown,
+ options?: { force?: boolean },
+) => {
+ const runtimeState = requireRuntime(teamRunId)
+ const deletableStatuses = options?.force
+ ? new Set(["active", "shutdown_requested", "deleting", "deleted", "creating", "orphaned"])
+ : new Set(["active", "shutdown_requested", "deleting", "deleted"])
+ if (!deletableStatuses.has(runtimeState.status)) {
+ throw new Error(`team cannot be deleted from '${runtimeState.status}'`)
+ }
+ if (!options?.force && runtimeState.members.some((member) => member.agentType !== "leader" && member.status !== "shutdown_approved" && member.status !== "completed" && member.status !== "errored")) {
+ throw new Error("members still active")
+ }
+ runtimes.delete(teamRunId)
+ return { removedWorktrees: [], removedLayout: false }
+})
+export const requestShutdownOfMemberMock = mock(async (teamRunId: string, targetMemberName: string, requesterName: string) => {
+ requireRuntime(teamRunId).shutdownRequests.push({ memberId: targetMemberName, requesterName, requestedAt: Date.now() })
+})
+export const approveShutdownMock = mock(async (teamRunId: string, memberName: string) => {
+ const runtimeState = requireRuntime(teamRunId)
+ const request = getLatestShutdownRequest(runtimeState, memberName)
+ if (request) request.approvedAt = Date.now()
+ const member = runtimeState.members.find((candidate) => candidate.name === memberName)
+ if (member) member.status = "shutdown_approved"
+})
+export const rejectShutdownMock = mock(async (teamRunId: string, memberName: string, reason: string) => {
+ const request = getLatestShutdownRequest(requireRuntime(teamRunId), memberName)
+ if (request) {
+ request.rejectedAt = Date.now()
+ request.rejectedReason = reason
+ }
+})
+export const loadTeamSpecMock = mock(async () => createSpec())
+export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({ teamRunId: runtimeState.teamRunId, teamName: runtimeState.teamName, status: runtimeState.status })))
+export const loadRuntimeStateMock = mock(async (teamRunId: string) => clone(requireRuntime(teamRunId)))
+
+export const config = TeamModeConfigSchema.parse({ enabled: true })
+export const mockClient = {} as OpencodeClient
+export const backgroundManager = {} as BackgroundManager
+
+export function resetLifecycleTestState(): void {
+ runtimes.clear()
+ teamRuns.clear()
+ nextTeamRunNumber = 1
+
+ for (const mockedFunction of [
+ createTeamRunMock,
+ deleteTeamMock,
+ requestShutdownOfMemberMock,
+ approveShutdownMock,
+ rejectShutdownMock,
+ loadTeamSpecMock,
+ listActiveTeamsMock,
+ loadRuntimeStateMock,
+ ]) {
+ mockedFunction.mockClear()
+ }
+}
+
+export function hasRuntime(teamRunId: string): boolean {
+ return runtimes.has(teamRunId)
+}
diff --git a/src/features/team-mode/tools/lifecycle.test.ts b/src/features/team-mode/tools/lifecycle.test.ts
new file mode 100644
index 000000000..c709619ee
--- /dev/null
+++ b/src/features/team-mode/tools/lifecycle.test.ts
@@ -0,0 +1,289 @@
+///
+
+import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
+
+import { normalizeTeamSpecInput } from "../team-registry/team-spec-input-normalizer"
+import type { RuntimeState } from "../types"
+import {
+ approveShutdownMock,
+ backgroundManager,
+ config,
+ createSpec,
+ createTeamRunMock,
+ createToolContext,
+ deleteTeamMock,
+ getLatestShutdownRequest,
+ hasRuntime,
+ listActiveTeamsMock,
+ loadRuntimeStateMock,
+ loadTeamSpecMock,
+ mockClient,
+ parseToolResult,
+ rejectShutdownMock,
+ requestShutdownOfMemberMock,
+ requireRuntime,
+ resetLifecycleTestState,
+} from "./lifecycle-test-fixture"
+
+mock.module("../team-runtime/create", () => ({ createTeamRun: createTeamRunMock }))
+mock.module("../team-runtime/shutdown", () => ({ approveShutdown: approveShutdownMock, deleteTeam: deleteTeamMock, rejectShutdown: rejectShutdownMock, requestShutdownOfMember: requestShutdownOfMemberMock }))
+mock.module("../team-registry/loader", () => ({ loadTeamSpec: loadTeamSpecMock, normalizeTeamSpecInput }))
+mock.module("../team-state-store/store", () => ({ listActiveTeams: listActiveTeamsMock, loadRuntimeState: loadRuntimeStateMock }))
+
+const {
+ createTeamApproveShutdownTool,
+ createTeamCreateTool,
+ createTeamDeleteTool,
+ createTeamRejectShutdownTool,
+ createTeamShutdownRequestTool,
+} = await import("./lifecycle")
+
+describe("team lifecycle tools", () => {
+ afterAll(() => {
+ mock.restore()
+ })
+
+ beforeEach(() => {
+ resetLifecycleTestState()
+ })
+
+ test("team_create works without toolContext.client field", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+
+ // when
+ const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+
+ // then
+ expect(result.teamRunId).toBe("team-run-1")
+ expect(createTeamRunMock).toHaveBeenCalledWith(
+ expect.anything(),
+ "lead-session",
+ expect.objectContaining({ client: mockClient }),
+ config,
+ backgroundManager,
+ undefined,
+ { callerAgentTypeId: undefined, parentMessageID: expect.any(String) },
+ )
+ })
+
+ test("team_create resolves a visible sort-prefixed sisyphus caller into callerAgentTypeId", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const toolContext = {
+ ...createToolContext("lead-session"),
+ agent: "00|Sisyphus",
+ }
+
+ // when
+ await teamCreateTool.execute({ inline_spec: createSpec() }, toolContext)
+
+ // then
+ expect(createTeamRunMock).toHaveBeenCalledWith(
+ expect.anything(),
+ "lead-session",
+ expect.objectContaining({ client: mockClient }),
+ config,
+ backgroundManager,
+ undefined,
+ { callerAgentTypeId: "sisyphus", parentMessageID: expect.any(String) },
+ )
+ })
+
+ test("team_create returns teamRunId and sanitized runtimeState for inline specs", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+
+ // when
+ const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+
+ // then
+ expect(result.teamRunId).toBe("team-run-1")
+ expect(result.runtimeState.status).toBe("active")
+ expect(result.runtimeState.members).toHaveLength(2)
+ expect(result.runtimeState.members[0]).not.toHaveProperty("lastInjectedTurnMarker")
+ expect(result.runtimeState.members[0]).not.toHaveProperty("pendingInjectedMessageIds")
+ })
+
+ test("team_create normalizes inline lead shorthand before creating the runtime", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const inlineSpec = {
+ name: "alpha-team",
+ lead: { kind: "subagent_type", subagent_type: "sisyphus" },
+ members: [{ kind: "category", name: "member-a", category: "quick", prompt: "Do the assigned work" }],
+ }
+
+ // when
+ const result = parseToolResult<{ runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session")))
+
+ // then
+ expect(createTeamRunMock).toHaveBeenCalledWith(
+ expect.objectContaining({ leadAgentId: "lead" }),
+ "lead-session",
+ expect.anything(),
+ config,
+ expect.anything(),
+ undefined,
+ { callerAgentTypeId: undefined, parentMessageID: expect.any(String) },
+ )
+ expect(result.runtimeState.members).toHaveLength(2)
+ expect(result.runtimeState.members[0]).toMatchObject({ name: "lead", agentType: "leader" })
+ })
+
+ test("team_create rejects an empty leadSessionId override", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+
+ // when
+ const result = teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
+
+ // then
+ await expect(result).rejects.toThrow("leadSessionId")
+ })
+
+ test("team_delete propagates active-member errors", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+
+ // when
+ const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session"))
+
+ // then
+ expect(result).rejects.toThrow("members still active")
+ })
+
+ test("team_delete force=true succeeds even with active members", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+
+ // when
+ const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("lead-session")))
+
+ // then
+ expect(result.deleted).toBe(true)
+ expect(hasRuntime(created.teamRunId)).toBe(false)
+ })
+
+ test("team_delete force=true allows non-lead caller on orphaned team", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ const runtimeState = requireRuntime(created.teamRunId)
+ runtimeState.status = "orphaned"
+ const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
+
+ // when
+ const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute(
+ { teamRunId: created.teamRunId, force: true },
+ createToolContext(memberSessionId ?? "member-a-session"),
+ ))
+
+ // then
+ expect(result.deleted).toBe(true)
+ expect(hasRuntime(created.teamRunId)).toBe(false)
+ })
+
+ test("team_delete still rejects non-participants even with force=true", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ requireRuntime(created.teamRunId).status = "orphaned"
+
+ // when
+ const result = deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("outside-session"))
+
+ // then
+ expect(result).rejects.toThrow("team_delete is lead-only")
+ })
+
+ test("team_delete force=true allows member participant to recover a stuck deleting team", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ const runtimeState = requireRuntime(created.teamRunId)
+ runtimeState.status = "deleting"
+ const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
+
+ // when
+ const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext(memberSessionId ?? "member-a-session")))
+
+ // then
+ expect(result.deleted).toBe(true)
+ expect(hasRuntime(created.teamRunId)).toBe(false)
+ })
+
+ test("team_delete force=false on orphaned team still requires lead", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ const runtimeState = requireRuntime(created.teamRunId)
+ runtimeState.status = "orphaned"
+ const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
+
+ // when
+ const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext(memberSessionId ?? "member-a-session"))
+
+ // then
+ expect(result).rejects.toThrow("team_delete is lead-only")
+ })
+
+ test("team_create is idempotent for the same spec and lead session", async () => {
+ // given
+ const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
+
+ // when
+ const firstResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ const secondResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+
+ // then
+ expect(firstResult.teamRunId).toBe(secondResult.teamRunId)
+ expect(createTeamRunMock).toHaveBeenCalledTimes(2)
+ })
+
+ test("runs full lifecycle through create, request, approve, and delete", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const requestTool = createTeamShutdownRequestTool(config, mockClient)
+ const approveTool = createTeamApproveShutdownTool(config, mockClient)
+ const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
+ const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
+ const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
+
+ // when
+ const requestResult = parseToolResult<{ status: string }>(await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session")))
+ const approveResult = parseToolResult<{ status: string }>(await approveTool.execute({ teamRunId: created.teamRunId, memberName: "member-a" }, createToolContext(memberSessionId ?? "member-a-session")))
+ const deleteResult = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session")))
+
+ // then
+ expect(requestResult.status).toBe("shutdown_requested")
+ expect(approveResult.status).toBe("shutdown_approved")
+ expect(deleteResult.deleted).toBe(true)
+ expect(hasRuntime(created.teamRunId)).toBe(false)
+ })
+
+ test("team_reject_shutdown records the rejection reason", async () => {
+ // given
+ const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
+ const requestTool = createTeamShutdownRequestTool(config, mockClient)
+ const rejectTool = createTeamRejectShutdownTool(config, mockClient)
+ const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ teamName: "alpha-team" }, createToolContext("lead-session")))
+ const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
+ await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session"))
+
+ // when
+ const result = parseToolResult<{ teamRunId: string; memberName: string; rejectedBy: string; reason: string; status: string }>(await rejectTool.execute({ teamRunId: created.teamRunId, memberName: "member-a", reason: "still working" }, createToolContext(memberSessionId ?? "member-a-session")))
+
+ // then
+ expect(result).toEqual({ teamRunId: created.teamRunId, memberName: "member-a", rejectedBy: "member-a", reason: "still working", status: "shutdown_rejected" })
+ expect(getLatestShutdownRequest(requireRuntime(created.teamRunId), "member-a")).toEqual(expect.objectContaining({ rejectedReason: "still working", rejectedAt: expect.any(Number) }))
+ })
+})
diff --git a/src/features/team-mode/tools/lifecycle.ts b/src/features/team-mode/tools/lifecycle.ts
new file mode 100644
index 000000000..c332da5a9
--- /dev/null
+++ b/src/features/team-mode/tools/lifecycle.ts
@@ -0,0 +1,271 @@
+import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
+import type { ToolContext } from "@opencode-ai/plugin/tool"
+import { z } from "zod"
+
+import type { TeamModeConfig } from "../../../config/schema/team-mode"
+import type { CategoriesConfig, AgentOverrides } from "../../../config/schema"
+import { mergeCategories } from "../../../shared/merge-categories"
+import type { OpencodeClient } from "../../../tools/delegate-task/types"
+import type { BackgroundManager } from "../../background-agent/manager"
+import type { TmuxSessionManager } from "../../tmux-subagent/manager"
+import { resolveCallerTeamLead } from "../resolve-caller-team-lead"
+import { loadTeamSpec, normalizeTeamSpecInput } from "../team-registry/loader"
+import { validateSpec } from "../team-registry/validator"
+import { createTeamRun } from "../team-runtime/create"
+import { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } from "../team-runtime/shutdown"
+import { listActiveTeams, loadRuntimeState } from "../team-state-store/store"
+import { TeamSpecSchema, type RuntimeState, type TeamSpec } from "../types"
+
+const ACTIVE_RUNTIME_STATUSES = new Set(["creating", "active", "shutdown_requested"])
+const TEAM_CREATE_USAGE = "team_create requires exactly one of teamName or inline_spec. Use team_create({ teamName: \"existing-team\" }) or team_create({ inline_spec: { name: \"team-name\", members: [{ name: \"worker\", category: \"quick\", prompt: \"Do the assigned work.\" }] } })."
+
+const TeamCreateArgsSchema = z.object({
+ teamName: z.string().min(1).optional(),
+ inline_spec: z.unknown().optional(),
+ leadSessionId: z.string().min(1).optional(),
+}).superRefine((value, ctx) => {
+ const optionCount = Number(value.teamName !== undefined) + Number(value.inline_spec !== undefined)
+ if (optionCount !== 1) {
+ ctx.addIssue({ code: "custom", message: "Provide exactly one of teamName or inline_spec." })
+ }
+})
+
+const TeamDeleteArgsSchema = z.object({ teamRunId: z.string().min(1), force: z.boolean().optional() })
+const TeamShutdownRequestArgsSchema = z.object({ teamRunId: z.string().min(1), targetMemberName: z.string().min(1) })
+const TeamApproveShutdownArgsSchema = z.object({ teamRunId: z.string().min(1), memberName: z.string().min(1) })
+const TeamRejectShutdownArgsSchema = z.object({
+ teamRunId: z.string().min(1),
+ memberName: z.string().min(1),
+ reason: z.string().min(1),
+})
+
+type TeamLifecycleToolContext = ToolContext & {
+ sessionID: string
+ directory?: string
+}
+
+type TeamParticipant = { role: "lead" | "member"; memberName: string }
+
+type TeamCreateArgs = z.infer
+
+function resolveDefaultInlineCategory(userCategories?: CategoriesConfig): string | undefined {
+ const userCategoryName = Object.entries(userCategories ?? {}).find(([, categoryConfig]) => categoryConfig.disable !== true)?.[0]
+ if (userCategoryName !== undefined) {
+ return userCategoryName
+ }
+
+ return Object.keys(mergeCategories(userCategories))[0]
+}
+
+function getLeadMemberName(runtimeState: RuntimeState): string {
+ const leadMember = runtimeState.members.find((member) => member.agentType === "leader")
+ if (!leadMember) throw new Error(`team '${runtimeState.teamRunId}' is missing a lead member`)
+ return leadMember.name
+}
+
+function sanitizeRuntimeState(runtimeState: RuntimeState): Omit & {
+ members: Array>
+} {
+ return {
+ ...runtimeState,
+ members: runtimeState.members.map(({ lastInjectedTurnMarker: _turnMarker, pendingInjectedMessageIds: _pendingIds, ...member }) => member),
+ }
+}
+
+function parseTeamCreateArgs(rawArgs: unknown): TeamCreateArgs {
+ const result = TeamCreateArgsSchema.safeParse(rawArgs)
+ if (!result.success) {
+ throw new Error(TEAM_CREATE_USAGE)
+ }
+
+ return result.data
+}
+
+function formatZodIssuePath(path: PropertyKey[]): string {
+ return path.length > 0 ? path.join(".") : ""
+}
+
+function formatTeamSpecIssues(error: z.ZodError): string {
+ return error.issues
+ .slice(0, 5)
+ .map((issue) => `${formatZodIssuePath(issue.path)}: ${issue.message}`)
+ .join("; ")
+}
+
+function parseInlineTeamSpec(
+ rawSpec: unknown,
+ options?: Parameters[1],
+): TeamSpec {
+ let specObject: unknown = rawSpec
+ if (typeof rawSpec === "string") {
+ try {
+ specObject = JSON.parse(rawSpec)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new Error(`inline_spec is a string but not valid JSON: ${message}`)
+ }
+ }
+
+ const parsedSpecResult = TeamSpecSchema.safeParse(normalizeTeamSpecInput(specObject, options))
+ if (!parsedSpecResult.success) {
+ throw new Error(`Invalid inline_spec for team_create: ${formatTeamSpecIssues(parsedSpecResult.error)}. Provide an object with name and members array. Example: team_create({ inline_spec: { name: "project-analysis-team", members: [{ name: "structure-analyst", category: "quick", prompt: "Analyze project structure." }] } }).`)
+ }
+
+ const parsedSpec = parsedSpecResult.data
+ validateSpec(parsedSpec)
+ return parsedSpec
+}
+
+async function findParticipantRuntime(sessionID: string, config: TeamModeConfig): Promise {
+ for (const activeTeam of await listActiveTeams(config)) {
+ const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined)
+ if (!runtimeState || !ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) continue
+ if (runtimeState.leadSessionId === sessionID) return runtimeState
+ if (runtimeState.members.some((member) => member.sessionId === sessionID)) return runtimeState
+ }
+}
+
+async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> {
+ const runtimeState = await loadRuntimeState(teamRunId, config)
+ if (runtimeState.leadSessionId === sessionID) {
+ return { runtimeState, participant: { role: "lead", memberName: getLeadMemberName(runtimeState) } }
+ }
+ const member = runtimeState.members.find((candidate) => candidate.sessionId === sessionID)
+ return member ? { runtimeState, participant: { role: "member", memberName: member.name } } : { runtimeState }
+}
+
+export type TeamCreateExecutorConfig = {
+ userCategories?: CategoriesConfig
+ sisyphusJuniorModel?: string
+ agentOverrides?: AgentOverrides
+}
+
+export function createTeamCreateTool(
+ config: TeamModeConfig,
+ client: OpencodeClient,
+ bgMgr: BackgroundManager,
+ tmuxMgr?: TmuxSessionManager,
+ executorConfig?: TeamCreateExecutorConfig,
+): ToolDefinition {
+ return tool({
+ description: "Create a team run from a named or inline team spec.",
+ args: {
+ teamName: tool.schema.string().optional().describe("Named team spec to load. Provide exactly one of teamName or inline_spec."),
+ inline_spec: tool.schema.unknown().optional().describe("Inline team spec object or JSON string. Provide exactly one of teamName or inline_spec."),
+ leadSessionId: tool.schema.string().optional().describe("Optional non-empty session ID override. Usually omit this and let team_create use the current session."),
+ },
+ async execute(rawArgs, toolContext) {
+ const args = parseTeamCreateArgs(rawArgs)
+ const runtimeContext = toolContext as TeamLifecycleToolContext
+ const leadSessionId = args.leadSessionId ?? runtimeContext.sessionID
+ if (!leadSessionId) throw new Error("team_create requires leadSessionId or tool context sessionID")
+ const projectRoot = typeof runtimeContext.directory === "string" ? runtimeContext.directory : process.cwd()
+ const callerTeamLead = resolveCallerTeamLead(runtimeContext.agent)
+ const defaultCategoryName = resolveDefaultInlineCategory(executorConfig?.userCategories)
+ const spec = args.teamName
+ ? await loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead })
+ : parseInlineTeamSpec(args.inline_spec, { callerTeamLead, defaultCategoryName })
+ const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config)
+ if (participantRuntime && (participantRuntime.teamName !== spec.name || participantRuntime.leadSessionId !== leadSessionId)) {
+ throw new Error(`team_create denied: session is already a participant of team ${participantRuntime.teamRunId}`)
+ }
+ const runtimeState = await createTeamRun(
+ spec,
+ leadSessionId,
+ {
+ client,
+ manager: bgMgr,
+ directory: projectRoot,
+ userCategories: executorConfig?.userCategories,
+ sisyphusJuniorModel: executorConfig?.sisyphusJuniorModel,
+ agentOverrides: executorConfig?.agentOverrides,
+ },
+ config,
+ bgMgr,
+ tmuxMgr,
+ {
+ callerAgentTypeId: callerTeamLead.agentTypeId,
+ parentMessageID: runtimeContext.messageID,
+ },
+ )
+ return JSON.stringify({ teamRunId: runtimeState.teamRunId, runtimeState: sanitizeRuntimeState(runtimeState) })
+ },
+ })
+}
+
+export function createTeamDeleteTool(
+ config: TeamModeConfig,
+ client: OpencodeClient,
+ backgroundManager: BackgroundManager,
+ tmuxMgr?: TmuxSessionManager,
+): ToolDefinition {
+ void client
+
+ return tool({
+ description: "Delete a completed or shutdown-approved team run. Pass force=true to tear it down even while members are still active.",
+ args: { teamRunId: tool.schema.string(), force: tool.schema.boolean().optional() },
+ async execute(rawArgs, toolContext) {
+ const args = TeamDeleteArgsSchema.parse(rawArgs)
+ const runtimeContext = toolContext as TeamLifecycleToolContext
+ const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
+ const isOrphanedForceDelete = args.force === true && runtimeState.status === "orphaned"
+ const isStuckDeletingForceDelete = args.force === true && runtimeState.status === "deleting"
+ const isForceBypass = (isStuckDeletingForceDelete || isOrphanedForceDelete) && participant !== undefined
+ if (!isForceBypass && participant?.role !== "lead") {
+ throw new Error("team_delete is lead-only")
+ }
+ return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) })
+ },
+ })
+}
+
+export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
+ void client
+
+ return tool({
+ description: "Request shutdown for a team member.",
+ args: { teamRunId: tool.schema.string(), targetMemberName: tool.schema.string() },
+ async execute(rawArgs, toolContext) {
+ const args = TeamShutdownRequestArgsSchema.parse(rawArgs)
+ const runtimeContext = toolContext as TeamLifecycleToolContext
+ const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
+ if (participant?.role !== "lead") throw new Error("team_shutdown_request is lead-only")
+ await requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config)
+ return JSON.stringify({ teamRunId: args.teamRunId, targetMemberName: args.targetMemberName, requesterName: participant.memberName, status: "shutdown_requested" })
+ },
+ })
+}
+
+export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
+ void client
+
+ return tool({
+ description: "Approve a pending shutdown request.",
+ args: { teamRunId: tool.schema.string(), memberName: tool.schema.string() },
+ async execute(rawArgs, toolContext) {
+ const args = TeamApproveShutdownArgsSchema.parse(rawArgs)
+ const runtimeContext = toolContext as TeamLifecycleToolContext
+ const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
+ if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_approve_shutdown: caller must be target member or team lead")
+ await approveShutdown(args.teamRunId, args.memberName, participant.memberName, config)
+ return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, approverName: participant.memberName, status: "shutdown_approved" })
+ },
+ })
+}
+
+export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
+ void client
+
+ return tool({
+ description: "Reject a pending shutdown request.",
+ args: { teamRunId: tool.schema.string(), memberName: tool.schema.string(), reason: tool.schema.string() },
+ async execute(rawArgs, toolContext) {
+ const args = TeamRejectShutdownArgsSchema.parse(rawArgs)
+ const runtimeContext = toolContext as TeamLifecycleToolContext
+ const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
+ if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_reject_shutdown: caller must be target member or team lead")
+ await rejectShutdown(args.teamRunId, args.memberName, args.reason, config)
+ return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, rejectedBy: participant.memberName, reason: args.reason, status: "shutdown_rejected" })
+ },
+ })
+}