@hueyexe/opencode-ensemble 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 +180 -0
- package/dist/index.js +13275 -0
- package/package.json +44 -0
- package/src/db.ts +23 -0
- package/src/hooks.ts +96 -0
- package/src/index.ts +282 -0
- package/src/messaging.ts +78 -0
- package/src/notify.ts +54 -0
- package/src/rate-limit.ts +76 -0
- package/src/recovery.ts +71 -0
- package/src/schema.ts +83 -0
- package/src/state.ts +103 -0
- package/src/tools/team-approve-plan.ts +42 -0
- package/src/tools/team-broadcast.ts +59 -0
- package/src/tools/team-claim.ts +36 -0
- package/src/tools/team-cleanup.ts +45 -0
- package/src/tools/team-create.ts +32 -0
- package/src/tools/team-message.ts +37 -0
- package/src/tools/team-shutdown.ts +51 -0
- package/src/tools/team-spawn.ts +77 -0
- package/src/tools/team-status.ts +49 -0
- package/src/tools/team-tasks-add.ts +49 -0
- package/src/tools/team-tasks-complete.ts +48 -0
- package/src/tools/team-tasks-list.ts +23 -0
- package/src/tools/team-view.ts +23 -0
- package/src/types.ts +82 -0
- package/src/util.ts +35 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/hueyexe/opencode-ensemble"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"opencode",
|
|
15
|
+
"plugin",
|
|
16
|
+
"agent",
|
|
17
|
+
"team",
|
|
18
|
+
"multi-agent",
|
|
19
|
+
"parallel",
|
|
20
|
+
"orchestration"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
32
|
+
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"typescript": "^5"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@opencode-ai/plugin": "^1.2.26",
|
|
42
|
+
"@opencode-ai/sdk": "^1.2.26"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { applyMigrations } from "./schema"
|
|
3
|
+
|
|
4
|
+
let instance: Database | undefined
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create and initialize a SQLite database at the given path.
|
|
8
|
+
* Applies all pending migrations and enables WAL mode.
|
|
9
|
+
*/
|
|
10
|
+
export function createDb(path: string): Database {
|
|
11
|
+
const db = new Database(path)
|
|
12
|
+
db.exec("PRAGMA journal_mode=WAL")
|
|
13
|
+
db.exec("PRAGMA foreign_keys=ON")
|
|
14
|
+
applyMigrations(db)
|
|
15
|
+
instance = db
|
|
16
|
+
return db
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Get the singleton database instance. Must call createDb first. */
|
|
20
|
+
export function getDb(): Database {
|
|
21
|
+
if (!instance) throw new Error("Database not initialized. Call createDb() first.")
|
|
22
|
+
return instance
|
|
23
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
3
|
+
|
|
4
|
+
const TEAM_TOOL_PREFIX = "team_"
|
|
5
|
+
|
|
6
|
+
/** Result of a session status event — tells the caller what transition happened. */
|
|
7
|
+
export interface StatusTransition {
|
|
8
|
+
memberName: string
|
|
9
|
+
teamId: string
|
|
10
|
+
from: string
|
|
11
|
+
to: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle a session.status event. Updates member status and execution_status
|
|
16
|
+
* in SQLite based on the new session status.
|
|
17
|
+
* Ignores events for unknown sessions or archived teams.
|
|
18
|
+
* Returns the transition if one occurred, for toast notifications.
|
|
19
|
+
*/
|
|
20
|
+
export function handleSessionStatusEvent(
|
|
21
|
+
db: Database,
|
|
22
|
+
registry: MemberRegistry,
|
|
23
|
+
sessionId: string,
|
|
24
|
+
status: "idle" | "busy" | "retry",
|
|
25
|
+
): StatusTransition | undefined {
|
|
26
|
+
const entry = registry.getBySession(sessionId)
|
|
27
|
+
if (!entry) return undefined
|
|
28
|
+
|
|
29
|
+
// Check if team is archived — if so, silently ignore
|
|
30
|
+
const team = db.query("SELECT status FROM team WHERE id = ?").get(entry.teamId) as { status: string } | null
|
|
31
|
+
if (!team || team.status === "archived") return undefined
|
|
32
|
+
|
|
33
|
+
const member = db.query("SELECT status, execution_status FROM team_member WHERE team_id = ? AND name = ?")
|
|
34
|
+
.get(entry.teamId, entry.memberName) as { status: string; execution_status: string } | null
|
|
35
|
+
if (!member) return undefined
|
|
36
|
+
|
|
37
|
+
if (status === "idle") {
|
|
38
|
+
const newStatus = member.status === "shutdown_requested" ? "shutdown" : "ready"
|
|
39
|
+
if (member.status === newStatus) return undefined
|
|
40
|
+
db.run(
|
|
41
|
+
"UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
42
|
+
[newStatus, Date.now(), entry.teamId, entry.memberName]
|
|
43
|
+
)
|
|
44
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: newStatus }
|
|
45
|
+
} else if (status === "busy") {
|
|
46
|
+
if (member.status === "ready" || member.status === "error") {
|
|
47
|
+
db.run(
|
|
48
|
+
"UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
49
|
+
[Date.now(), entry.teamId, entry.memberName]
|
|
50
|
+
)
|
|
51
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Handle a session.created event. Tracks the parent-child relationship
|
|
59
|
+
* in the DescendantTracker for sub-agent isolation.
|
|
60
|
+
*/
|
|
61
|
+
export function handleSessionCreatedEvent(
|
|
62
|
+
tracker: DescendantTracker,
|
|
63
|
+
sessionId: string,
|
|
64
|
+
parentId: string | undefined,
|
|
65
|
+
): void {
|
|
66
|
+
if (parentId) {
|
|
67
|
+
tracker.track(sessionId, parentId)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check whether a tool call should be blocked for sub-agent isolation.
|
|
73
|
+
* Throws if the tool is a team tool and the session is a descendant of a team member.
|
|
74
|
+
* OQ-11: assumes throwing inside tool.execute.before fails the tool call gracefully.
|
|
75
|
+
*/
|
|
76
|
+
export function checkToolIsolation(
|
|
77
|
+
registry: MemberRegistry,
|
|
78
|
+
tracker: DescendantTracker,
|
|
79
|
+
toolName: string,
|
|
80
|
+
sessionId: string,
|
|
81
|
+
): void {
|
|
82
|
+
if (!toolName.startsWith(TEAM_TOOL_PREFIX)) return
|
|
83
|
+
|
|
84
|
+
// If the session is a registered team member or lead, allow it
|
|
85
|
+
if (registry.isTeamSession(sessionId)) return
|
|
86
|
+
|
|
87
|
+
// Check if this session is a descendant of any team member
|
|
88
|
+
const allTeamSessions = new Set<string>()
|
|
89
|
+
for (const entry of registry["bySession"].keys()) {
|
|
90
|
+
allTeamSessions.add(entry)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
|
|
94
|
+
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { tool } from "@opencode-ai/plugin"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { createDb } from "./db"
|
|
5
|
+
import { recoverStaleMembers } from "./recovery"
|
|
6
|
+
import { MemberRegistry, DescendantTracker } from "./state"
|
|
7
|
+
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
8
|
+
import { notifyTeamEvent } from "./notify"
|
|
9
|
+
import { executeTeamCreate } from "./tools/team-create"
|
|
10
|
+
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
11
|
+
import { executeTeamMessage } from "./tools/team-message"
|
|
12
|
+
import { executeTeamBroadcast } from "./tools/team-broadcast"
|
|
13
|
+
import { executeTeamShutdown } from "./tools/team-shutdown"
|
|
14
|
+
import { executeTeamCleanup } from "./tools/team-cleanup"
|
|
15
|
+
import { executeTeamTasksList } from "./tools/team-tasks-list"
|
|
16
|
+
import { executeTeamTasksAdd } from "./tools/team-tasks-add"
|
|
17
|
+
import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
|
|
18
|
+
import { executeTeamClaim } from "./tools/team-claim"
|
|
19
|
+
import { executeTeamApprovePlan } from "./tools/team-approve-plan"
|
|
20
|
+
import { executeTeamStatus } from "./tools/team-status"
|
|
21
|
+
import { executeTeamView } from "./tools/team-view"
|
|
22
|
+
import type { ToolDeps, PluginClient } from "./types"
|
|
23
|
+
import { TokenBucket } from "./rate-limit"
|
|
24
|
+
|
|
25
|
+
const DEFAULT_RATE_LIMIT_CAPACITY = 10
|
|
26
|
+
const DEFAULT_RATE_LIMIT_REFILL = 2
|
|
27
|
+
const DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* opencode-ensemble plugin entry point.
|
|
31
|
+
* Enables agent teams: multiple agents running in parallel with
|
|
32
|
+
* peer-to-peer communication, shared task management, and coordinated execution.
|
|
33
|
+
*/
|
|
34
|
+
const plugin: Plugin = async (input) => {
|
|
35
|
+
// Initialize SQLite database in the project's .opencode directory
|
|
36
|
+
const dbPath = path.join(input.directory, ".opencode", "ensemble.db")
|
|
37
|
+
const db = createDb(dbPath)
|
|
38
|
+
|
|
39
|
+
// Initialize in-memory state
|
|
40
|
+
const registry = new MemberRegistry()
|
|
41
|
+
const tracker = new DescendantTracker()
|
|
42
|
+
|
|
43
|
+
// Run recovery on init — mark stale busy members as error
|
|
44
|
+
const recovery = recoverStaleMembers(db)
|
|
45
|
+
if (recovery.interrupted > 0) {
|
|
46
|
+
// Rebuild registry from DB for recovered members
|
|
47
|
+
const members = db.query(
|
|
48
|
+
"SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'"
|
|
49
|
+
).all() as Array<{ team_id: string; name: string; session_id: string }>
|
|
50
|
+
for (const m of members) {
|
|
51
|
+
registry.register(m.team_id, m.name, m.session_id)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build shared tool dependencies
|
|
56
|
+
const client = input.client as unknown as PluginClient
|
|
57
|
+
const deps: ToolDeps = { db, registry, tracker, client }
|
|
58
|
+
|
|
59
|
+
// Initialize rate limiter
|
|
60
|
+
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
61
|
+
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
|
|
62
|
+
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : (parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY)
|
|
63
|
+
const rateLimiter = new TokenBucket({
|
|
64
|
+
capacity: rateLimitCapacity,
|
|
65
|
+
refillRate: DEFAULT_RATE_LIMIT_REFILL,
|
|
66
|
+
refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
// Event hook — drives state machine transitions + descendant tracking + toasts
|
|
71
|
+
async event({ event }) {
|
|
72
|
+
if (event.type === "session.status") {
|
|
73
|
+
const { sessionID, status } = event.properties
|
|
74
|
+
const statusType = status.type as "idle" | "busy" | "retry"
|
|
75
|
+
const transition = handleSessionStatusEvent(db, registry, sessionID, statusType)
|
|
76
|
+
|
|
77
|
+
// Fire toast notifications for meaningful transitions
|
|
78
|
+
if (transition) {
|
|
79
|
+
if (transition.to === "shutdown") {
|
|
80
|
+
notifyTeamEvent(client, "shutdown", { memberName: transition.memberName })
|
|
81
|
+
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
82
|
+
notifyTeamEvent(client, "completed", { memberName: transition.memberName })
|
|
83
|
+
} else if (transition.to === "error") {
|
|
84
|
+
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.type === "session.created") {
|
|
90
|
+
const info = event.properties.info
|
|
91
|
+
if (info.parentID) {
|
|
92
|
+
handleSessionCreatedEvent(tracker, info.id, info.parentID)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Sub-agent isolation + rate limiting hook
|
|
98
|
+
"tool.execute.before": async (input, _output) => {
|
|
99
|
+
checkToolIsolation(registry, tracker, input.tool, input.sessionID)
|
|
100
|
+
// Rate limit team tools that trigger LLM inference
|
|
101
|
+
if (input.tool.startsWith("team_")) {
|
|
102
|
+
if (!rateLimiter.tryConsume()) {
|
|
103
|
+
await rateLimiter.waitForToken()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Register all team tools
|
|
109
|
+
tool: {
|
|
110
|
+
team_create: tool({
|
|
111
|
+
description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
|
|
112
|
+
args: {
|
|
113
|
+
name: tool.schema.string().describe("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
|
|
114
|
+
},
|
|
115
|
+
async execute(args, ctx) {
|
|
116
|
+
const result = await executeTeamCreate(deps, args, ctx.sessionID)
|
|
117
|
+
ctx.metadata({ title: `Created team: ${args.name}` })
|
|
118
|
+
return result
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
team_spawn: tool({
|
|
123
|
+
description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
|
|
124
|
+
"Teammates work asynchronously and will message you when done. Do not poll for their status.",
|
|
125
|
+
args: {
|
|
126
|
+
name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
127
|
+
agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
|
|
128
|
+
prompt: tool.schema.string().describe("Task instructions for the teammate"),
|
|
129
|
+
model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
|
|
130
|
+
claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
|
|
131
|
+
},
|
|
132
|
+
async execute(args, ctx) {
|
|
133
|
+
const result = await executeTeamSpawn(deps, args, ctx.sessionID)
|
|
134
|
+
ctx.metadata({ title: `Spawned ${args.name} (${args.agent})` })
|
|
135
|
+
return result
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
team_message: tool({
|
|
140
|
+
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
|
|
141
|
+
args: {
|
|
142
|
+
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
143
|
+
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
144
|
+
},
|
|
145
|
+
async execute(args, ctx) {
|
|
146
|
+
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
147
|
+
ctx.metadata({ title: `Message → ${args.to}` })
|
|
148
|
+
return result
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
team_broadcast: tool({
|
|
153
|
+
description: "Send a message to all teammates and the lead (excluding yourself).",
|
|
154
|
+
args: {
|
|
155
|
+
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
156
|
+
},
|
|
157
|
+
async execute(args, ctx) {
|
|
158
|
+
const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
|
|
159
|
+
ctx.metadata({ title: "Broadcast to team" })
|
|
160
|
+
return result
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
|
|
164
|
+
team_tasks_list: tool({
|
|
165
|
+
description: "View the shared team task board. Use this to check task status, not to wait for teammates. Teammates will message you when done.",
|
|
166
|
+
args: {},
|
|
167
|
+
async execute(_args, ctx) {
|
|
168
|
+
const result = await executeTeamTasksList(deps, ctx.sessionID)
|
|
169
|
+
const count = result === "No tasks on the board." ? 0 : result.split("\n").length
|
|
170
|
+
ctx.metadata({ title: count > 0 ? `Task board (${count} tasks)` : "Task board (empty)" })
|
|
171
|
+
return result
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
team_tasks_add: tool({
|
|
176
|
+
description: "Add tasks to the shared team task board so teammates can see what work is available and claim it.",
|
|
177
|
+
args: {
|
|
178
|
+
tasks: tool.schema.array(tool.schema.object({
|
|
179
|
+
content: tool.schema.string().describe("Task description"),
|
|
180
|
+
priority: tool.schema.enum(["high", "medium", "low"]).default("medium").describe("Task priority"),
|
|
181
|
+
depends_on: tool.schema.array(tool.schema.string()).optional().describe("Task IDs this depends on"),
|
|
182
|
+
})).describe("Tasks to add"),
|
|
183
|
+
},
|
|
184
|
+
async execute(args, ctx) {
|
|
185
|
+
const result = await executeTeamTasksAdd(deps, args, ctx.sessionID)
|
|
186
|
+
ctx.metadata({ title: `Added ${args.tasks.length} task${args.tasks.length !== 1 ? "s" : ""}` })
|
|
187
|
+
return result
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
|
|
191
|
+
team_tasks_complete: tool({
|
|
192
|
+
description: "Mark a task as completed on the shared board. This unblocks any tasks that depend on it.",
|
|
193
|
+
args: {
|
|
194
|
+
task_id: tool.schema.string().describe("ID of the task to mark complete"),
|
|
195
|
+
},
|
|
196
|
+
async execute(args, ctx) {
|
|
197
|
+
const result = await executeTeamTasksComplete(deps, args, ctx.sessionID)
|
|
198
|
+
ctx.metadata({ title: `Completed task` })
|
|
199
|
+
return result
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
|
|
203
|
+
team_claim: tool({
|
|
204
|
+
description: "Claim a pending task from the shared task list. Only unclaimed, unblocked tasks can be claimed.",
|
|
205
|
+
args: {
|
|
206
|
+
task_id: tool.schema.string().describe("ID of the task to claim"),
|
|
207
|
+
},
|
|
208
|
+
async execute(args, ctx) {
|
|
209
|
+
const result = await executeTeamClaim(deps, args, ctx.sessionID)
|
|
210
|
+
ctx.metadata({ title: `Claimed task` })
|
|
211
|
+
return result
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
|
|
215
|
+
team_approve_plan: tool({
|
|
216
|
+
description: "Approve or reject a teammate's implementation plan. Only the team lead can use this.",
|
|
217
|
+
args: {
|
|
218
|
+
member: tool.schema.string().describe("Teammate name"),
|
|
219
|
+
approved: tool.schema.boolean().describe("true to approve, false to reject"),
|
|
220
|
+
feedback: tool.schema.string().optional().describe("Feedback message (optional)"),
|
|
221
|
+
},
|
|
222
|
+
async execute(args, ctx) {
|
|
223
|
+
const result = await executeTeamApprovePlan(deps, args, ctx.sessionID)
|
|
224
|
+
ctx.metadata({ title: `Plan ${args.approved ? "approved" : "rejected"}: ${args.member}` })
|
|
225
|
+
return result
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
team_shutdown: tool({
|
|
230
|
+
description: "Request a teammate to shut down. The teammate finishes current work then stops.",
|
|
231
|
+
args: {
|
|
232
|
+
member: tool.schema.string().describe("Teammate name to shut down"),
|
|
233
|
+
},
|
|
234
|
+
async execute(args, ctx) {
|
|
235
|
+
const result = await executeTeamShutdown(deps, args, ctx.sessionID)
|
|
236
|
+
ctx.metadata({ title: `Shutdown → ${args.member}` })
|
|
237
|
+
return result
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
|
|
241
|
+
team_cleanup: tool({
|
|
242
|
+
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
243
|
+
args: {
|
|
244
|
+
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
245
|
+
},
|
|
246
|
+
async execute(args, ctx) {
|
|
247
|
+
const result = await executeTeamCleanup(deps, args, ctx.sessionID)
|
|
248
|
+
ctx.metadata({ title: "Team cleaned up" })
|
|
249
|
+
return result
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
|
|
253
|
+
team_status: tool({
|
|
254
|
+
description: "View team members with their current status, agent type, and session IDs. " +
|
|
255
|
+
"Use this to check who is working, idle, or shut down. Includes a task summary.",
|
|
256
|
+
args: {},
|
|
257
|
+
async execute(_args, ctx) {
|
|
258
|
+
const result = await executeTeamStatus(deps, ctx.sessionID)
|
|
259
|
+
const members = deps.db.query("SELECT status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID) as Array<{ status: string }>
|
|
260
|
+
const busy = members.filter(m => m.status === "busy").length
|
|
261
|
+
ctx.metadata({ title: `Team: ${members.length} members (${busy} working)` })
|
|
262
|
+
return result
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
|
|
266
|
+
team_view: tool({
|
|
267
|
+
description: "Navigate the TUI to a teammate's session so you can see what they are doing. " +
|
|
268
|
+
"Use the session picker (ctrl+p) to return to the lead session.",
|
|
269
|
+
args: {
|
|
270
|
+
member: tool.schema.string().describe("Teammate name to view"),
|
|
271
|
+
},
|
|
272
|
+
async execute(args, ctx) {
|
|
273
|
+
const result = await executeTeamView(deps, args, ctx.sessionID)
|
|
274
|
+
ctx.metadata({ title: `Viewing ${args.member}` })
|
|
275
|
+
return result
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export default plugin
|
package/src/messaging.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { generateId } from "./util"
|
|
3
|
+
|
|
4
|
+
const MAX_CONTENT_BYTES = 10 * 1024 // 10KB
|
|
5
|
+
|
|
6
|
+
/** Input for sending a direct message. */
|
|
7
|
+
export interface SendMessageInput {
|
|
8
|
+
teamId: string
|
|
9
|
+
from: string
|
|
10
|
+
to: string
|
|
11
|
+
content: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Input for broadcasting a message. */
|
|
15
|
+
export interface BroadcastMessageInput {
|
|
16
|
+
teamId: string
|
|
17
|
+
from: string
|
|
18
|
+
content: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A message row from the database. */
|
|
22
|
+
export interface MessageRow {
|
|
23
|
+
id: string
|
|
24
|
+
team_id: string
|
|
25
|
+
from_name: string
|
|
26
|
+
to_name: string | null
|
|
27
|
+
content: string
|
|
28
|
+
delivered: number
|
|
29
|
+
time_created: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Insert a direct message into team_message. Returns the message ID.
|
|
34
|
+
* Throws if content exceeds 10KB.
|
|
35
|
+
*/
|
|
36
|
+
export function sendMessage(db: Database, input: SendMessageInput): string {
|
|
37
|
+
if (new TextEncoder().encode(input.content).length > MAX_CONTENT_BYTES) {
|
|
38
|
+
throw new Error("Message content exceeds 10KB limit")
|
|
39
|
+
}
|
|
40
|
+
const id = generateId("msg")
|
|
41
|
+
db.run(
|
|
42
|
+
"INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
|
43
|
+
[id, input.teamId, input.from, input.to, input.content, Date.now()]
|
|
44
|
+
)
|
|
45
|
+
return id
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Insert a broadcast message (to_name = NULL) into team_message. Returns the message ID.
|
|
50
|
+
* Throws if content exceeds 10KB.
|
|
51
|
+
*/
|
|
52
|
+
export function broadcastMessage(db: Database, input: BroadcastMessageInput): string {
|
|
53
|
+
if (new TextEncoder().encode(input.content).length > MAX_CONTENT_BYTES) {
|
|
54
|
+
throw new Error("Message content exceeds 10KB limit")
|
|
55
|
+
}
|
|
56
|
+
const id = generateId("msg")
|
|
57
|
+
db.run(
|
|
58
|
+
"INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created) VALUES (?, ?, ?, NULL, ?, 0, ?)",
|
|
59
|
+
[id, input.teamId, input.from, input.content, Date.now()]
|
|
60
|
+
)
|
|
61
|
+
return id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get all undelivered messages for a team.
|
|
66
|
+
*/
|
|
67
|
+
export function getUndeliveredMessages(db: Database, teamId: string): MessageRow[] {
|
|
68
|
+
return db.query(
|
|
69
|
+
"SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
70
|
+
).all(teamId) as MessageRow[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mark a message as delivered.
|
|
75
|
+
*/
|
|
76
|
+
export function markDelivered(db: Database, messageId: string): void {
|
|
77
|
+
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
|
|
78
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PluginClient } from "./types"
|
|
2
|
+
|
|
3
|
+
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
4
|
+
|
|
5
|
+
interface SpawnEvent { memberName: string; agent: string }
|
|
6
|
+
interface MessageEvent { from: string; to: string }
|
|
7
|
+
interface MemberEvent { memberName: string }
|
|
8
|
+
|
|
9
|
+
type TeamEventData =
|
|
10
|
+
| { type: "spawn"; data: SpawnEvent }
|
|
11
|
+
| { type: "message"; data: MessageEvent }
|
|
12
|
+
| { type: "completed"; data: MemberEvent }
|
|
13
|
+
| { type: "error"; data: MemberEvent }
|
|
14
|
+
| { type: "shutdown"; data: MemberEvent }
|
|
15
|
+
|
|
16
|
+
const TOAST_CONFIG: Record<TeamEventType, { variant: "info" | "success" | "warning" | "error"; duration: number }> = {
|
|
17
|
+
spawn: { variant: "success", duration: 3000 },
|
|
18
|
+
message: { variant: "info", duration: 3000 },
|
|
19
|
+
completed: { variant: "info", duration: 4000 },
|
|
20
|
+
error: { variant: "error", duration: 5000 },
|
|
21
|
+
shutdown: { variant: "info", duration: 3000 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatMessage(type: TeamEventType, data: Record<string, string>): string {
|
|
25
|
+
switch (type) {
|
|
26
|
+
case "spawn": return `Teammate ${data.memberName} spawned (${data.agent})`
|
|
27
|
+
case "message": return `Message from ${data.from}`
|
|
28
|
+
case "completed": return `${data.memberName} finished work`
|
|
29
|
+
case "error": return `${data.memberName} encountered an error`
|
|
30
|
+
case "shutdown": return `${data.memberName} shut down`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fire a toast notification for a team event.
|
|
36
|
+
* Silently swallows errors — TUI may not be available.
|
|
37
|
+
*/
|
|
38
|
+
export async function notifyTeamEvent(
|
|
39
|
+
client: PluginClient,
|
|
40
|
+
type: TeamEventType,
|
|
41
|
+
data: Record<string, string>,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const config = TOAST_CONFIG[type]
|
|
44
|
+
try {
|
|
45
|
+
await client.tui.showToast({
|
|
46
|
+
title: "Team",
|
|
47
|
+
message: formatMessage(type, data),
|
|
48
|
+
variant: config.variant,
|
|
49
|
+
duration: config.duration,
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
// TUI may not be available — silently ignore
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token bucket rate limiter for controlling concurrent LLM inference.
|
|
3
|
+
* Capacity 0 disables the limiter (all calls pass immediately).
|
|
4
|
+
*/
|
|
5
|
+
export class TokenBucket {
|
|
6
|
+
private tokens: number
|
|
7
|
+
private readonly capacity: number
|
|
8
|
+
private readonly refillRate: number
|
|
9
|
+
private readonly refillIntervalMs: number
|
|
10
|
+
private lastRefill: number
|
|
11
|
+
private readonly disabled: boolean
|
|
12
|
+
|
|
13
|
+
constructor(opts: { capacity: number; refillRate: number; refillIntervalMs: number }) {
|
|
14
|
+
this.disabled = opts.capacity === 0
|
|
15
|
+
this.capacity = opts.capacity
|
|
16
|
+
this.tokens = opts.capacity
|
|
17
|
+
this.refillRate = opts.refillRate
|
|
18
|
+
this.refillIntervalMs = opts.refillIntervalMs
|
|
19
|
+
this.lastRefill = Date.now()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Refill tokens based on elapsed time. */
|
|
23
|
+
private refill(): void {
|
|
24
|
+
if (this.disabled) return
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
const elapsed = now - this.lastRefill
|
|
27
|
+
if (elapsed < this.refillIntervalMs) return
|
|
28
|
+
const intervals = Math.floor(elapsed / this.refillIntervalMs)
|
|
29
|
+
this.tokens = Math.min(this.capacity, this.tokens + intervals * this.refillRate)
|
|
30
|
+
this.lastRefill += intervals * this.refillIntervalMs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Try to consume one token. Returns true if a token was available,
|
|
35
|
+
* false if the bucket is empty. Disabled buckets always return true.
|
|
36
|
+
*/
|
|
37
|
+
tryConsume(): boolean {
|
|
38
|
+
if (this.disabled) return true
|
|
39
|
+
this.refill()
|
|
40
|
+
if (this.tokens > 0) {
|
|
41
|
+
this.tokens--
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wait until a token is available, then consume it.
|
|
49
|
+
* Respects an optional AbortSignal.
|
|
50
|
+
*/
|
|
51
|
+
async waitForToken(signal?: AbortSignal): Promise<void> {
|
|
52
|
+
if (this.disabled) return
|
|
53
|
+
if (this.tryConsume()) return
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const checkInterval = Math.max(10, Math.floor(this.refillIntervalMs / 2))
|
|
57
|
+
|
|
58
|
+
const timer = setInterval(() => {
|
|
59
|
+
if (signal?.aborted) {
|
|
60
|
+
clearInterval(timer)
|
|
61
|
+
reject(new Error("Rate limit wait aborted"))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (this.tryConsume()) {
|
|
65
|
+
clearInterval(timer)
|
|
66
|
+
resolve()
|
|
67
|
+
}
|
|
68
|
+
}, checkInterval)
|
|
69
|
+
|
|
70
|
+
signal?.addEventListener("abort", () => {
|
|
71
|
+
clearInterval(timer)
|
|
72
|
+
reject(new Error("Rate limit wait aborted"))
|
|
73
|
+
}, { once: true })
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|