@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_status tool. Shows team overview with member statuses and task summary.
|
|
6
|
+
*/
|
|
7
|
+
export async function executeTeamStatus(
|
|
8
|
+
deps: ToolDeps,
|
|
9
|
+
sessionId: string,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
12
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
|
|
14
|
+
const members = deps.db.query(
|
|
15
|
+
"SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
16
|
+
).all(teamInfo.teamId) as Array<{
|
|
17
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
const tasks = deps.db.query(
|
|
21
|
+
"SELECT status FROM team_task WHERE team_id = ?"
|
|
22
|
+
).all(teamInfo.teamId) as Array<{ status: string }>
|
|
23
|
+
|
|
24
|
+
const lines: string[] = []
|
|
25
|
+
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`)
|
|
26
|
+
lines.push("")
|
|
27
|
+
|
|
28
|
+
if (members.length === 0) {
|
|
29
|
+
lines.push("No teammates spawned yet.")
|
|
30
|
+
} else {
|
|
31
|
+
lines.push("Members:")
|
|
32
|
+
for (const m of members) {
|
|
33
|
+
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
|
|
34
|
+
lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent} session: ${m.session_id}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (tasks.length > 0) {
|
|
39
|
+
const byStatus = new Map<string, number>()
|
|
40
|
+
for (const t of tasks) {
|
|
41
|
+
byStatus.set(t.status, (byStatus.get(t.status) ?? 0) + 1)
|
|
42
|
+
}
|
|
43
|
+
const parts = Array.from(byStatus.entries()).map(([s, n]) => `${n} ${s}`)
|
|
44
|
+
lines.push("")
|
|
45
|
+
lines.push(`Tasks: ${tasks.length} total (${parts.join(", ")})`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return lines.join("\n")
|
|
49
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
import { generateId } from "../util"
|
|
4
|
+
|
|
5
|
+
interface TaskInput {
|
|
6
|
+
content: string
|
|
7
|
+
priority: string
|
|
8
|
+
depends_on?: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Execute the team_tasks_add tool. Adds tasks to the shared board.
|
|
13
|
+
* Tasks with unresolved dependencies are marked as 'blocked'.
|
|
14
|
+
*/
|
|
15
|
+
export async function executeTeamTasksAdd(
|
|
16
|
+
deps: ToolDeps,
|
|
17
|
+
args: { tasks: TaskInput[] },
|
|
18
|
+
sessionId: string,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
21
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
22
|
+
|
|
23
|
+
const ids: string[] = []
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
|
|
26
|
+
for (const task of args.tasks) {
|
|
27
|
+
const id = generateId("task")
|
|
28
|
+
const depsJson = task.depends_on?.length ? JSON.stringify(task.depends_on) : null
|
|
29
|
+
|
|
30
|
+
// Determine initial status — blocked if has unresolved dependencies
|
|
31
|
+
let status = "pending"
|
|
32
|
+
if (task.depends_on?.length) {
|
|
33
|
+
const resolved = task.depends_on.every(depId => {
|
|
34
|
+
const dep = deps.db.query("SELECT status FROM team_task WHERE id = ? AND team_id = ?")
|
|
35
|
+
.get(depId, teamInfo.teamId) as { status: string } | null
|
|
36
|
+
return dep && (dep.status === "completed" || dep.status === "cancelled")
|
|
37
|
+
})
|
|
38
|
+
if (!resolved) status = "blocked"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
deps.db.run(
|
|
42
|
+
"INSERT INTO team_task (id, team_id, content, status, priority, depends_on, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
43
|
+
[id, teamInfo.teamId, task.content, status, task.priority, depsJson, now, now]
|
|
44
|
+
)
|
|
45
|
+
ids.push(id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `Added ${ids.length} task${ids.length !== 1 ? "s" : ""}: ${ids.join(", ")}`
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_tasks_complete tool. Marks a task as completed
|
|
6
|
+
* and unblocks any dependent tasks.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamTasksComplete(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { task_id: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
|
+
|
|
16
|
+
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
17
|
+
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
18
|
+
if (!task) throw new Error(`Task "${args.task_id}" not found`)
|
|
19
|
+
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
|
|
22
|
+
|
|
23
|
+
// Unblock dependent tasks
|
|
24
|
+
const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?")
|
|
25
|
+
.all(teamInfo.teamId) as Array<{ id: string; depends_on: string | null; status: string }>
|
|
26
|
+
|
|
27
|
+
let unblocked = 0
|
|
28
|
+
for (const t of allTasks) {
|
|
29
|
+
if (t.status !== "blocked" || !t.depends_on) continue
|
|
30
|
+
const depIds: string[] = JSON.parse(t.depends_on)
|
|
31
|
+
if (!depIds.includes(args.task_id)) continue
|
|
32
|
+
|
|
33
|
+
// Check if all dependencies are now resolved
|
|
34
|
+
const allResolved = depIds.every(depId => {
|
|
35
|
+
if (depId === args.task_id) return true // just completed
|
|
36
|
+
const dep = allTasks.find(d => d.id === depId)
|
|
37
|
+
return dep && (dep.status === "completed" || dep.status === "cancelled")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (allResolved) {
|
|
41
|
+
deps.db.run("UPDATE team_task SET status = 'pending', time_updated = ? WHERE id = ?", [now, t.id])
|
|
42
|
+
unblocked++
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unblockedMsg = unblocked > 0 ? ` Unblocked ${unblocked} dependent task${unblocked !== 1 ? "s" : ""}.` : ""
|
|
47
|
+
return `Completed task: ${task.content}${unblockedMsg}`
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_tasks_list tool. Shows all tasks on the shared board.
|
|
6
|
+
*/
|
|
7
|
+
export async function executeTeamTasksList(
|
|
8
|
+
deps: ToolDeps,
|
|
9
|
+
sessionId: string,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
12
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
|
|
14
|
+
const tasks = deps.db.query(
|
|
15
|
+
"SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC"
|
|
16
|
+
).all(teamInfo.teamId) as Array<Record<string, unknown>>
|
|
17
|
+
|
|
18
|
+
if (tasks.length === 0) return "No tasks on the board."
|
|
19
|
+
|
|
20
|
+
return tasks.map(t =>
|
|
21
|
+
`[${t.status}] ${t.content} (${t.id})${t.assignee ? ` → ${t.assignee}` : ""}${t.priority !== "medium" ? ` [${t.priority}]` : ""}`
|
|
22
|
+
).join("\n")
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_view tool. Navigates the TUI to a teammate's session
|
|
6
|
+
* so the user can see what they're doing.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamView(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { member: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
|
+
|
|
16
|
+
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?")
|
|
17
|
+
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
|
18
|
+
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
19
|
+
|
|
20
|
+
await deps.client.tui.selectSession({ sessionID: member.session_id })
|
|
21
|
+
|
|
22
|
+
return `Switched view to ${args.member}'s session (${member.session_id}). Use the session picker to return to the lead session.`
|
|
23
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared dependencies injected into every tool's execute function.
|
|
6
|
+
* This avoids global state and makes tools testable with mocks.
|
|
7
|
+
*/
|
|
8
|
+
export interface ToolDeps {
|
|
9
|
+
db: Database
|
|
10
|
+
registry: MemberRegistry
|
|
11
|
+
tracker: DescendantTracker
|
|
12
|
+
/** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */
|
|
13
|
+
client: PluginClient
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal interface for the OpenCode client methods we actually use.
|
|
18
|
+
* This is a subset of OpencodeClient, typed for our needs.
|
|
19
|
+
* Makes mocking trivial in tests.
|
|
20
|
+
*/
|
|
21
|
+
export interface PluginClient {
|
|
22
|
+
session: {
|
|
23
|
+
create(options: { body?: { parentID?: string; title?: string } }): Promise<{ data?: { id: string } }>
|
|
24
|
+
promptAsync(options: {
|
|
25
|
+
path: { id: string }
|
|
26
|
+
body: { parts: Array<{ type: "text"; text: string }>; model?: { providerID: string; modelID: string } }
|
|
27
|
+
}): Promise<unknown>
|
|
28
|
+
abort(options: { path: { id: string } }): Promise<unknown>
|
|
29
|
+
status(): Promise<{ data?: Record<string, { type: string }> }>
|
|
30
|
+
}
|
|
31
|
+
tui: {
|
|
32
|
+
showToast(options: {
|
|
33
|
+
title?: string
|
|
34
|
+
message?: string
|
|
35
|
+
variant?: "info" | "success" | "warning" | "error"
|
|
36
|
+
duration?: number
|
|
37
|
+
}): Promise<unknown>
|
|
38
|
+
selectSession(options: { sessionID?: string }): Promise<unknown>
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Look up which team a session belongs to (as lead or member).
|
|
44
|
+
* Returns team info or undefined.
|
|
45
|
+
*/
|
|
46
|
+
export function findTeamBySession(
|
|
47
|
+
db: Database,
|
|
48
|
+
registry: MemberRegistry,
|
|
49
|
+
sessionId: string,
|
|
50
|
+
): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } | undefined {
|
|
51
|
+
// Check if session is a team member
|
|
52
|
+
const entry = registry.getBySession(sessionId)
|
|
53
|
+
if (entry) {
|
|
54
|
+
const team = db.query("SELECT name FROM team WHERE id = ? AND status = 'active'").get(entry.teamId) as { name: string } | null
|
|
55
|
+
if (team) return { teamId: entry.teamId, teamName: team.name, role: "member", memberName: entry.memberName }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check if session is a team lead
|
|
59
|
+
const leadTeam = db.query("SELECT id, name FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string; name: string } | null
|
|
60
|
+
if (leadTeam) return { teamId: leadTeam.id, teamName: leadTeam.name, role: "lead" }
|
|
61
|
+
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the session ID for a recipient by name.
|
|
67
|
+
* "lead" resolves to the team's lead_session_id.
|
|
68
|
+
* Otherwise looks up the member registry.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveRecipientSession(
|
|
71
|
+
db: Database,
|
|
72
|
+
registry: MemberRegistry,
|
|
73
|
+
teamId: string,
|
|
74
|
+
recipientName: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
if (recipientName === "lead") {
|
|
77
|
+
const team = db.query("SELECT lead_session_id FROM team WHERE id = ?").get(teamId) as { lead_session_id: string } | null
|
|
78
|
+
return team?.lead_session_id
|
|
79
|
+
}
|
|
80
|
+
const entry = registry.getByName(teamId, recipientName)
|
|
81
|
+
return entry?.sessionId
|
|
82
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
|
2
|
+
|
|
3
|
+
let counter = 0
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a time-sortable unique ID with the given prefix.
|
|
7
|
+
* Format: `{prefix}_{timestamp_hex}_{counter_hex}_{random_hex}`
|
|
8
|
+
*/
|
|
9
|
+
export function generateId(prefix: string): string {
|
|
10
|
+
const time = Date.now().toString(36)
|
|
11
|
+
const count = (counter++).toString(36).padStart(4, "0")
|
|
12
|
+
const rand = Math.random().toString(36).slice(2, 8)
|
|
13
|
+
return `${prefix}_${time}_${count}_${rand}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate a team name. Returns an error message string if invalid, undefined if valid.
|
|
18
|
+
* Rules: 1-64 chars, lowercase alphanumeric with hyphens, no leading/trailing hyphens.
|
|
19
|
+
*/
|
|
20
|
+
export function validateTeamName(name: string): string | undefined {
|
|
21
|
+
if (name.length < 1 || name.length > 64) return "Team name must be 1-64 characters"
|
|
22
|
+
if (!NAME_PATTERN.test(name)) return "Team name must be lowercase alphanumeric with hyphens only"
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate a member name. Same rules as team name, plus "lead" is reserved.
|
|
28
|
+
* Returns an error message string if invalid, undefined if valid.
|
|
29
|
+
*/
|
|
30
|
+
export function validateMemberName(name: string): string | undefined {
|
|
31
|
+
if (name.toLowerCase() === "lead") return `Name "lead" is reserved`
|
|
32
|
+
if (name.length < 1 || name.length > 64) return "Member name must be 1-64 characters"
|
|
33
|
+
if (!NAME_PATTERN.test(name)) return "Member name must be lowercase alphanumeric with hyphens only"
|
|
34
|
+
return undefined
|
|
35
|
+
}
|