Compare commits

...

41 Commits

Author SHA1 Message Date
YeonGyu-Kim
8a83020b51 feat(agent-teams): register team tools behind experimental.team_system flag
- Create barrel export in src/tools/agent-teams/index.ts
- Create factory function createAgentTeamsTools() in tools.ts
- Register 7 team tools in tool-registry.ts behind experimental flag
- Add integration tests for tool registration gating
- Fix type errors: add TeamTaskStatus, update schemas
- Task 13 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
16e034492c feat(task): add team_name routing to task_list and task_update tools
- Add optional team_name parameter to task_list and task_update
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add comprehensive tests for both team and regular task operations
- Task 12 complete (4/4 files: create, get, list, update)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
3d5754089e feat(task): add team_name routing to task_get tool
- Add optional team_name parameter to task_get
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add tests for both team and regular task retrieval
- Part of Task 12 (2/4 files complete)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
eabc20de9e feat(task): add team_name routing to task_create tool
- Add optional team_name parameter to task_create
- Route to team-namespaced storage when team_name provided
- Preserve existing behavior when team_name absent
- Add tests for both team and regular task creation
- Part of Task 12 (1/4 files complete)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
48441b831c feat(agent-teams): implement teammate control tools (force_kill, process_shutdown_approved)
- Add force_kill_teammate tool for immediate teammate removal
- Add process_shutdown_approved tool for graceful shutdown processing
- Both tools validate team-lead protection and teammate status
- Comprehensive test coverage with 8 test cases
- Task 10/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
88be194805 feat(agent-teams): add read_inbox and read_config tools
- Add simple read_inbox tool as thin wrapper over readInbox store function
- Add simple read_config tool as thin wrapper over readTeamConfig store function
- Both tools support basic filtering (unread_only for inbox, none for config)
- Comprehensive test coverage with TDD approach
- Tools are separate from registered read_inbox/read_config (which have authorization)
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4a38e09a33 feat(agent-teams): add send_message tool with 5 message types
- Implement discriminated union for 5 message types
- message: requires recipient + content
- broadcast: sends to all teammates
- shutdown_request: requires recipient
- shutdown_response: requires request_id + approve
- plan_approval_response: requires request_id + approve
- 14 comprehensive tests with unique team names
- Extract inbox-message-sender.ts for message delivery logic

Task 8/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
aa83b05f1f feat(agent-teams): add team_create and team_delete tools
- Implement tool factories for team lifecycle management
- team_create: Creates team with initial config, returns team info
- team_delete: Deletes team if no active teammates
- Name validation: ^[A-Za-z0-9_-]+$, max 64 chars
- 9 comprehensive tests with unique team names per test

Task 7/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
d67138575c feat(agent-teams): add team task store with namespace routing
- Implement team-namespaced task storage at ~/.sisyphus/tasks/{teamName}/
- Follow existing task storage patterns from features/claude-tasks/storage.ts
- Import TaskObjectSchema from tools/task/types.ts (no duplication)
- Export getTeamTaskPath for test access
- 16 comprehensive tests with temp directory isolation

Task 6/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4c52bf32cd feat(agent-teams): add inbox store with atomic message operations
- Implement atomic message append/read/mark-read operations
- Messages stored per-agent at ~/.sisyphus/teams/{team}/inboxes/{agent}.json
- Use acquireLock for concurrent access safety
- Inbox append is atomic (read-append-write under lock)
- 2 comprehensive tests with locking verification

Task 5/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
f0ae1131de feat(agent-teams): add team config store with atomic writes
- Implement CRUD operations for team config.json
- Use atomic writes with temp+rename pattern
- Reuse acquireLock for concurrent access safety
- Team config lives at ~/.sisyphus/teams/{teamName}/config.json
- deleteTeamDir removes team + inbox + task dirs recursively
- Fix timestamp: use ISO string instead of number
- 4 comprehensive tests with locking verification

Task 4/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
d65912bc63 feat(agent-teams): add team, message, and task Zod schemas
- TeamConfigSchema with lead/teammate members
- TeamMemberSchema and TeamTeammateMemberSchema
- InboxMessageSchema with 5 message types
- SendMessageInputSchema as discriminated union
- Import TaskObjectSchema from tools/task/types.ts
- 39 comprehensive tests covering all schemas

Task 3/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
3e2e4e29df feat(agent-teams): add team path resolution utilities
- Implement user-global paths (~/.sisyphus/teams/, ~/.sisyphus/tasks/)
- Reuse sanitizePathSegment for team name sanitization
- Cross-platform home directory resolution
- Comprehensive test coverage with sanitization tests

Task 2/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
5e06db0c60 feat(config): add experimental.team_system flag
- Add team_system boolean flag to ExperimentalConfigSchema
- Defaults to false
- Enables experimental agent teams toolset
- Added comprehensive BDD-style tests

Task 1/25 complete
2026-02-14 13:33:30 +09:00
YeonGyu-Kim
4282de139b feat(agent-teams): gate agent-teams tools behind experimental.agent_teams flag 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
386521d185 test(agent-teams): set explicit lead agent in delegation consistency test 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
accb874155 fix(agent-teams): close delete race and preserve parent-agent fallback 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
1e2c10e7b0 fix(agent-teams): harden inbox parsing and behavioral tests 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
a9d4cefdfe fix(agent-teams): authorize task tools by team session 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
2a57feb810 fix(agent-teams): tighten config access and context propagation 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
f422cfc7af fix(agent-teams): harden deletion and messaging safety 2026-02-14 13:33:30 +09:00
Nguyen Khac Trung Kien
0f0ba0f71b fix(agent-teams): address race condition in team deletion locking 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
c15bad6d00 fix(agent-teams): enforce lead spawn auth and dedupe shutdown 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
805df45722 fix(agent-teams): lock team deletion behind config mutex 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
cf42082c5f fix(agent-teams): accept teammate agent IDs in messaging
Normalize send_message recipients so name@team values resolve to member names, preventing false recipient-not-found fallbacks into duplicate delegation paths. Also add delegation consistency coverage and split teammate runtime helpers for clearer spawn and parent-context handling.
2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
40f844fb85 fix(agent-teams): align spawn schema and harden inbox rollback behavior 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
fe05a1f254 fix(agent-teams): harden lead auth and require teammate categories 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
e984ce7493 feat(agent-teams): support category-based teammate spawning 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
3f859828cc fix(agent-teams): rotate lead session and clear stale teammate inbox 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
11766b085d fix(agent-teams): enforce T-prefixed task id validation 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
2103061123 fix(agent-teams): close latest review gaps for auth and race safety 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
79c3823762 fix(agent-teams): enforce session-bound messaging and shutdown cleanup 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
dc3d81a0b8 fix(agent-teams): tighten reviewer-raised runtime and messaging guards
Validate sender/owner/team flows more strictly, fail fast on invalid model overrides, and cancel failed launches to prevent orphaned background tasks while expanding functional coverage for these paths.
2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
7ad60cbedb fix(agent-teams): atomically write inbox files 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
1a5030d359 fix(agent-teams): fail fast on teammate launch errors 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
dbcad8fd97 fix(agent-teams): harden task operations against traversal 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
0ec6afcd9e fix(agent-teams): move team existence check under lock 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
f4e4fdb2e4 fix(agent-teams): add strict identifier validation rules 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
db08cc22cc test(agent-teams): add functional and utility coverage 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
766794e0f5 fix(agent-teams): store data under project .sisyphus 2026-02-14 13:33:29 +09:00
Nguyen Khac Trung Kien
0f9c93fd55 feat(tools): add native team orchestration tool suite
Port team lifecycle, teammate runtime, inbox messaging, and team-scoped task flows into built-in tools so multi-agent coordination works natively without external server dependencies.
2026-02-14 13:33:29 +09:00
54 changed files with 8130 additions and 137 deletions

View File

@@ -649,7 +649,21 @@ describe("ExperimentalConfigSchema feature flags", () => {
}
})
test("both fields are optional", () => {
test("accepts team_system as boolean", () => {
//#given
const config = { team_system: true }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.team_system).toBe(true)
}
})
test("defaults team_system to false when not provided", () => {
//#given
const config = {}
@@ -659,10 +673,34 @@ describe("ExperimentalConfigSchema feature flags", () => {
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.plugin_load_timeout_ms).toBeUndefined()
expect(result.data.safe_hook_creation).toBeUndefined()
expect(result.data.team_system).toBe(false)
}
})
test("accepts team_system as false", () => {
//#given
const config = { team_system: false }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.team_system).toBe(false)
}
})
test("rejects non-boolean team_system", () => {
//#given
const config = { team_system: "true" }
//#when
const result = ExperimentalConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
})
describe("GitMasterConfigSchema", () => {

View File

@@ -15,6 +15,10 @@ export const ExperimentalConfigSchema = z.object({
plugin_load_timeout_ms: z.number().min(1000).optional(),
/** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */
safe_hook_creation: z.boolean().optional(),
/** Enable experimental agent teams toolset (default: false) */
agent_teams: z.boolean().optional(),
/** Enable experimental team system (default: false) */
team_system: z.boolean().default(false),
})
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@@ -0,0 +1,72 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { createToolRegistry } from "./tool-registry"
import type { OhMyOpenCodeConfig } from "../config/schema"
describe("team system tool registration", () => {
test("registers team tools when experimental.team_system is true", () => {
const pluginConfig = {
experimental: { team_system: true },
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).toContain("team_create")
expect(Object.keys(result.filteredTools)).toContain("team_delete")
expect(Object.keys(result.filteredTools)).toContain("send_message")
expect(Object.keys(result.filteredTools)).toContain("read_inbox")
expect(Object.keys(result.filteredTools)).toContain("read_config")
expect(Object.keys(result.filteredTools)).toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).toContain("process_shutdown_approved")
})
test("does not register team tools when experimental.team_system is false", () => {
const pluginConfig = {
experimental: { team_system: false },
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
})
test("does not register team tools when experimental.team_system is undefined", () => {
const pluginConfig = {
experimental: {},
} as unknown as OhMyOpenCodeConfig
const result = createToolRegistry({
ctx: {} as any,
pluginConfig,
managers: {} as any,
skillContext: {} as any,
availableCategories: [],
})
expect(Object.keys(result.filteredTools)).not.toContain("team_create")
expect(Object.keys(result.filteredTools)).not.toContain("team_delete")
expect(Object.keys(result.filteredTools)).not.toContain("send_message")
expect(Object.keys(result.filteredTools)).not.toContain("read_inbox")
expect(Object.keys(result.filteredTools)).not.toContain("read_config")
expect(Object.keys(result.filteredTools)).not.toContain("force_kill_teammate")
expect(Object.keys(result.filteredTools)).not.toContain("process_shutdown_approved")
})
})

View File

@@ -19,6 +19,7 @@ import {
createAstGrepTools,
createSessionManagerTools,
createDelegateTask,
createAgentTeamsTools,
discoverCommandsSync,
interactive_bash,
createTaskCreateTool,
@@ -117,6 +118,15 @@ export function createToolRegistry(args: {
}
: {}
const teamSystemEnabled = pluginConfig.experimental?.team_system ?? false
const agentTeamsRecord: Record<string, ToolDefinition> = teamSystemEnabled
? createAgentTeamsTools(managers.backgroundManager, {
client: ctx.client,
userCategories: pluginConfig.categories,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
})
: {}
const allTools: Record<string, ToolDefinition> = {
...builtinTools,
...createGrepTools(ctx),
@@ -132,6 +142,7 @@ export function createToolRegistry(args: {
slashcommand: slashcommandTool,
interactive_bash,
...taskToolsRecord,
...agentTeamsRecord,
}
const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools)

View File

@@ -0,0 +1,87 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createTeamConfig, deleteTeamData } from "./team-config-store"
import { createReadConfigTool } from "./config-tools"
describe("read_config tool", () => {
let originalCwd: string
let tempProjectDir: string
let teamName: string
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-tools-"))
process.chdir(tempProjectDir)
teamName = `test-team-${randomUUID()}`
})
afterEach(() => {
try {
deleteTeamData(teamName)
} catch {
// ignore
}
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("read config action", () => {
test("returns team config when team exists", async () => {
//#given
const config = createTeamConfig(teamName, "Test team", TEST_SESSION_ID, "/tmp", "claude-opus-4-6")
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.name).toBe(teamName)
expect(result.description).toBe("Test team")
expect(result.members).toHaveLength(1)
expect(result.members[0].name).toBe("team-lead")
expect(result.members[0].agentType).toBe("team-lead")
})
test("returns error for non-existent team", async () => {
//#given
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({
team_name: "nonexistent-team-12345",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("team_not_found")
})
test("requires team_name parameter", async () => {
//#given
const tool = createReadConfigTool()
//#when
const resultStr = await tool.execute({}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
})
})

View File

@@ -0,0 +1,24 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readTeamConfig } from "./team-config-store"
import { ReadConfigInputSchema } from "./types"
export function createReadConfigTool(): ToolDefinition {
return tool({
description: "Read team configuration and member list.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ReadConfigInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
return JSON.stringify(config)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_config_failed" })
}
},
})
}

View File

@@ -0,0 +1,190 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { createAgentTeamsTools } from "./tools"
interface LaunchCall {
description: string
prompt: string
agent: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
parentModel?: {
providerID: string
modelID: string
variant?: string
}
}
interface ResumeCall {
sessionId: string
prompt: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
parentModel?: {
providerID: string
modelID: string
variant?: string
}
}
interface ToolContextLike {
sessionID: string
messageID: string
abort: AbortSignal
agent?: string
}
function createMockManager(): {
manager: BackgroundManager
launchCalls: LaunchCall[]
resumeCalls: ResumeCall[]
} {
const launchCalls: LaunchCall[] = []
const resumeCalls: ResumeCall[] = []
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
let launchCount = 0
const manager = {
launch: async (args: LaunchCall) => {
launchCount += 1
launchCalls.push(args)
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
launchedTasks.set(task.id, task)
return task
},
getTask: (taskId: string) => launchedTasks.get(taskId),
resume: async (args: ResumeCall) => {
resumeCalls.push(args)
return { id: `resume-${resumeCalls.length}` }
},
} as unknown as BackgroundManager
return { manager, launchCalls, resumeCalls }
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: ToolContextLike,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context as any)
return JSON.parse(output)
}
describe("agent-teams delegation consistency", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-consistency-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("team delegation forwards parent context like normal delegate-task", async () => {
//#given
const { manager, launchCalls, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext: ToolContextLike = {
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
agent: "sisyphus",
}
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
//#when
const spawnResult = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
leadContext,
) as { error?: string }
//#then
expect(spawnResult.error).toBeUndefined()
expect(launchCalls).toHaveLength(1)
expect(launchCalls[0].parentAgent).toBe("sisyphus")
expect("parentModel" in launchCalls[0]).toBe(true)
//#when
const messageResult = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1",
summary: "sync",
content: "Please update status.",
},
leadContext,
) as { error?: string }
//#then
expect(messageResult.error).toBeUndefined()
expect(resumeCalls).toHaveLength(1)
expect(resumeCalls[0].parentAgent).toBe("sisyphus")
expect("parentModel" in resumeCalls[0]).toBe(true)
})
test("send_message accepts teammate agent_id as recipient", async () => {
//#given
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext: ToolContextLike = {
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
}
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
leadContext,
)
//#when
const messageResult = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1@core",
summary: "sync",
content: "Please update status.",
},
leadContext,
) as { error?: string }
//#then
expect(messageResult.error).toBeUndefined()
expect(resumeCalls).toHaveLength(1)
})
})

View File

@@ -0,0 +1,61 @@
import { randomUUID } from "node:crypto"
import { InboxMessageSchema } from "./types"
import { appendInboxMessage } from "./inbox-store"
function nowIso(): string {
return new Date().toISOString()
}
const STRUCTURED_TYPE_MAP: Record<string, string> = {
shutdown_request: "shutdown_request",
shutdown_approved: "shutdown_response",
shutdown_rejected: "shutdown_response",
plan_approved: "plan_approval_response",
plan_rejected: "plan_approval_response",
}
export function buildShutdownRequestId(recipient: string): string {
return `shutdown-${recipient}-${randomUUID().slice(0, 8)}`
}
export function sendPlainInboxMessage(
teamName: string,
sender: string,
recipient: string,
content: string,
summary: string,
_color?: string,
): void {
const message = InboxMessageSchema.parse({
id: randomUUID(),
type: "message",
sender,
recipient,
content,
summary,
timestamp: nowIso(),
read: false,
})
appendInboxMessage(teamName, recipient, message)
}
export function sendStructuredInboxMessage(
teamName: string,
sender: string,
recipient: string,
data: Record<string, unknown>,
summaryType: string,
): void {
const messageType = STRUCTURED_TYPE_MAP[summaryType] ?? "message"
const message = InboxMessageSchema.parse({
id: randomUUID(),
type: messageType,
sender,
recipient,
content: JSON.stringify(data),
summary: summaryType,
timestamp: nowIso(),
read: false,
})
appendInboxMessage(teamName, recipient, message)
}

View File

@@ -0,0 +1,59 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store"
import { getTeamInboxPath } from "./paths"
describe("agent-teams inbox store", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("readInbox fails on malformed inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, "{", "utf-8")
//#when
const readMalformedInbox = () => readInbox("core", "team-lead", false, false)
//#then
expect(readMalformedInbox).toThrow("team_inbox_parse_failed")
expect(readFileSync(inboxPath, "utf-8")).toBe("{")
})
test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8")
//#when
const appendIntoInvalidInbox = () => {
appendInboxMessage("core", "team-lead", {
from: "team-lead",
text: "hello",
timestamp: new Date().toISOString(),
read: false,
summary: "note",
})
}
//#then
expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid")
expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true }))
})
})

View File

@@ -0,0 +1,197 @@
import { existsSync, readFileSync, unlinkSync } from "node:fs"
import { z } from "zod"
import { acquireLock, ensureDir, writeJsonAtomic } from "../../features/claude-tasks/storage"
import { getTeamInboxPath } from "./paths"
import type { InboxMessage } from "./types"
import { InboxMessageSchema } from "./types"
const InboxMessageListSchema = z.array(InboxMessageSchema)
function assertValidTeamName(teamName: string): void {
const errors: string[] = []
if (!/^[A-Za-z0-9_-]+$/.test(teamName)) {
errors.push("Team name must contain only letters, numbers, hyphens, and underscores")
}
if (teamName.length > 64) {
errors.push("Team name must be at most 64 characters")
}
if (errors.length > 0) {
throw new Error(`Invalid team name: ${errors.join(", ")}`)
}
}
function assertValidAgentName(agentName: string): void {
if (!agentName || agentName.length === 0) {
throw new Error("Agent name must not be empty")
}
}
function getTeamInboxDirFromName(teamName: string): string {
const { dirname } = require("node:path")
return dirname(getTeamInboxPath(teamName, "dummy"))
}
function withInboxLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const inboxDir = getTeamInboxDirFromName(teamName)
ensureDir(inboxDir)
const lock = acquireLock(inboxDir)
if (!lock.acquired) {
throw new Error("inbox_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
function parseInboxFile(content: string): InboxMessage[] {
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
throw new Error("team_inbox_parse_failed")
}
const result = InboxMessageListSchema.safeParse(parsed)
if (!result.success) {
throw new Error("team_inbox_schema_invalid")
}
return result.data
}
function readInboxMessages(teamName: string, agentName: string): InboxMessage[] {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
const path = getTeamInboxPath(teamName, agentName)
if (!existsSync(path)) {
return []
}
return parseInboxFile(readFileSync(path, "utf-8"))
}
function writeInboxMessages(teamName: string, agentName: string, messages: InboxMessage[]): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
const path = getTeamInboxPath(teamName, agentName)
writeJsonAtomic(path, messages)
}
export function ensureInbox(teamName: string, agentName: string): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
if (!existsSync(path)) {
writeJsonAtomic(path, [])
}
})
}
export function appendInboxMessage(teamName: string, agentName: string, message: InboxMessage): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
const messages = existsSync(path) ? parseInboxFile(readFileSync(path, "utf-8")) : []
messages.push(InboxMessageSchema.parse(message))
writeInboxMessages(teamName, agentName, messages)
})
}
export function readInbox(teamName: string, agentName: string, unreadOnly = false, markAsRead = false): InboxMessage[] {
return withInboxLock(teamName, () => {
const messages = readInboxMessages(teamName, agentName)
const selectedIndexes = new Set<number>()
const selected = unreadOnly
? messages.filter((message, index) => {
if (!message.read) {
selectedIndexes.add(index)
return true
}
return false
})
: messages.map((message, index) => {
selectedIndexes.add(index)
return message
})
if (!markAsRead || selected.length === 0) {
return selected
}
let changed = false
const updated = messages.map((message, index) => {
if (selectedIndexes.has(index) && !message.read) {
changed = true
return { ...message, read: true }
}
return message
})
if (changed) {
writeInboxMessages(teamName, agentName, updated)
}
return updated.filter((_, index) => selectedIndexes.has(index))
})
}
export function markMessagesRead(teamName: string, agentName: string, messageIds: string[]): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
if (messageIds.length === 0) {
return
}
withInboxLock(teamName, () => {
const messages = readInboxMessages(teamName, agentName)
const idsToMark = new Set(messageIds)
const updated = messages.map((message) => {
if (idsToMark.has(message.id) && !message.read) {
return { ...message, read: true }
}
return message
})
const changed = updated.some((msg, index) => msg.read !== messages[index].read)
if (changed) {
writeInboxMessages(teamName, agentName, updated)
}
})
}
export function deleteInbox(teamName: string, agentName: string): void {
assertValidTeamName(teamName)
assertValidAgentName(agentName)
withInboxLock(teamName, () => {
const path = getTeamInboxPath(teamName, agentName)
if (existsSync(path)) {
unlinkSync(path)
}
})
}
export const clearInbox = deleteInbox
export { buildShutdownRequestId, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-message-sender"

View File

@@ -0,0 +1,182 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { appendInboxMessage, ensureInbox } from "./inbox-store"
import { deleteTeamData } from "./team-config-store"
import { createReadInboxTool } from "./inbox-tools"
describe("read_inbox tool", () => {
let originalCwd: string
let tempProjectDir: string
let teamName: string
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-tools-"))
process.chdir(tempProjectDir)
teamName = `test-team-${randomUUID()}`
})
afterEach(() => {
try {
deleteTeamData(teamName)
} catch {
// ignore
}
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("read inbox action", () => {
test("returns all messages when no filters", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
appendInboxMessage(teamName, "team-lead", {
id: "msg-2",
type: "message",
sender: "user",
recipient: "team-lead",
content: "World",
timestamp: new Date().toISOString(),
read: true,
})
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(2)
expect(result[0].id).toBe("msg-1")
expect(result[1].id).toBe("msg-2")
})
test("returns only unread messages when unread_only is true", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
appendInboxMessage(teamName, "team-lead", {
id: "msg-2",
type: "message",
sender: "user",
recipient: "team-lead",
content: "World",
timestamp: new Date().toISOString(),
read: true,
})
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
unread_only: true,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("msg-1")
})
test("marks messages as read when mark_as_read is true", async () => {
//#given
ensureInbox(teamName, "team-lead")
appendInboxMessage(teamName, "team-lead", {
id: "msg-1",
type: "message",
sender: "user",
recipient: "team-lead",
content: "Hello",
timestamp: new Date().toISOString(),
read: false,
})
const tool = createReadInboxTool()
//#when
await tool.execute({
team_name: teamName,
agent_name: "team-lead",
mark_as_read: true,
}, TEST_CONTEXT)
// Read again to check if marked as read
const resultStr = await tool.execute({
team_name: teamName,
agent_name: "team-lead",
unread_only: true,
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveLength(0) // Should be marked as read
})
test("returns empty array for non-existent inbox", async () => {
//#given
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({
team_name: "nonexistent",
agent_name: "team-lead",
}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toEqual([])
})
test("requires team_name and agent_name parameters", async () => {
//#given
const tool = createReadInboxTool()
//#when
const resultStr = await tool.execute({}, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
})
})

View File

@@ -0,0 +1,29 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readInbox } from "./inbox-store"
import { ReadInboxInputSchema } from "./types"
export function createReadInboxTool(): ToolDefinition {
return tool({
description: "Read inbox messages for a team member.",
args: {
team_name: tool.schema.string().describe("Team name"),
agent_name: tool.schema.string().describe("Member name"),
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const input = ReadInboxInputSchema.parse(args)
const messages = readInbox(
input.team_name,
input.agent_name,
input.unread_only ?? false,
input.mark_as_read ?? false,
)
return JSON.stringify(messages)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
}
},
})
}

View File

@@ -0,0 +1 @@
export { createAgentTeamsTools } from "./tools"

View File

@@ -0,0 +1,467 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { readInbox } from "./inbox-store"
import { createAgentTeamsTools } from "./tools"
import { readTeamConfig, upsertTeammate, writeTeamConfig } from "./team-config-store"
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
interface ResumeCall {
sessionId: string
prompt: string
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
return JSON.parse(output)
}
function uniqueTeam(): string {
return `msg-${randomUUID().slice(0, 8)}`
}
function createMockManager(): { manager: BackgroundManager; resumeCalls: ResumeCall[] } {
const resumeCalls: ResumeCall[] = []
let launchCount = 0
const manager = {
launch: async () => {
launchCount += 1
return { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
},
getTask: () => undefined,
resume: async (args: ResumeCall) => {
resumeCalls.push(args)
return { id: `resume-${resumeCalls.length}` }
},
} as unknown as BackgroundManager
return { manager, resumeCalls }
}
async function setupTeamWithWorker(
_tools: ReturnType<typeof createAgentTeamsTools>,
context: TestToolContext,
teamName = "core",
workerName = "worker_1",
): Promise<void> {
await executeJsonTool(_tools, "team_create", { team_name: teamName }, context)
const config = readTeamConfig(teamName)
if (config) {
const teammate = {
agentId: `agent-${randomUUID()}`,
name: workerName,
agentType: "teammate" as const,
category: "quick",
model: "default",
prompt: "Handle tasks",
joinedAt: new Date().toISOString(),
color: "#FF5733",
cwd: process.cwd(),
planModeRequired: false,
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
}
}
async function addTeammateManually(teamName: string, workerName: string): Promise<void> {
const config = readTeamConfig(teamName)
if (config) {
const teammate = {
agentId: `agent-${randomUUID()}`,
name: workerName,
agentType: "teammate" as const,
category: "quick",
model: "default",
prompt: "Handle tasks",
joinedAt: new Date().toISOString(),
color: "#FF5733",
cwd: process.cwd(),
planModeRequired: false,
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
const updatedConfig = upsertTeammate(config, teammate)
writeTeamConfig(teamName, updatedConfig)
}
}
describe("agent-teams messaging tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-messaging-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("message type", () => {
test("delivers message to recipient inbox", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "message",
recipient: "worker_1",
content: "Please update status.",
summary: "status_request",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("message_sent:worker_1")
const inbox = readInbox(tn, "worker_1")
const delivered = inbox.filter((m) => m.summary === "status_request")
expect(delivered.length).toBeGreaterThanOrEqual(1)
expect(delivered[0]?.sender).toBe("team-lead")
expect(delivered[0]?.content).toBe("Please update status.")
})
test("rejects message to nonexistent recipient", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "nonexistent", content: "hello", summary: "test" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("message_recipient_not_found")
})
test("rejects recipient with team suffix mismatch", async () => {
//#given
const tn = uniqueTeam()
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1@other-team", summary: "sync", content: "hi" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("recipient_team_mismatch")
expect(resumeCalls).toHaveLength(0)
})
test("rejects recipient with empty team suffix", async () => {
//#given
const tn = uniqueTeam()
const { manager, resumeCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1@", summary: "sync", content: "hi" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("recipient_team_invalid")
expect(resumeCalls).toHaveLength(0)
})
})
describe("broadcast type", () => {
test("writes to all teammate inboxes", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await executeJsonTool(tools, "team_create", { team_name: tn }, leadContext)
for (const name of ["worker_1", "worker_2"]) {
await addTeammateManually(tn, name)
}
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "broadcast", summary: "sync", content: "Status update needed" },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("broadcast_sent:2")
for (const name of ["worker_1", "worker_2"]) {
const inbox = readInbox(tn, name)
const broadcastMessages = inbox.filter((m) => m.summary === "sync")
expect(broadcastMessages.length).toBeGreaterThanOrEqual(1)
}
})
test("rejects broadcast without summary", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "broadcast", content: "hello" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("broadcast_requires_summary")
})
})
describe("shutdown_request type", () => {
test("sends shutdown request and returns request_id", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_request", recipient: "worker_1", content: "Work completed" },
leadContext,
) as { success?: boolean; request_id?: string; target?: string }
//#then
expect(result.success).toBe(true)
expect(result.request_id).toMatch(/^shutdown-worker_1-/)
expect(result.target).toBe("worker_1")
})
test("rejects shutdown targeting team-lead", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_request", recipient: "team-lead" },
leadContext,
) as { error?: string }
//#then
expect(result.error).toBe("cannot_shutdown_team_lead")
})
})
describe("shutdown_response type", () => {
test("sends approved shutdown response to team-lead inbox", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "shutdown_response", request_id: "shutdown-worker_1-abc12345", approve: true },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("shutdown_approved:shutdown-worker_1-abc12345")
const leadInbox = readInbox(tn, "team-lead")
const shutdownMessages = leadInbox.filter((m) => m.summary === "shutdown_approved")
expect(shutdownMessages.length).toBeGreaterThanOrEqual(1)
})
test("sends rejected shutdown response", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "shutdown_response",
request_id: "shutdown-worker_1-abc12345",
approve: false,
content: "Still working on it",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("shutdown_rejected:shutdown-worker_1-abc12345")
})
})
describe("plan_approval_response type", () => {
test("sends approved plan response", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "plan_approval_response", request_id: "plan-req-001", approve: true, recipient: "worker_1" },
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("plan_approved:worker_1")
})
test("sends rejected plan response with content", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{
team_name: tn,
type: "plan_approval_response",
request_id: "plan-req-002",
approve: false,
recipient: "worker_1",
content: "Need more details",
},
leadContext,
) as { success?: boolean; message?: string }
//#then
expect(result.success).toBe(true)
expect(result.message).toBe("plan_rejected:worker_1")
})
})
describe("authorization", () => {
test("rejects message from unauthorized session", async () => {
//#given
const tn = uniqueTeam()
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await setupTeamWithWorker(tools, leadContext, tn)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: tn, type: "message", recipient: "worker_1", content: "hello", summary: "test" },
createContext("ses-intruder"),
) as { error?: string }
//#then
expect(result.error).toBe("unauthorized_sender_session")
})
test("rejects message to nonexistent team", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
//#when
const result = await executeJsonTool(
tools,
"send_message",
{ team_name: "nonexistent-xyz", type: "message", recipient: "w", content: "hello", summary: "test" },
createContext(),
) as { error?: string }
//#then
expect(result.error).toBe("team_not_found")
})
})
})

View File

@@ -0,0 +1,282 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { BackgroundManager } from "../../features/background-agent"
import { buildShutdownRequestId, readInbox, sendPlainInboxMessage, sendStructuredInboxMessage } from "./inbox-store"
import { getTeamMember, listTeammates, readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTeamName } from "./name-validation"
import { resumeTeammateWithMessage } from "./teammate-runtime"
import {
TeamConfig,
TeamReadInboxInputSchema,
TeamSendMessageInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
function nowIso(): string {
return new Date().toISOString()
}
function validateRecipientTeam(recipient: unknown, teamName: string): string | null {
if (typeof recipient !== "string") {
return null
}
const trimmed = recipient.trim()
const atIndex = trimmed.indexOf("@")
if (atIndex <= 0) {
return null
}
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
if (!specifiedTeam) {
return "recipient_team_invalid"
}
if (specifiedTeam === teamName) {
return null
}
return "recipient_team_mismatch"
}
function resolveSenderFromContext(config: TeamConfig, context: TeamToolContext): string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
export function createSendMessageTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Send direct or broadcast team messages and protocol responses.",
args: {
team_name: tool.schema.string().describe("Team name"),
type: tool.schema.enum(["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]),
recipient: tool.schema.string().optional().describe("Message recipient"),
content: tool.schema.string().optional().describe("Message body"),
summary: tool.schema.string().optional().describe("Short summary"),
request_id: tool.schema.string().optional().describe("Protocol request id"),
approve: tool.schema.boolean().optional().describe("Approval flag"),
sender: tool.schema
.string()
.optional()
.describe("Sender name inferred from calling session; explicit value must match resolved sender."),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamSendMessageInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const recipientTeamError = validateRecipientTeam(args.recipient, input.team_name)
if (recipientTeamError) {
return JSON.stringify({ error: recipientTeamError })
}
const requestedSender = input.sender
const senderError = requestedSender ? validateAgentNameOrLead(requestedSender) : null
if (senderError) {
return JSON.stringify({ error: senderError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveSenderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_sender_session" })
}
if (requestedSender && requestedSender !== actor) {
return JSON.stringify({ error: "sender_context_mismatch" })
}
const sender = requestedSender ?? actor
const memberNames = new Set(config.members.map((member) => member.name))
if (sender !== "team-lead" && !memberNames.has(sender)) {
return JSON.stringify({ error: "invalid_sender" })
}
if (input.type === "message") {
if (!input.recipient || !input.summary || !input.content) {
return JSON.stringify({ error: "message_requires_recipient_summary_content" })
}
if (!memberNames.has(input.recipient)) {
return JSON.stringify({ error: "message_recipient_not_found" })
}
const targetMember = getTeamMember(config, input.recipient)
const color = targetMember && isTeammateMember(targetMember) ? targetMember.color : undefined
sendPlainInboxMessage(input.team_name, sender, input.recipient, input.content, input.summary, color)
if (targetMember && isTeammateMember(targetMember)) {
await resumeTeammateWithMessage(manager, context, input.team_name, targetMember, input.summary, input.content)
}
return JSON.stringify({ success: true, message: `message_sent:${input.recipient}` })
}
if (input.type === "broadcast") {
if (!input.summary) {
return JSON.stringify({ error: "broadcast_requires_summary" })
}
const broadcastSummary = input.summary
const teammates = listTeammates(config)
for (const teammate of teammates) {
sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", broadcastSummary)
}
await Promise.allSettled(
teammates.map((teammate) =>
resumeTeammateWithMessage(manager, context, input.team_name, teammate, broadcastSummary, input.content ?? ""),
),
)
return JSON.stringify({ success: true, message: `broadcast_sent:${teammates.length}` })
}
if (input.type === "shutdown_request") {
if (!input.recipient) {
return JSON.stringify({ error: "shutdown_request_requires_recipient" })
}
if (input.recipient === "team-lead") {
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
}
const targetMember = getTeamMember(config, input.recipient)
if (!targetMember || !isTeammateMember(targetMember)) {
return JSON.stringify({ error: "shutdown_recipient_not_found" })
}
const requestId = buildShutdownRequestId(input.recipient)
sendStructuredInboxMessage(
input.team_name,
sender,
input.recipient,
{
type: "shutdown_request",
requestId,
from: sender,
reason: input.content ?? "",
timestamp: nowIso(),
},
"shutdown_request",
)
await resumeTeammateWithMessage(
manager,
context,
input.team_name,
targetMember,
"shutdown_request",
input.content ?? "Shutdown requested",
)
return JSON.stringify({ success: true, request_id: requestId, target: input.recipient })
}
if (input.type === "shutdown_response") {
if (!input.request_id) {
return JSON.stringify({ error: "shutdown_response_requires_request_id" })
}
if (input.approve) {
sendStructuredInboxMessage(
input.team_name,
sender,
"team-lead",
{
type: "shutdown_approved",
requestId: input.request_id,
from: sender,
timestamp: nowIso(),
backendType: "native",
},
"shutdown_approved",
)
return JSON.stringify({ success: true, message: `shutdown_approved:${input.request_id}` })
}
sendPlainInboxMessage(
input.team_name,
sender,
"team-lead",
input.content ?? "Shutdown rejected",
"shutdown_rejected",
)
return JSON.stringify({ success: true, message: `shutdown_rejected:${input.request_id}` })
}
if (!input.recipient) {
return JSON.stringify({ error: "plan_response_requires_recipient" })
}
if (!memberNames.has(input.recipient)) {
return JSON.stringify({ error: "plan_response_recipient_not_found" })
}
if (input.approve) {
sendStructuredInboxMessage(
input.team_name,
sender,
input.recipient,
{
type: "plan_approval",
approved: true,
requestId: input.request_id,
},
"plan_approved",
)
return JSON.stringify({ success: true, message: `plan_approved:${input.recipient}` })
}
sendPlainInboxMessage(
input.team_name,
sender,
input.recipient,
input.content ?? "Plan rejected",
"plan_rejected",
)
return JSON.stringify({ success: true, message: `plan_rejected:${input.recipient}` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "send_message_failed" })
}
},
})
}
export function createReadInboxTool(): ToolDefinition {
return tool({
description: "Read inbox messages for a team member.",
args: {
team_name: tool.schema.string().describe("Team name"),
agent_name: tool.schema.string().describe("Member name"),
unread_only: tool.schema.boolean().optional().describe("Return only unread messages"),
mark_as_read: tool.schema.boolean().optional().describe("Mark returned messages as read"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadInboxInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentNameOrLead(input.agent_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveSenderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead" && actor !== input.agent_name) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
const messages = readInbox(
input.team_name,
input.agent_name,
input.unread_only ?? false,
input.mark_as_read ?? true,
)
return JSON.stringify(messages)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "read_inbox_failed" })
}
},
})
}

View File

@@ -0,0 +1,79 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import {
validateAgentName,
validateAgentNameOrLead,
validateTaskId,
validateTeamName,
} from "./name-validation"
describe("agent-teams name validation", () => {
test("accepts valid team names", () => {
//#given
const validNames = ["team_1", "alpha-team", "A1"]
//#when
const result = validNames.map(validateTeamName)
//#then
expect(result).toEqual([null, null, null])
})
test("rejects invalid and empty team names", () => {
//#given
const blank = ""
const invalid = "team space"
const tooLong = "a".repeat(65)
//#when
const blankResult = validateTeamName(blank)
const invalidResult = validateTeamName(invalid)
const tooLongResult = validateTeamName(tooLong)
//#then
expect(blankResult).toBe("team_name_required")
expect(invalidResult).toBe("team_name_invalid")
expect(tooLongResult).toBe("team_name_too_long")
})
test("rejects reserved teammate name", () => {
//#given
const reservedName = "team-lead"
//#when
const result = validateAgentName(reservedName)
//#then
expect(result).toBe("agent_name_reserved")
})
test("validates regular agent names", () => {
//#given
const valid = "worker_1"
const invalid = "worker one"
//#when
const validResult = validateAgentName(valid)
const invalidResult = validateAgentName(invalid)
//#then
expect(validResult).toBeNull()
expect(invalidResult).toBe("agent_name_invalid")
})
test("allows team-lead for inbox-compatible validation", () => {
//#then
expect(validateAgentNameOrLead("team-lead")).toBeNull()
expect(validateAgentNameOrLead("worker_1")).toBeNull()
expect(validateAgentNameOrLead("worker one")).toBe("agent_name_invalid")
})
test("validates task ids", () => {
//#then
expect(validateTaskId("T-123")).toBeNull()
expect(validateTaskId("123")).toBe("task_id_invalid")
expect(validateTaskId("")).toBe("task_id_required")
expect(validateTaskId("../../etc/passwd")).toBe("task_id_invalid")
expect(validateTaskId(`T-${"a".repeat(127)}`)).toBe("task_id_too_long")
})
})

View File

@@ -0,0 +1,54 @@
const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/
const MAX_NAME_LENGTH = 64
const VALID_TASK_ID_RE = /^T-[A-Za-z0-9_-]+$/
const MAX_TASK_ID_LENGTH = 128
function validateName(value: string, label: "team" | "agent"): string | null {
if (!value || !value.trim()) {
return `${label}_name_required`
}
if (!VALID_NAME_RE.test(value)) {
return `${label}_name_invalid`
}
if (value.length > MAX_NAME_LENGTH) {
return `${label}_name_too_long`
}
return null
}
export function validateTeamName(teamName: string): string | null {
return validateName(teamName, "team")
}
export function validateAgentName(agentName: string): string | null {
if (agentName === "team-lead") {
return "agent_name_reserved"
}
return validateName(agentName, "agent")
}
export function validateAgentNameOrLead(agentName: string): string | null {
if (agentName === "team-lead") {
return null
}
return validateName(agentName, "agent")
}
export function validateTaskId(taskId: string): string | null {
if (!taskId || !taskId.trim()) {
return "task_id_required"
}
if (!VALID_TASK_ID_RE.test(taskId)) {
return "task_id_invalid"
}
if (taskId.length > MAX_TASK_ID_LENGTH) {
return "task_id_too_long"
}
return null
}

View File

@@ -0,0 +1,81 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import {
getAgentTeamsRootDir,
getTeamConfigPath,
getTeamDir,
getTeamInboxDir,
getTeamInboxPath,
getTeamTaskDir,
getTeamTaskPath,
getTeamsRootDir,
getTeamTasksRootDir,
} from "./paths"
describe("agent-teams paths", () => {
test("uses user-global .sisyphus directory as storage root", () => {
//#given
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
//#when
const root = getAgentTeamsRootDir()
//#then
expect(root).toBe(expectedRoot)
})
test("builds expected teams and tasks root directories", () => {
//#given
const expectedRoot = join(homedir(), ".sisyphus", "agent-teams")
//#when
const teamsRoot = getTeamsRootDir()
const tasksRoot = getTeamTasksRootDir()
//#then
expect(teamsRoot).toBe(join(expectedRoot, "teams"))
expect(tasksRoot).toBe(join(expectedRoot, "tasks"))
})
test("builds team-scoped config, inbox, and task file paths", () => {
//#given
const teamName = "alpha_team"
const agentName = "worker_1"
const taskId = "T-123"
const expectedTeamDir = join(getTeamsRootDir(), "alpha_team")
//#when
const teamDir = getTeamDir(teamName)
const configPath = getTeamConfigPath(teamName)
const inboxDir = getTeamInboxDir(teamName)
const inboxPath = getTeamInboxPath(teamName, agentName)
const taskDir = getTeamTaskDir(teamName)
const taskPath = getTeamTaskPath(teamName, taskId)
//#then
expect(teamDir).toBe(expectedTeamDir)
expect(configPath).toBe(join(expectedTeamDir, "config.json"))
expect(inboxDir).toBe(join(expectedTeamDir, "inboxes"))
expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`))
expect(taskDir).toBe(join(getTeamTasksRootDir(), "alpha_team"))
expect(taskPath).toBe(join(getTeamTasksRootDir(), "alpha_team", `${taskId}.json`))
})
test("sanitizes team names with invalid characters", () => {
//#given
const invalidTeamName = "team space/with@special#chars"
const expectedSanitized = "team-space-with-special-chars"
//#when
const teamDir = getTeamDir(invalidTeamName)
const configPath = getTeamConfigPath(invalidTeamName)
const taskDir = getTeamTaskDir(invalidTeamName)
//#then
expect(teamDir).toBe(join(getTeamsRootDir(), expectedSanitized))
expect(configPath).toBe(join(getTeamsRootDir(), expectedSanitized, "config.json"))
expect(taskDir).toBe(join(getTeamTasksRootDir(), expectedSanitized))
})
})

View File

@@ -0,0 +1,42 @@
import { join } from "node:path"
import { homedir } from "node:os"
import { sanitizePathSegment } from "../../features/claude-tasks/storage"
const SISYPHUS_DIR = ".sisyphus"
const AGENT_TEAMS_DIR = "agent-teams"
export function getAgentTeamsRootDir(): string {
return join(homedir(), SISYPHUS_DIR, AGENT_TEAMS_DIR)
}
export function getTeamsRootDir(): string {
return join(getAgentTeamsRootDir(), "teams")
}
export function getTeamTasksRootDir(): string {
return join(getAgentTeamsRootDir(), "tasks")
}
export function getTeamDir(teamName: string): string {
return join(getTeamsRootDir(), sanitizePathSegment(teamName))
}
export function getTeamConfigPath(teamName: string): string {
return join(getTeamDir(teamName), "config.json")
}
export function getTeamInboxDir(teamName: string): string {
return join(getTeamDir(teamName), "inboxes")
}
export function getTeamInboxPath(teamName: string, agentName: string): string {
return join(getTeamInboxDir(teamName), `${agentName}.json`)
}
export function getTeamTaskDir(teamName: string): string {
return join(getTeamTasksRootDir(), sanitizePathSegment(teamName))
}
export function getTeamTaskPath(teamName: string, taskId: string): string {
return join(getTeamTaskDir(teamName), `${taskId}.json`)
}

View File

@@ -0,0 +1,214 @@
/// <reference types="bun-types" />
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 {
createTeamConfig,
deleteTeamData,
deleteTeamDir,
listTeams,
readTeamConfigOrThrow,
teamExists,
upsertTeammate,
writeTeamConfig,
} from "./team-config-store"
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 = []
teamPrefix = randomUUID().slice(0, 8)
createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus")
createdTeams.push(`core-${teamPrefix}`)
})
afterAll(() => {
for (const teamName of createdTeams) {
if (teamExists(teamName)) {
try {
deleteTeamData(teamName)
} catch {
// Ignore cleanup errors
}
}
}
process.chdir(originalCwd)
try {
rmSync(tempProjectDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
test("deleteTeamData waits for team lock before removing team files", () => {
//#given
const teamName = createdTeams[0]
const lock = acquireLock(getTeamDir(teamName))
expect(lock.acquired).toBe(true)
try {
//#when
const deleteWhileLocked = () => deleteTeamData(teamName)
//#then
expect(deleteWhileLocked).toThrow("team_lock_unavailable")
expect(teamExists(teamName)).toBe(true)
} finally {
//#when
lock.release()
}
deleteTeamData(teamName)
//#then
expect(teamExists(teamName)).toBe(false)
})
test("deleteTeamData waits for task lock before removing task files", () => {
//#given
const teamName = createdTeams[0]
const lock = acquireLock(getTeamTaskDir(teamName))
expect(lock.acquired).toBe(true)
try {
//#when
const deleteWhileLocked = () => deleteTeamData(teamName)
//#then
expect(deleteWhileLocked).toThrow("team_task_lock_unavailable")
expect(teamExists(teamName)).toBe(true)
} finally {
lock.release()
}
//#when
deleteTeamData(teamName)
//#then
expect(teamExists(teamName)).toBe(false)
})
test("deleteTeamData removes task files before deleting team directory", () => {
//#given
const teamName = createdTeams[0]
const taskDir = getTeamTaskDir(teamName)
const teamDir = getTeamDir(teamName)
const teamsRootDir = getTeamsRootDir()
expect(existsSync(taskDir)).toBe(true)
expect(existsSync(teamDir)).toBe(true)
//#when
chmodSync(teamsRootDir, 0o555)
try {
const deleteWithBlockedTeamParent = () => deleteTeamData(teamName)
expect(deleteWithBlockedTeamParent).toThrow()
} finally {
chmodSync(teamsRootDir, 0o755)
}
//#then
expect(existsSync(taskDir)).toBe(false)
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]
const config = readTeamConfigOrThrow(teamName)
const updated = upsertTeammate(config, {
agentId: `teammate@${teamName}`,
name: "teammate",
agentType: "teammate",
category: "test",
model: "sisyphus",
prompt: "test prompt",
color: "#000000",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: process.cwd(),
subscriptions: [],
backendType: "native",
isActive: true,
sessionID: "ses-sub",
})
writeTeamConfig(teamName, updated)
//#when
const deleteWithTeammates = () => deleteTeamData(teamName)
//#then
expect(deleteWithTeammates).toThrow("team_has_active_members")
expect(teamExists(teamName)).toBe(true)
})
})

View File

@@ -0,0 +1,217 @@
import { existsSync, readdirSync, rmSync } from "node:fs"
import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
import {
getTeamConfigPath,
getTeamDir,
getTeamInboxDir,
getTeamTaskDir,
getTeamTasksRootDir,
getTeamsRootDir,
} from "./paths"
import {
TEAM_COLOR_PALETTE,
TeamConfig,
TeamConfigSchema,
TeamLeadMember,
TeamMember,
TeamTeammateMember,
isTeammateMember,
} from "./types"
import { validateTeamName } from "./name-validation"
import { withTeamTaskLock } from "./team-task-store"
function nowMs(): string {
return new Date().toISOString()
}
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function withTeamLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const teamDir = getTeamDir(teamName)
ensureDir(teamDir)
const lock = acquireLock(teamDir)
if (!lock.acquired) {
throw new Error("team_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
function createLeadMember(teamName: string, cwd: string, leadModel: string): TeamLeadMember {
return {
agentId: `team-lead@${teamName}`,
name: "team-lead",
agentType: "team-lead",
color: "#2D3748",
model: leadModel,
joinedAt: nowMs(),
cwd,
subscriptions: [],
}
}
export function ensureTeamStorageDirs(teamName: string): void {
assertValidTeamName(teamName)
ensureDir(getTeamsRootDir())
ensureDir(getTeamTasksRootDir())
ensureDir(getTeamDir(teamName))
ensureDir(getTeamInboxDir(teamName))
ensureDir(getTeamTaskDir(teamName))
}
export function teamExists(teamName: string): boolean {
assertValidTeamName(teamName)
return existsSync(getTeamConfigPath(teamName))
}
export function createTeamConfig(
teamName: string,
description: string,
leadSessionId: string,
cwd: string,
leadModel: string,
): TeamConfig {
ensureTeamStorageDirs(teamName)
const leadAgentId = `team-lead@${teamName}`
const config: TeamConfig = {
name: teamName,
description,
createdAt: nowMs(),
leadAgentId,
leadSessionId,
members: [createLeadMember(teamName, cwd, leadModel)],
}
return withTeamLock(teamName, () => {
if (teamExists(teamName)) {
throw new Error("team_already_exists")
}
writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config))
return config
})
}
export function readTeamConfig(teamName: string): TeamConfig | null {
assertValidTeamName(teamName)
return readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
}
export function readTeamConfigOrThrow(teamName: string): TeamConfig {
const config = readTeamConfig(teamName)
if (!config) {
throw new Error("team_not_found")
}
return config
}
export function writeTeamConfig(teamName: string, config: TeamConfig): TeamConfig {
assertValidTeamName(teamName)
return withTeamLock(teamName, () => {
const validated = TeamConfigSchema.parse(config)
writeJsonAtomic(getTeamConfigPath(teamName), validated)
return validated
})
}
export function updateTeamConfig(teamName: string, updater: (config: TeamConfig) => TeamConfig): TeamConfig {
assertValidTeamName(teamName)
return withTeamLock(teamName, () => {
const current = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
if (!current) {
throw new Error("team_not_found")
}
const next = TeamConfigSchema.parse(updater(current))
writeJsonAtomic(getTeamConfigPath(teamName), next)
return next
})
}
export function listTeammates(config: TeamConfig): TeamTeammateMember[] {
return config.members.filter(isTeammateMember)
}
export function getTeamMember(config: TeamConfig, name: string): TeamMember | undefined {
return config.members.find((member) => member.name === name)
}
export function upsertTeammate(config: TeamConfig, teammate: TeamTeammateMember): TeamConfig {
const members = config.members.filter((member) => member.name !== teammate.name)
members.push(teammate)
return { ...config, members }
}
export function removeTeammate(config: TeamConfig, agentName: string): TeamConfig {
if (agentName === "team-lead") {
throw new Error("cannot_remove_team_lead")
}
return {
...config,
members: config.members.filter((member) => member.name !== agentName),
}
}
export function assignNextColor(config: TeamConfig): string {
const teammateCount = listTeammates(config).length
return TEAM_COLOR_PALETTE[teammateCount % TEAM_COLOR_PALETTE.length]
}
export function deleteTeamData(teamName: string): void {
assertValidTeamName(teamName)
withTeamLock(teamName, () => {
const config = readJsonSafe(getTeamConfigPath(teamName), TeamConfigSchema)
if (!config) {
throw new Error("team_not_found")
}
if (listTeammates(config).length > 0) {
throw new Error("team_has_active_members")
}
withTeamTaskLock(teamName, () => {
const teamDir = getTeamDir(teamName)
const taskDir = getTeamTaskDir(teamName)
if (existsSync(taskDir)) {
rmSync(taskDir, { recursive: true, force: true })
}
if (existsSync(teamDir)) {
rmSync(teamDir, { recursive: true, force: true })
}
})
})
}
export function deleteTeamDir(teamName: string): void {
deleteTeamData(teamName)
}
export function listTeams(): string[] {
const teamsRootDir = getTeamsRootDir()
if (!existsSync(teamsRootDir)) {
return []
}
try {
const entries = readdirSync(teamsRootDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => existsSync(getTeamConfigPath(name)))
} catch {
return []
}
}

View File

@@ -0,0 +1,297 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } 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 { createTeamCreateTool, createTeamDeleteTool } from "./team-lifecycle-tools"
import { getTeamConfigPath, getTeamDir, getTeamTaskDir } from "./paths"
import { readTeamConfig, listTeammates } from "./team-config-store"
import { getTeamsRootDir, getTeamTasksRootDir } from "./paths"
import { deleteTeamData } from "./team-config-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: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tool.execute(args, context)
return JSON.parse(output)
}
describe("team_lifecycle tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("team_create", () => {
test("creates team with valid name and description", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `test-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
description: "My test team",
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
// Verify team was actually created
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
expect(teamConfig?.name).toBe(teamName)
expect(teamConfig?.description).toBe("My test team")
expect(teamConfig?.leadAgentId).toBe(`team-lead@${teamName}`)
expect(teamConfig?.leadSessionId).toBe("ses-main")
expect(teamConfig?.members).toHaveLength(1)
expect(teamConfig?.members[0].agentType).toBe("team-lead")
})
test("creates team with only name (description optional)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `minimal-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
const teamConfig = readTeamConfig(teamName)
expect(teamConfig?.description).toBe("")
})
test("validates team name format (alphanumeric, hyphens, underscores only)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
//#when
const result = await executeJsonTool(tool, {
team_name: "invalid@name",
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("validates team name max length (64 chars)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const longName = "a".repeat(65)
//#when
const result = await executeJsonTool(tool, {
team_name: longName,
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("rejects duplicate team names", async () => {
//#given
const tool = createTeamCreateTool()
const context1 = createContext("ses-1")
const context2 = createContext("ses-2")
const teamName = `duplicate-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(tool, {
team_name: teamName,
}, context1)
//#when - try to create same team again
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context2)
//#then
expect(result).toEqual({
error: "team_already_exists",
})
// Verify first team still exists
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
})
})
describe("team_delete", () => {
test("deletes team when no active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `test-delete-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
deleted: true,
team_name: teamName,
})
// Verify team dir is deleted
expect(existsSync(getTeamDir(teamName))).toBe(false)
expect(existsSync(getTeamTaskDir(teamName))).toBe(false)
expect(existsSync(getTeamConfigPath(teamName))).toBe(false)
})
test("blocks deletion when team has active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `team-with-members-${TEST_SUFFIX}`
// Create team
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
// Add a teammate by modifying config directly for test
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
// Manually add a teammate to simulate active member
const { writeTeamConfig } = await import("./team-config-store")
if (teamConfig) {
writeTeamConfig(teamName, {
...teamConfig,
members: [
...teamConfig.members,
{
agentId: "teammate-1",
name: "test-teammate",
agentType: "teammate",
color: "#FF6B6B",
category: "test",
model: "test-model",
prompt: "Test prompt",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: "/tmp",
subscriptions: [],
backendType: "native",
isActive: true,
sessionID: "test-session",
},
],
})
}
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
error: "team_has_active_members",
members: ["test-teammate"],
})
// Cleanup - manually remove teammates first, then delete
const configApi = await import("./team-config-store")
const cleanupConfig = readTeamConfig(teamName)
if (cleanupConfig) {
configApi.writeTeamConfig(teamName, {
...cleanupConfig,
members: cleanupConfig.members.filter((m) => m.agentType === "team-lead"),
})
configApi.deleteTeamData(teamName)
}
})
test("validates team name format on deletion", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `invalid-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "invalid@name",
}, context)
//#then - Zod returns detailed validation error array
const parsedResult = result as { error: string }
expect(parsedResult.error).toContain("Team name must contain only letters")
})
test("returns error for non-existent team", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "non-existent-team",
}, context)
//#then
expect(result).toEqual({
error: "team_not_found",
})
})
})
})

View File

@@ -0,0 +1,151 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { z } from "zod"
import { getTeamConfigPath } from "./paths"
import { validateTeamName } from "./name-validation"
import { ensureInbox } from "./inbox-store"
import {
TeamConfig,
TeamCreateInputSchema,
TeamDeleteInputSchema,
TeamReadConfigInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
function toPublicTeamConfig(config: TeamConfig): {
team_name: string
description: string | undefined
lead_agent_id: string
teammates: Array<{ name: string }>
} {
return {
team_name: config.name,
description: config.description,
lead_agent_id: config.leadAgentId,
teammates: listTeammates(config).map((member) => ({ name: member.name })),
}
}
export function createTeamCreateTool(): ToolDefinition {
return tool({
description: "Create a team workspace with config, inboxes, and task storage.",
args: {
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
description: tool.schema.string().optional().describe("Team description"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamCreateInputSchema.parse(args)
const config = createTeamConfig(
input.team_name,
input.description ?? "",
context.sessionID,
process.cwd(),
"native/team-lead",
)
ensureInbox(config.name, "team-lead")
return JSON.stringify({
team_name: config.name,
config_path: getTeamConfigPath(config.name) as string,
lead_agent_id: config.leadAgentId,
})
} catch (error) {
if (error instanceof Error && error.message === "team_already_exists") {
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_create_failed" })
}
},
})
}
export function createTeamDeleteTool(): ToolDefinition {
return tool({
description: "Delete a team and its stored data. Fails if teammates still exist.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, _context: TeamToolContext): Promise<string> => {
let teamName: string | undefined
try {
const input = TeamDeleteInputSchema.parse(args)
teamName = input.team_name
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const teammates = listTeammates(config)
if (teammates.length > 0) {
return JSON.stringify({
error: "team_has_active_members",
members: teammates.map((member) => member.name),
})
}
deleteTeamData(input.team_name)
return JSON.stringify({ deleted: true, team_name: input.team_name })
} catch (error) {
if (error instanceof Error) {
if (error.message === "team_has_active_members") {
const config = readTeamConfig(teamName!)
const activeMembers = config ? listTeammates(config) : []
return JSON.stringify({
error: "team_has_active_members",
members: activeMembers.map((member) => member.name),
})
}
if (error.message === "team_not_found") {
return JSON.stringify({ error: "team_not_found" })
}
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_delete_failed" })
}
},
})
}
export function createTeamReadConfigTool(): ToolDefinition {
return tool({
description: "Read team configuration and member list.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadConfigInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const actor = resolveReaderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead") {
return JSON.stringify(toPublicTeamConfig(config))
}
return JSON.stringify(config)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
}
},
})
}

View File

@@ -0,0 +1,94 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import {
addPendingEdge,
createPendingEdgeMap,
ensureDependenciesCompleted,
ensureForwardStatusTransition,
wouldCreateCycle,
} from "./team-task-dependency"
import type { TeamTask, TeamTaskStatus } from "./types"
function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask {
return {
id,
subject: `Task ${id}`,
description: `Description ${id}`,
status,
blocks: [],
blockedBy,
}
}
describe("agent-teams task dependency utilities", () => {
test("detects cycle from existing blockedBy chain", () => {
//#given
const tasks = new Map<string, TeamTask>([
["A", createTask("A", "pending", ["B"])],
["B", createTask("B", "pending")],
])
const pending = createPendingEdgeMap()
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
//#then
expect(hasCycle).toBe(true)
})
test("detects cycle from pending edge map", () => {
//#given
const tasks = new Map<string, TeamTask>([["A", createTask("A", "pending")]])
const pending = createPendingEdgeMap()
addPendingEdge(pending, "A", "B")
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
//#then
expect(hasCycle).toBe(true)
})
test("returns false when dependency graph has no cycle", () => {
//#given
const tasks = new Map<string, TeamTask>([
["A", createTask("A", "pending")],
["B", createTask("B", "pending", ["A"])],
])
const pending = createPendingEdgeMap()
const readTask = (id: string) => tasks.get(id) ?? null
//#when
const hasCycle = wouldCreateCycle("C", "B", pending, readTask)
//#then
expect(hasCycle).toBe(false)
})
test("allows forward status transitions and blocks backward transitions", () => {
//#then
expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow()
expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow()
expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow(
"invalid_status_transition:in_progress->pending",
)
})
test("requires blockers to be completed for in_progress/completed", () => {
//#given
const tasks = new Map<string, TeamTask>([
["done", createTask("done", "completed")],
["wait", createTask("wait", "pending")],
])
const readTask = (id: string) => tasks.get(id) ?? null
//#then
expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow()
expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow()
expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow(
"blocked_by_incomplete:wait:pending",
)
})
})

View File

@@ -0,0 +1,93 @@
import type { TeamTask, TeamTaskStatus } from "./types"
type PendingEdges = Record<string, Set<string>>
export const TEAM_TASK_STATUS_ORDER: Record<TeamTaskStatus, number> = {
pending: 0,
in_progress: 1,
completed: 2,
deleted: 3,
}
export type TaskReader = (taskId: string) => TeamTask | null
export function wouldCreateCycle(
fromTaskId: string,
toTaskId: string,
pendingEdges: PendingEdges,
readTask: TaskReader,
): boolean {
const visited = new Set<string>()
const queue: string[] = [toTaskId]
while (queue.length > 0) {
const current = queue.shift()
if (!current) {
continue
}
if (current === fromTaskId) {
return true
}
if (visited.has(current)) {
continue
}
visited.add(current)
const task = readTask(current)
if (task) {
for (const dep of task.blockedBy) {
if (!visited.has(dep)) {
queue.push(dep)
}
}
}
const pending = pendingEdges[current]
if (pending) {
for (const dep of pending) {
if (!visited.has(dep)) {
queue.push(dep)
}
}
}
}
return false
}
export function ensureForwardStatusTransition(current: TeamTaskStatus, next: TeamTaskStatus): void {
const currentOrder = TEAM_TASK_STATUS_ORDER[current]
const nextOrder = TEAM_TASK_STATUS_ORDER[next]
if (nextOrder < currentOrder) {
throw new Error(`invalid_status_transition:${current}->${next}`)
}
}
export function ensureDependenciesCompleted(
status: TeamTaskStatus,
blockedBy: string[],
readTask: TaskReader,
): void {
if (status !== "in_progress" && status !== "completed") {
return
}
for (const blockerId of blockedBy) {
const blocker = readTask(blockerId)
if (blocker && blocker.status !== "completed") {
throw new Error(`blocked_by_incomplete:${blockerId}:${blocker.status}`)
}
}
}
export function createPendingEdgeMap(): PendingEdges {
return {}
}
export function addPendingEdge(pendingEdges: PendingEdges, from: string, to: string): void {
const existing = pendingEdges[from] ?? new Set<string>()
existing.add(to)
pendingEdges[from] = existing
}

View File

@@ -0,0 +1,460 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { dirname } from "node:path"
import { ensureDir } from "../../features/claude-tasks/storage"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
getTeamTaskPath,
readTeamTask,
writeTeamTask,
listTeamTasks,
deleteTeamTask,
} from "./team-task-store"
import type { TeamTask } from "./types"
describe("getTeamTaskPath", () => {
test("returns correct file path for team task", () => {
//#given
const teamName = "my-team"
const taskId = "T-abc123"
//#when
const result = getTeamTaskPath(teamName, taskId)
//#then
expect(result).toContain("my-team")
expect(result).toContain("T-abc123.json")
})
})
describe("readTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("returns null when task file does not exist", () => {
//#given
const teamName = "nonexistent-team"
const taskId = "T-does-not-exist"
//#when
const result = readTeamTask(teamName, taskId)
//#then
expect(result).toBeNull()
})
test("returns task when valid task file exists", () => {
//#given
const task: TeamTask = {
id: "T-existing-task",
subject: "Test task",
description: "Test description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_test",
}
writeTeamTask("test-team", "T-existing-task", task)
//#when
const result = readTeamTask("test-team", "T-existing-task")
//#then
expect(result).not.toBeNull()
expect(result?.id).toBe("T-existing-task")
expect(result?.subject).toBe("Test task")
})
test("returns null when task file contains invalid JSON", () => {
//#given
const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json")
const parentDir = dirname(taskPath)
rmSync(parentDir, { recursive: true, force: true })
ensureDir(parentDir)
writeFileSync(taskPath, "{ invalid json }")
//#when
const result = readTeamTask("invalid-team", "T-invalid-json")
//#then
expect(result).toBeNull()
})
test("returns null when task file does not match schema", () => {
//#given
const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema")
const parentDir = dirname(taskPath)
rmSync(parentDir, { recursive: true, force: true })
ensureDir(parentDir)
const invalidData = { id: "T-bad-schema" }
writeFileSync(taskPath, JSON.stringify(invalidData))
//#when
const result = readTeamTask("invalid-schema-team", "T-bad-schema")
//#then
expect(result).toBeNull()
})
})
describe("writeTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("creates task file in team namespace", () => {
//#given
const task: TeamTask = {
id: "T-write-test",
subject: "Write test task",
description: "Test writing task",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_write",
}
//#when
writeTeamTask("write-team", "T-write-test", task)
//#then
const taskPath = getTeamTaskPath("write-team", "T-write-test")
expect(existsSync(taskPath)).toBe(true)
})
test("overwrites existing task file", () => {
//#given
const task: TeamTask = {
id: "T-overwrite-test",
subject: "Original subject",
description: "Original description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_original",
}
writeTeamTask("overwrite-team", "T-overwrite-test", task)
const updatedTask: TeamTask = {
...task,
subject: "Updated subject",
status: "completed",
}
//#when
writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask)
//#then
const result = readTeamTask("overwrite-team", "T-overwrite-test")
expect(result?.subject).toBe("Updated subject")
expect(result?.status).toBe("completed")
})
test("creates team directory if it does not exist", () => {
//#given
const task: TeamTask = {
id: "T-new-dir",
subject: "New directory test",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_newdir",
}
//#when
writeTeamTask("new-team-directory", "T-new-dir", task)
//#then
const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir")
expect(existsSync(taskPath)).toBe(true)
})
})
describe("listTeamTasks", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("returns empty array when team has no tasks", () => {
//#given
// No tasks written
//#when
const result = listTeamTasks("empty-team")
//#then
expect(result).toEqual([])
})
test("returns all tasks for a team", () => {
//#given
const task1: TeamTask = {
id: "T-task-1",
subject: "Task 1",
description: "First task",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_1",
}
const task2: TeamTask = {
id: "T-task-2",
subject: "Task 2",
description: "Second task",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_2",
}
writeTeamTask("list-test-team", "T-task-1", task1)
writeTeamTask("list-test-team", "T-task-2", task2)
//#when
const result = listTeamTasks("list-test-team")
//#then
expect(result).toHaveLength(2)
expect(result.some((t) => t.id === "T-task-1")).toBe(true)
expect(result.some((t) => t.id === "T-task-2")).toBe(true)
})
test("includes tasks with all statuses", () => {
//#given
const pendingTask: TeamTask = {
id: "T-pending",
subject: "Pending task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_pending",
}
const inProgressTask: TeamTask = {
id: "T-in-progress",
subject: "In progress task",
description: "Test",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "ses_inprogress",
}
const completedTask: TeamTask = {
id: "T-completed",
subject: "Completed task",
description: "Test",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "ses_completed",
}
const deletedTask: TeamTask = {
id: "T-deleted",
subject: "Deleted task",
description: "Test",
status: "deleted",
blocks: [],
blockedBy: [],
threadID: "ses_deleted",
}
writeTeamTask("status-test-team", "T-pending", pendingTask)
writeTeamTask("status-test-team", "T-in-progress", inProgressTask)
writeTeamTask("status-test-team", "T-completed", completedTask)
writeTeamTask("status-test-team", "T-deleted", deletedTask)
//#when
const result = listTeamTasks("status-test-team")
//#then
expect(result).toHaveLength(4)
const statuses = result.map((t) => t.status)
expect(statuses).toContain("pending")
expect(statuses).toContain("in_progress")
expect(statuses).toContain("completed")
expect(statuses).toContain("deleted")
})
test("does not include tasks from other teams", () => {
//#given
const taskTeam1: TeamTask = {
id: "T-team1-task",
subject: "Team 1 task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_team1",
}
const taskTeam2: TeamTask = {
id: "T-team2-task",
subject: "Team 2 task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_team2",
}
writeTeamTask("team-1", "T-team1-task", taskTeam1)
writeTeamTask("team-2", "T-team2-task", taskTeam2)
//#when
const result = listTeamTasks("team-1")
//#then
expect(result).toHaveLength(1)
expect(result[0].id).toBe("T-team1-task")
})
})
describe("deleteTeamTask", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true })
}
})
test("deletes existing task file", () => {
//#given
const task: TeamTask = {
id: "T-delete-me",
subject: "Delete this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_delete",
}
writeTeamTask("delete-test-team", "T-delete-me", task)
const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me")
//#when
deleteTeamTask("delete-test-team", "T-delete-me")
//#then
expect(existsSync(taskPath)).toBe(false)
})
test("does not throw when task does not exist", () => {
//#given
// Task does not exist
//#when
expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow()
//#then
// No exception thrown
})
test("does not affect other tasks in same team", () => {
//#given
const task1: TeamTask = {
id: "T-keep-me",
subject: "Keep this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_keep",
}
const task2: TeamTask = {
id: "T-delete-me",
subject: "Delete this task",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_delete",
}
writeTeamTask("mixed-test-team", "T-keep-me", task1)
writeTeamTask("mixed-test-team", "T-delete-me", task2)
//#when
deleteTeamTask("mixed-test-team", "T-delete-me")
//#then
const remaining = listTeamTasks("mixed-test-team")
expect(remaining).toHaveLength(1)
expect(remaining[0].id).toBe("T-keep-me")
})
test("does not affect tasks from other teams", () => {
//#given
const task1: TeamTask = {
id: "T-task-1",
subject: "Task 1",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_1",
}
const task2: TeamTask = {
id: "T-task-2",
subject: "Task 2",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "ses_2",
}
writeTeamTask("team-a", "T-task-1", task1)
writeTeamTask("team-b", "T-task-2", task2)
//#when
deleteTeamTask("team-a", "T-task-1")
//#then
const remainingInTeamB = listTeamTasks("team-b")
expect(remainingInTeamB).toHaveLength(1)
expect(remainingInTeamB[0].id).toBe("T-task-2")
})
})

View File

@@ -0,0 +1,165 @@
import { existsSync, readdirSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import {
acquireLock,
ensureDir,
generateTaskId,
readJsonSafe,
writeJsonAtomic,
} from "../../features/claude-tasks/storage"
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
import { TeamTask, TeamTaskSchema } from "./types"
import { validateTaskId, validateTeamName } from "./name-validation"
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function assertValidTaskId(taskId: string): void {
const validationError = validateTaskId(taskId)
if (validationError) {
throw new Error(validationError)
}
}
function withTaskLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
const taskDir = getTeamTaskDir(teamName)
ensureDir(taskDir)
const lock = acquireLock(taskDir)
if (!lock.acquired) {
throw new Error("team_task_lock_unavailable")
}
try {
return operation()
} finally {
lock.release()
}
}
export { getTeamTaskPath } from "./paths"
export function readTeamTask(teamName: string, taskId: string): TeamTask | null {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
return readJsonSafe(getTeamTaskPath(teamName, taskId), TeamTaskSchema)
}
export function readTeamTaskOrThrow(teamName: string, taskId: string): TeamTask {
const task = readTeamTask(teamName, taskId)
if (!task) {
throw new Error("team_task_not_found")
}
return task
}
export function listTeamTasks(teamName: string): TeamTask[] {
assertValidTeamName(teamName)
const taskDir = getTeamTaskDir(teamName)
if (!existsSync(taskDir)) {
return []
}
const files = readdirSync(taskDir)
.filter((file) => file.endsWith(".json") && file.startsWith("T-"))
.sort((a, b) => a.localeCompare(b))
const tasks: TeamTask[] = []
for (const file of files) {
const taskId = file.replace(/\.json$/, "")
if (validateTaskId(taskId)) {
continue
}
const task = readTeamTask(teamName, taskId)
if (task) {
tasks.push(task)
}
}
return tasks
}
export function createTeamTask(
teamName: string,
subject: string,
description: string,
activeForm?: string,
metadata?: Record<string, unknown>,
): TeamTask {
assertValidTeamName(teamName)
if (!subject.trim()) {
throw new Error("team_task_subject_required")
}
return withTaskLock(teamName, () => {
const taskId = generateTaskId()
const task: TeamTask = {
id: taskId,
subject,
description,
activeForm,
status: "pending",
blocks: [],
blockedBy: [],
threadID: `unknown_${taskId}`,
...(metadata ? { metadata } : {}),
}
const validated = TeamTaskSchema.parse(task)
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
return validated
})
}
export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
const validated = TeamTaskSchema.parse(task)
writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated)
}
export function deleteTeamTask(teamName: string, taskId: string): void {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
const taskPath = getTeamTaskPath(teamName, taskId)
if (existsSync(taskPath)) {
unlinkSync(taskPath)
}
}
// Backward compatibility alias
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
deleteTeamTask(teamName, taskId)
}
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
assertValidTaskId(taskId)
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
}
export function resetOwnerTasks(teamName: string, ownerName: string): void {
assertValidTeamName(teamName)
withTaskLock(teamName, () => {
const tasks = listTeamTasks(teamName)
for (const task of tasks) {
if (task.owner !== ownerName) {
continue
}
const next: TeamTask = {
...task,
owner: undefined,
status: task.status === "completed" ? "completed" : "pending",
}
writeTeamTask(teamName, next.id, next)
}
})
}
export function withTeamTaskLock<T>(teamName: string, operation: () => T): T {
assertValidTeamName(teamName)
return withTaskLock(teamName, operation)
}

View File

@@ -0,0 +1,160 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { sendStructuredInboxMessage } from "./inbox-store"
import { readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
import {
TeamConfig,
TeamTaskCreateInputSchema,
TeamTaskGetInputSchema,
TeamTaskListInputSchema,
TeamTask,
TeamToolContext,
isTeammateMember,
} from "./types"
import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store"
function buildTaskAssignmentPayload(task: TeamTask, assignedBy: string): Record<string, unknown> {
return {
type: "task_assignment",
taskId: task.id,
subject: task.subject,
description: task.description,
assignedBy,
timestamp: new Date().toISOString(),
}
}
export function resolveTaskActorFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
export function createTeamTaskCreateTool(): ToolDefinition {
return tool({
description: "Create a task in team-scoped storage.",
args: {
team_name: tool.schema.string().describe("Team name"),
subject: tool.schema.string().describe("Task subject"),
description: tool.schema.string().describe("Task description"),
active_form: tool.schema.string().optional().describe("Present-continuous form"),
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Task metadata"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskCreateInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const task = createTeamTask(
input.team_name,
input.subject,
input.description,
input.active_form,
input.metadata,
)
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_create_failed" })
}
},
})
}
export function createTeamTaskListTool(): ToolDefinition {
return tool({
description: "List tasks for one team.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskListInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
return JSON.stringify(listTeamTasks(input.team_name))
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_list_failed" })
}
},
})
}
export function createTeamTaskGetTool(): ToolDefinition {
return tool({
description: "Get one task from team-scoped storage.",
args: {
team_name: tool.schema.string().describe("Team name"),
task_id: tool.schema.string().describe("Task id"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskGetInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const taskIdError = validateTaskId(input.task_id)
if (taskIdError) {
return JSON.stringify({ error: taskIdError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const task = readTeamTask(input.team_name, input.task_id)
if (!task) {
return JSON.stringify({ error: "team_task_not_found" })
}
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_get_failed" })
}
},
})
}
export function notifyOwnerAssignment(teamName: string, task: TeamTask, assignedBy: string): void {
if (!task.owner || task.status === "deleted") {
return
}
if (validateTeamName(teamName)) {
return
}
if (validateAgentNameOrLead(task.owner)) {
return
}
if (validateAgentNameOrLead(assignedBy)) {
return
}
sendStructuredInboxMessage(
teamName,
assignedBy,
task.owner,
buildTaskAssignmentPayload(task, assignedBy),
"task_assignment",
)
}

View File

@@ -0,0 +1,91 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { readTeamConfigOrThrow } from "./team-config-store"
import { validateAgentNameOrLead, validateTaskId, validateTeamName } from "./name-validation"
import { TeamTaskUpdateInputSchema, TeamToolContext } from "./types"
import { updateTeamTask } from "./team-task-update"
import { notifyOwnerAssignment, resolveTaskActorFromContext } from "./team-task-tools"
export function createTeamTaskUpdateTool(): ToolDefinition {
return tool({
description: "Update task status, owner, dependencies, and metadata in a team task list.",
args: {
team_name: tool.schema.string().describe("Team name"),
task_id: tool.schema.string().describe("Task id"),
status: tool.schema.enum(["pending", "in_progress", "completed", "deleted"]).optional().describe("Task status"),
owner: tool.schema.string().optional().describe("Task owner"),
subject: tool.schema.string().optional().describe("Task subject"),
description: tool.schema.string().optional().describe("Task description"),
active_form: tool.schema.string().optional().describe("Present-continuous form"),
add_blocks: tool.schema.array(tool.schema.string()).optional().describe("Add task ids this task blocks"),
add_blocked_by: tool.schema.array(tool.schema.string()).optional().describe("Add blocker task ids"),
metadata: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Metadata patch (null removes key)"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamTaskUpdateInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const taskIdError = validateTaskId(input.task_id)
if (taskIdError) {
return JSON.stringify({ error: taskIdError })
}
const config = readTeamConfigOrThrow(input.team_name)
const actor = resolveTaskActorFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_task_session" })
}
const memberNames = new Set(config.members.map((member) => member.name))
if (input.owner !== undefined) {
if (input.owner !== "") {
const ownerError = validateAgentNameOrLead(input.owner)
if (ownerError) {
return JSON.stringify({ error: ownerError })
}
if (!memberNames.has(input.owner)) {
return JSON.stringify({ error: "owner_not_in_team" })
}
}
}
if (input.add_blocks) {
for (const blockerId of input.add_blocks) {
const blockerError = validateTaskId(blockerId)
if (blockerError) {
return JSON.stringify({ error: blockerError })
}
}
}
if (input.add_blocked_by) {
for (const dependencyId of input.add_blocked_by) {
const dependencyError = validateTaskId(dependencyId)
if (dependencyError) {
return JSON.stringify({ error: dependencyError })
}
}
}
const task = updateTeamTask(input.team_name, input.task_id, {
status: input.status,
owner: input.owner,
subject: input.subject,
description: input.description,
activeForm: input.active_form,
addBlocks: input.add_blocks,
addBlockedBy: input.add_blocked_by,
metadata: input.metadata,
})
if (input.owner !== undefined) {
notifyOwnerAssignment(input.team_name, task, actor)
}
return JSON.stringify(task)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" })
}
},
})
}

View File

@@ -0,0 +1,247 @@
import { existsSync, readdirSync, unlinkSync } from "node:fs"
import { join } from "node:path"
import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
import { validateTaskId, validateTeamName } from "./name-validation"
import { getTeamTaskDir, getTeamTaskPath } from "./paths"
import {
addPendingEdge,
createPendingEdgeMap,
ensureDependenciesCompleted,
ensureForwardStatusTransition,
wouldCreateCycle,
} from "./team-task-dependency"
import { TeamTask, TeamTaskSchema, TeamTaskStatus } from "./types"
import { withTeamTaskLock } from "./team-task-store"
export interface TeamTaskUpdatePatch {
status?: TeamTaskStatus
owner?: string
subject?: string
description?: string
activeForm?: string
addBlocks?: string[]
addBlockedBy?: string[]
metadata?: Record<string, unknown>
}
function assertValidTeamName(teamName: string): void {
const validationError = validateTeamName(teamName)
if (validationError) {
throw new Error(validationError)
}
}
function assertValidTaskId(taskId: string): void {
const validationError = validateTaskId(taskId)
if (validationError) {
throw new Error(validationError)
}
}
function writeTaskToPath(path: string, task: TeamTask): void {
writeJsonAtomic(path, TeamTaskSchema.parse(task))
}
export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask {
assertValidTeamName(teamName)
assertValidTaskId(taskId)
if (patch.addBlocks) {
for (const blockedTaskId of patch.addBlocks) {
assertValidTaskId(blockedTaskId)
}
}
if (patch.addBlockedBy) {
for (const blockerId of patch.addBlockedBy) {
assertValidTaskId(blockerId)
}
}
return withTeamTaskLock(teamName, () => {
const taskDir = getTeamTaskDir(teamName)
const taskPath = getTeamTaskPath(teamName, taskId)
const currentTask = readJsonSafe(taskPath, TeamTaskSchema)
if (!currentTask) {
throw new Error("team_task_not_found")
}
const cache = new Map<string, TeamTask | null>()
cache.set(taskId, currentTask)
const readTask = (id: string): TeamTask | null => {
if (cache.has(id)) {
return cache.get(id) ?? null
}
const loaded = readJsonSafe(join(taskDir, `${id}.json`), TeamTaskSchema)
cache.set(id, loaded)
return loaded
}
const pendingEdges = createPendingEdgeMap()
if (patch.addBlocks) {
for (const blockedTaskId of patch.addBlocks) {
if (blockedTaskId === taskId) {
throw new Error("team_task_self_block")
}
if (!readTask(blockedTaskId)) {
throw new Error(`team_task_reference_not_found:${blockedTaskId}`)
}
addPendingEdge(pendingEdges, blockedTaskId, taskId)
}
for (const blockedTaskId of patch.addBlocks) {
if (wouldCreateCycle(blockedTaskId, taskId, pendingEdges, readTask)) {
throw new Error(`team_task_cycle_detected:${taskId}->${blockedTaskId}`)
}
}
}
if (patch.addBlockedBy) {
for (const blockerId of patch.addBlockedBy) {
if (blockerId === taskId) {
throw new Error("team_task_self_dependency")
}
if (!readTask(blockerId)) {
throw new Error(`team_task_reference_not_found:${blockerId}`)
}
addPendingEdge(pendingEdges, taskId, blockerId)
}
for (const blockerId of patch.addBlockedBy) {
if (wouldCreateCycle(taskId, blockerId, pendingEdges, readTask)) {
throw new Error(`team_task_cycle_detected:${taskId}<-${blockerId}`)
}
}
}
if (patch.status && patch.status !== "deleted") {
ensureForwardStatusTransition(currentTask.status, patch.status)
}
const effectiveStatus = patch.status ?? currentTask.status
const effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
const shouldValidateDependencies =
(patch.status !== undefined || (patch.addBlockedBy?.length ?? 0) > 0) && effectiveStatus !== "deleted"
if (shouldValidateDependencies) {
ensureDependenciesCompleted(effectiveStatus, effectiveBlockedBy, readTask)
}
let nextTask: TeamTask = { ...currentTask }
if (patch.subject !== undefined) {
nextTask.subject = patch.subject
}
if (patch.description !== undefined) {
nextTask.description = patch.description
}
if (patch.activeForm !== undefined) {
nextTask.activeForm = patch.activeForm
}
if (patch.owner !== undefined) {
nextTask.owner = patch.owner === "" ? undefined : patch.owner
}
const pendingWrites = new Map<string, TeamTask>()
if (patch.addBlocks) {
const existingBlocks = new Set(nextTask.blocks)
for (const blockedTaskId of patch.addBlocks) {
if (!existingBlocks.has(blockedTaskId)) {
nextTask.blocks.push(blockedTaskId)
existingBlocks.add(blockedTaskId)
}
const otherPath = getTeamTaskPath(teamName, blockedTaskId)
const other = pendingWrites.get(otherPath) ?? readTask(blockedTaskId)
if (other && !other.blockedBy.includes(taskId)) {
pendingWrites.set(otherPath, { ...other, blockedBy: [...other.blockedBy, taskId] })
}
}
}
if (patch.addBlockedBy) {
const existingBlockedBy = new Set(nextTask.blockedBy)
for (const blockerId of patch.addBlockedBy) {
if (!existingBlockedBy.has(blockerId)) {
nextTask.blockedBy.push(blockerId)
existingBlockedBy.add(blockerId)
}
const otherPath = getTeamTaskPath(teamName, blockerId)
const other = pendingWrites.get(otherPath) ?? readTask(blockerId)
if (other && !other.blocks.includes(taskId)) {
pendingWrites.set(otherPath, { ...other, blocks: [...other.blocks, taskId] })
}
}
}
if (patch.metadata !== undefined) {
const merged: Record<string, unknown> = { ...(nextTask.metadata ?? {}) }
for (const [key, value] of Object.entries(patch.metadata)) {
if (value === null) {
delete merged[key]
} else {
merged[key] = value
}
}
nextTask.metadata = Object.keys(merged).length > 0 ? merged : undefined
}
if (patch.status !== undefined) {
nextTask.status = patch.status
}
const allTaskFiles = readdirSync(taskDir).filter((file) => file.endsWith(".json") && file.startsWith("T-"))
if (nextTask.status === "completed") {
for (const file of allTaskFiles) {
const otherId = file.replace(/\.json$/, "")
if (otherId === taskId) continue
const otherPath = getTeamTaskPath(teamName, otherId)
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
if (other?.blockedBy.includes(taskId)) {
pendingWrites.set(otherPath, {
...other,
blockedBy: other.blockedBy.filter((id) => id !== taskId),
})
}
}
}
if (patch.status === "deleted") {
for (const file of allTaskFiles) {
const otherId = file.replace(/\.json$/, "")
if (otherId === taskId) continue
const otherPath = getTeamTaskPath(teamName, otherId)
const other = pendingWrites.get(otherPath) ?? readTask(otherId)
if (!other) continue
const nextOther = {
...other,
blockedBy: other.blockedBy.filter((id) => id !== taskId),
blocks: other.blocks.filter((id) => id !== taskId),
}
pendingWrites.set(otherPath, nextOther)
}
}
for (const [path, task] of pendingWrites.entries()) {
writeTaskToPath(path, task)
}
if (patch.status === "deleted") {
if (existsSync(taskPath)) {
unlinkSync(taskPath)
}
return TeamTaskSchema.parse({ ...nextTask, status: "deleted" })
}
writeTaskToPath(taskPath, nextTask)
return TeamTaskSchema.parse(nextTask)
})
}

View File

@@ -0,0 +1,243 @@
/// <reference types="bun-types" />
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<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
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)
})
})
})

View File

@@ -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<string, unknown>): Promise<string> => {
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<string, unknown>): Promise<string> => {
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" })
}
},
})
}

View File

@@ -0,0 +1,36 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { buildTeamParentToolContext } from "./teammate-parent-context"
describe("agent-teams teammate parent context", () => {
test("forwards incoming abort signal to parent context resolver", () => {
//#given
const abortSignal = new AbortController().signal
//#when
const parentToolContext = buildTeamParentToolContext({
sessionID: "ses-main",
messageID: "msg-main",
agent: "sisyphus",
abort: abortSignal,
})
//#then
expect(parentToolContext.abort).toBe(abortSignal)
expect(parentToolContext.sessionID).toBe("ses-main")
expect(parentToolContext.messageID).toBe("msg-main")
expect(parentToolContext.agent).toBe("sisyphus")
})
test("leaves agent undefined if missing in tool context", () => {
//#when
const parentToolContext = buildTeamParentToolContext({
sessionID: "ses-main",
messageID: "msg-main",
abort: new AbortController().signal,
})
//#then
expect(parentToolContext.agent).toBeUndefined()
})
})

View File

@@ -0,0 +1,17 @@
import type { ParentContext } from "../delegate-task/executor"
import { resolveParentContext } from "../delegate-task/executor"
import type { ToolContextWithMetadata } from "../delegate-task/types"
import type { TeamToolContext } from "./types"
export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata {
return {
sessionID: context.sessionID,
messageID: context.messageID,
agent: context.agent,
abort: context.abort ?? new AbortController().signal,
}
}
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
return resolveParentContext(buildTeamParentToolContext(context))
}

View File

@@ -0,0 +1,28 @@
export function buildLaunchPrompt(
teamName: string,
teammateName: string,
userPrompt: string,
categoryPromptAppend?: string,
): string {
const sections = [
`You are teammate "${teammateName}" in team "${teamName}".`,
`When you need updates, call read_inbox with team_name="${teamName}" and agent_name="${teammateName}".`,
"Initial assignment:",
userPrompt,
]
if (categoryPromptAppend) {
sections.push("Category guidance:", categoryPromptAppend)
}
return sections.join("\n\n")
}
export function buildDeliveryPrompt(teamName: string, teammateName: string, summary: string, content: string): string {
return [
`New team message for "${teammateName}" in team "${teamName}".`,
`Summary: ${summary}`,
"Content:",
content,
].join("\n\n")
}

View File

@@ -0,0 +1,197 @@
import type { BackgroundManager } from "../../features/background-agent"
import { clearInbox, ensureInbox, sendPlainInboxMessage } from "./inbox-store"
import { assignNextColor, getTeamMember, removeTeammate, updateTeamConfig, upsertTeammate } from "./team-config-store"
import type { TeamTeammateMember, TeamToolContext } from "./types"
import { resolveTeamParentContext } from "./teammate-parent-context"
import { buildDeliveryPrompt, buildLaunchPrompt } from "./teammate-prompts"
import { resolveSpawnExecution, type TeamCategoryContext } from "./teammate-spawn-execution"
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function resolveLaunchFailureMessage(status: string | undefined, error: string | undefined): string {
if (status === "error") {
return error ? `teammate_launch_failed:${error}` : "teammate_launch_failed"
}
if (status === "cancelled") {
return "teammate_launch_cancelled"
}
return "teammate_launch_timeout"
}
export interface SpawnTeammateParams {
teamName: string
name: string
prompt: string
category: string
subagentType: string
model?: string
planModeRequired: boolean
context: TeamToolContext
manager: BackgroundManager
categoryContext?: TeamCategoryContext
}
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
const parentContext = resolveTeamParentContext(params.context)
const execution = await resolveSpawnExecution(
{
teamName: params.teamName,
name: params.name,
prompt: params.prompt,
category: params.category,
subagentType: params.subagentType,
model: params.model,
manager: params.manager,
categoryContext: params.categoryContext,
},
parentContext,
)
let teammate: TeamTeammateMember | undefined
let launchedTaskID: string | undefined
updateTeamConfig(params.teamName, (current) => {
if (getTeamMember(current, params.name)) {
throw new Error("teammate_already_exists")
}
teammate = {
agentId: `${params.name}@${params.teamName}`,
name: params.name,
agentType: "teammate",
category: params.category,
model: execution.teammateModel,
prompt: params.prompt,
color: assignNextColor(current),
planModeRequired: params.planModeRequired,
joinedAt: new Date().toISOString(),
cwd: process.cwd(),
subscriptions: [],
backendType: "native",
isActive: false,
}
return upsertTeammate(current, teammate)
})
if (!teammate) {
throw new Error("teammate_create_failed")
}
try {
ensureInbox(params.teamName, params.name)
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
const launched = await params.manager.launch({
description: `[team:${params.teamName}] ${params.name}`,
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt, execution.categoryPromptAppend),
agent: execution.agentType,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
...(execution.launchModel ? { model: execution.launchModel } : {}),
...(params.category ? { category: params.category } : {}),
parentAgent: parentContext.agent,
})
launchedTaskID = launched.id
const start = Date.now()
let sessionID = launched.sessionID
let latestStatus: string | undefined
let latestError: string | undefined
while (!sessionID && Date.now() - start < 30_000) {
await delay(50)
const task = params.manager.getTask(launched.id)
latestStatus = task?.status
latestError = task?.error
if (task?.status === "error" || task?.status === "cancelled") {
throw new Error(resolveLaunchFailureMessage(task.status, task.error))
}
sessionID = task?.sessionID
}
if (!sessionID) {
throw new Error(resolveLaunchFailureMessage(latestStatus, latestError))
}
const nextMember: TeamTeammateMember = {
...teammate,
isActive: true,
backgroundTaskID: launched.id,
sessionID,
}
updateTeamConfig(params.teamName, (current) => upsertTeammate(current, nextMember))
return nextMember
} catch (error) {
const originalError = error
if (launchedTaskID) {
await params.manager
.cancelTask(launchedTaskID, {
source: "team_launch_failed",
abortSession: true,
skipNotification: true,
})
.catch(() => undefined)
}
try {
updateTeamConfig(params.teamName, (current) => removeTeammate(current, params.name))
} catch (cleanupError) {
void cleanupError
}
try {
clearInbox(params.teamName, params.name)
} catch (cleanupError) {
void cleanupError
}
throw originalError
}
}
export async function resumeTeammateWithMessage(
manager: BackgroundManager,
context: TeamToolContext,
teamName: string,
teammate: TeamTeammateMember,
summary: string,
content: string,
): Promise<void> {
if (!teammate.sessionID) {
return
}
const parentContext = resolveTeamParentContext(context)
try {
await manager.resume({
sessionId: teammate.sessionID,
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
})
} catch {
return
}
}
export async function cancelTeammateRun(manager: BackgroundManager, teammate: TeamTeammateMember): Promise<void> {
if (!teammate.backgroundTaskID) {
return
}
await manager.cancelTask(teammate.backgroundTaskID, {
source: "team_force_kill",
abortSession: true,
skipNotification: true,
})
}

View File

@@ -0,0 +1,119 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
import type { ParentContext } from "../delegate-task/executor"
import { resolveCategoryExecution } from "../delegate-task/executor"
import type { DelegateTaskArgs } from "../delegate-task/types"
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model) {
return undefined
}
const separatorIndex = model.indexOf("/")
if (separatorIndex <= 0 || separatorIndex >= model.length - 1) {
throw new Error("invalid_model_override_format")
}
return {
providerID: model.slice(0, separatorIndex),
modelID: model.slice(separatorIndex + 1),
}
}
async function getSystemDefaultModel(client: PluginInput["client"]): Promise<string | undefined> {
try {
const openCodeConfig = await client.config.get()
return (openCodeConfig as { data?: { model?: string } })?.data?.model
} catch {
return undefined
}
}
export interface TeamCategoryContext {
client: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
export interface SpawnExecutionRequest {
teamName: string
name: string
prompt: string
category: string
subagentType: string
model?: string
manager: BackgroundManager
categoryContext?: TeamCategoryContext
}
export interface SpawnExecutionResult {
agentType: string
teammateModel: string
launchModel?: { providerID: string; modelID: string; variant?: string }
categoryPromptAppend?: string
}
export async function resolveSpawnExecution(
request: SpawnExecutionRequest,
parentContext: ParentContext,
): Promise<SpawnExecutionResult> {
if (request.model) {
const launchModel = parseModel(request.model)
return {
agentType: request.subagentType,
teammateModel: request.model,
...(launchModel ? { launchModel } : {}),
}
}
if (!request.categoryContext?.client) {
return {
agentType: request.subagentType,
teammateModel: "native",
}
}
const inheritedModel = parentContext.model
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
: undefined
const systemDefaultModel = await getSystemDefaultModel(request.categoryContext.client)
const delegateArgs: DelegateTaskArgs = {
description: `[team:${request.teamName}] ${request.name}`,
prompt: request.prompt,
category: request.category,
subagent_type: "sisyphus-junior",
run_in_background: true,
load_skills: [],
}
const resolution = await resolveCategoryExecution(
delegateArgs,
{
manager: request.manager,
client: request.categoryContext.client,
directory: process.cwd(),
userCategories: request.categoryContext.userCategories,
sisyphusJuniorModel: request.categoryContext.sisyphusJuniorModel,
},
inheritedModel,
systemDefaultModel,
)
if (resolution.error) {
throw new Error(resolution.error)
}
if (!resolution.categoryModel) {
throw new Error("category_model_not_resolved")
}
return {
agentType: resolution.agentToUse,
teammateModel: `${resolution.categoryModel.providerID}/${resolution.categoryModel.modelID}`,
launchModel: resolution.categoryModel,
categoryPromptAppend: resolution.categoryPromptAppend,
}
}

View File

@@ -0,0 +1,97 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { createAgentTeamsTools } from "./tools"
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
interface MockManagerHandles {
manager: BackgroundManager
launchCalls: Array<Record<string, unknown>>
}
function createMockManager(): MockManagerHandles {
const launchCalls: Array<Record<string, unknown>> = []
const manager = {
launch: async (args: Record<string, unknown>) => {
launchCalls.push(args)
return { id: `bg-${launchCalls.length}`, sessionID: `ses-worker-${launchCalls.length}` }
},
getTask: () => undefined,
resume: async () => ({ id: "resume-1" }),
cancelTask: async () => true,
} as unknown as BackgroundManager
return { manager, launchCalls }
}
function createContext(sessionID = "ses-main"): TestToolContext {
return {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
return JSON.parse(output)
}
describe("agent-teams teammate tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-teammate-tools-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("spawn_teammate requires lead session authorization", async () => {
//#given
const { manager, launchCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext("ses-lead")
const teammateContext = createContext("ses-worker")
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
//#when
const unauthorized = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
teammateContext,
) as { error?: string }
//#then
expect(unauthorized.error).toBe("unauthorized_lead_session")
expect(launchCalls).toHaveLength(0)
})
})

View File

@@ -0,0 +1,198 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import type { BackgroundManager } from "../../features/background-agent"
import { clearInbox } from "./inbox-store"
import { validateAgentName, validateTeamName } from "./name-validation"
import {
TeamForceKillInputSchema,
TeamProcessShutdownInputSchema,
TeamSpawnInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, updateTeamConfig } from "./team-config-store"
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
import { resetOwnerTasks } from "./team-task-store"
export interface AgentTeamsSpawnOptions {
client?: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
async function shutdownTeammateWithCleanup(
manager: BackgroundManager,
context: TeamToolContext,
teamName: string,
agentName: string,
): Promise<string | null> {
const config = readTeamConfigOrThrow(teamName)
if (context.sessionID !== config.leadSessionId) {
return "unauthorized_lead_session"
}
const member = getTeamMember(config, agentName)
if (!member || !isTeammateMember(member)) {
return "teammate_not_found"
}
await cancelTeammateRun(manager, member)
let removed = false
updateTeamConfig(teamName, (current) => {
const refreshedMember = getTeamMember(current, agentName)
if (!refreshedMember || !isTeammateMember(refreshedMember)) {
return current
}
removed = true
return removeTeammate(current, agentName)
})
if (removed) {
clearInbox(teamName, agentName)
}
resetOwnerTasks(teamName, agentName)
return null
}
export function createSpawnTeammateTool(manager: BackgroundManager, options?: AgentTeamsSpawnOptions): ToolDefinition {
return tool({
description: "Spawn a teammate using native internal agent execution.",
args: {
team_name: tool.schema.string().describe("Team name"),
name: tool.schema.string().describe("Teammate name"),
prompt: tool.schema.string().describe("Initial teammate prompt"),
category: tool.schema.string().describe("Required category for teammate metadata and routing"),
subagent_type: tool.schema.string().optional().describe("Agent name to run (default: sisyphus-junior)"),
model: tool.schema.string().optional().describe("Optional model override in provider/model format"),
plan_mode_required: tool.schema.boolean().optional().describe("Enable plan mode flag in teammate metadata"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamSpawnInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentName(input.name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
if (!input.category.trim()) {
return JSON.stringify({ error: "category_required" })
}
if (input.subagent_type && input.subagent_type !== "sisyphus-junior") {
return JSON.stringify({ error: "category_conflicts_with_subagent_type" })
}
const config = readTeamConfigOrThrow(input.team_name)
if (context.sessionID !== config.leadSessionId) {
return JSON.stringify({ error: "unauthorized_lead_session" })
}
const resolvedSubagentType = input.subagent_type ?? "sisyphus-junior"
const teammate = await spawnTeammate({
teamName: input.team_name,
name: input.name,
prompt: input.prompt,
category: input.category,
subagentType: resolvedSubagentType,
model: input.model,
planModeRequired: input.plan_mode_required ?? false,
context,
manager,
categoryContext: options?.client
? {
client: options.client,
userCategories: options.userCategories,
sisyphusJuniorModel: options.sisyphusJuniorModel,
}
: undefined,
})
return JSON.stringify({
agent_id: teammate.agentId,
name: teammate.name,
team_name: input.team_name,
session_id: teammate.sessionID,
task_id: teammate.backgroundTaskID,
})
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "spawn_teammate_failed" })
}
},
})
}
export function createForceKillTeammateTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Force stop a teammate and clean up ownership state.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamForceKillInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
const agentError = validateAgentName(input.teammate_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
if (shutdownError) {
return JSON.stringify({ error: shutdownError })
}
return JSON.stringify({ success: true, message: `${input.teammate_name} stopped` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" })
}
},
})
}
export function createProcessShutdownTool(manager: BackgroundManager): ToolDefinition {
return tool({
description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.",
args: {
team_name: tool.schema.string().describe("Team name"),
teammate_name: tool.schema.string().describe("Teammate name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamProcessShutdownInputSchema.parse(args)
const teamError = validateTeamName(input.team_name)
if (teamError) {
return JSON.stringify({ error: teamError })
}
if (input.teammate_name === "team-lead") {
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
}
const agentError = validateAgentName(input.teammate_name)
if (agentError) {
return JSON.stringify({ error: agentError })
}
const shutdownError = await shutdownTeammateWithCleanup(manager, context, input.team_name, input.teammate_name)
if (shutdownError) {
return JSON.stringify({ error: shutdownError })
}
return JSON.stringify({ success: true, message: `${input.teammate_name} removed` })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" })
}
},
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import type { ToolDefinition } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin"
import type { CategoriesConfig } from "../../config/schema"
import { createReadInboxTool, createSendMessageTool } from "./messaging-tools"
import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools"
import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools"
export interface AgentTeamsToolOptions {
client?: PluginInput["client"]
userCategories?: CategoriesConfig
sisyphusJuniorModel?: string
}
export function createAgentTeamsTools(
_manager: BackgroundManager,
_options?: AgentTeamsToolOptions,
): Record<string, ToolDefinition> {
return {
team_create: createTeamCreateTool(),
team_delete: createTeamDeleteTool(),
send_message: createSendMessageTool(_manager),
read_inbox: createReadInboxTool(),
read_config: createTeamReadConfigTool(),
force_kill_teammate: createForceKillTeammateTool(),
process_shutdown_approved: createProcessShutdownApprovedTool(),
}
}

View File

@@ -0,0 +1,721 @@
import { describe, it, expect } from "bun:test"
import { z } from "zod"
import {
TeamConfigSchema,
TeamMemberSchema,
TeamTeammateMemberSchema,
MessageTypeSchema,
InboxMessageSchema,
TeamTaskSchema,
TeamCreateInputSchema,
TeamDeleteInputSchema,
SendMessageInputSchema,
ReadInboxInputSchema,
ReadConfigInputSchema,
TeamSpawnInputSchema,
ForceKillTeammateInputSchema,
ProcessShutdownApprovedInputSchema,
} from "./types"
describe("TeamConfigSchema", () => {
it("validates a complete team config", () => {
// given
const validConfig = {
name: "my-team",
description: "A test team",
createdAt: "2026-02-11T10:00:00Z",
leadAgentId: "agent-123",
leadSessionId: "ses-456",
members: [
{
agentId: "agent-123",
name: "Lead Agent",
agentType: "lead",
color: "blue",
},
{
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: ["task-updates"],
backendType: "native" as const,
isActive: true,
sessionID: "ses-789",
backgroundTaskID: "task-123",
},
],
}
// when
const result = TeamConfigSchema.safeParse(validConfig)
// then
expect(result.success).toBe(true)
})
it("rejects invalid team config", () => {
// given
const invalidConfig = {
name: "",
description: "A test team",
createdAt: "invalid-date",
leadAgentId: "",
leadSessionId: "ses-456",
members: [],
}
// when
const result = TeamConfigSchema.safeParse(invalidConfig)
// then
expect(result.success).toBe(false)
})
})
describe("TeamMemberSchema", () => {
it("validates a lead member", () => {
// given
const leadMember = {
agentId: "agent-123",
name: "Lead Agent",
agentType: "lead",
color: "blue",
}
// when
const result = TeamMemberSchema.safeParse(leadMember)
// then
expect(result.success).toBe(true)
})
it("rejects invalid member", () => {
// given
const invalidMember = {
agentId: "",
name: "",
agentType: "invalid",
color: "invalid",
}
// when
const result = TeamMemberSchema.safeParse(invalidMember)
// then
expect(result.success).toBe(false)
})
})
describe("TeamTeammateMemberSchema", () => {
it("validates a complete teammate member", () => {
// given
const teammateMember = {
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: ["task-updates"],
backendType: "native" as const,
isActive: true,
sessionID: "ses-789",
backgroundTaskID: "task-123",
}
// when
const result = TeamTeammateMemberSchema.safeParse(teammateMember)
// then
expect(result.success).toBe(true)
})
it("validates teammate member with optional fields missing", () => {
// given
const minimalTeammate = {
agentId: "agent-789",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: [],
backendType: "native" as const,
isActive: true,
}
// when
const result = TeamTeammateMemberSchema.safeParse(minimalTeammate)
// then
expect(result.success).toBe(true)
})
it("rejects invalid teammate member", () => {
// given
const invalidTeammate = {
agentId: "",
name: "Worker 1",
agentType: "teammate",
color: "green",
category: "quick",
model: "claude-sonnet-4-5",
prompt: "You are a helpful assistant",
planModeRequired: false,
joinedAt: "invalid-date",
cwd: "/tmp",
subscriptions: [],
backendType: "invalid" as const,
isActive: true,
}
// when
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
// then
expect(result.success).toBe(false)
})
it("rejects reserved agentType for teammate schema", () => {
// given
const invalidTeammate = {
agentId: "worker@team",
name: "worker",
agentType: "team-lead",
category: "quick",
model: "native",
prompt: "do work",
color: "blue",
planModeRequired: false,
joinedAt: "2026-02-11T10:05:00Z",
cwd: "/tmp",
subscriptions: [],
backendType: "native",
isActive: false,
}
// when
const result = TeamTeammateMemberSchema.safeParse(invalidTeammate)
// then
expect(result.success).toBe(false)
})
})
describe("MessageTypeSchema", () => {
it("validates all 5 message types", () => {
// given
const types = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]
// when & then
types.forEach(type => {
const result = MessageTypeSchema.safeParse(type)
expect(result.success).toBe(true)
expect(result.data).toBe(type)
})
})
it("rejects invalid message type", () => {
// given
const invalidType = "invalid_type"
// when
const result = MessageTypeSchema.safeParse(invalidType)
// then
expect(result.success).toBe(false)
})
})
describe("InboxMessageSchema", () => {
it("validates a complete inbox message", () => {
// given
const message = {
id: "msg-123",
type: "message" as const,
sender: "agent-123",
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
timestamp: "2026-02-11T10:00:00Z",
read: false,
requestId: "req-123",
approve: true,
}
// when
const result = InboxMessageSchema.safeParse(message)
// then
expect(result.success).toBe(true)
})
it("validates message with optional fields missing", () => {
// given
const minimalMessage = {
id: "msg-123",
type: "broadcast" as const,
sender: "agent-123",
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
timestamp: "2026-02-11T10:00:00Z",
read: false,
}
// when
const result = InboxMessageSchema.safeParse(minimalMessage)
// then
expect(result.success).toBe(true)
})
it("rejects invalid inbox message", () => {
// given
const invalidMessage = {
id: "",
type: "invalid" as const,
sender: "",
recipient: "",
content: "",
summary: "",
timestamp: "invalid-date",
read: "not-boolean",
}
// when
const result = InboxMessageSchema.safeParse(invalidMessage)
// then
expect(result.success).toBe(false)
})
})
describe("TeamTaskSchema", () => {
it("validates a task object", () => {
// given
const task = {
id: "T-12345678-1234-1234-1234-123456789012",
subject: "Implement feature",
description: "Add new functionality",
status: "pending" as const,
activeForm: "Implementing feature",
blocks: [],
blockedBy: [],
owner: "agent-123",
metadata: { priority: "high" },
repoURL: "https://github.com/user/repo",
parentID: "T-parent",
threadID: "thread-123",
}
// when
const result = TeamTaskSchema.safeParse(task)
// then
expect(result.success).toBe(true)
})
it("rejects invalid task", () => {
// given
const invalidTask = {
id: "invalid-id",
subject: "",
description: "Add new functionality",
status: "invalid" as const,
activeForm: "Implementing feature",
blocks: [],
blockedBy: [],
}
// when
const result = TeamTaskSchema.safeParse(invalidTask)
// then
expect(result.success).toBe(false)
})
})
describe("TeamCreateInputSchema", () => {
it("validates create input with description", () => {
// given
const input = {
team_name: "my-team",
description: "A test team",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates create input without description", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid create input", () => {
// given
const input = {
team_name: "invalid team name with spaces and special chars!",
}
// when
const result = TeamCreateInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("TeamDeleteInputSchema", () => {
it("validates delete input", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = TeamDeleteInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid delete input", () => {
// given
const input = {
team_name: "",
}
// when
const result = TeamDeleteInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("SendMessageInputSchema", () => {
it("validates message type input", () => {
// given
const input = {
team_name: "my-team",
type: "message" as const,
recipient: "agent-456",
content: "Hello world",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates broadcast type input", () => {
// given
const input = {
team_name: "my-team",
type: "broadcast" as const,
content: "Team announcement",
summary: "Announcement",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates shutdown_request type input", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_request" as const,
recipient: "agent-456",
content: "Please shutdown",
summary: "Shutdown request",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates shutdown_response type input", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_response" as const,
request_id: "req-123",
approve: true,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates plan_approval_response type input", () => {
// given
const input = {
team_name: "my-team",
type: "plan_approval_response" as const,
request_id: "req-456",
approve: false,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects message type without recipient", () => {
// given
const input = {
team_name: "my-team",
type: "message" as const,
content: "Hello world",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
it("rejects shutdown_response without request_id", () => {
// given
const input = {
team_name: "my-team",
type: "shutdown_response" as const,
approve: true,
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
it("rejects invalid team_name", () => {
// given
const input = {
team_name: "invalid team name",
type: "broadcast" as const,
content: "Hello",
summary: "Greeting",
}
// when
const result = SendMessageInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ReadInboxInputSchema", () => {
it("validates read inbox input", () => {
// given
const input = {
team_name: "my-team",
agent_name: "worker-1",
unread_only: true,
mark_as_read: false,
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("validates minimal read inbox input", () => {
// given
const input = {
team_name: "my-team",
agent_name: "worker-1",
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid read inbox input", () => {
// given
const input = {
team_name: "",
agent_name: "",
}
// when
const result = ReadInboxInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ReadConfigInputSchema", () => {
it("validates read config input", () => {
// given
const input = {
team_name: "my-team",
}
// when
const result = ReadConfigInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid read config input", () => {
// given
const input = {
team_name: "",
}
// when
const result = ReadConfigInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("TeamSpawnInputSchema", () => {
it("validates spawn input", () => {
// given
const input = {
team_name: "my-team",
name: "worker-1",
category: "quick",
prompt: "You are a helpful assistant",
}
// when
const result = TeamSpawnInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid spawn input", () => {
// given
const input = {
team_name: "invalid team",
name: "",
category: "quick",
prompt: "You are a helpful assistant",
}
// when
const result = TeamSpawnInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ForceKillTeammateInputSchema", () => {
it("validates force kill input", () => {
// given
const input = {
team_name: "my-team",
teammate_name: "worker-1",
}
// when
const result = ForceKillTeammateInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid force kill input", () => {
// given
const input = {
team_name: "",
teammate_name: "",
}
// when
const result = ForceKillTeammateInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})
describe("ProcessShutdownApprovedInputSchema", () => {
it("validates shutdown approved input", () => {
// given
const input = {
team_name: "my-team",
teammate_name: "worker-1",
}
// when
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
// then
expect(result.success).toBe(true)
})
it("rejects invalid shutdown approved input", () => {
// given
const input = {
team_name: "",
teammate_name: "",
}
// when
const result = ProcessShutdownApprovedInputSchema.safeParse(input)
// then
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,272 @@
import { z } from "zod"
import { TaskObjectSchema } from "../task/types"
// Team member schemas
export const TeamMemberSchema = z.object({
agentId: z.string().min(1),
name: z.string().min(1),
agentType: z.enum(["team-lead", "lead", "teammate"]),
color: z.string().min(1),
})
export type TeamMember = z.infer<typeof TeamMemberSchema>
export type TeamLeadMember = TeamMember & {
agentType: "team-lead"
model: string
joinedAt: string
cwd: string
subscriptions: string[]
}
export function isTeammateMember(member: TeamMember): member is TeamTeammateMember {
return member.agentType === "teammate"
}
export const TEAM_COLOR_PALETTE = [
"#FF6B6B", // Red
"#4ECDC4", // Teal
"#45B7D1", // Blue
"#96CEB4", // Sage
"#FFEEAD", // Yellow
"#FF9F43", // Orange
"#6C5CE7", // Purple
"#00CEC9", // Cyan
"#F368E0", // Pink
"#FD7272", // Coral
] as const
export const TeamTeammateMemberSchema = TeamMemberSchema.extend({
category: z.string().min(1),
model: z.string().min(1),
prompt: z.string().min(1),
planModeRequired: z.boolean(),
joinedAt: z.string().datetime(),
cwd: z.string().min(1),
subscriptions: z.array(z.string()),
backendType: z.literal("native"),
isActive: z.boolean(),
sessionID: z.string().optional(),
backgroundTaskID: z.string().optional(),
}).refine(
(data) => data.agentType === "teammate",
"TeamTeammateMemberSchema requires agentType to be 'teammate'"
).refine(
(data) => data.agentType !== "team-lead",
"agentType 'team-lead' is reserved and not allowed"
)
export type TeamTeammateMember = z.infer<typeof TeamTeammateMemberSchema>
// Team config schema
export const TeamConfigSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
createdAt: z.string().datetime(),
leadAgentId: z.string().min(1),
leadSessionId: z.string().min(1),
members: z.array(z.union([TeamMemberSchema, TeamTeammateMemberSchema])),
})
export type TeamConfig = z.infer<typeof TeamConfigSchema>
// Message schemas
export const MessageTypeSchema = z.enum([
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
])
export type MessageType = z.infer<typeof MessageTypeSchema>
export const InboxMessageSchema = z.object({
id: z.string().min(1),
type: MessageTypeSchema,
sender: z.string().min(1),
recipient: z.string().min(1),
content: z.string().optional(),
summary: z.string().optional(),
timestamp: z.string().datetime(),
read: z.boolean(),
requestId: z.string().optional(),
approve: z.boolean().optional(),
})
export type InboxMessage = z.infer<typeof InboxMessageSchema>
// Task schema (reuse from task/types.ts)
export const TeamTaskSchema = TaskObjectSchema
export type TeamTask = z.infer<typeof TeamTaskSchema>
export type TeamTaskStatus = "pending" | "in_progress" | "completed" | "deleted"
// Input schemas for tools
export const TeamCreateInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
description: z.string().optional(),
})
export type TeamCreateInput = z.infer<typeof TeamCreateInputSchema>
export const TeamDeleteInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
})
export type TeamDeleteInput = z.infer<typeof TeamDeleteInputSchema>
const teamNameField = z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64)
const senderField = z.string().optional()
export const SendMessageInputSchema = z.discriminatedUnion("type", [
z.object({
team_name: teamNameField,
type: z.literal("message"),
recipient: z.string().min(1),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: teamNameField,
type: z.literal("broadcast"),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: teamNameField,
type: z.literal("shutdown_request"),
recipient: z.string().min(1),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: teamNameField,
type: z.literal("shutdown_response"),
request_id: z.string().min(1),
approve: z.boolean(),
content: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: teamNameField,
type: z.literal("plan_approval_response"),
request_id: z.string().min(1),
approve: z.boolean(),
recipient: z.string().optional(),
content: z.string().optional(),
sender: senderField,
}),
])
export type SendMessageInput = z.infer<typeof SendMessageInputSchema>
export const ReadInboxInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
agent_name: z.string().min(1),
unread_only: z.boolean().optional(),
mark_as_read: z.boolean().optional(),
})
export type ReadInboxInput = z.infer<typeof ReadInboxInputSchema>
export const ReadConfigInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
})
export type ReadConfigInput = z.infer<typeof ReadConfigInputSchema>
export const TeamSpawnInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
name: z.string().min(1),
category: z.string().min(1),
prompt: z.string().min(1),
subagent_type: z.string().optional(),
model: z.string().optional(),
plan_mode_required: z.boolean().optional(),
})
export type TeamSpawnInput = z.infer<typeof TeamSpawnInputSchema>
export const ForceKillTeammateInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
teammate_name: z.string().min(1),
})
export type ForceKillTeammateInput = z.infer<typeof ForceKillTeammateInputSchema>
export type TeamToolContext = {
sessionID: string
messageID: string
agent: string
abort?: AbortSignal
}
export const TeamSendMessageInputSchema = SendMessageInputSchema
export type TeamSendMessageInput = z.infer<typeof TeamSendMessageInputSchema>
export const TeamReadInboxInputSchema = ReadInboxInputSchema
export type TeamReadInboxInput = z.infer<typeof TeamReadInboxInputSchema>
export const TeamReadConfigInputSchema = ReadConfigInputSchema
export type TeamReadConfigInput = z.infer<typeof TeamReadConfigInputSchema>
export const ProcessShutdownApprovedInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
teammate_name: z.string().min(1),
})
export type ProcessShutdownApprovedInput = z.infer<typeof ProcessShutdownApprovedInputSchema>
export const TeamForceKillInputSchema = ForceKillTeammateInputSchema
export type TeamForceKillInput = z.infer<typeof TeamForceKillInputSchema>
export const TeamProcessShutdownInputSchema = ProcessShutdownApprovedInputSchema
export type TeamProcessShutdownInput = z.infer<typeof TeamProcessShutdownInputSchema>
export const TeamTaskCreateInputSchema = z.object({
team_name: teamNameField,
subject: z.string().min(1),
description: z.string(),
active_form: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TeamTaskCreateInput = z.infer<typeof TeamTaskCreateInputSchema>
export const TeamTaskListInputSchema = z.object({
team_name: teamNameField,
})
export type TeamTaskListInput = z.infer<typeof TeamTaskListInputSchema>
export const TeamTaskGetInputSchema = z.object({
team_name: teamNameField,
task_id: z.string().min(1),
})
export type TeamTaskGetInput = z.infer<typeof TeamTaskGetInputSchema>
export const TeamTaskUpdateInputSchema = z.object({
team_name: teamNameField,
task_id: z.string().min(1),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
owner: z.string().optional(),
subject: z.string().optional(),
description: z.string().optional(),
active_form: z.string().optional(),
add_blocks: z.array(z.string()).optional(),
add_blocked_by: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TeamTaskUpdateInput = z.infer<typeof TeamTaskUpdateInputSchema>

View File

@@ -26,7 +26,7 @@ export interface DelegateTaskArgs {
export interface ToolContextWithMetadata {
sessionID: string
messageID: string
agent: string
agent?: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
/**

View File

@@ -37,6 +37,7 @@ type OpencodeClient = PluginInput["client"]
export { createCallOmoAgent } from "./call-omo-agent"
export { createLookAt } from "./look-at"
export { createDelegateTask } from "./delegate-task"
export { createAgentTeamsTools } from "./agent-teams"
export {
createTaskCreateTool,
createTaskGetTool,

View File

@@ -10,6 +10,7 @@ const TEST_CONFIG = {
sisyphus: {
tasks: {
storage_path: TEST_STORAGE,
claude_code_compat: true,
},
},
}
@@ -297,5 +298,41 @@ describe("task_create tool", () => {
expect(taskContent.subject).toBe("Test task")
expect(taskContent.description).toBe("Test description")
})
test("creates task in team namespace when team_name provided", async () => {
//#given
const args = {
subject: "Team task",
team_name: "test-team",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).toHaveProperty("id")
expect(result.task.subject).toBe("Team task")
})
test("creates task in regular storage when no team_name", async () => {
//#given
const args = {
subject: "Regular task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task.subject).toBe("Regular task")
// Verify it's in regular storage
const taskId = result.task.id
const taskFile = join(TEST_DIR, `${taskId}.json`)
expect(existsSync(taskFile)).toBe(true)
})
})
})

View File

@@ -11,6 +11,8 @@ import {
generateTaskId,
} from "../../features/claude-tasks/storage";
import { syncTaskTodoUpdate } from "./todo-sync";
import { writeTeamTask } from "../agent-teams/team-task-store";
import { getTeamTaskDir } from "../agent-teams/paths";
export function createTaskCreateTool(
config: Partial<OhMyOpenCodeConfig>,
@@ -49,6 +51,7 @@ Calculate dependencies carefully to maximize parallel execution:
.describe("Task IDs this task blocks"),
repoURL: tool.schema.string().optional().describe("Repository URL"),
parentID: tool.schema.string().optional().describe("Parent task ID"),
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
},
execute: async (args, context) => {
return handleCreate(args, config, ctx, context);
@@ -64,6 +67,46 @@ async function handleCreate(
): Promise<string> {
try {
const validatedArgs = TaskCreateInputSchema.parse(args);
if (validatedArgs.team_name) {
const teamName = validatedArgs.team_name as string;
const teamTaskDir = getTeamTaskDir(teamName);
const lock = acquireLock(teamTaskDir);
if (!lock.acquired) {
return JSON.stringify({ error: "team_task_lock_unavailable" });
}
try {
const taskId = generateTaskId();
const task: TaskObject = {
id: taskId,
subject: validatedArgs.subject,
description: validatedArgs.description ?? "",
status: "pending",
blocks: validatedArgs.blocks ?? [],
blockedBy: validatedArgs.blockedBy ?? [],
activeForm: validatedArgs.activeForm,
metadata: validatedArgs.metadata,
repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID,
threadID: context.sessionID,
};
const validatedTask = TaskObjectSchema.parse(task);
writeTeamTask(teamName, taskId, validatedTask);
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
return JSON.stringify({
task: {
id: validatedTask.id,
subject: validatedTask.subject,
},
});
} finally {
lock.release();
}
} else {
const taskDir = getTaskDir(config);
const lock = acquireLock(taskDir);
@@ -101,6 +144,7 @@ async function handleCreate(
} finally {
lock.release();
}
}
} catch (error) {
if (error instanceof Error && error.message.includes("Required")) {
return JSON.stringify({

View File

@@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"
import { join } from "path"
import type { TaskObject } from "./types"
import { createTaskGetTool } from "./task-get"
import { writeTeamTask } from "../agent-teams/team-task-store"
const TEST_STORAGE = ".test-task-get-tool"
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
@@ -10,6 +11,7 @@ const TEST_CONFIG = {
sisyphus: {
tasks: {
storage_path: TEST_STORAGE,
claude_code_compat: true,
},
},
}
@@ -218,5 +220,55 @@ describe("task_get tool", () => {
expect(result.task.owner).toBeUndefined()
expect(result.task.metadata).toBeUndefined()
})
test("retrieves task from team namespace when team_name provided", async () => {
//#given
const teamName = "test-team"
const taskId = "T-team-task-404"
const taskData: TaskObject = {
id: taskId,
subject: "Team task",
description: "Team description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
writeTeamTask(teamName, taskId, taskData)
//#when
const resultStr = await tool.execute({ id: taskId, team_name: teamName }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).not.toBeNull()
expect(result.task.id).toBe(taskId)
expect(result.task.subject).toBe("Team task")
})
test("retrieves task from regular storage when no team_name", async () => {
//#given
const taskId = "T-regular-task-505"
const taskData: TaskObject = {
id: taskId,
subject: "Regular task",
description: "Regular",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).not.toBeNull()
expect(result.task.subject).toBe("Regular task")
})
})
})

View File

@@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../../config/schema"
import type { TaskGetInput } from "./types"
import { TaskGetInputSchema, TaskObjectSchema } from "./types"
import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage"
import { readTeamTask } from "../agent-teams/team-task-store"
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/
@@ -21,6 +22,7 @@ Returns the full task object including all fields: id, subject, description, sta
Returns null if the task does not exist or the file is invalid.`,
args: {
id: tool.schema.string().describe("Task ID to retrieve (format: T-{uuid})"),
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
@@ -31,12 +33,15 @@ Returns null if the task does not exist or the file is invalid.`,
return JSON.stringify({ error: "invalid_task_id" })
}
if (validatedArgs.team_name) {
const task = readTeamTask(validatedArgs.team_name, taskId)
return JSON.stringify({ task: task ?? null })
} else {
const taskDir = getTaskDir(config)
const taskPath = join(taskDir, `${taskId}.json`)
const task = readJsonSafe(taskPath, TaskObjectSchema)
return JSON.stringify({ task: task ?? null })
}
} catch (error) {
if (error instanceof Error && error.message.includes("validation")) {
return JSON.stringify({ error: "invalid_arguments" })

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createTaskList } from "./task-list"
import { writeJsonAtomic } from "../../features/claude-tasks/storage"
import { writeTeamTask } from "../agent-teams/team-task-store"
import { getTeamTaskDir } from "../agent-teams/paths"
import type { TaskObject } from "./types"
import { join } from "path"
import { existsSync, rmSync } from "fs"
@@ -21,6 +23,11 @@ describe("createTaskList", () => {
if (existsSync(taskDir)) {
rmSync(taskDir, { recursive: true })
}
// Clean up team task directories
const teamTaskDir = getTeamTaskDir("test-team")
if (existsSync(teamTaskDir)) {
rmSync(teamTaskDir, { recursive: true })
}
})
it("returns empty array when no tasks exist", async () => {
@@ -332,4 +339,70 @@ describe("createTaskList", () => {
const parsed = JSON.parse(result)
expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"])
})
it("lists tasks from team namespace when team_name provided", async () => {
//#given
const teamTask = {
id: "T-team-1",
subject: "Team task",
description: "",
status: "pending" as const,
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeTeamTask("test-team", "T-team-1", teamTask)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({ team_name: "test-team" }, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(1)
expect(parsed.tasks[0].subject).toBe("Team task")
})
it("lists tasks from regular storage when no team_name", async () => {
//#given
const task: TaskObject = {
id: "T-1",
subject: "Regular task",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(1)
expect(parsed.tasks[0].subject).toBe("Regular task")
})
})

View File

@@ -3,8 +3,9 @@ import { join } from "path"
import { existsSync, readdirSync } from "fs"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import type { TaskObject, TaskStatus } from "./types"
import { TaskObjectSchema } from "./types"
import { TaskObjectSchema, TaskListInputSchema } from "./types"
import { readJsonSafe, getTaskDir } from "../../features/claude-tasks/storage"
import { listTeamTasks } from "../agent-teams/team-task-store"
interface TaskSummary {
id: string
@@ -21,8 +22,43 @@ export function createTaskList(config: Partial<OhMyOpenCodeConfig>): ToolDefinit
Returns tasks excluding completed and deleted statuses by default.
For each task's blockedBy field, filters to only include unresolved (non-completed) blockers.
Returns summary format: id, subject, status, owner, blockedBy (not full description).`,
args: {},
execute: async (): Promise<string> => {
args: {
team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
const validatedArgs = TaskListInputSchema.parse(args)
if (validatedArgs.team_name) {
const allTasks = listTeamTasks(validatedArgs.team_name)
// Filter out completed and deleted tasks
const activeTasks = allTasks.filter(
(task) => task.status !== "completed" && task.status !== "deleted"
)
// Build summary with filtered blockedBy
const summaries: TaskSummary[] = activeTasks.map((task) => {
// Filter blockedBy to only include unresolved (non-completed) blockers
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
const blockerTask = allTasks.find((t) => t.id === blockerId)
// Include if blocker doesn't exist (missing) or if it's not completed
return !blockerTask || blockerTask.status !== "completed"
})
return {
id: task.id,
subject: task.subject,
status: task.status,
owner: task.owner,
blockedBy: unresolvedBlockers,
}
})
return JSON.stringify({
tasks: summaries,
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
})
} else {
const taskDir = getTaskDir(config)
if (!existsSync(taskDir)) {
@@ -72,6 +108,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
tasks: summaries,
reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently."
})
}
},
})
}

View File

@@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync } from "fs"
import { join } from "path"
import type { TaskObject } from "./types"
import { createTaskUpdateTool } from "./task-update"
import { writeTeamTask } from "../agent-teams/team-task-store"
const TEST_STORAGE = ".test-task-update-tool"
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
@@ -428,5 +429,53 @@ describe("task_update tool", () => {
expect(result.task.status).toBe("in_progress")
expect(result.task.owner).toBe("alice")
})
test("updates task in team namespace when team_name provided", async () => {
//#given
const taskId = "T-team-test-135"
const teamName = "test-team"
const initialTask: TaskObject = {
id: taskId,
subject: "Original team subject",
description: "Team task description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
writeTeamTask(teamName, taskId, initialTask)
//#when
const args = {
id: taskId,
team_name: teamName,
subject: "Updated team subject",
status: "in_progress" as const,
}
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task.subject).toBe("Updated team subject")
expect(result.task.status).toBe("in_progress")
expect(result.task.description).toBe("Team task description")
})
test("returns error when team task not found", async () => {
//#given
const args = {
id: "T-nonexistent-team-task",
team_name: "test-team",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("task_not_found")
})
})
})

View File

@@ -1,9 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
import { join } from "path";
import type { PluginInput } from "@opencode-ai/plugin";
import type { OhMyOpenCodeConfig } from "../../config/schema";
import type { TaskObject, TaskUpdateInput } from "./types";
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types";
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
import {
getTaskDir,
readJsonSafe,
@@ -11,6 +11,7 @@ import {
acquireLock,
} from "../../features/claude-tasks/storage";
import { syncTaskTodoUpdate } from "./todo-sync";
import { readTeamTask, writeTeamTask } from "../agent-teams/team-task-store";
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/;
@@ -62,8 +63,9 @@ Properly managed dependencies enable maximum parallel execution.`,
.record(tool.schema.string(), tool.schema.unknown())
.optional()
.describe("Task metadata to merge (set key to null to delete)"),
team_name: tool.schema.string().optional().describe("Team namespace for task storage"),
},
execute: async (args, context) => {
execute: async (args: Record<string, unknown>, context) => {
return handleUpdate(args, config, ctx, context);
},
});
@@ -82,6 +84,56 @@ async function handleUpdate(
return JSON.stringify({ error: "invalid_task_id" });
}
if (validatedArgs.team_name) {
// Team namespace routing
const task = readTeamTask(validatedArgs.team_name, taskId);
if (!task) {
return JSON.stringify({ error: "task_not_found" });
}
if (validatedArgs.subject !== undefined) {
task.subject = validatedArgs.subject;
}
if (validatedArgs.description !== undefined) {
task.description = validatedArgs.description;
}
if (validatedArgs.status !== undefined) {
task.status = validatedArgs.status;
}
if (validatedArgs.activeForm !== undefined) {
task.activeForm = validatedArgs.activeForm;
}
if (validatedArgs.owner !== undefined) {
task.owner = validatedArgs.owner;
}
const addBlocks = args.addBlocks as string[] | undefined;
if (addBlocks) {
task.blocks = [...new Set([...task.blocks, ...addBlocks])];
}
const addBlockedBy = args.addBlockedBy as string[] | undefined;
if (addBlockedBy) {
task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])];
}
if (validatedArgs.metadata !== undefined) {
task.metadata = { ...task.metadata, ...validatedArgs.metadata };
Object.keys(task.metadata).forEach((key) => {
if (task.metadata?.[key] === null) {
delete task.metadata[key];
}
});
}
const validatedTask = TaskObjectSchema.parse(task);
writeTeamTask(validatedArgs.team_name, taskId, validatedTask);
await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
return JSON.stringify({ task: validatedTask });
} else {
// Regular task storage
const taskDir = getTaskDir(config);
const lock = acquireLock(taskDir);
@@ -141,6 +193,7 @@ async function handleUpdate(
} finally {
lock.release();
}
}
} catch (error) {
if (error instanceof Error && error.message.includes("Required")) {
return JSON.stringify({

View File

@@ -37,6 +37,7 @@ export const TaskCreateInputSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
repoURL: z.string().optional(),
parentID: z.string().optional(),
team_name: z.string().optional(),
})
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
@@ -44,12 +45,14 @@ export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
export const TaskListInputSchema = z.object({
status: TaskStatusSchema.optional(),
parentID: z.string().optional(),
team_name: z.string().optional(),
})
export type TaskListInput = z.infer<typeof TaskListInputSchema>
export const TaskGetInputSchema = z.object({
id: z.string(),
team_name: z.string().optional(),
})
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
@@ -66,6 +69,7 @@ export const TaskUpdateInputSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
repoURL: z.string().optional(),
parentID: z.string().optional(),
team_name: z.string().optional(),
})
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>