@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 +21 -0
- package/README.md +97 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +10 -0
- package/dist/messaging.d.ts +17 -0
- package/dist/messaging.js +89 -0
- package/dist/state.d.ts +61 -0
- package/dist/state.js +245 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.js +467 -0
- package/package.json +38 -0
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.
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tools.d.ts
ADDED
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
|
+
}
|