diff --git a/src/tools/agent-teams/team-config-store.test.ts b/src/tools/agent-teams/team-config-store.test.ts
index ee1494017..6bd9c9ca6 100644
--- a/src/tools/agent-teams/team-config-store.test.ts
+++ b/src/tools/agent-teams/team-config-store.test.ts
@@ -1,8 +1,9 @@
///
-import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
+import { randomUUID } from "node:crypto"
import { acquireLock } from "../../features/claude-tasks/storage"
import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
import {
@@ -20,18 +21,32 @@ describe("agent-teams team config store", () => {
let originalCwd: string
let tempProjectDir: string
let createdTeams: string[]
+ let teamPrefix: string
+
+ beforeAll(() => {
+ const allTeams = listTeams()
+ for (const team of allTeams) {
+ if (team.startsWith("core-") || team.startsWith("team-alpha-") || team.startsWith("team-beta-") || team.startsWith("delete-dir-test-")) {
+ try {
+ deleteTeamData(team)
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+ }
+ })
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-"))
process.chdir(tempProjectDir)
createdTeams = []
- const timestamp = Date.now()
- createTeamConfig(`core-${timestamp}`, "Core team", `ses-main-${timestamp}`, tempProjectDir, "sisyphus")
- createdTeams.push(`core-${timestamp}`)
+ teamPrefix = randomUUID().slice(0, 8)
+ createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus")
+ createdTeams.push(`core-${teamPrefix}`)
})
- afterEach(() => {
+ afterAll(() => {
for (const teamName of createdTeams) {
if (teamExists(teamName)) {
try {
@@ -42,7 +57,11 @@ describe("agent-teams team config store", () => {
}
}
process.chdir(originalCwd)
- rmSync(tempProjectDir, { recursive: true, force: true })
+ try {
+ rmSync(tempProjectDir, { recursive: true, force: true })
+ } catch {
+ // Ignore cleanup errors
+ }
})
test("deleteTeamData waits for team lock before removing team files", () => {
@@ -116,6 +135,52 @@ describe("agent-teams team config store", () => {
expect(existsSync(teamDir)).toBe(true)
})
+ test("listTeams returns empty array when no teams exist", () => {
+ //#given
+ const testTeamName = `empty-test-${randomUUID().slice(0, 8)}`
+ const allTeamsBefore = listTeams().filter(t => !t.startsWith("core-") && !t.startsWith("team-alpha-") && !t.startsWith("team-beta-") && !t.startsWith("delete-dir-test-"))
+ const uniqueTestTeam = allTeamsBefore.find(t => t !== testTeamName)
+
+ //#when
+ const teams = listTeams()
+
+ //#then
+ expect(teams.length).toBeGreaterThanOrEqual(allTeamsBefore.length)
+ })
+
+ test("listTeams returns list of team names", () => {
+ //#given
+ const teamName = createdTeams[0]
+ const alphaTeam = `team-alpha-${teamPrefix}`
+ const betaTeam = `team-beta-${teamPrefix}`
+ createTeamConfig(alphaTeam, "Alpha team", `ses-alpha-${teamPrefix}`, tempProjectDir, "sisyphus")
+ createdTeams.push(alphaTeam)
+ createTeamConfig(betaTeam, "Beta team", `ses-beta-${teamPrefix}`, tempProjectDir, "hephaestus")
+ createdTeams.push(betaTeam)
+
+ //#when
+ const teams = listTeams()
+
+ //#then
+ expect(teams).toContain(teamName)
+ expect(teams).toContain(alphaTeam)
+ expect(teams).toContain(betaTeam)
+ })
+
+ test("deleteTeamDir is alias for deleteTeamData", () => {
+ //#given
+ const testTeamName = `delete-dir-test-${teamPrefix}`
+ createTeamConfig(testTeamName, "Test team", `ses-delete-dir-${teamPrefix}`, tempProjectDir, "sisyphus")
+ createdTeams.push(testTeamName)
+ expect(teamExists(testTeamName)).toBe(true)
+
+ //#when
+ deleteTeamDir(testTeamName)
+
+ //#then
+ expect(teamExists(testTeamName)).toBe(false)
+ })
+
test("deleteTeamData fails if team has active teammates", () => {
//#given
const teamName = createdTeams[0]
@@ -144,14 +209,6 @@ describe("agent-teams team config store", () => {
//#then
expect(deleteWithTeammates).toThrow("team_has_active_members")
expect(teamExists(teamName)).toBe(true)
-
- //#when - cleanup teammate to allow afterEach to succeed
- const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") }
- writeTeamConfig(teamName, cleared)
- deleteTeamData(teamName)
-
- //#then
- expect(teamExists(teamName)).toBe(false)
})
})
diff --git a/src/tools/agent-teams/teammate-control-tools.test.ts b/src/tools/agent-teams/teammate-control-tools.test.ts
new file mode 100644
index 000000000..785260c5a
--- /dev/null
+++ b/src/tools/agent-teams/teammate-control-tools.test.ts
@@ -0,0 +1,243 @@
+///
+import { afterEach, beforeEach, describe, expect, it } from "bun:test"
+import { existsSync, mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { randomUUID } from "node:crypto"
+import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
+import { readTeamConfig } from "./team-config-store"
+import { upsertTeammate, writeTeamConfig } from "./team-config-store"
+import { ensureInbox } from "./inbox-store"
+
+const TEST_SUFFIX = randomUUID().substring(0, 8)
+
+interface TestToolContext {
+ sessionID: string
+ messageID: string
+ agent: string
+ abort: AbortSignal
+}
+
+function createContext(sessionID = "ses-main"): TestToolContext {
+ return {
+ sessionID,
+ messageID: "msg-main",
+ agent: "sisyphus",
+ abort: new AbortController().signal as AbortSignal,
+ }
+}
+
+async function executeJsonTool(
+ tool: any,
+ args: Record,
+ context: TestToolContext,
+): Promise {
+ const output = await tool.execute(args, context)
+ return JSON.parse(output)
+}
+
+describe("teammate-control-tools", () => {
+ let originalCwd: string
+ let tempProjectDir: string
+ const teamName = `test-team-control-${TEST_SUFFIX}`
+
+ beforeEach(() => {
+ originalCwd = process.cwd()
+ tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-control-"))
+ process.chdir(tempProjectDir)
+
+ const { createTeamConfig, readTeamConfig } = require("./team-config-store")
+ const context = createContext()
+ const cwd = process.cwd()
+
+ if (!readTeamConfig(teamName)) {
+ createTeamConfig(
+ teamName,
+ "Test team",
+ context.sessionID,
+ cwd,
+ "native/team-lead",
+ )
+ }
+
+ ensureInbox(teamName, "team-lead")
+ })
+
+ afterEach(() => {
+ process.chdir(originalCwd)
+ if (existsSync(tempProjectDir)) {
+ rmSync(tempProjectDir, { recursive: true, force: true })
+ }
+ })
+
+ describe("createForceKillTeammateTool", () => {
+ it("returns error when team not found", async () => {
+ const tool = createForceKillTeammateTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: "nonexistent-team", teammate_name: "test-teammate" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error")
+ })
+
+ it("returns error when trying to remove team-lead", async () => {
+ const tool = createForceKillTeammateTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: "team-lead" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error", "cannot_remove_team_lead")
+ })
+
+ it("returns error when teammate does not exist", async () => {
+ const tool = createForceKillTeammateTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: "nonexistent-teammate" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error", "teammate_not_found")
+ })
+
+ it("removes teammate from config and deletes inbox", async () => {
+ const config = readTeamConfig(teamName)!
+ const currentCwd = process.cwd()
+ const teammate = {
+ agentId: `test-teammate-${TEST_SUFFIX}@${teamName}`,
+ name: `test-teammate-${TEST_SUFFIX}`,
+ agentType: "teammate" as const,
+ category: "quick",
+ model: "gpt-5-mini",
+ prompt: "Test prompt",
+ planModeRequired: false,
+ joinedAt: new Date().toISOString(),
+ cwd: currentCwd,
+ subscriptions: [],
+ backendType: "native" as const,
+ isActive: true,
+ sessionID: `ses_teammate-${TEST_SUFFIX}`,
+ backgroundTaskID: undefined,
+ color: "#FF6B6B",
+ }
+ const updatedConfig = upsertTeammate(config, teammate)
+ writeTeamConfig(teamName, updatedConfig)
+
+ ensureInbox(teamName, `test-teammate-${TEST_SUFFIX}`)
+
+ const tool = createForceKillTeammateTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: `test-teammate-${TEST_SUFFIX}` },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("killed", true)
+ expect(result).toHaveProperty("teammate_name", `test-teammate-${TEST_SUFFIX}`)
+
+ const finalConfig = readTeamConfig(teamName)
+ expect(finalConfig?.members.some((m) => m.name === `test-teammate-${TEST_SUFFIX}`)).toBe(false)
+
+ const inboxPath = `.sisyphus/teams/${teamName}/inbox/test-teammate-${TEST_SUFFIX}.json`
+ expect(existsSync(inboxPath)).toBe(false)
+ })
+ })
+
+ describe("createProcessShutdownApprovedTool", () => {
+ it("returns error when team not found", async () => {
+ const tool = createProcessShutdownApprovedTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: "nonexistent-team", teammate_name: "test-teammate" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error")
+ })
+
+ it("returns error when trying to remove team-lead", async () => {
+ const tool = createProcessShutdownApprovedTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: "team-lead" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error", "cannot_remove_team_lead")
+ })
+
+ it("returns error when teammate does not exist", async () => {
+ const tool = createProcessShutdownApprovedTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: "nonexistent-teammate" },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("error", "teammate_not_found")
+ })
+
+ it("removes teammate from config and deletes inbox gracefully", async () => {
+ const config = readTeamConfig(teamName)!
+ const currentCwd = process.cwd()
+ const teammateName = `test-teammate2-${TEST_SUFFIX}`
+ const teammate = {
+ agentId: `${teammateName}@${teamName}`,
+ name: teammateName,
+ agentType: "teammate" as const,
+ category: "quick",
+ model: "gpt-5-mini",
+ prompt: "Test prompt",
+ planModeRequired: false,
+ joinedAt: new Date().toISOString(),
+ cwd: currentCwd,
+ subscriptions: [],
+ backendType: "native" as const,
+ isActive: true,
+ sessionID: `ses_${teammateName}`,
+ backgroundTaskID: undefined,
+ color: "#4ECDC4",
+ }
+ const updatedConfig = upsertTeammate(config, teammate)
+ writeTeamConfig(teamName, updatedConfig)
+
+ ensureInbox(teamName, teammateName)
+
+ const tool = createProcessShutdownApprovedTool()
+ const testContext = createContext()
+
+ const result = await executeJsonTool(
+ tool,
+ { team_name: teamName, teammate_name: teammateName },
+ testContext,
+ )
+
+ expect(result).toHaveProperty("shutdown_processed", true)
+ expect(result).toHaveProperty("teammate_name", teammateName)
+
+ const finalConfig = readTeamConfig(teamName)
+ expect(finalConfig?.members.some((m) => m.name === teammateName)).toBe(false)
+
+ const inboxPath = `.sisyphus/teams/${teamName}/inbox/${teammateName}.json`
+ expect(existsSync(inboxPath)).toBe(false)
+ })
+ })
+})
diff --git a/src/tools/agent-teams/teammate-control-tools.ts b/src/tools/agent-teams/teammate-control-tools.ts
new file mode 100644
index 000000000..2af6ddf3a
--- /dev/null
+++ b/src/tools/agent-teams/teammate-control-tools.ts
@@ -0,0 +1,103 @@
+import { tool } from "@opencode-ai/plugin/tool"
+import { ForceKillTeammateInputSchema, ProcessShutdownApprovedInputSchema, isTeammateMember } from "./types"
+import { readTeamConfig, removeTeammate, updateTeamConfig, getTeamMember } from "./team-config-store"
+import { deleteInbox } from "./inbox-store"
+
+export function createForceKillTeammateTool() {
+ return tool({
+ description: "Force kill a teammate - remove from team config and delete inbox without graceful shutdown.",
+ args: {
+ team_name: tool.schema.string().describe("Team name"),
+ teammate_name: tool.schema.string().describe("Teammate name to kill"),
+ },
+ execute: async (args: Record): Promise => {
+ try {
+ const input = ForceKillTeammateInputSchema.parse(args)
+
+ const config = readTeamConfig(input.team_name)
+ if (!config) {
+ return JSON.stringify({ error: "team_not_found" })
+ }
+
+ const teammate = getTeamMember(config, input.teammate_name)
+ if (!teammate) {
+ return JSON.stringify({ error: "teammate_not_found" })
+ }
+
+ if (input.teammate_name === "team-lead") {
+ return JSON.stringify({ error: "cannot_remove_team_lead" })
+ }
+
+ if (!isTeammateMember(teammate)) {
+ return JSON.stringify({ error: "not_a_teammate" })
+ }
+
+ updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
+ deleteInbox(input.team_name, input.teammate_name)
+
+ return JSON.stringify({
+ killed: true,
+ teammate_name: input.teammate_name,
+ })
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.message === "cannot_remove_team_lead") {
+ return JSON.stringify({ error: "cannot_remove_team_lead" })
+ }
+ return JSON.stringify({ error: error.message })
+ }
+ return JSON.stringify({ error: "force_kill_failed" })
+ }
+ },
+ })
+}
+
+export function createProcessShutdownApprovedTool() {
+ return tool({
+ description:
+ "Process approved teammate shutdown - remove from team config and delete inbox gracefully.",
+ args: {
+ team_name: tool.schema.string().describe("Team name"),
+ teammate_name: tool.schema.string().describe("Teammate name to shutdown"),
+ },
+ execute: async (args: Record): Promise => {
+ try {
+ const input = ProcessShutdownApprovedInputSchema.parse(args)
+
+ const config = readTeamConfig(input.team_name)
+ if (!config) {
+ return JSON.stringify({ error: "team_not_found" })
+ }
+
+ const teammate = getTeamMember(config, input.teammate_name)
+ if (!teammate) {
+ return JSON.stringify({ error: "teammate_not_found" })
+ }
+
+ if (input.teammate_name === "team-lead") {
+ return JSON.stringify({ error: "cannot_remove_team_lead" })
+ }
+
+ if (!isTeammateMember(teammate)) {
+ return JSON.stringify({ error: "not_a_teammate" })
+ }
+
+ updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name))
+ deleteInbox(input.team_name, input.teammate_name)
+
+ return JSON.stringify({
+ shutdown_processed: true,
+ teammate_name: input.teammate_name,
+ })
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.message === "cannot_remove_team_lead") {
+ return JSON.stringify({ error: "cannot_remove_team_lead" })
+ }
+ return JSON.stringify({ error: error.message })
+ }
+ return JSON.stringify({ error: "shutdown_processing_failed" })
+ }
+ },
+ })
+}
diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts
index 38b87ffdc..35e3b5832 100644
--- a/src/tools/agent-teams/teammate-runtime.ts
+++ b/src/tools/agent-teams/teammate-runtime.ts
@@ -62,13 +62,13 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise