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.
This commit is contained in:
committed by
YeonGyu-Kim
parent
4ab93c0cf7
commit
0f9c93fd55
139
src/tools/agent-teams/inbox-store.ts
Normal file
139
src/tools/agent-teams/inbox-store.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { z } from "zod"
|
||||
import { acquireLock, ensureDir } from "../../features/claude-tasks/storage"
|
||||
import { getTeamInboxDir, getTeamInboxPath } from "./paths"
|
||||
import { TeamInboxMessage, TeamInboxMessageSchema } from "./types"
|
||||
|
||||
const TeamInboxListSchema = z.array(TeamInboxMessageSchema)
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function withInboxLock<T>(teamName: string, operation: () => T): T {
|
||||
const inboxDir = getTeamInboxDir(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): TeamInboxMessage[] {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
const result = TeamInboxListSchema.safeParse(parsed)
|
||||
return result.success ? result.data : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function readInboxMessages(teamName: string, agentName: string): TeamInboxMessage[] {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
if (!existsSync(path)) {
|
||||
return []
|
||||
}
|
||||
return parseInboxFile(readFileSync(path, "utf-8"))
|
||||
}
|
||||
|
||||
function writeInboxMessages(teamName: string, agentName: string, messages: TeamInboxMessage[]): void {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
writeFileSync(path, JSON.stringify(messages), "utf-8")
|
||||
}
|
||||
|
||||
export function ensureInbox(teamName: string, agentName: string): void {
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
if (!existsSync(path)) {
|
||||
writeFileSync(path, "[]", "utf-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function appendInboxMessage(teamName: string, agentName: string, message: TeamInboxMessage): void {
|
||||
withInboxLock(teamName, () => {
|
||||
const path = getTeamInboxPath(teamName, agentName)
|
||||
if (!existsSync(path)) {
|
||||
writeFileSync(path, "[]", "utf-8")
|
||||
}
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
messages.push(TeamInboxMessageSchema.parse(message))
|
||||
writeInboxMessages(teamName, agentName, messages)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendPlainInboxMessage(
|
||||
teamName: string,
|
||||
from: string,
|
||||
to: string,
|
||||
text: string,
|
||||
summary: string,
|
||||
color?: string,
|
||||
): void {
|
||||
appendInboxMessage(teamName, to, {
|
||||
from,
|
||||
text,
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
summary,
|
||||
...(color ? { color } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function sendStructuredInboxMessage(
|
||||
teamName: string,
|
||||
from: string,
|
||||
to: string,
|
||||
payload: Record<string, unknown>,
|
||||
summary?: string,
|
||||
): void {
|
||||
appendInboxMessage(teamName, to, {
|
||||
from,
|
||||
text: JSON.stringify(payload),
|
||||
timestamp: nowIso(),
|
||||
read: false,
|
||||
...(summary ? { summary } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function readInbox(
|
||||
teamName: string,
|
||||
agentName: string,
|
||||
unreadOnly = false,
|
||||
markAsRead = true,
|
||||
): TeamInboxMessage[] {
|
||||
return withInboxLock(teamName, () => {
|
||||
const messages = readInboxMessages(teamName, agentName)
|
||||
|
||||
const selected = unreadOnly ? messages.filter((message) => !message.read) : [...messages]
|
||||
|
||||
if (!markAsRead || selected.length === 0) {
|
||||
return selected
|
||||
}
|
||||
|
||||
const updated = messages.map((message) => {
|
||||
if (!unreadOnly) {
|
||||
return { ...message, read: true }
|
||||
}
|
||||
|
||||
if (selected.some((selectedMessage) => selectedMessage.timestamp === message.timestamp && selectedMessage.from === message.from && selectedMessage.text === message.text)) {
|
||||
return { ...message, read: true }
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
writeInboxMessages(teamName, agentName, updated)
|
||||
return selected
|
||||
})
|
||||
}
|
||||
|
||||
export function buildShutdownRequestId(recipient: string): string {
|
||||
return `shutdown-${Date.now()}@${recipient}`
|
||||
}
|
||||
1
src/tools/agent-teams/index.ts
Normal file
1
src/tools/agent-teams/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createAgentTeamsTools } from "./tools"
|
||||
199
src/tools/agent-teams/messaging-tools.ts
Normal file
199
src/tools/agent-teams/messaging-tools.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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 { resumeTeammateWithMessage } from "./teammate-runtime"
|
||||
import {
|
||||
TeamReadInboxInputSchema,
|
||||
TeamSendMessageInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
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 (default: team-lead)"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||
try {
|
||||
const input = TeamSendMessageInputSchema.parse(args)
|
||||
const sender = input.sender ?? "team-lead"
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const memberNames = new Set(config.members.map((member) => member.name))
|
||||
|
||||
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 teammates = listTeammates(config)
|
||||
for (const teammate of teammates) {
|
||||
sendPlainInboxMessage(input.team_name, sender, teammate.name, input.content ?? "", input.summary)
|
||||
await resumeTeammateWithMessage(manager, context, input.team_name, teammate, input.summary, 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,
|
||||
"team-lead",
|
||||
input.recipient,
|
||||
{
|
||||
type: "shutdown_request",
|
||||
requestId,
|
||||
from: "team-lead",
|
||||
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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadInboxInputSchema.parse(args)
|
||||
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" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
29
src/tools/agent-teams/name-validation.ts
Normal file
29
src/tools/agent-teams/name-validation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const VALID_NAME_RE = /^[A-Za-z0-9_-]+$/
|
||||
const MAX_NAME_LENGTH = 64
|
||||
|
||||
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")
|
||||
}
|
||||
40
src/tools/agent-teams/paths.ts
Normal file
40
src/tools/agent-teams/paths.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { join } from "node:path"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
|
||||
const AGENT_TEAMS_DIR = "agent-teams"
|
||||
|
||||
export function getAgentTeamsRootDir(): string {
|
||||
return join(getOpenCodeConfigDir({ binary: "opencode" }), 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(), 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(), teamName)
|
||||
}
|
||||
|
||||
export function getTeamTaskPath(teamName: string, taskId: string): string {
|
||||
return join(getTeamTaskDir(teamName), `${taskId}.json`)
|
||||
}
|
||||
159
src/tools/agent-teams/team-config-store.ts
Normal file
159
src/tools/agent-teams/team-config-store.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { existsSync, 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"
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function withTeamLock<T>(teamName: string, operation: () => T): T {
|
||||
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, leadSessionId: string, cwd: string, model: string): TeamLeadMember {
|
||||
return {
|
||||
agentId: `team-lead@${teamName}`,
|
||||
name: "team-lead",
|
||||
agentType: "team-lead",
|
||||
model,
|
||||
joinedAt: nowMs(),
|
||||
cwd,
|
||||
subscriptions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureTeamStorageDirs(teamName: string): void {
|
||||
ensureDir(getTeamsRootDir())
|
||||
ensureDir(getTeamTasksRootDir())
|
||||
ensureDir(getTeamDir(teamName))
|
||||
ensureDir(getTeamInboxDir(teamName))
|
||||
ensureDir(getTeamTaskDir(teamName))
|
||||
}
|
||||
|
||||
export function teamExists(teamName: string): boolean {
|
||||
return existsSync(getTeamConfigPath(teamName))
|
||||
}
|
||||
|
||||
export function createTeamConfig(
|
||||
teamName: string,
|
||||
description: string,
|
||||
leadSessionId: string,
|
||||
cwd: string,
|
||||
model: string,
|
||||
): TeamConfig {
|
||||
ensureTeamStorageDirs(teamName)
|
||||
|
||||
if (teamExists(teamName)) {
|
||||
throw new Error("team_already_exists")
|
||||
}
|
||||
|
||||
const leadAgentId = `team-lead@${teamName}`
|
||||
const config: TeamConfig = {
|
||||
name: teamName,
|
||||
description,
|
||||
createdAt: nowMs(),
|
||||
leadAgentId,
|
||||
leadSessionId,
|
||||
members: [createLeadMember(teamName, leadSessionId, cwd, model)],
|
||||
}
|
||||
|
||||
return withTeamLock(teamName, () => {
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), TeamConfigSchema.parse(config))
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
export function readTeamConfig(teamName: string): TeamConfig | null {
|
||||
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 {
|
||||
return withTeamLock(teamName, () => {
|
||||
const validated = TeamConfigSchema.parse(config)
|
||||
writeJsonAtomic(getTeamConfigPath(teamName), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
|
||||
if (existsSync(teamDir)) {
|
||||
rmSync(teamDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (existsSync(taskDir)) {
|
||||
rmSync(taskDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
95
src/tools/agent-teams/team-lifecycle-tools.ts
Normal file
95
src/tools/agent-teams/team-lifecycle-tools.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { getTeamConfigPath } from "./paths"
|
||||
import { validateTeamName } from "./name-validation"
|
||||
import { ensureInbox } from "./inbox-store"
|
||||
import {
|
||||
TeamCreateInputSchema,
|
||||
TeamDeleteInputSchema,
|
||||
TeamReadConfigInputSchema,
|
||||
TeamToolContext,
|
||||
} from "./types"
|
||||
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
|
||||
|
||||
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 nameError = validateTeamName(input.team_name)
|
||||
if (nameError) {
|
||||
return JSON.stringify({ error: nameError })
|
||||
}
|
||||
|
||||
const config = createTeamConfig(
|
||||
input.team_name,
|
||||
input.description ?? "",
|
||||
context.sessionID,
|
||||
process.cwd(),
|
||||
context.agent ?? "native",
|
||||
)
|
||||
ensureInbox(config.name, "team-lead")
|
||||
|
||||
return JSON.stringify({
|
||||
team_name: config.name,
|
||||
team_file_path: getTeamConfigPath(config.name),
|
||||
lead_agent_id: config.leadAgentId,
|
||||
})
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamDeleteInputSchema.parse(args)
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
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({ success: true, team_name: input.team_name })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamReadConfigInputSchema.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 : "team_read_config_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
93
src/tools/agent-teams/team-task-dependency.ts
Normal file
93
src/tools/agent-teams/team-task-dependency.ts
Normal 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
|
||||
}
|
||||
127
src/tools/agent-teams/team-task-store.ts
Normal file
127
src/tools/agent-teams/team-task-store.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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"
|
||||
|
||||
function withTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
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 function readTeamTask(teamName: string, taskId: string): TeamTask | null {
|
||||
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[] {
|
||||
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$/, "")
|
||||
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 {
|
||||
if (!subject.trim()) {
|
||||
throw new Error("team_task_subject_required")
|
||||
}
|
||||
|
||||
return withTaskLock(teamName, () => {
|
||||
const task: TeamTask = {
|
||||
id: generateTaskId(),
|
||||
subject,
|
||||
description,
|
||||
activeForm,
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
...(metadata ? { metadata } : {}),
|
||||
}
|
||||
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated)
|
||||
return validated
|
||||
})
|
||||
}
|
||||
|
||||
export function writeTeamTask(teamName: string, task: TeamTask): TeamTask {
|
||||
const validated = TeamTaskSchema.parse(task)
|
||||
writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated)
|
||||
return validated
|
||||
}
|
||||
|
||||
export function deleteTeamTaskFile(teamName: string, taskId: string): void {
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
if (existsSync(taskPath)) {
|
||||
unlinkSync(taskPath)
|
||||
}
|
||||
}
|
||||
|
||||
export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null {
|
||||
return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema)
|
||||
}
|
||||
|
||||
export function resetOwnerTasks(teamName: string, ownerName: string): void {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function withTeamTaskLock<T>(teamName: string, operation: () => T): T {
|
||||
return withTaskLock(teamName, operation)
|
||||
}
|
||||
106
src/tools/agent-teams/team-task-tools.ts
Normal file
106
src/tools/agent-teams/team-task-tools.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { sendStructuredInboxMessage } from "./inbox-store"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import {
|
||||
TeamTaskCreateInputSchema,
|
||||
TeamTaskGetInputSchema,
|
||||
TeamTaskListInputSchema,
|
||||
TeamTask,
|
||||
} from "./types"
|
||||
import { createTeamTask, listTeamTasks, readTeamTask } from "./team-task-store"
|
||||
|
||||
function buildTaskAssignmentPayload(task: TeamTask): Record<string, unknown> {
|
||||
return {
|
||||
type: "task_assignment",
|
||||
taskId: task.id,
|
||||
subject: task.subject,
|
||||
description: task.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskCreateInputSchema.parse(args)
|
||||
readTeamConfigOrThrow(input.team_name)
|
||||
|
||||
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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskListInputSchema.parse(args)
|
||||
readTeamConfigOrThrow(input.team_name)
|
||||
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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskGetInputSchema.parse(args)
|
||||
readTeamConfigOrThrow(input.team_name)
|
||||
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): void {
|
||||
if (!task.owner || task.status === "deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
sendStructuredInboxMessage(
|
||||
teamName,
|
||||
"team-lead",
|
||||
task.owner,
|
||||
buildTaskAssignmentPayload(task),
|
||||
"task_assignment",
|
||||
)
|
||||
}
|
||||
48
src/tools/agent-teams/team-task-update-tool.ts
Normal file
48
src/tools/agent-teams/team-task-update-tool.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { readTeamConfigOrThrow } from "./team-config-store"
|
||||
import { TeamTaskUpdateInputSchema } from "./types"
|
||||
import { updateTeamTask } from "./team-task-update"
|
||||
import { notifyOwnerAssignment } 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>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamTaskUpdateInputSchema.parse(args)
|
||||
readTeamConfigOrThrow(input.team_name)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return JSON.stringify(task)
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_task_update_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
210
src/tools/agent-teams/team-task-update.ts
Normal file
210
src/tools/agent-teams/team-task-update.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage"
|
||||
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 writeTaskToPath(path: string, task: TeamTask): void {
|
||||
writeJsonAtomic(path, TeamTaskSchema.parse(task))
|
||||
}
|
||||
|
||||
export function updateTeamTask(teamName: string, taskId: string, patch: TeamTaskUpdatePatch): TeamTask {
|
||||
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 effectiveBlockedBy = Array.from(new Set([...(currentTask.blockedBy ?? []), ...(patch.addBlockedBy ?? [])]))
|
||||
ensureDependenciesCompleted(patch.status, 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
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
148
src/tools/agent-teams/teammate-runtime.ts
Normal file
148
src/tools/agent-teams/teammate-runtime.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { ensureInbox, sendPlainInboxMessage } from "./inbox-store"
|
||||
import { assignNextColor, getTeamMember, readTeamConfigOrThrow, removeTeammate, upsertTeammate, writeTeamConfig } from "./team-config-store"
|
||||
import type { TeamTeammateMember, TeamToolContext } from "./types"
|
||||
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined
|
||||
}
|
||||
const [providerID, modelID] = model.split("/", 2)
|
||||
if (!providerID || !modelID) {
|
||||
return undefined
|
||||
}
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function buildLaunchPrompt(teamName: string, teammateName: string, userPrompt: string): string {
|
||||
return [
|
||||
`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,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
export interface SpawnTeammateParams {
|
||||
teamName: string
|
||||
name: string
|
||||
prompt: string
|
||||
subagentType: string
|
||||
model?: string
|
||||
planModeRequired: boolean
|
||||
context: TeamToolContext
|
||||
manager: BackgroundManager
|
||||
}
|
||||
|
||||
export async function spawnTeammate(params: SpawnTeammateParams): Promise<TeamTeammateMember> {
|
||||
const config = readTeamConfigOrThrow(params.teamName)
|
||||
|
||||
if (getTeamMember(config, params.name)) {
|
||||
throw new Error("teammate_already_exists")
|
||||
}
|
||||
|
||||
const teammate: TeamTeammateMember = {
|
||||
agentId: `${params.name}@${params.teamName}`,
|
||||
name: params.name,
|
||||
agentType: params.subagentType,
|
||||
model: params.model ?? "native",
|
||||
prompt: params.prompt,
|
||||
color: assignNextColor(config),
|
||||
planModeRequired: params.planModeRequired,
|
||||
joinedAt: Date.now(),
|
||||
cwd: process.cwd(),
|
||||
subscriptions: [],
|
||||
backendType: "native",
|
||||
isActive: false,
|
||||
}
|
||||
|
||||
writeTeamConfig(params.teamName, upsertTeammate(config, teammate))
|
||||
ensureInbox(params.teamName, params.name)
|
||||
sendPlainInboxMessage(params.teamName, "team-lead", params.name, params.prompt, "initial_prompt", teammate.color)
|
||||
|
||||
try {
|
||||
const resolvedModel = parseModel(params.model)
|
||||
const launched = await params.manager.launch({
|
||||
description: `[team:${params.teamName}] ${params.name}`,
|
||||
prompt: buildLaunchPrompt(params.teamName, params.name, params.prompt),
|
||||
agent: params.subagentType,
|
||||
parentSessionID: params.context.sessionID,
|
||||
parentMessageID: params.context.messageID,
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
parentAgent: params.context.agent,
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
let sessionID = launched.sessionID
|
||||
while (!sessionID && Date.now() - start < 30_000) {
|
||||
await delay(50)
|
||||
const task = params.manager.getTask(launched.id)
|
||||
sessionID = task?.sessionID
|
||||
}
|
||||
|
||||
const nextMember: TeamTeammateMember = {
|
||||
...teammate,
|
||||
isActive: true,
|
||||
backgroundTaskID: launched.id,
|
||||
...(sessionID ? { sessionID } : {}),
|
||||
}
|
||||
|
||||
const current = readTeamConfigOrThrow(params.teamName)
|
||||
writeTeamConfig(params.teamName, upsertTeammate(current, nextMember))
|
||||
return nextMember
|
||||
} catch (error) {
|
||||
const rollback = readTeamConfigOrThrow(params.teamName)
|
||||
writeTeamConfig(params.teamName, removeTeammate(rollback, params.name))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeTeammateWithMessage(
|
||||
manager: BackgroundManager,
|
||||
context: TeamToolContext,
|
||||
teamName: string,
|
||||
teammate: TeamTeammateMember,
|
||||
summary: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
if (!teammate.sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.resume({
|
||||
sessionId: teammate.sessionID,
|
||||
prompt: buildDeliveryPrompt(teamName, teammate.name, summary, content),
|
||||
parentSessionID: context.sessionID,
|
||||
parentMessageID: context.messageID,
|
||||
parentAgent: context.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,
|
||||
})
|
||||
}
|
||||
121
src/tools/agent-teams/teammate-tools.ts
Normal file
121
src/tools/agent-teams/teammate-tools.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { validateAgentName, validateTeamName } from "./name-validation"
|
||||
import {
|
||||
TeamForceKillInputSchema,
|
||||
TeamProcessShutdownInputSchema,
|
||||
TeamSpawnInputSchema,
|
||||
TeamToolContext,
|
||||
isTeammateMember,
|
||||
} from "./types"
|
||||
import { getTeamMember, readTeamConfigOrThrow, removeTeammate, writeTeamConfig } from "./team-config-store"
|
||||
import { cancelTeammateRun, spawnTeammate } from "./teammate-runtime"
|
||||
import { resetOwnerTasks } from "./team-task-store"
|
||||
|
||||
export function createSpawnTeammateTool(manager: BackgroundManager): 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"),
|
||||
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 })
|
||||
}
|
||||
|
||||
const teammate = await spawnTeammate({
|
||||
teamName: input.team_name,
|
||||
name: input.name,
|
||||
prompt: input.prompt,
|
||||
subagentType: input.subagent_type ?? "sisyphus-junior",
|
||||
model: input.model,
|
||||
planModeRequired: input.plan_mode_required ?? false,
|
||||
context,
|
||||
manager,
|
||||
})
|
||||
|
||||
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"),
|
||||
agent_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamForceKillInputSchema.parse(args)
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const member = getTeamMember(config, input.agent_name)
|
||||
if (!member || !isTeammateMember(member)) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
await cancelTeammateRun(manager, member)
|
||||
writeTeamConfig(input.team_name, removeTeammate(config, input.agent_name))
|
||||
resetOwnerTasks(input.team_name, input.agent_name)
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.agent_name} stopped` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "force_kill_teammate_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createProcessShutdownTool(): ToolDefinition {
|
||||
return tool({
|
||||
description: "Finalize an approved shutdown by removing teammate and resetting owned tasks.",
|
||||
args: {
|
||||
team_name: tool.schema.string().describe("Team name"),
|
||||
agent_name: tool.schema.string().describe("Teammate name"),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
try {
|
||||
const input = TeamProcessShutdownInputSchema.parse(args)
|
||||
if (input.agent_name === "team-lead") {
|
||||
return JSON.stringify({ error: "cannot_shutdown_team_lead" })
|
||||
}
|
||||
|
||||
const config = readTeamConfigOrThrow(input.team_name)
|
||||
const member = getTeamMember(config, input.agent_name)
|
||||
if (!member || !isTeammateMember(member)) {
|
||||
return JSON.stringify({ error: "teammate_not_found" })
|
||||
}
|
||||
|
||||
writeTeamConfig(input.team_name, removeTeammate(config, input.agent_name))
|
||||
resetOwnerTasks(input.team_name, input.agent_name)
|
||||
|
||||
return JSON.stringify({ success: true, message: `${input.agent_name} removed` })
|
||||
} catch (error) {
|
||||
return JSON.stringify({ error: error instanceof Error ? error.message : "process_shutdown_failed" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
24
src/tools/agent-teams/tools.ts
Normal file
24
src/tools/agent-teams/tools.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { createReadInboxTool, createSendMessageTool } from "./messaging-tools"
|
||||
import { createTeamCreateTool, createTeamDeleteTool, createTeamReadConfigTool } from "./team-lifecycle-tools"
|
||||
import { createTeamTaskCreateTool, createTeamTaskGetTool, createTeamTaskListTool } from "./team-task-tools"
|
||||
import { createTeamTaskUpdateTool } from "./team-task-update-tool"
|
||||
import { createForceKillTeammateTool, createProcessShutdownTool, createSpawnTeammateTool } from "./teammate-tools"
|
||||
|
||||
export function createAgentTeamsTools(manager: BackgroundManager): Record<string, ToolDefinition> {
|
||||
return {
|
||||
team_create: createTeamCreateTool(),
|
||||
team_delete: createTeamDeleteTool(),
|
||||
spawn_teammate: createSpawnTeammateTool(manager),
|
||||
send_message: createSendMessageTool(manager),
|
||||
read_inbox: createReadInboxTool(),
|
||||
read_config: createTeamReadConfigTool(),
|
||||
team_task_create: createTeamTaskCreateTool(),
|
||||
team_task_update: createTeamTaskUpdateTool(),
|
||||
team_task_list: createTeamTaskListTool(),
|
||||
team_task_get: createTeamTaskGetTool(),
|
||||
force_kill_teammate: createForceKillTeammateTool(manager),
|
||||
process_shutdown_approved: createProcessShutdownTool(),
|
||||
}
|
||||
}
|
||||
180
src/tools/agent-teams/types.ts
Normal file
180
src/tools/agent-teams/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TEAM_COLOR_PALETTE = [
|
||||
"blue",
|
||||
"green",
|
||||
"yellow",
|
||||
"purple",
|
||||
"orange",
|
||||
"pink",
|
||||
"cyan",
|
||||
"red",
|
||||
] as const
|
||||
|
||||
export const TeamTaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"])
|
||||
export type TeamTaskStatus = z.infer<typeof TeamTaskStatusSchema>
|
||||
|
||||
export const TeamLeadMemberSchema = z.object({
|
||||
agentId: z.string(),
|
||||
name: z.literal("team-lead"),
|
||||
agentType: z.literal("team-lead"),
|
||||
model: z.string(),
|
||||
joinedAt: z.number(),
|
||||
cwd: z.string(),
|
||||
subscriptions: z.array(z.unknown()).default([]),
|
||||
}).strict()
|
||||
|
||||
export const TeamTeammateMemberSchema = z.object({
|
||||
agentId: z.string(),
|
||||
name: z.string(),
|
||||
agentType: z.string(),
|
||||
model: z.string(),
|
||||
prompt: z.string(),
|
||||
color: z.string(),
|
||||
planModeRequired: z.boolean().default(false),
|
||||
joinedAt: z.number(),
|
||||
cwd: z.string(),
|
||||
subscriptions: z.array(z.unknown()).default([]),
|
||||
backendType: z.literal("native").default("native"),
|
||||
isActive: z.boolean().default(false),
|
||||
sessionID: z.string().optional(),
|
||||
backgroundTaskID: z.string().optional(),
|
||||
}).strict()
|
||||
|
||||
export const TeamMemberSchema = z.union([TeamLeadMemberSchema, TeamTeammateMemberSchema])
|
||||
|
||||
export const TeamConfigSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(""),
|
||||
createdAt: z.number(),
|
||||
leadAgentId: z.string(),
|
||||
leadSessionId: z.string(),
|
||||
members: z.array(TeamMemberSchema),
|
||||
}).strict()
|
||||
|
||||
export const TeamInboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
read: z.boolean().default(false),
|
||||
summary: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
}).strict()
|
||||
|
||||
export const TeamTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
status: TeamTaskStatusSchema,
|
||||
blocks: z.array(z.string()).default([]),
|
||||
blockedBy: z.array(z.string()).default([]),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
}).strict()
|
||||
|
||||
export const TeamSendMessageTypeSchema = z.enum([
|
||||
"message",
|
||||
"broadcast",
|
||||
"shutdown_request",
|
||||
"shutdown_response",
|
||||
"plan_approval_response",
|
||||
])
|
||||
|
||||
export type TeamLeadMember = z.infer<typeof TeamLeadMemberSchema>
|
||||
export type TeamTeammateMember = z.infer<typeof TeamTeammateMemberSchema>
|
||||
export type TeamMember = z.infer<typeof TeamMemberSchema>
|
||||
export type TeamConfig = z.infer<typeof TeamConfigSchema>
|
||||
export type TeamInboxMessage = z.infer<typeof TeamInboxMessageSchema>
|
||||
export type TeamTask = z.infer<typeof TeamTaskSchema>
|
||||
export type TeamSendMessageType = z.infer<typeof TeamSendMessageTypeSchema>
|
||||
|
||||
export const TeamCreateInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const TeamDeleteInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
})
|
||||
|
||||
export const TeamReadConfigInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
})
|
||||
|
||||
export const TeamSpawnInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
name: z.string(),
|
||||
prompt: z.string(),
|
||||
subagent_type: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
plan_mode_required: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const TeamSendMessageInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
type: TeamSendMessageTypeSchema,
|
||||
recipient: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
request_id: z.string().optional(),
|
||||
approve: z.boolean().optional(),
|
||||
sender: z.string().optional(),
|
||||
})
|
||||
|
||||
export const TeamReadInboxInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
agent_name: z.string(),
|
||||
unread_only: z.boolean().optional(),
|
||||
mark_as_read: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const TeamTaskCreateInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
active_form: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const TeamTaskUpdateInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
task_id: z.string(),
|
||||
status: TeamTaskStatusSchema.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 const TeamTaskListInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
})
|
||||
|
||||
export const TeamTaskGetInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
task_id: z.string(),
|
||||
})
|
||||
|
||||
export const TeamForceKillInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
agent_name: z.string(),
|
||||
})
|
||||
|
||||
export const TeamProcessShutdownInputSchema = z.object({
|
||||
team_name: z.string(),
|
||||
agent_name: z.string(),
|
||||
})
|
||||
|
||||
export interface TeamToolContext {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export function isTeammateMember(member: TeamMember): member is TeamTeammateMember {
|
||||
return member.agentType !== "team-lead"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user