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