Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ logs
# OpenCode local state
.opencode/
.superpowers/
.worktrees/
dogfood-output/
docs/superpowers/

Expand Down
31 changes: 20 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,26 @@ All PRs require at least one approval from a code owner before merging. Direct p

```
src/
├── index.ts # Plugin entry point
├── db.ts # SQLite connection + init
├── schema.ts # CREATE TABLE migrations
├── state.ts # In-memory registry + descendant tracker
├── messaging.ts # Message persistence + delivery helpers
├── recovery.ts # Crash recovery (stale members + undelivered messages)
├── hooks.ts # Event hook + sub-agent isolation
├── rate-limit.ts # Token bucket rate limiter
├── types.ts # Shared types + helper functions
├── util.ts # ID generation + name validation
└── tools/ # One file per tool (13 total)
├── index.ts # Plugin entry point and tool registration
├── client.ts # SDK wrapper that throws on API errors
├── config.ts # Global/project/env configuration loading
├── dashboard*.ts # Dashboard HTML, JS, and data endpoint
├── db.ts # SQLite connection + init
├── hooks.ts # Event hook + sub-agent isolation
├── log.ts # Plugin logging helpers
├── messaging.ts # Message persistence + delivery helpers
├── notify.ts # TUI notification helpers
├── progress.ts # Progress/stall tracking
├── rate-limit.ts # Token bucket rate limiter
├── recovery.ts # Crash recovery and orphan cleanup
├── result-parser.ts # Teammate result parsing helpers
├── schema.ts # CREATE TABLE migrations
├── state.ts # In-memory registry, descendant tracker, and purge approval state
├── system-prompt.ts # Lead/teammate prompt injection text
├── types.ts # Shared types + helper functions
├── util.ts # ID generation + name validation
├── watchdog.ts # Timeout and stall watchdog
└── tools/ # 14 team tools plus shared/merge helpers

test/
├── helpers.ts # Shared test utilities (setupDb, mockClient, etc.)
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

[![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
[![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
[![tests](https://img.shields.io/badge/tests-512%20passing-brightgreen.svg)]()
[![tests](https://img.shields.io/badge/tests-558%20passing-brightgreen.svg)]()
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
[![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
Expand All @@ -19,7 +19,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.

```json
{
"plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
}
```

Expand Down Expand Up @@ -140,15 +140,15 @@ Add to your OpenCode config with a pinned version. Project-level or global.

```json
{
"plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
}
```

**Global** (`~/.config/opencode/opencode.json`):

```json
{
"plugin": ["@hueyexe/opencode-ensemble@0.13.3"]
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
}
```

Expand Down Expand Up @@ -198,18 +198,20 @@ Build with `bun run build`, then restart OpenCode to pick up changes.

14 tools. The lead has all of them. Teammates get 6 (messaging + tasks).

**Team lifecycle** (lead only)
**Team lifecycle** (lead only, except archived-team purge may also be run from the main session)

| Tool | What it does |
|------|-------------|
| `team_create` | Create a team. Caller becomes the lead. |
| `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
| `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
| `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. |
| `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. |
| `team_cleanup` | Remove the current team when done. Safety-net merges forgotten branches. With `purge`, previews archived-team deletion and returns exact approval labels plus a confirmation token. |
| `team_status` | See all members, their status, and a task summary. |
| `team_view` | Switch the TUI to a teammate's session. |

Archived-team purge is intentionally two-step. First call `team_cleanup` with `purge` to get a preview, exact approval and denial option labels, and `confirm_token`; no data is deleted. Stale archived worktree/workspace references and stale Ensemble-owned branches are counted in the preview and cleaned during confirmed purge. Arbitrary non-Ensemble branches still block purge for safety. The lead must then use the question tool with those exact options. Only after the user selects the exact approval option should it call `team_cleanup` again with the same `purge`, `confirm_purge: true`, and the preview token.

**Communication** (everyone)

| Tool | What it does |
Expand Down Expand Up @@ -410,7 +412,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
```bash
bun install
bun run typecheck
bun test # 512 tests
bun test # 558 tests
bun run build
```

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hueyexe/opencode-ensemble",
"version": "0.13.3",
"version": "0.14.0",
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
"module": "src/index.ts",
"main": "dist/index.js",
Expand Down
40 changes: 35 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { mkdirSync } from "node:fs"
import { createDb, getDbPath } from "./db"
import { wrapThrowingClient } from "./client"
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery"
import { MemberRegistry, DescendantTracker } from "./state"
import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
import { isWorktreeInstance } from "./util"
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
Expand Down Expand Up @@ -56,6 +56,7 @@ const plugin: Plugin = async (input) => {
// Initialize in-memory state
const registry = new MemberRegistry()
const tracker = new DescendantTracker()
const purgeApprovals = new PendingPurgeApprovals()
const nudgedMembers = new Set<string>()
const progressTracker = new ProgressTracker()
const wakeLeadTimestamps = new Map<string, number>()
Expand All @@ -69,7 +70,7 @@ const plugin: Plugin = async (input) => {
const rawClient = new OpencodeClient({ client: pluginTransport })
initLog(rawClient)
const client = wrapThrowingClient(rawClient)
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory, config }
const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }

// Recovery only runs for the main project instance — NOT for teammate worktree instances.
// Worktree instances are created during session.create. Running recovery there makes HTTP
Expand Down Expand Up @@ -306,6 +307,12 @@ const plugin: Plugin = async (input) => {
}
},

"tool.execute.after": async (input, output) => {
if (input.tool === "question") {
purgeApprovals.recordQuestionAnswer(input.sessionID, output.output, input.args)
}
},

// System prompt injection — keeps lead aware of team state, reminds teammates of role
"experimental.chat.system.transform": async (input, output) => {
if (!input.sessionID) return
Expand Down Expand Up @@ -498,15 +505,38 @@ const plugin: Plugin = async (input) => {
}),

team_cleanup: tool({
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
description: "Clean up the current team, or purge archived teams after human approval. " +
"Omit purge for normal cleanup. Pass purge with archived team names, or ['*'] for all archived teams. " +
"First purge call returns a preview, exact approval and denial options, and confirmation token only. " +
"Archived worktree/workspace references and stale Ensemble-owned branches are shown in the preview and cleaned during confirmed purge. " +
"Use the question tool with those exact options, then call again with confirm_purge: true and confirm_token only if the user selected the exact approval option.",
args: {
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
acknowledge_uncommitted: tool.schema.boolean().default(false),
purge: tool.schema.array(tool.schema.string()).optional().describe("Archived team names to permanently delete, or ['*'] for all archived teams. Requires human approval."),
confirm_purge: tool.schema.boolean().default(false).describe("Set true only after the user explicitly selects the exact approval option from the purge preview via the question tool."),
confirm_token: tool.schema.string().optional().describe("Confirmation token from the purge preview. Valid only after the matching exact approval answer is selected in this session."),
},
async execute(args, ctx) {
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup)
const approvePurge = args.purge && args.purge.length > 0 && args.confirm_purge
? async (preview: string) => {
await ctx.ask({
permission: "team_cleanup.purge",
patterns: args.purge ?? [],
always: [],
metadata: {
title: "Purge archived teams",
preview,
},
})
}
: undefined
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup, undefined, approvePurge)
const blocked = result.includes("uncommitted")
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
const title = args.purge
? result.startsWith("No archived teams") ? "No archived teams to purge" : result.startsWith("Purge preview") ? "Purge confirmation required" : "Archived teams purged"
: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up"
ctx.metadata({ title })
return result
},
}),
Expand Down
107 changes: 107 additions & 0 deletions src/state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto"

/** Info about a registered team member in the in-memory registry. */
export interface MemberEntry {
teamId: string
Expand Down Expand Up @@ -67,6 +69,40 @@ export class MemberRegistry {
}

const DEFAULT_MAX_DEPTH = 10
const DEFAULT_PURGE_APPROVAL_TTL_MS = 10 * 60 * 1000

interface PendingPurgeApproval {
sessionId: string
purgeKey: string
approved: boolean
timeCreated: number
}

function canonicalPurgeKey(purge: string[]): string {
if (purge.includes("*")) return JSON.stringify(["*"])
return JSON.stringify([...new Set(purge)].sort())
}

function outputSelectedAnswer(output: string, label: string): boolean {
const quoted = JSON.stringify(label)
return output.includes(`=${quoted}`) || output.includes(`=[${quoted}]`)
}

function optionLabels(args: unknown): string[] {
if (!args || typeof args !== "object") return []
const questions = (args as { questions?: unknown }).questions
if (!Array.isArray(questions)) return []
return questions.flatMap(question => {
if (!question || typeof question !== "object") return []
const options = (question as { options?: unknown }).options
if (!Array.isArray(options)) return []
return options.flatMap(option => {
if (!option || typeof option !== "object") return []
const label = (option as { label?: unknown }).label
return typeof label === "string" ? [label] : []
})
})
}

/**
* Tracks parent-child session relationships for sub-agent isolation.
Expand Down Expand Up @@ -106,3 +142,74 @@ export class DescendantTracker {
this.parents.delete(sessionId)
}
}

/** Tracks pending archived-team purge confirmations between preview and execution. */
export class PendingPurgeApprovals {
private pending = new Map<string, PendingPurgeApproval>()
private readonly ttlMs: number
private readonly now: () => number
private readonly createToken: () => string

constructor(ttlMs = DEFAULT_PURGE_APPROVAL_TTL_MS, now: () => number = () => Date.now(), createToken: () => string = randomUUID) {
this.ttlMs = ttlMs
this.now = now
this.createToken = createToken
}

/** Create a confirmation token for a purge preview. */
create(sessionId: string, purge: string[]): string {
this.pruneExpired()
const token = this.createToken()
this.pending.set(token, {
sessionId,
purgeKey: canonicalPurgeKey(purge),
approved: false,
timeCreated: this.now(),
})
return token
}

/** Return the exact user-facing answer label that approves a confirmation token. */
approvalLabel(token: string): string {
return `Approve purge ${token.slice(0, 8)}`
}

/** Return the exact user-facing answer label that denies a confirmation token. */
denialLabel(token: string): string {
return `Deny purge ${token.slice(0, 8)}`
}

/** Record a question tool answer and approve only tokens whose exact approval label was selected. */
recordQuestionAnswer(sessionId: string, output: string, args: unknown): void {
this.pruneExpired()
const labels = new Set(optionLabels(args))
for (const [token, approval] of this.pending.entries()) {
const approvalLabel = this.approvalLabel(token)
const denialLabel = this.denialLabel(token)
const hasRequiredOptions = labels.has(approvalLabel) && labels.has(denialLabel)
if (approval.sessionId === sessionId && hasRequiredOptions && outputSelectedAnswer(output, approvalLabel)) {
approval.approved = true
}
}
}

/** Consume a confirmation token once it is safe to execute the matching purge. */
consume(sessionId: string, token: string, purge: string[]): void {
this.pruneExpired()
const approval = this.pending.get(token)
if (!approval || approval.sessionId !== sessionId || approval.purgeKey !== canonicalPurgeKey(purge)) {
throw new Error("Purge confirmation token is invalid or expired.")
}
if (!approval.approved) {
throw new Error("Purge was not approved by the user. Use the question tool with the exact approval option from the preview before confirming.")
}
this.pending.delete(token)
}

private pruneExpired(): void {
const cutoff = this.now() - this.ttlMs
for (const [token, approval] of this.pending.entries()) {
if (approval.timeCreated < cutoff) this.pending.delete(token)
}
}
}
5 changes: 5 additions & 0 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
"Before calling team_cleanup, verify teammates have committed their work.",
"team_shutdown will warn you if a teammate has uncommitted changes.",
"team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
"To permanently delete archived teams, call team_cleanup with purge: [\"team-name\"] or purge: [\"*\"] for all archived teams.",
"The first purge call is preview-only and deletes nothing.",
"Use the question tool to ask the user for visible human approval before deleting archived team records or preserved Ensemble branches.",
"The question must include the exact approval and denial option labels shown in the preview.",
"Only if the user selects that exact approval option, call team_cleanup again with the same purge value, confirm_purge: true, and the confirm_token from the preview.",
)

return lines.join("\n")
Expand Down
35 changes: 35 additions & 0 deletions src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,38 @@ export function requireTeamMember(
if (!teamInfo) throw new Error("This session is not in a team.")
return teamInfo
}

/** Validate that a session can purge archived teams. Throws if not allowed. */
export function requireCanPurgeArchivedTeams(
deps: Pick<ToolDeps, "db" | "registry" | "tracker">,
sessionId: string,
): void {
const activeMembers = deps.db.query(
`SELECT tm.session_id
FROM team_member tm
JOIN team t ON tm.team_id = t.id
WHERE t.status = 'active'`
).all() as Array<{ session_id: string }>

if (activeMembers.some(member => member.session_id === sessionId)) {
throw new Error("Team members cannot purge archived teams")
}

const activeLeads = deps.db.query("SELECT lead_session_id FROM team WHERE status = 'active'")
.all() as Array<{ lead_session_id: string }>

if (deps.tracker.getParent(sessionId)) {
throw new Error("Sub-agents cannot purge archived teams")
}

if (activeLeads.some(team => team.lead_session_id === sessionId)) return

const activeTeamSessions = new Set([
...activeMembers.map(member => member.session_id),
...activeLeads.map(team => team.lead_session_id),
])

if (deps.tracker.isDescendantOf(sessionId, activeTeamSessions)) {
throw new Error("Sub-agents cannot purge archived teams")
}
}
Loading