diff --git a/packages/opencode/migration/20260321160000_add_teams/migration.sql b/packages/opencode/migration/20260321160000_add_teams/migration.sql new file mode 100644 index 000000000000..9cec987f8e24 --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `team` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `name` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_member` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `team_id` text NOT NULL, + `session_id` text NOT NULL, + `agent` text NOT NULL, + `role` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_task` ( + `id` text PRIMARY KEY NOT NULL, + `team_id` text NOT NULL, + `subject` text NOT NULL, + `description` text, + `owner` text, + `status` text DEFAULT 'pending' NOT NULL, + `metadata` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `agent_memory` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `agent` text NOT NULL, + `content` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX `team_session_idx` ON `team` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_member_team_idx` ON `team_member` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_member_session_idx` ON `team_member` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_task_team_idx` ON `team_task` (`team_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `agent_memory_project_agent_idx` ON `agent_memory` (`project_id`,`agent`); \ No newline at end of file diff --git a/packages/opencode/migration/20260321160000_add_teams/snapshot.json b/packages/opencode/migration/20260321160000_add_teams/snapshot.json new file mode 100644 index 000000000000..1c1e5292d9cc --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/snapshot.json @@ -0,0 +1,7 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "add-teams-migration", + "prevIds": [], + "ddl": [] +} diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 30d09861447e..835a84e3b230 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -43,6 +43,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + memory: z.enum(["none", "local"]).optional(), }) .meta({ ref: "Agent", @@ -228,6 +229,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.memory = value.memory ?? item.memory item.options = mergeDeep(item.options, value.options ?? {}) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } diff --git a/packages/opencode/src/agent/memory.ts b/packages/opencode/src/agent/memory.ts new file mode 100644 index 000000000000..522c44d6e2c1 --- /dev/null +++ b/packages/opencode/src/agent/memory.ts @@ -0,0 +1,109 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and } from "../storage/db" +import { AgentMemoryTable } from "../team/team.sql" +import { MemoryID } from "../team/schema" +import { Instance } from "../project/instance" +import { Log } from "../util/log" + +const log = Log.create({ service: "agent.memory" }) + +const MAX_SIZE = 102_400 // 100KB + +export namespace AgentMemory { + export const Info = z + .object({ + id: MemoryID.zod, + projectID: z.string(), + agent: z.string(), + content: z.string(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "AgentMemory" }) + export type Info = z.infer + + export const Event = { + Updated: BusEvent.define( + "agent.memory.updated", + z.object({ + agent: z.string(), + projectID: z.string(), + }), + ), + } + + function toInfo(row: typeof AgentMemoryTable.$inferSelect): Info { + return { + id: row.id, + projectID: row.project_id, + agent: row.agent, + content: row.content, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function read(agent: string): Info | undefined { + const pid = Instance.project.id + const row = Database.use((db) => + db + .select() + .from(AgentMemoryTable) + .where(and(eq(AgentMemoryTable.project_id, pid), eq(AgentMemoryTable.agent, agent))) + .get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function write(agent: string, content: string) { + if (Buffer.byteLength(content, "utf8") > MAX_SIZE) { + content = content.slice(0, MAX_SIZE) + log.warn("memory truncated to 100KB", { agent }) + } + const pid = Instance.project.id + const now = Date.now() + const existing = read(agent) + if (existing) { + Database.use((db) => + db + .update(AgentMemoryTable) + .set({ content, time_updated: now }) + .where(eq(AgentMemoryTable.id, existing.id)) + .run(), + ) + } else { + const id = MemoryID.ascending() + Database.use((db) => + db + .insert(AgentMemoryTable) + .values({ + id, + project_id: pid, + agent, + content, + time_created: now, + time_updated: now, + }) + .run(), + ) + } + log.info("written", { agent, projectID: pid }) + Database.effect(() => Bus.publish(Event.Updated, { agent, projectID: pid })) + } + + export function append(agent: string, content: string) { + const existing = read(agent) + if (existing) { + write(agent, existing.content + "\n\n" + content) + } else { + write(agent, content) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..6c8f0c72c997 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -18,6 +18,27 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" + +export interface TeamMember { + teamID: string + sessionID: string + agent: string + role: "lead" | "member" + status: "active" | "completed" | "failed" | "cancelled" +} + +export interface TeamInfo { + id: string + sessionID: string + name: string + status: "active" | "disbanded" + time: { created: number; updated: number } +} + +export interface TeamWithMembers { + team: TeamInfo + members: TeamMember[] +} import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" @@ -72,6 +93,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] + teams: TeamWithMembers[] vcs: VcsInfo | undefined path: Path workspaceList: Workspace[] @@ -100,6 +122,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], + teams: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, workspaceList: [], @@ -113,8 +136,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("workspaceList", reconcile(result.data)) } + async function syncTeams() { + const result = (await sdk + .fetch(`${sdk.url}/team/active`) + .then((r) => r.json()) + .catch(() => undefined)) as TeamWithMembers[] | undefined + if (!result) return + setStore("teams", reconcile(result)) + } + sdk.event.listen((e) => { const event = e.details + // Handle team events (not yet in SDK types) + const type = event.type as string + if (type === "team.created") { + const team = (event as any).properties.team as TeamInfo + setStore( + "teams", + produce((draft) => { + draft.push({ team, members: [] }) + }), + ) + return + } + if (type === "team.disbanded") { + const teamID = (event as any).properties.teamID as string + setStore( + "teams", + produce((draft) => { + const idx = draft.findIndex((t) => t.team.id === teamID) + if (idx !== -1) draft.splice(idx, 1) + }), + ) + return + } + if (type === "team.member.added") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (entry) entry.members.push(member) + }), + ) + return + } + if (type === "team.member.updated") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (!entry) return + const idx = entry.members.findIndex((m) => m.sessionID === member.sessionID) + if (idx !== -1) entry.members[idx] = member + }), + ) + return + } switch (event.type) { case "server.instance.disposed": bootstrap() @@ -423,6 +502,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), + syncTeams(), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080a..081e40659b4b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,4 +1,4 @@ -import { useSync } from "@tui/context/sync" +import { useSync, type TeamWithMembers } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" @@ -25,6 +25,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff: true, todo: true, lsp: true, + teams: true, }) // Sort MCP servers alphabetically for consistent display order @@ -60,6 +61,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { } }) + const teams = createMemo(() => sync.data.teams) + const agents = createMemo(() => { + const all: { agent: string; status: string; team: string }[] = [] + for (const t of teams()) { + for (const m of t.members) { + if (m.role === "member") all.push({ agent: m.agent, status: m.status, team: t.team.name }) + } + } + return all + }) + const directory = useDirectory() const kv = useKV() @@ -106,6 +118,71 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + 0}> + + setExpanded("teams", !expanded.teams)}> + {expanded.teams ? "▼" : "▶"} + + Teams + + + {" "} + ({teams().length} active, {agents().filter((a) => a.status === "active").length} agents) + + + + + + + {(entry) => ( + + + {entry.team.name} + + m.role === "member")}> + {(member) => ( + + + )[member.status], + }} + > + {member.status === "active" + ? "◌" + : member.status === "completed" + ? "●" + : member.status === "failed" + ? "✕" + : "○"} + + + @{member.agent}{" "} + + + working + done + failed + cancelled + + + + + )} + + + )} + + + + 0}> { @@ -763,6 +767,7 @@ export namespace Config { "permission", "disable", "tools", + "memory", ]) // Extract unknown properties into options @@ -1206,6 +1211,24 @@ export namespace Config { .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), }) .optional(), + team: z + .object({ + max_agents: z + .number() + .int() + .positive() + .optional() + .default(10) + .describe("Maximum number of concurrent background agents (default: 10)"), + member_timeout: z + .number() + .int() + .positive() + .optional() + .default(300_000) + .describe("Timeout in milliseconds for team members waiting for messages (default: 300000 = 5 minutes)"), + }) + .optional(), experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6673297cbfac..44625b8ce967 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -12,6 +12,9 @@ export namespace Identifier { pty: "pty", tool: "tool", workspace: "wrk", + team: "tem", + team_task: "ttk", + memory: "mem", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/server/routes/team.ts b/packages/opencode/src/server/routes/team.ts new file mode 100644 index 000000000000..ea7c2528d75d --- /dev/null +++ b/packages/opencode/src/server/routes/team.ts @@ -0,0 +1,226 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Team } from "../../team" +import { TeamTask } from "../../team/task" +import { TeamID, TeamTaskID } from "../../team/schema" +import { SessionID } from "@/session/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const TeamRoutes = lazy(() => + new Hono() + .get( + "/active", + describeRoute({ + summary: "List active teams with members", + description: "Get all active teams and their members for the TUI status panel.", + operationId: "team.active", + responses: { + 200: { + description: "Active teams with members", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + team: Team.Info, + members: Team.Member.array(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const teams = Team.active() + const result = teams.map((team) => ({ + team, + members: Team.members(team.id), + })) + return c.json(result) + }, + ) + .get( + "/", + describeRoute({ + summary: "List teams", + description: "Get teams for a session.", + operationId: "team.list", + responses: { + 200: { + description: "List of teams", + content: { + "application/json": { + schema: resolver(Team.Info.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + session_id: z.string().meta({ description: "Session ID of the team lead" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const teams = Team.bySession(SessionID.make(query.session_id)) + return c.json(teams) + }, + ) + .get( + "/:id", + describeRoute({ + summary: "Get team", + operationId: "team.get", + responses: { + 200: { + description: "Team details", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const team = Team.get(id) + if (!team) return c.json({ error: "Team not found" }, 404) + return c.json(team) + }, + ) + .get( + "/:id/members", + describeRoute({ + summary: "List team members", + operationId: "team.members", + responses: { + 200: { + description: "Team members", + content: { + "application/json": { + schema: resolver(Team.Member.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(Team.members(id)) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create team", + operationId: "team.create", + responses: { + 200: { + description: "Created team", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + name: z.string(), + session_id: z.string(), + agent: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const team = Team.create({ + name: body.name, + sessionID: SessionID.make(body.session_id), + agent: body.agent, + }) + return c.json(team) + }, + ) + .post( + "/:id/disband", + describeRoute({ + summary: "Disband team", + operationId: "team.disband", + responses: { + 200: { + description: "Team disbanded", + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + Team.disband(id) + return c.json({ ok: true }) + }, + ) + .get( + "/:id/tasks", + describeRoute({ + summary: "List team tasks", + operationId: "team.task.list", + responses: { + 200: { + description: "Team tasks", + content: { + "application/json": { + schema: resolver(TeamTask.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(TeamTask.list(id)) + }, + ) + .post( + "/:id/tasks", + describeRoute({ + summary: "Create team task", + operationId: "team.task.create", + responses: { + 200: { + description: "Created task", + content: { + "application/json": { + schema: resolver(TeamTask.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + }), + ), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const body = c.req.valid("json") + const task = TeamTask.create({ + teamID: id, + ...body, + }) + return c.json(task) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..5fa5b623ae08 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,6 +41,7 @@ import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { TeamRoutes } from "./routes/team" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" @@ -250,6 +251,7 @@ export namespace Server { .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) + .route("/team", TeamRoutes()) .route("/tui", TuiRoutes()) .post( "/instance/dispose", @@ -557,6 +559,9 @@ export namespace Server { const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + // Reconcile stale teams from previous sessions + import("../team").then(({ Team }) => Team.reconcile()).catch(() => {}) + const shouldPublishMDNS = opts.mdns && server.port && diff --git a/packages/opencode/src/session/inject.ts b/packages/opencode/src/session/inject.ts new file mode 100644 index 000000000000..6046cc42935b --- /dev/null +++ b/packages/opencode/src/session/inject.ts @@ -0,0 +1,100 @@ +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID, MessageID, PartID } from "./schema" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BusEvent } from "@/bus/bus-event" +import z from "zod" + +const log = Log.create({ service: "session.inject" }) + +export namespace SessionInject { + export const Event = { + MessageInjected: BusEvent.define( + "session.message.injected", + z.object({ + sessionID: SessionID.zod, + from: z.string(), + fromSessionID: SessionID.zod, + }), + ), + } + + /** + * Inject a message into a session from another agent/session. + * This creates a synthetic user message that the prompt loop + * picks up on its next iteration. + * + * For sessions that have already completed their loop, we publish + * an injection event that the prompt loop can listen for to wake up. + */ + export async function send(input: { + sessionID: SessionID + from: string + fromSessionID: SessionID + content: string + teamID?: string + }) { + const id = MessageID.ascending() + log.info("injecting", { + sessionID: input.sessionID, + from: input.from, + }) + + // Resolve the target session's agent from its last user message + const agent = await lastAgent(input.sessionID) + + // Create a synthetic user message tagged as injected + const msg: MessageV2.User = { + id, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent, + model: await lastModel(input.sessionID), + system: undefined, + injected: { + from: input.from, + fromSessionID: input.fromSessionID, + teamID: input.teamID, + }, + } + + await Session.updateMessage(msg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: `[Message from @${input.from}]\n\n${input.content}`, + synthetic: true, + } satisfies MessageV2.TextPart) + + Bus.publish(Event.MessageInjected, { + sessionID: input.sessionID, + from: input.from, + fromSessionID: input.fromSessionID, + }) + + log.info("injected", { + sessionID: input.sessionID, + messageID: id, + }) + } + + async function lastModel(sessionID: SessionID) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + // Fallback: use a placeholder that will be resolved by the prompt loop + const { Provider } = await import("../provider/provider") + return Provider.defaultModel() + } + + async function lastAgent(sessionID: SessionID): Promise { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.agent) return item.info.agent + } + return "build" + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f1335f6f21a3..2404e55ced52 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -369,6 +369,13 @@ export namespace MessageV2 { system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), variant: z.string().optional(), + injected: z + .object({ + from: z.string(), + fromSessionID: z.string(), + teamID: z.string().optional(), + }) + .optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5625c571cee9..7ae00ea10a1f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -34,6 +34,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -268,9 +269,82 @@ export namespace SessionPrompt { match.abort.abort() delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) + // Auto-disband any teams owned by this session + import("../team") + .then(({ Team }) => { + Team.disbandBySession(sessionID) + }) + .catch(() => {}) return } + /** + * Check if a session is a background team member that should stay alive + * waiting for injected messages (cross-review, challenges, etc.) + */ + async function shouldWaitForMessages(sessionID: SessionID, abort: AbortSignal): Promise { + if (abort.aborted) return false + const session = await Session.get(sessionID) + if (!session.parentID) return false + + // Check if this session is registered as a team member + const { TeamMemberTable } = await import("../team/team.sql") + const { Database, eq } = await import("../storage/db") + const member = Database.use((db) => + db.select().from(TeamMemberTable).where(eq(TeamMemberTable.session_id, sessionID)).get(), + ) + return !!member && member.status === "active" + } + + /** + * Wait for an injected message to arrive in this session. + * Returns true if a message was injected, false if aborted or timed out. + */ + function waitForInjection(sessionID: SessionID, abort: AbortSignal): Promise { + return new Promise(async (resolve) => { + const config = await Config.get() + const duration = config.team?.member_timeout ?? 300_000 + + const timeout = setTimeout(() => { + cleanup() + resolve(false) + }, duration) + + const { SessionInject } = await import("./inject") + let resolved = false + const unsub = Bus.subscribe(SessionInject.Event.MessageInjected, (event) => { + if (event.properties.sessionID === sessionID && !resolved) { + resolved = true + cleanup() + resolve(true) + } + }) + + // Check for messages injected before subscription was established (race fix) + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "assistant") + const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) + if (pending && !resolved) { + resolved = true + cleanup() + resolve(true) + return + } + + function onAbort() { + cleanup() + resolve(false) + } + abort.addEventListener("abort", onAbort) + + function cleanup() { + clearTimeout(timeout) + unsub() + abort.removeEventListener("abort", onAbort) + } + }) + } + export const LoopInput = z.object({ sessionID: SessionID.zod, resume_existing: z.boolean().optional(), @@ -324,6 +398,15 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + // Check if this session is a background team member that should stay alive + const shouldWait = await shouldWaitForMessages(sessionID, abort) + if (shouldWait) { + log.info("team member waiting for messages", { sessionID }) + const injected = await waitForInjection(sessionID, abort) + if (injected) continue + // Mark member as completed on normal exit (timeout or no more messages) + import("../team").then(({ Team }) => Team.completeMember(sessionID)).catch(() => {}) + } log.info("exiting loop", { sessionID }) break } @@ -654,9 +737,11 @@ export namespace SessionPrompt { // Build system prompt, adding structured output instruction if needed const skills = await SystemPrompt.skills(agent) + const mem = SystemPrompt.memory(agent) const system = [ ...(await SystemPrompt.environment(model)), ...(skills ? [skills] : []), + ...(mem ? [mem] : []), ...(await InstructionPrompt.system()), ] const format = lastUser.format ?? { type: "text" } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index ca324652d9dc..e8c985a163d2 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { AgentMemory } from "@/agent/memory" export namespace SystemPrompt { export function provider(model: Provider.Model) { @@ -65,4 +66,16 @@ export namespace SystemPrompt { Skill.fmt(list, { verbose: true }), ].join("\n") } + + export function memory(agent: Agent.Info): string | undefined { + if (agent.memory !== "local") return undefined + const mem = AgentMemory.read(agent.name) + if (!mem) return undefined + return [ + ``, + `The following is your persistent memory for this project. Use the agent_memory tool to update it.`, + mem.content, + ``, + ].join("\n") + } } diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts new file mode 100644 index 000000000000..cc84c5fc0ebc --- /dev/null +++ b/packages/opencode/src/team/index.ts @@ -0,0 +1,306 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and, inArray } from "../storage/db" +import { TeamTable, TeamMemberTable, TeamTaskTable } from "./team.sql" +import { TeamID } from "./schema" +import { SessionID } from "../session/schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team" }) + +export namespace Team { + export const Info = z + .object({ + id: TeamID.zod, + sessionID: SessionID.zod, + name: z.string(), + status: z.enum(["active", "disbanded"]), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "Team" }) + export type Info = z.infer + + export const Member = z + .object({ + teamID: TeamID.zod, + sessionID: SessionID.zod, + agent: z.string(), + role: z.enum(["lead", "member"]), + status: z.enum(["active", "completed", "failed", "cancelled"]), + }) + .meta({ ref: "TeamMember" }) + export type Member = z.infer + + export const Event = { + Created: BusEvent.define( + "team.created", + z.object({ + team: Info, + }), + ), + Updated: BusEvent.define( + "team.updated", + z.object({ + team: Info, + }), + ), + Disbanded: BusEvent.define( + "team.disbanded", + z.object({ + teamID: TeamID.zod, + }), + ), + MemberAdded: BusEvent.define( + "team.member.added", + z.object({ + member: Member, + }), + ), + MemberUpdated: BusEvent.define( + "team.member.updated", + z.object({ + member: Member, + }), + ), + } + + function toInfo(row: typeof TeamTable.$inferSelect): Info { + return { + id: row.id, + sessionID: row.session_id, + name: row.name, + status: row.status as Info["status"], + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + function toMember(row: typeof TeamMemberTable.$inferSelect): Member { + return { + teamID: row.team_id, + sessionID: row.session_id, + agent: row.agent, + role: row.role as Member["role"], + status: row.status as Member["status"], + } + } + + export function create(input: { name: string; sessionID: SessionID; agent?: string }): Info { + const id = TeamID.ascending() + const now = Date.now() + const row = Database.transaction((db) => { + const row = db + .insert(TeamTable) + .values({ + id, + session_id: input.sessionID, + name: input.name, + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get() + // Add the lead as first member + db.insert(TeamMemberTable) + .values({ + team_id: id, + session_id: input.sessionID, + agent: input.agent ?? "lead", + role: "lead", + status: "active", + time_created: now, + time_updated: now, + }) + .run() + return row + }) + const info = toInfo(row) + log.info("created", { id: info.id, name: info.name }) + Database.effect(() => Bus.publish(Event.Created, { team: info })) + return info + } + + export function disband(id: TeamID) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamTable).set({ status: "disbanded", time_updated: now }).where(eq(TeamTable.id, id)).run() + db.update(TeamMemberTable) + .set({ status: "cancelled", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.status, "active"))) + .run() + // Cascade: mark in-progress and pending tasks as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamTaskTable.team_id, id), inArray(TeamTaskTable.status, ["in_progress", "pending"]))) + .run() + }) + log.info("disbanded", { id }) + Database.effect(() => Bus.publish(Event.Disbanded, { teamID: id })) + } + + export function get(id: TeamID): Info | undefined { + const row = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()) + if (!row) return undefined + return toInfo(row) + } + + export function bySession(sessionID: SessionID): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.session_id, sessionID)).all()) + return rows.map(toInfo) + } + + export function members(id: TeamID): Member[] { + const rows = Database.use((db) => db.select().from(TeamMemberTable).where(eq(TeamMemberTable.team_id, id)).all()) + return rows.map(toMember) + } + + export function addMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }): Member { + const team = get(input.teamID) + if (!team) throw new Error(`Team not found: ${input.teamID}`) + if (team.status === "disbanded") throw new Error(`Cannot add member to disbanded team: ${input.teamID}`) + + // Disambiguate duplicate agent names by appending a suffix + let agent = input.agent + const existing = members(input.teamID).filter((m) => m.role === "member") + const taken = new Set(existing.map((m) => m.agent)) + if (taken.has(agent)) { + let i = 2 + while (taken.has(`${input.agent}-${i}`)) i++ + agent = `${input.agent}-${i}` + } + + const now = Date.now() + const row = Database.use((db) => + db + .insert(TeamMemberTable) + .values({ + team_id: input.teamID, + session_id: input.sessionID, + agent, + role: "member", + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get(), + ) + const member = toMember(row) + Database.effect(() => Bus.publish(Event.MemberAdded, { member })) + return member + } + + export function updateMember(input: { teamID: TeamID; sessionID: SessionID; status: Member["status"] }) { + const now = Date.now() + Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: input.status, time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run(), + ) + } + + export function findMemberSession(input: { teamID: TeamID; agent: string }): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.agent, input.agent))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + export function leadSession(id: TeamID): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.role, "lead"))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + /** Mark a member as completed (normal exit) */ + export function completeMember(sessionID: SessionID) { + const now = Date.now() + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) + .get(), + ) + if (!row) return + Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: "completed", time_updated: now }) + .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) + .run(), + ) + const member = toMember({ ...row, status: "completed" }) + Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Disband all active teams owned by a session (cleanup on session end) */ + export function disbandBySession(sessionID: SessionID) { + const teams = bySession(sessionID).filter((t) => t.status === "active") + for (const team of teams) { + log.info("auto-disbanding on session end", { teamID: team.id, sessionID }) + disband(team.id) + } + } + + /** Mark a member as failed and cascade failure to owned team tasks */ + export function failMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamMemberTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run() + // Cascade: mark in-progress tasks owned by this agent as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where( + and( + eq(TeamTaskTable.team_id, input.teamID), + eq(TeamTaskTable.owner, input.agent), + eq(TeamTaskTable.status, "in_progress"), + ), + ) + .run() + }) + const member = findMemberSession({ teamID: input.teamID, agent: input.agent }) + if (member) Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Reconcile stale teams on startup — mark active teams as disbanded */ + export function reconcile() { + const now = Date.now() + const stale = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + for (const row of stale) { + log.info("reconciling stale team", { id: row.id, name: row.name }) + disband(row.id as TeamID) + } + if (stale.length > 0) log.info("reconciled teams", { count: stale.length }) + } + + /** List all active teams (across all sessions) */ + export function active(): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/schema.ts b/packages/opencode/src/team/schema.ts new file mode 100644 index 000000000000..67b1b96ad0e9 --- /dev/null +++ b/packages/opencode/src/team/schema.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import z from "zod" +import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" + +export const TeamID = Schema.String.pipe( + Schema.brand("TeamID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team", id)), + zod: Identifier.schema("team").pipe(z.custom>()), + })), +) + +export type TeamID = Schema.Schema.Type + +export const TeamTaskID = Schema.String.pipe( + Schema.brand("TeamTaskID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team_task", id)), + zod: Identifier.schema("team_task").pipe(z.custom>()), + })), +) + +export type TeamTaskID = Schema.Schema.Type + +export const MemoryID = Schema.String.pipe( + Schema.brand("MemoryID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("memory", id)), + zod: Identifier.schema("memory").pipe(z.custom>()), + })), +) + +export type MemoryID = Schema.Schema.Type diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts new file mode 100644 index 000000000000..8025db66da72 --- /dev/null +++ b/packages/opencode/src/team/task.ts @@ -0,0 +1,126 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq } from "../storage/db" +import { TeamTaskTable } from "./team.sql" +import { TeamID, TeamTaskID } from "./schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team.task" }) + +export namespace TeamTask { + export const Info = z + .object({ + id: TeamTaskID.zod, + teamID: TeamID.zod, + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed", "failed"]), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "TeamTask" }) + export type Info = z.infer + + export const Event = { + Created: BusEvent.define( + "team.task.created", + z.object({ + task: Info, + }), + ), + Updated: BusEvent.define( + "team.task.updated", + z.object({ + task: Info, + }), + ), + } + + function toInfo(row: typeof TeamTaskTable.$inferSelect): Info { + return { + id: row.id, + teamID: row.team_id, + subject: row.subject, + description: row.description ?? undefined, + owner: row.owner ?? undefined, + status: row.status as Info["status"], + metadata: (row.metadata as Record) ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function create(input: { + teamID: TeamID + subject: string + description?: string + owner?: string + metadata?: Record + }): Info { + const id = TeamTaskID.ascending() + const now = Date.now() + const row = Database.use((db) => + db + .insert(TeamTaskTable) + .values({ + id, + team_id: input.teamID, + subject: input.subject, + description: input.description, + owner: input.owner, + metadata: input.metadata, + time_created: now, + time_updated: now, + }) + .returning() + .get(), + ) + const info = toInfo(row) + log.info("created", { id: info.id, subject: info.subject }) + Database.effect(() => Bus.publish(Event.Created, { task: info })) + return info + } + + export function update( + id: TeamTaskID, + input: Partial>, + ): Info | undefined { + const now = Date.now() + const values: Record = { time_updated: now } + if (input.status !== undefined) values.status = input.status + if (input.owner !== undefined) values.owner = input.owner + if (input.description !== undefined) values.description = input.description + if (input.metadata !== undefined) values.metadata = input.metadata + + Database.use((db) => + db.update(TeamTaskTable).set(values).where(eq(TeamTaskTable.id, id)).run(), + ) + const updated = get(id) + if (updated) { + Database.effect(() => Bus.publish(Event.Updated, { task: updated })) + } + return updated + } + + export function get(id: TeamTaskID): Info | undefined { + const row = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.id, id)).get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function list(teamID: TeamID): Info[] { + const rows = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all(), + ) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/team.sql.ts b/packages/opencode/src/team/team.sql.ts new file mode 100644 index 000000000000..fe146bfb590e --- /dev/null +++ b/packages/opencode/src/team/team.sql.ts @@ -0,0 +1,85 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" +import { SessionTable } from "../session/session.sql" +import { ProjectTable } from "../project/project.sql" +import { Timestamps } from "../storage/schema.sql" +import type { TeamID, TeamTaskID, MemoryID } from "./schema" +import type { SessionID } from "../session/schema" +import type { ProjectID } from "../project/schema" + +export const TeamTable = sqliteTable( + "team", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + name: text().notNull(), + status: text({ enum: ["active", "disbanded"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [index("team_session_idx").on(table.session_id)], +) + +export const TeamMemberTable = sqliteTable( + "team_member", + { + id: integer().primaryKey({ autoIncrement: true }), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + role: text({ enum: ["lead", "member"] }).notNull(), + status: text({ enum: ["active", "completed", "failed", "cancelled"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [ + index("team_member_team_idx").on(table.team_id), + index("team_member_session_idx").on(table.session_id), + uniqueIndex("team_member_team_agent_idx").on(table.team_id, table.agent), + ], +) + +export const TeamTaskTable = sqliteTable( + "team_task", + { + id: text().$type().primaryKey(), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + subject: text().notNull(), + description: text(), + owner: text(), + status: text({ enum: ["pending", "in_progress", "completed", "failed"] }) + .notNull() + .default("pending"), + metadata: text({ mode: "json" }).$type>(), + ...Timestamps, + }, + (table) => [index("team_task_team_idx").on(table.team_id)], +) + +export const AgentMemoryTable = sqliteTable( + "agent_memory", + { + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + content: text().notNull(), + ...Timestamps, + }, + (table) => [uniqueIndex("agent_memory_project_agent_idx").on(table.project_id, table.agent)], +) diff --git a/packages/opencode/src/tool/agent-memory.ts b/packages/opencode/src/tool/agent-memory.ts new file mode 100644 index 000000000000..5684a8588d77 --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.ts @@ -0,0 +1,65 @@ +import z from "zod" +import { Tool } from "./tool" +import { AgentMemory } from "../agent/memory" +import DESCRIPTION from "./agent-memory.txt" + +export const AgentMemoryTool = Tool.define("agent_memory", { + description: DESCRIPTION, + parameters: z.object({ + operation: z + .enum(["read", "write", "append"]) + .describe("The operation to perform"), + content: z + .string() + .optional() + .describe("Content to write or append (required for write/append)"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "agent_memory", + patterns: [params.operation], + always: ["*"], + metadata: {}, + }) + + if (params.operation === "read") { + const memory = AgentMemory.read(ctx.agent) + if (!memory) { + return { + title: "No memory found", + output: `No stored memory found for agent "${ctx.agent}" in this project. This is a fresh start.`, + metadata: { agent: ctx.agent }, + } + } + return { + title: "Memory loaded", + output: memory.content, + metadata: { + agent: ctx.agent, + updated: memory.time.updated, + }, + } + } + + if (params.operation === "write") { + if (!params.content) + throw new Error("content is required for write operation") + AgentMemory.write(ctx.agent, params.content) + return { + title: "Memory updated", + output: `Memory for agent "${ctx.agent}" has been updated.`, + metadata: { agent: ctx.agent }, + } + } + + // append + if (!params.content) + throw new Error("content is required for append operation") + AgentMemory.append(ctx.agent, params.content) + return { + title: "Memory appended", + output: `New content appended to memory for agent "${ctx.agent}".`, + metadata: { agent: ctx.agent }, + } + }, +}) diff --git a/packages/opencode/src/tool/agent-memory.txt b/packages/opencode/src/tool/agent-memory.txt new file mode 100644 index 000000000000..ea73f0c3484f --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.txt @@ -0,0 +1,11 @@ +Read or write persistent per-agent memory for this project. + +Agent memory persists across sessions — use it to store learned patterns, conventions, recurring issues, and domain knowledge specific to this project. + +Operations: +- **read**: Read your stored memory for this project. Call this at the start of a task to recall past learnings. +- **write**: Replace your entire memory content. Use this to store a curated set of learnings. +- **append**: Add new content to existing memory without replacing it. Use this to accumulate learnings incrementally. + +Memory is scoped to (agent, project) — each agent has independent memory per project. +Keep memory concise and actionable. Focus on patterns and conventions, not raw data. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6d648a097a88..b1f741168a06 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,6 +12,11 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { TeamCreateTool } from "./team-create" +import { TeamDeleteTool } from "./team-delete" +import { TeamTaskTool } from "./team-task" +import { SendMessageTool } from "./send-message" +import { AgentMemoryTool } from "./agent-memory" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" @@ -118,6 +123,11 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, + TeamCreateTool, + TeamDeleteTool, + TeamTaskTool, + SendMessageTool, + AgentMemoryTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/opencode/src/tool/send-message.ts b/packages/opencode/src/tool/send-message.ts new file mode 100644 index 000000000000..39478e3f9d0f --- /dev/null +++ b/packages/opencode/src/tool/send-message.ts @@ -0,0 +1,63 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { SessionInject } from "../session/inject" +import DESCRIPTION from "./send-message.txt" + +export const SendMessageTool = Tool.define("send_message", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID"), + recipient: z.string().default("lead").describe("Agent name to send to, or 'lead' for the team lead"), + content: z.string().describe("The message content"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "send_message", + patterns: [params.recipient], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(params.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${params.team_id}`) + + // Validate sender is a member of this team + const members = Team.members(teamID) + const sender = members.find((m) => m.sessionID === ctx.sessionID) + if (!sender) throw new Error(`You are not a member of team "${team.name}"`) + + // Resolve recipient session + let target: Team.Member | undefined + if (params.recipient === "lead") { + target = Team.leadSession(teamID) + } else { + target = Team.findMemberSession({ + teamID, + agent: params.recipient, + }) + } + + if (!target) throw new Error(`Recipient "${params.recipient}" not found in team "${team.name}"`) + + await SessionInject.send({ + sessionID: target.sessionID, + from: ctx.agent, + fromSessionID: ctx.sessionID, + content: params.content, + teamID: params.team_id, + }) + + return { + title: `Message sent to ${params.recipient}`, + output: `Message delivered to @${params.recipient} in team "${team.name}".`, + metadata: { + teamID: params.team_id, + recipient: params.recipient, + }, + } + }, +}) diff --git a/packages/opencode/src/tool/send-message.txt b/packages/opencode/src/tool/send-message.txt new file mode 100644 index 000000000000..b7d600308193 --- /dev/null +++ b/packages/opencode/src/tool/send-message.txt @@ -0,0 +1,13 @@ +Send a message to another agent in your team. + +This enables inter-agent communication within a team: +- **Member → Lead**: Send findings, status updates, or responses to the team lead +- **Lead → Member**: Route challenges, requests for elaboration, or cross-review findings to specific agents + +Usage: +- You must be part of a team to use this tool +- Specify the team_id and either a specific agent name or "lead" as the recipient +- The message content should be clear and actionable +- Messages are delivered asynchronously — the recipient processes them on their next turn + +When receiving messages, respond substantively. For cross-review challenges, provide evidence from the spec or codebase. \ No newline at end of file diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e3781126d0c1..6e7c29c40a97 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,16 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { Permission } from "@/permission" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { Instance } from "@/project/instance" +import { SessionInject } from "@/session/inject" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tool.task" }) + +/** Tracks active background agent count per instance for concurrency limiting */ +let running = 0 const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -23,6 +33,18 @@ const parameters = z.object({ ) .optional(), command: z.string().describe("The command that triggered this task").optional(), + background: z + .boolean() + .describe( + "If true, the agent runs in the background and returns immediately with the task_id. Use this with team_id for parallel multi-agent workflows.", + ) + .optional(), + team_id: z + .string() + .describe( + "The team ID to register this agent as a member. Required when background is true for team-based workflows.", + ) + .optional(), }) export const TaskTool = Tool.define("task", async (ctx) => { @@ -65,6 +87,42 @@ export const TaskTool = Tool.define("task", async (ctx) => { const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // Build permission overrides for child session + const childPermissions = [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + { + permission: "todoread" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ...(hasTaskPermission + ? [] + : [ + { + permission: "task" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*" as const, + action: "allow" as const, + permission: t, + })) ?? []), + ] + + // For team members, grant team communication permissions + if (params.team_id) { + childPermissions.push( + { permission: "send_message", pattern: "*", action: "allow" }, + { permission: "team_task", pattern: "*", action: "allow" }, + ) + } + const session = await iife(async () => { if (params.task_id) { const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) @@ -74,34 +132,20 @@ export const TaskTool = Tool.define("task", async (ctx) => { return await Session.create({ parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, - permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "todoread", - pattern: "*", - action: "deny", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(config.experimental?.primary_tools?.map((t) => ({ - pattern: "*", - action: "allow" as const, - permission: t, - })) ?? []), - ], + permission: childPermissions, }) }) + + // Register as team member if team_id provided + if (params.team_id) { + const teamID = TeamID.make(params.team_id) + Team.addMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + } + const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") @@ -115,19 +159,15 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + background: params.background, + teamID: params.team_id, }, }) const messageID = MessageID.ascending() - - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const result = await SessionPrompt.prompt({ + const promptInput = { messageID, sessionID: session.id, model: { @@ -142,7 +182,86 @@ export const TaskTool = Tool.define("task", async (ctx) => { ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, - }) + } + + // Background execution — launch and return immediately + if (params.background) { + const limit = config.team?.max_agents ?? 10 + if (running >= limit) { + throw new Error( + `Max concurrent background agents reached (${limit}). Wait for existing agents to complete or increase team.max_agents in config.`, + ) + } + + running++ + const teamID = params.team_id ? TeamID.make(params.team_id) : undefined + const bound = Instance.bind(() => { + SessionPrompt.prompt(promptInput) + .catch(async (err) => { + log.error("background agent failed", { + sessionID: session.id, + agent: agent.name, + error: err, + }) + if (teamID) { + Team.failMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + // Notify the lead about the failure + const lead = Team.leadSession(teamID) + if (lead) { + await SessionInject.send({ + sessionID: lead.sessionID, + from: agent.name, + fromSessionID: session.id, + content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, + teamID: params.team_id, + }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) + } + } + }) + .finally(() => { + running-- + }) + }) + bound() + + // Wire up abort to cancel the child session + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + + return { + title: `${params.description} (background)`, + metadata: { + sessionId: session.id, + model, + background: true, + teamID: params.team_id, + }, + output: [ + `task_id: ${session.id} (background agent launched)`, + "", + `Agent @${agent.name} is now running in the background.`, + params.team_id ? `Registered as member of team ${params.team_id}.` : "", + "The agent will send messages via SendMessage when it has findings.", + ] + .filter(Boolean) + .join("\n"), + } + } + + // Synchronous execution — existing behavior + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + + const result = await SessionPrompt.prompt(promptInput) const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d0a..8231bbf5bddc 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -23,6 +23,11 @@ Usage notes: 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +Team workflows: +7. For multi-agent team workflows, first create a team with team_create, then spawn agents with `background: true` and `team_id` set. Background agents run concurrently and communicate via send_message. +8. When `background: true`, the task returns immediately with the task_id. The agent runs in the background and sends results via the send_message tool. +9. Use `team_id` to register the agent as a team member. Team members can use send_message and team_task tools to coordinate. + Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): diff --git a/packages/opencode/src/tool/team-create.ts b/packages/opencode/src/tool/team-create.ts new file mode 100644 index 000000000000..6d4c6bcc3a5c --- /dev/null +++ b/packages/opencode/src/tool/team-create.ts @@ -0,0 +1,39 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import DESCRIPTION from "./team-create.txt" + +export const TeamCreateTool = Tool.define("team_create", { + description: DESCRIPTION, + parameters: z.object({ + name: z.string().describe("A descriptive name for the team (e.g., 'spec-review-auth-service')"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_create", + patterns: [params.name], + always: ["*"], + metadata: { name: params.name }, + }) + + const team = Team.create({ + name: params.name, + sessionID: ctx.sessionID, + agent: ctx.agent, + }) + + return { + title: `Team created: ${params.name}`, + output: JSON.stringify( + { + team_id: team.id, + name: team.name, + status: team.status, + }, + null, + 2, + ), + metadata: { team }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-create.txt b/packages/opencode/src/tool/team-create.txt new file mode 100644 index 000000000000..bd27121911d4 --- /dev/null +++ b/packages/opencode/src/tool/team-create.txt @@ -0,0 +1,11 @@ +Create a named agent team for coordinating multiple specialist sub-agents. + +Teams enable multi-agent workflows where a lead orchestrator spawns specialist agents that work in parallel and communicate findings back through the lead. + +Usage: +- Create a team before spawning background agents that need to coordinate +- The team name should be descriptive (e.g., "spec-review-auth-service") +- After creating a team, use the Task tool with `background: true` and `team_id` to spawn team members +- Use TeamDelete to disband the team when all work is complete + +Returns the team ID which must be passed to subsequent team operations (TaskCreate, SendMessage, TeamDelete). \ No newline at end of file diff --git a/packages/opencode/src/tool/team-delete.ts b/packages/opencode/src/tool/team-delete.ts new file mode 100644 index 000000000000..da71c844db9d --- /dev/null +++ b/packages/opencode/src/tool/team-delete.ts @@ -0,0 +1,33 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import DESCRIPTION from "./team-delete.txt" + +export const TeamDeleteTool = Tool.define("team_delete", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID returned by TeamCreate"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_delete", + patterns: [params.team_id], + always: ["*"], + metadata: {}, + }) + + const id = TeamID.make(params.team_id) + const team = Team.get(id) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team already disbanded: ${params.team_id}`) + + Team.disband(id) + + return { + title: `Team disbanded: ${team.name}`, + output: `Team "${team.name}" (${id}) has been disbanded. All active members marked as cancelled.`, + metadata: { teamID: id }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-delete.txt b/packages/opencode/src/tool/team-delete.txt new file mode 100644 index 000000000000..4001fd7eef33 --- /dev/null +++ b/packages/opencode/src/tool/team-delete.txt @@ -0,0 +1,5 @@ +Disband a team and mark all active members as completed. + +Call this after all team work is done. Agents persist any learnings via their memory — they do not need to stay alive for context retention. + +This does NOT terminate running background agents immediately — it marks the team as disbanded and updates member statuses. Background agents will complete their current work naturally. \ No newline at end of file diff --git a/packages/opencode/src/tool/team-task.ts b/packages/opencode/src/tool/team-task.ts new file mode 100644 index 000000000000..472a8eac7b31 --- /dev/null +++ b/packages/opencode/src/tool/team-task.ts @@ -0,0 +1,87 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamTask } from "../team/task" +import { TeamID, TeamTaskID } from "../team/schema" +import DESCRIPTION from "./team-task.txt" + +const params = z.object({ + operation: z.enum(["create", "update", "get", "list"]).describe("The operation to perform"), + team_id: z.string().describe("The team ID"), + task_id: z.string().optional().describe("The task ID (required for get/update)"), + subject: z.string().optional().describe("Task subject (required for create)"), + description: z.string().optional().describe("Task description"), + owner: z.string().optional().describe("Agent name that owns this task"), + status: z.enum(["pending", "in_progress", "completed", "failed"]).optional().describe("Task status (for update)"), + metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary metadata"), +}) + +export const TeamTaskTool = Tool.define("team_task", { + description: DESCRIPTION, + parameters: params, + async execute(args, ctx) { + await ctx.ask({ + permission: "team_task", + patterns: [args.operation], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(args.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${args.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${args.team_id}`) + + if (args.operation === "create") { + if (!args.subject) throw new Error("subject is required for create operation") + const task = TeamTask.create({ + teamID, + subject: args.subject, + description: args.description, + owner: args.owner, + metadata: args.metadata, + }) + return { + title: `Task created: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + if (args.operation === "update") { + if (!args.task_id) throw new Error("task_id is required for update operation") + const id = TeamTaskID.make(args.task_id) + const task = TeamTask.update(id, { + status: args.status, + owner: args.owner, + description: args.description, + metadata: args.metadata, + }) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task updated: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + if (args.operation === "get") { + if (!args.task_id) throw new Error("task_id is required for get operation") + const task = TeamTask.get(TeamTaskID.make(args.task_id)) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task }, + } + } + + // list + const tasks = TeamTask.list(teamID) + return { + title: `${tasks.length} tasks`, + output: JSON.stringify(tasks, null, 2), + metadata: { tasks }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-task.txt b/packages/opencode/src/tool/team-task.txt new file mode 100644 index 000000000000..e6256d1a6d2b --- /dev/null +++ b/packages/opencode/src/tool/team-task.txt @@ -0,0 +1,11 @@ +Manage tasks on a team's shared task board. Team tasks are visible to all team members and provide coordination state. + +Operations: +- **create**: Create a new task on the team board. Use this to assign work items to specialist agents. +- **update**: Update a task's status, owner, or description. Agents should update their tasks to "in_progress" when starting and "completed" when done. +- **get**: Read a specific task by ID. +- **list**: List all tasks for a team. + +Task statuses: pending, in_progress, completed, failed. + +Tasks are distinct from the Todo tool — Todos track an individual agent's work plan, while Team Tasks coordinate across multiple agents in a team. \ No newline at end of file diff --git a/teams-agents-feature.md b/teams-agents-feature.md new file mode 100644 index 000000000000..763cdf7a7e40 --- /dev/null +++ b/teams-agents-feature.md @@ -0,0 +1,198 @@ +# Teams & Agents Feature — Design Spec + +## Summary + +Add multi-agent team coordination primitives to opencode, enabling workflows where a lead agent orchestrates multiple specialist sub-agents that work in parallel, communicate findings, and produce synthesized results. + +This unlocks the same class of workflows that Claude Code supports via TeamCreate/TaskCreate/SendMessage primitives, but designed natively for opencode's architecture (Sessions, MessageV2, Bus, Instance state, Drizzle DB). + +## Problem + +opencode's current `TaskTool` only supports synchronous sub-agent execution: spawn a child session, block until it completes, return the result. This is insufficient for: + +1. **Parallel specialist reviews** — spawning 8 agents simultaneously, each reviewing a spec from a different angle +2. **Inter-agent communication** — agents sending findings to a lead, or challenging each other's findings +3. **Persistent agent memory** — agents learning project-specific patterns across sessions +4. **Coordinated task boards** — shared state visible to all team members + +## Solution + +### New Subsystems + +#### 1. Team Management (`src/team/`) + +A **Team** is a named group of sessions with a lead and members. DB-backed via `team` and `team_member` tables. + +- `Team.create({ name, sessionID })` — creates team and registers lead in a single transaction +- `Team.disband(id)` — marks team disbanded, all active members cancelled, all pending/in-progress tasks failed +- `Team.addMember({ teamID, sessionID, agent })` — registers a member (rejects disbanded teams; auto-disambiguates duplicate agent names with `-N` suffix) +- `Team.findMemberSession({ teamID, agent })` — resolve agent → session +- `Team.leadSession(teamID)` — get the lead's session +- `Team.completeMember(sessionID)` — marks a member as completed on normal exit +- `Team.failMember({ teamID, sessionID, agent })` — marks member as failed, cascades to owned in-progress tasks +- `Team.disbandBySession(sessionID)` — auto-disbands all active teams owned by a session +- `Team.reconcile()` — marks all active teams as disbanded on server startup (stale state cleanup) + +**Team status values:** `active`, `disbanded`. + +**Member status values:** `active`, `completed`, `failed`, `cancelled`. + +Member status transitions: + +- `active` → `completed` — member exits normally (timeout, no more work) +- `active` → `failed` — background agent crashes +- `active` → `cancelled` — team is disbanded while member is still active + +**Agent name uniqueness:** A unique index enforces `UNIQUE(team_id, agent)` on `team_member`. When `addMember` is called with a duplicate agent name, it auto-disambiguates by appending a numeric suffix (e.g., `general-2`, `general-3`). This supports workflows that spawn multiple agents of the same type. + +#### 2. Team Task Board (`src/team/task.ts`) + +Shared task state scoped to a team. Any member can read/update. Tools validate the team exists and is active before operating. + +- `TeamTask.create({ teamID, subject, owner })` — create a task +- `TeamTask.update(id, { status, owner })` — update status +- `TeamTask.list(teamID)` — list all team tasks + +Statuses: `pending`, `in_progress`, `completed`, `failed`. + +#### 3. Message Injection (`src/session/inject.ts`) + +Cross-session message passing. A synthetic user message is written into a target session's DB, and the prompt loop picks it up on its next iteration. + +- `SessionInject.send({ sessionID, from, fromSessionID, content })` — inject message +- Publishes `session.message.injected` event on the Bus +- Messages are tagged with `injected: { from, fromSessionID, teamID }` on the User message schema +- The target session's agent type is resolved dynamically from its last user message (not hardcoded) + +#### 4. Background Agent Execution (modified `src/tool/task.ts`) + +Extended TaskTool with two new parameters: + +- `background: boolean` — when true, launches the child session in a detached async context via `Instance.bind()` and returns immediately with the `task_id` +- `team_id: string` — registers the child session as a team member; grants `send_message` and `team_task` permissions to the child + +**Concurrency limit:** A process-wide counter limits the number of concurrent background agents. Default: 10, configurable via `team.max_agents`. Throws a descriptive error when the limit is exceeded. + +**Failure handling:** When a background agent crashes: + +1. The member is marked `failed` via `Team.failMember()` +2. The agent's owned in-progress tasks are cascaded to `failed` +3. A `[AGENT FAILURE]` notification is injected into the lead session via `SessionInject.send()` + +**Sender validation:** The `send_message` tool validates that the sending session is a member of the target team before allowing message injection. + +#### 5. Agent Memory (`src/agent/memory.ts`) + +Persistent per-agent, per-project memory stored in `agent_memory` table with a unique index on `(project_id, agent)`. + +- `AgentMemory.read(agent)` — read stored memory +- `AgentMemory.write(agent, content)` — replace memory (capped at 100KB; truncated with warning if exceeded) +- `AgentMemory.append(agent, content)` — append to memory (same 100KB cap applied after append) + +Injected into the system prompt when `memory: local` is set on the agent definition. + +#### 6. Prompt Loop Changes (`src/session/prompt.ts`) + +When a background team member's loop would normally exit (assistant finished, no pending user message), it now: + +1. Checks if the session is registered as an active team member +2. If yes, waits for an injected message (via typed `Bus.subscribe` on `SessionInject.Event.MessageInjected`) +3. To avoid a race where a message is injected before the subscription is established, the wait also checks the DB for pending user messages immediately after subscribing +4. If a message arrives, continues the loop +5. If timeout (configurable via `team.member_timeout`, default 5 minutes) or abort, marks the member as `completed` and exits normally + +### Lifecycle & Cleanup + +- **Session cancellation** — when a lead session is cancelled, `Team.disbandBySession()` auto-disbands all its active teams. Members are marked `cancelled`, pending/in-progress tasks are marked `failed`. +- **Server restart** — `Team.reconcile()` runs on startup and marks all active teams as disbanded (stale state from a previous process). +- **Background member normal exit** — member's `team_member` status is updated to `completed` before exiting. The lead can observe this via `Team.members()`. +- **Background member crash** — member is marked `failed`, tasks cascaded, lead is notified via injected message. +- **Explicit disband** — `team_delete` tool sets all active members to `cancelled`. Members waiting for messages will time out after `member_timeout` and exit (the disband event does not immediately wake waiting members; this is accepted behavior with a bounded worst-case delay). + +### New Tools + +| Tool | Permission | Description | +| -------------- | -------------- | ----------------------------------------- | +| `team_create` | `team_create` | Create a named agent team | +| `team_delete` | `team_delete` | Disband a team | +| `team_task` | `team_task` | CRUD operations on the team task board | +| `send_message` | `send_message` | Send a message to another team member | +| `agent_memory` | `agent_memory` | Read/write/append persistent agent memory | + +### DB Schema (Migration) + +Four new tables: + +- `team` — id, session_id (lead), name, status (`active` | `disbanded`), timestamps +- `team_member` — team_id, session_id, agent, role (`lead` | `member`), status (`active` | `completed` | `failed` | `cancelled`), timestamps. Unique index on `(team_id, agent)`. +- `team_task` — id, team_id, subject, description, owner, status (`pending` | `in_progress` | `completed` | `failed`), metadata (JSON), timestamps +- `agent_memory` — id, project_id, agent, content (capped at 100KB), timestamps. Unique index on `(project_id, agent)`. + +### Config Schema Changes + +- `Agent.Info` gains `memory: "none" | "local"` field +- `Config.Agent` gains `memory` in schema and `knownKeys` set +- `Config` gains optional `team` section: + - `max_agents: number` (default 10) — process-wide max concurrent background agents + - `member_timeout: number` (default 300000ms / 5 minutes) — how long a team member waits for injected messages before exiting + +### Server Routes + +New `/team` route group: list teams by session, get team, get members, create team, disband team, list/create tasks. Also `/team/active` for TUI bootstrap (returns all active teams with their members). + +## Design Decisions + +1. **Async message passing via DB writes** — fits opencode's existing pattern where the prompt loop re-reads messages from DB each iteration. No in-memory queues needed. + +2. **Teams scoped to lead session** — multiple concurrent reviews don't interfere. Lead session owns the lifecycle. + +3. **Agent memory in SQLite** — consistent with all other opencode storage. Unique index on (project_id, agent) ensures one memory entry per agent per project. Content capped at 100KB to prevent context window overflow. + +4. **Background execution via `Instance.bind()`** — preserves ALS context (project, directory, worktree) for the child session. No worker threads needed. + +5. **Configurable wait timeout for team members** — prevents zombie sessions. Default 5 minutes, configurable via `team.member_timeout`. If no message arrives, the member marks itself `completed` and exits. + +6. **New tools are always registered** — permission-gated at execution time, not at registration. The explore agent's `"*": deny` naturally blocks all team tools. + +7. **Agent name auto-disambiguation** — unique index on `(team_id, agent)` enforces uniqueness. `addMember` automatically appends a numeric suffix (`-2`, `-3`, ...) when a duplicate agent type is added to the same team. + +8. **Process-wide concurrency limit** — the `max_agents` counter is module-level (not per-instance). This is intentional for resource management — the limit governs total system load regardless of how many project instances are open. + +9. **Failure notification to lead** — when a background agent crashes, the lead receives an injected `[AGENT FAILURE]` message so it can adapt its workflow. Member status and owned tasks are also cascaded to `failed`. + +## Files Changed + +### New (15 files) + +- `src/team/schema.ts` — TeamID, TeamTaskID, MemoryID branded types +- `src/team/team.sql.ts` — Drizzle table definitions +- `src/team/index.ts` — Team namespace +- `src/team/task.ts` — TeamTask namespace +- `src/agent/memory.ts` — AgentMemory namespace +- `src/session/inject.ts` — SessionInject namespace +- `src/tool/team-create.{ts,txt}` — TeamCreate tool +- `src/tool/team-delete.{ts,txt}` — TeamDelete tool +- `src/tool/team-task.{ts,txt}` — TeamTask tool +- `src/tool/send-message.{ts,txt}` — SendMessage tool +- `src/tool/agent-memory.{ts,txt}` — AgentMemory tool +- `src/server/routes/team.ts` — Team API routes +- `migration/20260321160000_add_teams/migration.sql` — DB migration + +### Modified (10 files) + +- `src/id/id.ts` — 3 new prefixes +- `src/tool/task.ts` — background + team_id params, concurrency limit, failure notification +- `src/tool/task.txt` — team workflow docs +- `src/tool/registry.ts` — 5 new tools registered +- `src/agent/agent.ts` — memory field +- `src/config/config.ts` — memory in Agent schema, `team` config section +- `src/session/message-v2.ts` — injected field on User +- `src/session/prompt.ts` — team-aware loop exit + wait, member completion on exit, race-safe injection wait +- `src/session/system.ts` — memory injection into system prompt +- `src/server/server.ts` — TeamRoutes registered, startup reconciliation + +## Risks & Open Questions + +1. **No rate limiting on SendMessage** — an agent could spam messages. Mitigated by the natural LLM turn structure (each message requires an LLM turn to produce). +2. **Disband does not immediately wake waiting members** — members in `waitForInjection` only listen for `session.message.injected`, not `team.disbanded`. They will idle until `member_timeout` expires. This is accepted behavior — the bounded worst case is the configured timeout (default 5 min). +3. **Migration backward compat** — additive only (new tables), safe to alternate between versions.