@robinmarin/opencode-teams 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robin Marin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @robinmarin/opencode-teams
2
+
3
+ An [OpenCode](https://opencode.ai) plugin that adds agent teams — spawn, coordinate, and shut down sub-agent sessions directly from your lead session.
4
+
5
+ ## Tools
6
+
7
+ | Tool | Description |
8
+ |---|---|
9
+ | `team_create` | Create a new team. The calling session becomes the lead. |
10
+ | `team_spawn` | Spawn a sub-agent with a role and initial prompt. |
11
+ | `team_message` | Send a message to a specific member or the lead. |
12
+ | `team_broadcast` | Send a message to all active members. |
13
+ | `team_status` | Show member statuses and task counts. |
14
+ | `team_shutdown` | Shut down one or all members. |
15
+ | `team_task_add` | Add a task to the team's task board. |
16
+ | `team_task_claim` | Claim a pending task (respects dependency ordering). |
17
+ | `team_task_done` | Mark a task as completed and report newly unblocked tasks. |
18
+
19
+ ## How it works
20
+
21
+ - Team state is persisted to `~/.config/opencode/teams/<teamName>/config.json`
22
+ - When a member goes idle after being busy, the plugin automatically notifies the lead
23
+ - Spawned sub-agents receive a system prompt instructing them not to use team tools
24
+
25
+ ## Installation
26
+
27
+ Add to `~/.config/opencode/opencode.json`:
28
+
29
+ ```json
30
+ {
31
+ "plugin": ["npm:@robinmarin/opencode-teams"]
32
+ }
33
+ ```
34
+
35
+ Restart OpenCode. The nine team tools will be available in your session.
36
+
37
+ ## Local Development
38
+
39
+ ```bash
40
+ # 1. Install dependencies
41
+ bun install
42
+
43
+ # 2. Build
44
+ bun run build
45
+
46
+ # 3. Point OpenCode at the local build
47
+ # Add to ~/.config/opencode/opencode.json:
48
+ # {
49
+ # "plugin": ["file:///absolute/path/to/opencode-teams/dist/index.js"]
50
+ # }
51
+ ```
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ # Run tests
57
+ bun test
58
+
59
+ # Type check
60
+ bun run typecheck
61
+
62
+ # Lint
63
+ bun run lint
64
+ ```
65
+
66
+ ## State schema
67
+
68
+ Team configs live at `~/.config/opencode/teams/<name>/config.json`:
69
+
70
+ ```json
71
+ {
72
+ "name": "my-team",
73
+ "leadSessionId": "sess_abc123",
74
+ "members": {
75
+ "alice": {
76
+ "name": "alice",
77
+ "sessionId": "sess_def456",
78
+ "status": "ready",
79
+ "agentType": "default",
80
+ "model": "anthropic/claude-sonnet-4-5",
81
+ "spawnedAt": "2026-04-05T10:00:00.000Z"
82
+ }
83
+ },
84
+ "tasks": {},
85
+ "createdAt": "2026-04-05T10:00:00.000Z"
86
+ }
87
+ ```
88
+
89
+ Member statuses: `ready | busy | shutdown_requested | shutdown | error`
90
+
91
+ ## Known Limitations
92
+
93
+ **Sub-agent tool isolation is instruction-only.** The `@opencode-ai/sdk` `session.create()` body only accepts `{ parentID, title }` — there is no deny list or permissions field. Sub-agents are told not to use team tools via their system prompt, but a model could ignore this instruction. A future SDK version may expose per-session deny rules; at that point the six team tools should be explicitly denied for all spawned sessions.
94
+
95
+ **Mid-turn idle notification race.** If the lead session is mid-turn (actively generating a response) when a teammate goes idle, the `session.idle` event fires and the plugin sends a `promptAsync` to the lead. OpenCode queues this message; the lead will not re-enter its loop until the current turn completes. There is no mechanism to interrupt an in-progress turn.
96
+
97
+ **No nested teams.** Sub-agents spawned by a lead cannot themselves create teams or spawn further sub-agents. The team management tools are reserved for the original lead session.
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const TeamPlugin: Plugin;
3
+ export declare const server: Plugin;
4
+ export default TeamPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import { createEventHandler } from "./messaging.js";
2
+ import { createTools } from "./tools.js";
3
+ const TeamPlugin = async ({ client }) => {
4
+ return {
5
+ event: createEventHandler(client),
6
+ tool: createTools(client),
7
+ };
8
+ };
9
+ export const server = TeamPlugin;
10
+ export default TeamPlugin;
@@ -0,0 +1,17 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import type { Event } from "@opencode-ai/sdk";
3
+ type Client = PluginInput["client"];
4
+ /**
5
+ * Creates an event handler to be registered as the `event` hook in the plugin's
6
+ * returned Hooks object.
7
+ *
8
+ * On every `session.idle` event:
9
+ * - If the session is not part of any team → ignore
10
+ * - If the session is the team lead → log if any members are busy (no auto-prompt)
11
+ * - If the session is a team member going idle → update status to "ready";
12
+ * if they were previously "busy", notify the lead
13
+ */
14
+ export declare function createEventHandler(client: Client): (input: {
15
+ event: Event;
16
+ }) => Promise<void>;
17
+ export {};
@@ -0,0 +1,89 @@
1
+ import { findTeamBySession, listTeams, markStaleMembersAsError, updateMember } from "./state.js";
2
+ /**
3
+ * On startup, scan all teams for members whose status is "busy" or
4
+ * "shutdown_requested" — these represent sessions that were live when the
5
+ * process last died. Mark them as "error" so the lead can decide what to do.
6
+ */
7
+ async function recoverStaleMembers() {
8
+ const teamNames = await listTeams();
9
+ for (const teamName of teamNames) {
10
+ try {
11
+ const recovered = await markStaleMembersAsError(teamName);
12
+ for (const { memberName, previousStatus } of recovered) {
13
+ console.log(`[opencode-teams] Recovery: member ${memberName} in team ${teamName} was stale (${previousStatus}), marked as error`);
14
+ }
15
+ }
16
+ catch (err) {
17
+ console.error(`[opencode-teams] Recovery: failed to recover team ${teamName}:`, err);
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * Creates an event handler to be registered as the `event` hook in the plugin's
23
+ * returned Hooks object.
24
+ *
25
+ * On every `session.idle` event:
26
+ * - If the session is not part of any team → ignore
27
+ * - If the session is the team lead → log if any members are busy (no auto-prompt)
28
+ * - If the session is a team member going idle → update status to "ready";
29
+ * if they were previously "busy", notify the lead
30
+ */
31
+ export function createEventHandler(client) {
32
+ // Fire-and-forget startup recovery — do not block plugin init
33
+ recoverStaleMembers().catch((err) => {
34
+ console.error("[opencode-teams] recoverStaleMembers failed:", err);
35
+ });
36
+ return async ({ event }) => {
37
+ if (event.type !== "session.idle")
38
+ return;
39
+ const { sessionID } = event.properties;
40
+ let found;
41
+ try {
42
+ found = await findTeamBySession(sessionID);
43
+ }
44
+ catch (err) {
45
+ console.error("[opencode-teams] error in findTeamBySession:", err);
46
+ return;
47
+ }
48
+ if (found === null)
49
+ return;
50
+ const { team, memberName } = found;
51
+ // Lead going idle — check if any members are still busy
52
+ if (memberName === "__lead__") {
53
+ const busyMembers = Object.values(team.members).filter((m) => m.status === "busy");
54
+ if (busyMembers.length > 0) {
55
+ const names = busyMembers.map((m) => m.name).join(", ");
56
+ console.debug(`[opencode-teams] Lead session ${sessionID} is idle but members [${names}] are still busy. ` +
57
+ `Lead may need to be re-prompted when they finish.`);
58
+ }
59
+ return;
60
+ }
61
+ // Team member going idle
62
+ const member = team.members[memberName];
63
+ if (member === undefined)
64
+ return;
65
+ const wasBusy = member.status === "busy";
66
+ // Update member status to ready
67
+ try {
68
+ await updateMember(team.name, memberName, { status: "ready" });
69
+ }
70
+ catch (err) {
71
+ console.error(`[opencode-teams] Failed to update member ${memberName} status to ready:`, err);
72
+ return;
73
+ }
74
+ // If they were busy, notify the lead
75
+ if (wasBusy) {
76
+ const notifyMsg = `[System]: Teammate ${memberName} has gone idle and may need a follow-up or new task.`;
77
+ try {
78
+ const parts = [{ type: "text", text: notifyMsg }];
79
+ await client.session.promptAsync({
80
+ path: { id: team.leadSessionId },
81
+ body: { parts },
82
+ });
83
+ }
84
+ catch (err) {
85
+ console.error(`[opencode-teams] Failed to notify lead of idle member ${memberName}:`, err);
86
+ }
87
+ }
88
+ };
89
+ }
@@ -0,0 +1,61 @@
1
+ export type MemberStatus = "ready" | "busy" | "shutdown_requested" | "shutdown" | "error";
2
+ export type TeamMember = {
3
+ name: string;
4
+ sessionId: string;
5
+ status: MemberStatus;
6
+ agentType: string;
7
+ model: string;
8
+ spawnedAt: string;
9
+ };
10
+ export type TeamTask = {
11
+ id: string;
12
+ title: string;
13
+ description: string;
14
+ status: "pending" | "in_progress" | "completed" | "blocked";
15
+ assignee: string | null;
16
+ dependsOn: string[];
17
+ createdAt: string;
18
+ };
19
+ export type TeamConfig = {
20
+ name: string;
21
+ leadSessionId: string;
22
+ members: Record<string, TeamMember>;
23
+ tasks: Record<string, TeamTask>;
24
+ createdAt: string;
25
+ };
26
+ /** For use in tests only. Pass the temp directory to isolate state. */
27
+ export declare function setTestTeamsDir(dir: string | undefined): void;
28
+ export declare function readTeam(name: string): Promise<TeamConfig | null>;
29
+ export declare function writeTeam(config: TeamConfig): Promise<void>;
30
+ export declare function updateMember(teamName: string, memberName: string, patch: Partial<TeamMember>): Promise<void>;
31
+ export declare function findTeamBySession(sessionId: string): Promise<{
32
+ team: TeamConfig;
33
+ memberName: string;
34
+ } | null>;
35
+ export declare function claimTask(teamName: string, taskId: string, memberName: string): Promise<{
36
+ ok: true;
37
+ } | {
38
+ ok: false;
39
+ reason: string;
40
+ }>;
41
+ /**
42
+ * Atomically marks a task as completed and computes which tasks are now
43
+ * unblocked — all in a single lock pass to avoid TOCTOU races.
44
+ */
45
+ export declare function completeTask(teamName: string, taskId: string): Promise<{
46
+ ok: true;
47
+ unblockedTaskIds: string[];
48
+ } | {
49
+ ok: false;
50
+ reason: string;
51
+ }>;
52
+ /**
53
+ * In a single lock pass, marks all busy/shutdown_requested members in a team
54
+ * as error. Returns the list of recovered members with their previous status,
55
+ * so callers can log without re-reading the file.
56
+ */
57
+ export declare function markStaleMembersAsError(teamName: string): Promise<Array<{
58
+ memberName: string;
59
+ previousStatus: MemberStatus;
60
+ }>>;
61
+ export declare function listTeams(): Promise<string[]>;
package/dist/state.js ADDED
@@ -0,0 +1,245 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ // ---------------------------------------------------------------------------
5
+ // Paths
6
+ // ---------------------------------------------------------------------------
7
+ // Overridable for testing — set via setTestTeamsDir()
8
+ let _overrideTeamsDir;
9
+ /** For use in tests only. Pass the temp directory to isolate state. */
10
+ export function setTestTeamsDir(dir) {
11
+ _overrideTeamsDir = dir;
12
+ // Clear the reverse index so tests start clean
13
+ sessionIndex.clear();
14
+ }
15
+ function teamsDir() {
16
+ if (_overrideTeamsDir !== undefined)
17
+ return _overrideTeamsDir;
18
+ return path.join(os.homedir(), ".config", "opencode", "teams");
19
+ }
20
+ function teamDir(name) {
21
+ return path.join(teamsDir(), name);
22
+ }
23
+ function teamConfigPath(name) {
24
+ return path.join(teamDir(name), "config.json");
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // In-process write lock (per team name)
28
+ // ---------------------------------------------------------------------------
29
+ const writeLocks = new Map();
30
+ function withLock(key, fn) {
31
+ const current = writeLocks.get(key) ?? Promise.resolve();
32
+ let resolve;
33
+ const next = new Promise((res) => {
34
+ resolve = res;
35
+ });
36
+ writeLocks.set(key, next);
37
+ return current.then(fn).finally(resolve);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // In-memory reverse index: sessionId → { teamName, memberName }
41
+ //
42
+ // Kept in sync on every write. Unknown sessions fall back to a full disk scan
43
+ // that populates the index as a side effect, making subsequent lookups O(1).
44
+ // ---------------------------------------------------------------------------
45
+ const sessionIndex = new Map();
46
+ function indexTeam(config) {
47
+ // Remove any stale entries for this team before re-indexing
48
+ for (const [sessionId, entry] of sessionIndex) {
49
+ if (entry.teamName === config.name)
50
+ sessionIndex.delete(sessionId);
51
+ }
52
+ sessionIndex.set(config.leadSessionId, {
53
+ teamName: config.name,
54
+ memberName: "__lead__",
55
+ });
56
+ for (const [memberName, member] of Object.entries(config.members)) {
57
+ sessionIndex.set(member.sessionId, { teamName: config.name, memberName });
58
+ }
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+ export async function readTeam(name) {
64
+ const configPath = teamConfigPath(name);
65
+ try {
66
+ const raw = await fs.readFile(configPath, "utf-8");
67
+ return JSON.parse(raw);
68
+ }
69
+ catch (err) {
70
+ if (err.code === "ENOENT")
71
+ return null;
72
+ throw err;
73
+ }
74
+ }
75
+ export async function writeTeam(config) {
76
+ return withLock(config.name, async () => {
77
+ const dir = teamDir(config.name);
78
+ await fs.mkdir(dir, { recursive: true });
79
+ const configPath = teamConfigPath(config.name);
80
+ const tmpPath = `${configPath}.tmp`;
81
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
82
+ await fs.rename(tmpPath, configPath);
83
+ indexTeam(config);
84
+ });
85
+ }
86
+ export async function updateMember(teamName, memberName, patch) {
87
+ return withLock(teamName, async () => {
88
+ const configPath = teamConfigPath(teamName);
89
+ const raw = await fs.readFile(configPath, "utf-8");
90
+ const config = JSON.parse(raw);
91
+ const existing = config.members[memberName];
92
+ if (existing === undefined) {
93
+ throw new Error(`Member "${memberName}" not found in team "${teamName}"`);
94
+ }
95
+ config.members[memberName] = { ...existing, ...patch };
96
+ const tmpPath = `${configPath}.tmp`;
97
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
98
+ await fs.rename(tmpPath, configPath);
99
+ indexTeam(config);
100
+ });
101
+ }
102
+ export async function findTeamBySession(sessionId) {
103
+ // Fast path: index hit
104
+ const entry = sessionIndex.get(sessionId);
105
+ if (entry !== undefined) {
106
+ const team = await readTeam(entry.teamName);
107
+ if (team === null) {
108
+ sessionIndex.delete(sessionId);
109
+ return null;
110
+ }
111
+ return { team, memberName: entry.memberName };
112
+ }
113
+ // Slow path: full disk scan; populate index as a side effect so future
114
+ // lookups for any session in these teams are O(1).
115
+ let names;
116
+ try {
117
+ names = await listTeams();
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ for (const name of names) {
123
+ const team = await readTeam(name);
124
+ if (team === null)
125
+ continue;
126
+ indexTeam(team);
127
+ if (team.leadSessionId === sessionId)
128
+ return { team, memberName: "__lead__" };
129
+ for (const [memberName, member] of Object.entries(team.members)) {
130
+ if (member.sessionId === sessionId)
131
+ return { team, memberName };
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ export async function claimTask(teamName, taskId, memberName) {
137
+ return withLock(teamName, async () => {
138
+ const configPath = teamConfigPath(teamName);
139
+ const raw = await fs.readFile(configPath, "utf-8");
140
+ const config = JSON.parse(raw);
141
+ const task = config.tasks[taskId];
142
+ if (task === undefined) {
143
+ return { ok: false, reason: `Task "${taskId}" not found in team "${teamName}".` };
144
+ }
145
+ if (task.status !== "pending") {
146
+ return { ok: false, reason: `Task "${taskId}" is not pending (current status: "${task.status}").` };
147
+ }
148
+ const blocking = [];
149
+ for (const depId of task.dependsOn) {
150
+ const dep = config.tasks[depId];
151
+ if (dep === undefined || dep.status !== "completed") {
152
+ blocking.push(depId);
153
+ }
154
+ }
155
+ if (blocking.length > 0) {
156
+ return {
157
+ ok: false,
158
+ reason: `Task "${taskId}" is blocked by: ${blocking.join(", ")}. Complete those tasks first.`,
159
+ };
160
+ }
161
+ config.tasks[taskId] = { ...task, status: "in_progress", assignee: memberName };
162
+ const tmpPath = `${configPath}.tmp`;
163
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
164
+ await fs.rename(tmpPath, configPath);
165
+ return { ok: true };
166
+ });
167
+ }
168
+ /**
169
+ * Atomically marks a task as completed and computes which tasks are now
170
+ * unblocked — all in a single lock pass to avoid TOCTOU races.
171
+ */
172
+ export async function completeTask(teamName, taskId) {
173
+ return withLock(teamName, async () => {
174
+ const configPath = teamConfigPath(teamName);
175
+ const raw = await fs.readFile(configPath, "utf-8");
176
+ const config = JSON.parse(raw);
177
+ const task = config.tasks[taskId];
178
+ if (task === undefined) {
179
+ return { ok: false, reason: `Task "${taskId}" not found in team "${teamName}".` };
180
+ }
181
+ config.tasks[taskId] = { ...task, status: "completed" };
182
+ const unblockedTaskIds = [];
183
+ for (const [id, t] of Object.entries(config.tasks)) {
184
+ if (t.status !== "pending")
185
+ continue;
186
+ if (!t.dependsOn.includes(taskId))
187
+ continue;
188
+ const allDone = t.dependsOn.every((depId) => config.tasks[depId]?.status === "completed");
189
+ if (allDone)
190
+ unblockedTaskIds.push(id);
191
+ }
192
+ const tmpPath = `${configPath}.tmp`;
193
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
194
+ await fs.rename(tmpPath, configPath);
195
+ return { ok: true, unblockedTaskIds };
196
+ });
197
+ }
198
+ /**
199
+ * In a single lock pass, marks all busy/shutdown_requested members in a team
200
+ * as error. Returns the list of recovered members with their previous status,
201
+ * so callers can log without re-reading the file.
202
+ */
203
+ export async function markStaleMembersAsError(teamName) {
204
+ return withLock(teamName, async () => {
205
+ const configPath = teamConfigPath(teamName);
206
+ let raw;
207
+ try {
208
+ raw = await fs.readFile(configPath, "utf-8");
209
+ }
210
+ catch (err) {
211
+ if (err.code === "ENOENT")
212
+ return [];
213
+ throw err;
214
+ }
215
+ const config = JSON.parse(raw);
216
+ const recovered = [];
217
+ let dirty = false;
218
+ for (const [name, member] of Object.entries(config.members)) {
219
+ if (member.status === "busy" || member.status === "shutdown_requested") {
220
+ recovered.push({ memberName: name, previousStatus: member.status });
221
+ config.members[name] = { ...member, status: "error" };
222
+ dirty = true;
223
+ }
224
+ }
225
+ if (!dirty)
226
+ return [];
227
+ const tmpPath = `${configPath}.tmp`;
228
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
229
+ await fs.rename(tmpPath, configPath);
230
+ // Session IDs did not change — index does not need updating
231
+ return recovered;
232
+ });
233
+ }
234
+ export async function listTeams() {
235
+ const dir = teamsDir();
236
+ try {
237
+ const entries = await fs.readdir(dir, { withFileTypes: true });
238
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
239
+ }
240
+ catch (err) {
241
+ if (err.code === "ENOENT")
242
+ return [];
243
+ throw err;
244
+ }
245
+ }
@@ -0,0 +1,4 @@
1
+ import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin";
2
+ type Client = PluginInput["client"];
3
+ export declare function createTools(client: Client): Record<string, ToolDefinition>;
4
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,467 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { claimTask, completeTask, findTeamBySession, readTeam, updateMember, writeTeam, } from "./state.js";
3
+ // Use the plugin's bundled Zod instance to avoid version mismatch
4
+ const z = tool.schema;
5
+ // ---------------------------------------------------------------------------
6
+ // tool definitions
7
+ // ---------------------------------------------------------------------------
8
+ function makeTools(client) {
9
+ // ---------------------------------------------------------------------------
10
+ // team_create
11
+ // ---------------------------------------------------------------------------
12
+ const team_create = tool({
13
+ description: "Create a new agent team. The calling session becomes the team lead.",
14
+ args: {
15
+ name: z.string().describe("Unique team name"),
16
+ description: z.string().optional().describe("Optional team description"),
17
+ },
18
+ async execute(args, context) {
19
+ try {
20
+ const existing = await readTeam(args.name);
21
+ if (existing !== null) {
22
+ return `Error: Team "${args.name}" already exists.`;
23
+ }
24
+ await writeTeam({
25
+ name: args.name,
26
+ leadSessionId: context.sessionID,
27
+ members: {},
28
+ tasks: {},
29
+ createdAt: new Date().toISOString(),
30
+ });
31
+ return `Team "${args.name}" created. You are the lead (session: ${context.sessionID}).`;
32
+ }
33
+ catch (err) {
34
+ console.error("[team_create] error:", err);
35
+ return `Error creating team: ${String(err)}`;
36
+ }
37
+ },
38
+ });
39
+ // ---------------------------------------------------------------------------
40
+ // team_spawn
41
+ // ---------------------------------------------------------------------------
42
+ const team_spawn = tool({
43
+ description: "Spawn a new sub-agent session as a team member with a specific role.",
44
+ args: {
45
+ teamName: z.string().describe("Name of the team to add the member to"),
46
+ memberName: z.string().describe("Unique name for this team member"),
47
+ role: z
48
+ .string()
49
+ .describe("Role description for this member (e.g. 'backend engineer', 'code reviewer')"),
50
+ initialPrompt: z
51
+ .string()
52
+ .describe("Initial task or instructions to send to the member"),
53
+ model: z
54
+ .string()
55
+ .optional()
56
+ .describe("Model override (e.g. 'anthropic/claude-haiku-4-5'). Defaults to session model."),
57
+ },
58
+ async execute(args, context) {
59
+ try {
60
+ const team = await readTeam(args.teamName);
61
+ if (team === null) {
62
+ return `Error: Team "${args.teamName}" does not exist. Create it first with team_create.`;
63
+ }
64
+ if (team.members[args.memberName] !== undefined) {
65
+ return `Error: Member "${args.memberName}" already exists in team "${args.teamName}".`;
66
+ }
67
+ // Create the new session
68
+ const createResult = await client.session.create({
69
+ body: { title: `[${args.teamName}] ${args.memberName}` },
70
+ });
71
+ if (createResult.error !== undefined) {
72
+ return `Error creating session for member: ${JSON.stringify(createResult.error)}`;
73
+ }
74
+ const sessionId = createResult.data.id;
75
+ // Known limitation: @opencode-ai/sdk session.create() only accepts
76
+ // { parentID, title } in its body — there is no deny list or permissions
77
+ // field. Sub-agent tool isolation is therefore instruction-only. A
78
+ // future SDK version may expose a deny list; at that point add explicit
79
+ // deny rules for all six team tools here for defence in depth.
80
+ const systemPrompt = [
81
+ `You are "${args.memberName}", a ${args.role} on team "${args.teamName}".`,
82
+ `Your lead session ID is: ${team.leadSessionId}.`,
83
+ `You were spawned to work on specific tasks. Complete them thoroughly.`,
84
+ `IMPORTANT: You must NOT use team management tools (team_create, team_spawn, team_message, team_broadcast, team_status, team_shutdown). These tools are reserved for the team lead only.`,
85
+ `When you complete a task, summarize your results clearly so the lead can review them.`,
86
+ ].join("\n");
87
+ // Parse optional model selector
88
+ let modelOpt;
89
+ if (typeof args.model === "string") {
90
+ const slashIdx = args.model.indexOf("/");
91
+ if (slashIdx !== -1) {
92
+ modelOpt = {
93
+ providerID: args.model.slice(0, slashIdx),
94
+ modelID: args.model.slice(slashIdx + 1),
95
+ };
96
+ }
97
+ }
98
+ // Write member into team state BEFORE firing the prompt so that state
99
+ // is always consistent — a live session always has a state record.
100
+ const now = new Date().toISOString();
101
+ await writeTeam({
102
+ ...team,
103
+ members: {
104
+ ...team.members,
105
+ [args.memberName]: {
106
+ name: args.memberName,
107
+ sessionId,
108
+ status: "busy",
109
+ agentType: "default",
110
+ model: args.model ?? "default",
111
+ spawnedAt: now,
112
+ },
113
+ },
114
+ });
115
+ // Fire initial prompt; if it throws, mark the member as error so
116
+ // callers know the session is in an unknown state.
117
+ try {
118
+ await client.session.promptAsync({
119
+ path: { id: sessionId },
120
+ body: {
121
+ parts: [{ type: "text", text: args.initialPrompt }],
122
+ system: systemPrompt,
123
+ ...(modelOpt !== undefined ? { model: modelOpt } : {}),
124
+ },
125
+ });
126
+ }
127
+ catch (promptErr) {
128
+ console.error("[team_spawn] promptAsync failed:", promptErr);
129
+ try {
130
+ await updateMember(args.teamName, args.memberName, {
131
+ status: "error",
132
+ });
133
+ }
134
+ catch (updateErr) {
135
+ console.error("[team_spawn] failed to update member status to error:", updateErr);
136
+ }
137
+ return `Error sending initial prompt to member "${args.memberName}": ${String(promptErr)}`;
138
+ }
139
+ return `Member "${args.memberName}" spawned (session: ${sessionId}). Initial prompt sent.`;
140
+ }
141
+ catch (err) {
142
+ console.error("[team_spawn] error:", err);
143
+ return `Error spawning member: ${String(err)}`;
144
+ }
145
+ },
146
+ });
147
+ // ---------------------------------------------------------------------------
148
+ // team_message
149
+ // ---------------------------------------------------------------------------
150
+ const team_message = tool({
151
+ description: "Send a message to a specific team member or the team lead.",
152
+ args: {
153
+ teamName: z.string().describe("Name of the team"),
154
+ to: z.string().describe("Recipient: member name or 'lead'"),
155
+ message: z.string().describe("Message to send"),
156
+ },
157
+ async execute(args, context) {
158
+ try {
159
+ const team = await readTeam(args.teamName);
160
+ if (team === null) {
161
+ return `Error: Team "${args.teamName}" not found.`;
162
+ }
163
+ // Determine sender name
164
+ const senderInfo = await findTeamBySession(context.sessionID);
165
+ const senderName = senderInfo !== null ? senderInfo.memberName : context.sessionID;
166
+ // Resolve target session ID
167
+ let targetSessionId;
168
+ if (args.to === "lead") {
169
+ targetSessionId = team.leadSessionId;
170
+ }
171
+ else {
172
+ const member = team.members[args.to];
173
+ if (member === undefined) {
174
+ return `Error: Member "${args.to}" not found in team "${args.teamName}".`;
175
+ }
176
+ if (member.status === "shutdown" ||
177
+ member.status === "shutdown_requested") {
178
+ return `Error: Member "${args.to}" is in shutdown state and cannot receive messages.`;
179
+ }
180
+ targetSessionId = member.sessionId;
181
+ }
182
+ const prefixedMessage = `[Team message from ${senderName}]: ${args.message}`;
183
+ await client.session.promptAsync({
184
+ path: { id: targetSessionId },
185
+ body: { parts: [{ type: "text", text: prefixedMessage }] },
186
+ });
187
+ return `Message sent to "${args.to}".`;
188
+ }
189
+ catch (err) {
190
+ console.error("[team_message] error:", err);
191
+ return `Error sending message: ${String(err)}`;
192
+ }
193
+ },
194
+ });
195
+ // ---------------------------------------------------------------------------
196
+ // team_broadcast
197
+ // ---------------------------------------------------------------------------
198
+ const team_broadcast = tool({
199
+ description: "Broadcast a message to all active team members (status: ready or busy), excluding the sender.",
200
+ args: {
201
+ teamName: z.string().describe("Name of the team"),
202
+ message: z
203
+ .string()
204
+ .describe("Message to broadcast to all active members"),
205
+ },
206
+ async execute(args, context) {
207
+ try {
208
+ const team = await readTeam(args.teamName);
209
+ if (team === null) {
210
+ return `Error: Team "${args.teamName}" not found.`;
211
+ }
212
+ const senderInfo = await findTeamBySession(context.sessionID);
213
+ const senderName = senderInfo !== null ? senderInfo.memberName : context.sessionID;
214
+ const prefixedMessage = `[Team broadcast from ${senderName}]: ${args.message}`;
215
+ const messaged = [];
216
+ for (const [memberName, member] of Object.entries(team.members)) {
217
+ if (member.sessionId === context.sessionID)
218
+ continue; // skip sender
219
+ if (member.status !== "ready" && member.status !== "busy") {
220
+ continue;
221
+ }
222
+ try {
223
+ await client.session.promptAsync({
224
+ path: { id: member.sessionId },
225
+ body: { parts: [{ type: "text", text: prefixedMessage }] },
226
+ });
227
+ messaged.push(memberName);
228
+ }
229
+ catch (err) {
230
+ console.error(`[team_broadcast] failed to message ${memberName}:`, err);
231
+ }
232
+ }
233
+ if (messaged.length === 0) {
234
+ return `Broadcast sent to no members (no active members found excluding sender).`;
235
+ }
236
+ return `Broadcast sent to: ${messaged.join(", ")}.`;
237
+ }
238
+ catch (err) {
239
+ console.error("[team_broadcast] error:", err);
240
+ return `Error broadcasting: ${String(err)}`;
241
+ }
242
+ },
243
+ });
244
+ // ---------------------------------------------------------------------------
245
+ // team_status
246
+ // ---------------------------------------------------------------------------
247
+ const team_status = tool({
248
+ description: "Get the current status of a team: members, their statuses, and task counts.",
249
+ args: {
250
+ teamName: z.string().describe("Name of the team"),
251
+ },
252
+ async execute(args, _context) {
253
+ try {
254
+ const team = await readTeam(args.teamName);
255
+ if (team === null) {
256
+ return `Error: Team "${args.teamName}" not found.`;
257
+ }
258
+ const memberLines = Object.values(team.members).map((m) => ` - ${m.name} [${m.status}] session=${m.sessionId} model=${m.model} spawned=${m.spawnedAt}`);
259
+ const tasksByStatus = {
260
+ pending: 0,
261
+ in_progress: 0,
262
+ completed: 0,
263
+ blocked: 0,
264
+ };
265
+ for (const task of Object.values(team.tasks)) {
266
+ tasksByStatus[task.status] = (tasksByStatus[task.status] ?? 0) + 1;
267
+ }
268
+ const lines = [
269
+ `Team: ${team.name}`,
270
+ `Lead session: ${team.leadSessionId}`,
271
+ `Created: ${team.createdAt}`,
272
+ ``,
273
+ `Members (${Object.keys(team.members).length}):`,
274
+ ...memberLines,
275
+ ``,
276
+ `Tasks: pending=${tasksByStatus["pending"] ?? 0} in_progress=${tasksByStatus["in_progress"] ?? 0} completed=${tasksByStatus["completed"] ?? 0} blocked=${tasksByStatus["blocked"] ?? 0}`,
277
+ ];
278
+ return lines.join("\n");
279
+ }
280
+ catch (err) {
281
+ console.error("[team_status] error:", err);
282
+ return `Error getting status: ${String(err)}`;
283
+ }
284
+ },
285
+ });
286
+ // ---------------------------------------------------------------------------
287
+ // team_shutdown
288
+ // ---------------------------------------------------------------------------
289
+ const team_shutdown = tool({
290
+ description: "Shut down one or all team members by sending them a shutdown message.",
291
+ args: {
292
+ teamName: z.string().describe("Name of the team"),
293
+ memberName: z
294
+ .string()
295
+ .optional()
296
+ .describe("Member to shut down. Omit to shut down all active members."),
297
+ },
298
+ async execute(args, _context) {
299
+ try {
300
+ const team = await readTeam(args.teamName);
301
+ if (team === null) {
302
+ return `Error: Team "${args.teamName}" not found.`;
303
+ }
304
+ const shutdownMsg = "[System]: You are being shut down. Complete your current thought and stop.";
305
+ const targets = [];
306
+ if (typeof args.memberName === "string") {
307
+ const member = team.members[args.memberName];
308
+ if (member === undefined) {
309
+ return `Error: Member "${args.memberName}" not found in team "${args.teamName}".`;
310
+ }
311
+ targets.push({ name: args.memberName, sessionId: member.sessionId });
312
+ }
313
+ else {
314
+ for (const [name, member] of Object.entries(team.members)) {
315
+ if (member.status === "ready" || member.status === "busy") {
316
+ targets.push({ name, sessionId: member.sessionId });
317
+ }
318
+ }
319
+ }
320
+ const shut = [];
321
+ for (const target of targets) {
322
+ try {
323
+ await client.session.promptAsync({
324
+ path: { id: target.sessionId },
325
+ body: { parts: [{ type: "text", text: shutdownMsg }] },
326
+ });
327
+ await updateMember(args.teamName, target.name, {
328
+ status: "shutdown_requested",
329
+ });
330
+ shut.push(target.name);
331
+ }
332
+ catch (err) {
333
+ console.error(`[team_shutdown] failed for ${target.name}:`, err);
334
+ }
335
+ }
336
+ if (shut.length === 0) {
337
+ return `No members were shut down (no active members found).`;
338
+ }
339
+ return `Shutdown requested for: ${shut.join(", ")}.`;
340
+ }
341
+ catch (err) {
342
+ console.error("[team_shutdown] error:", err);
343
+ return `Error shutting down: ${String(err)}`;
344
+ }
345
+ },
346
+ });
347
+ // ---------------------------------------------------------------------------
348
+ // team_task_add
349
+ // ---------------------------------------------------------------------------
350
+ const team_task_add = tool({
351
+ description: "Add a task to the team's task board.",
352
+ args: {
353
+ teamName: z.string().describe("Name of the team"),
354
+ title: z.string().describe("Short title for the task"),
355
+ description: z.string().describe("Full description of the task"),
356
+ assignee: z
357
+ .string()
358
+ .optional()
359
+ .describe("Name of the member to assign the task to (optional)"),
360
+ dependsOn: z
361
+ .array(z.string())
362
+ .optional()
363
+ .describe("Task IDs this task is blocked by (optional)"),
364
+ },
365
+ async execute(args, _context) {
366
+ try {
367
+ const team = await readTeam(args.teamName);
368
+ if (team === null) {
369
+ return `Error: Team "${args.teamName}" not found.`;
370
+ }
371
+ const taskId = `task_${Date.now()}`;
372
+ const now = new Date().toISOString();
373
+ await writeTeam({
374
+ ...team,
375
+ tasks: {
376
+ ...team.tasks,
377
+ [taskId]: {
378
+ id: taskId,
379
+ title: args.title,
380
+ description: args.description,
381
+ status: "pending",
382
+ assignee: args.assignee ?? null,
383
+ dependsOn: args.dependsOn ?? [],
384
+ createdAt: now,
385
+ },
386
+ },
387
+ });
388
+ return `Task "${args.title}" added with ID: ${taskId}.`;
389
+ }
390
+ catch (err) {
391
+ console.error("[team_task_add] error:", err);
392
+ return `Error adding task: ${String(err)}`;
393
+ }
394
+ },
395
+ });
396
+ // ---------------------------------------------------------------------------
397
+ // team_task_claim
398
+ // ---------------------------------------------------------------------------
399
+ const team_task_claim = tool({
400
+ description: "Claim a pending task, marking it as in_progress. Fails if the task has incomplete dependencies.",
401
+ args: {
402
+ teamName: z.string().describe("Name of the team"),
403
+ taskId: z.string().describe("ID of the task to claim"),
404
+ },
405
+ async execute(args, context) {
406
+ try {
407
+ // Look up the calling member's name via their session ID
408
+ const found = await findTeamBySession(context.sessionID);
409
+ const memberName = found !== null && found.memberName !== "__lead__"
410
+ ? found.memberName
411
+ : context.sessionID;
412
+ const result = await claimTask(args.teamName, args.taskId, memberName);
413
+ if (!result.ok) {
414
+ return `Error: ${result.reason}`;
415
+ }
416
+ return `Task "${args.taskId}" claimed by "${memberName}" and is now in_progress.`;
417
+ }
418
+ catch (err) {
419
+ console.error("[team_task_claim] error:", err);
420
+ return `Error claiming task: ${String(err)}`;
421
+ }
422
+ },
423
+ });
424
+ // ---------------------------------------------------------------------------
425
+ // team_task_done
426
+ // ---------------------------------------------------------------------------
427
+ const team_task_done = tool({
428
+ description: "Mark a task as completed and report any newly unblocked tasks.",
429
+ args: {
430
+ teamName: z.string().describe("Name of the team"),
431
+ taskId: z.string().describe("ID of the task to mark as completed"),
432
+ },
433
+ async execute(args, _context) {
434
+ try {
435
+ const result = await completeTask(args.teamName, args.taskId);
436
+ if (!result.ok) {
437
+ return `Error: ${result.reason}`;
438
+ }
439
+ const unblockedMsg = result.unblockedTaskIds.length > 0
440
+ ? ` Newly unblocked: ${result.unblockedTaskIds.join(", ")}.`
441
+ : "";
442
+ return `Task "${args.taskId}" marked as completed.${unblockedMsg}`;
443
+ }
444
+ catch (err) {
445
+ console.error("[team_task_done] error:", err);
446
+ return `Error completing task: ${String(err)}`;
447
+ }
448
+ },
449
+ });
450
+ return {
451
+ team_create,
452
+ team_spawn,
453
+ team_message,
454
+ team_broadcast,
455
+ team_status,
456
+ team_shutdown,
457
+ team_task_add,
458
+ team_task_claim,
459
+ team_task_done,
460
+ };
461
+ }
462
+ // ---------------------------------------------------------------------------
463
+ // Factory — call this at plugin init with the live client
464
+ // ---------------------------------------------------------------------------
465
+ export function createTools(client) {
466
+ return makeTools(client);
467
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@robinmarin/opencode-teams",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin — spawn and coordinate sub-agent teams from your lead session",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [
14
+ "opencode",
15
+ "opencode-plugin",
16
+ "ai",
17
+ "agents",
18
+ "multi-agent",
19
+ "teams"
20
+ ],
21
+ "license": "MIT",
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "bun test",
25
+ "typecheck": "tsc --noEmit",
26
+ "lint": "biome check src"
27
+ },
28
+ "dependencies": {
29
+ "@opencode-ai/plugin": "^1.3.15",
30
+ "@opencode-ai/sdk": "^1.3.15",
31
+ "zod": "^4.3.6"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "^2.4.10",
35
+ "bun-types": "^1.3.11",
36
+ "typescript": "^6.0.2"
37
+ }
38
+ }