@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/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
@@ -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
+ }