@hueyexe/opencode-ensemble 0.14.1 → 0.15.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/src/hooks.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker } from "./state"
3
+ import { sendMessage } from "./messaging"
4
+ import { findTeamBySession } from "./types"
3
5
 
4
6
  const TEAM_TOOL_PREFIX = "team_"
5
7
 
@@ -94,22 +96,51 @@ export function handleSessionCreatedEvent(
94
96
  * Check whether a tool call should be blocked for sub-agent isolation.
95
97
  * Throws if the tool is a team tool and the session is a descendant of a team member.
96
98
  * OQ-11: confirmed — throwing inside tool.execute.before fails the tool call gracefully (verified in live testing).
99
+ *
100
+ * The optional `db` parameter enables a SQLite fallback so the check works
101
+ * across multi-Plugin-instance scenarios where the in-memory registry may
102
+ * not have the parent teammate's session. SQLite is the canonical source.
103
+ *
104
+ * Order:
105
+ * 1. Registry fast-path: if the caller is itself a registered teammate, allow.
106
+ * 2. Otherwise enumerate active teammate session IDs from registry + SQLite.
107
+ * 3. If the caller is among them, allow.
108
+ * 4. If the caller is a descendant of any teammate, block.
109
+ *
110
+ * The fast-path skips the SQL query entirely when the registry already
111
+ * has the caller. Lead sessions are NOT in the MemberRegistry by design
112
+ * (only teammates are), so a lead's team_* call always misses the
113
+ * fast-path and does the SQLite enumeration. That's acceptable — the
114
+ * scan is bounded by total active members and runs once per tool call.
97
115
  */
98
116
  export function checkToolIsolation(
99
117
  registry: MemberRegistry,
100
118
  tracker: DescendantTracker,
101
119
  toolName: string,
102
120
  sessionId: string,
121
+ db?: Database,
103
122
  ): void {
104
123
  if (!toolName.startsWith(TEAM_TOOL_PREFIX)) return
105
124
 
106
- // If the session is a registered team member or lead, allow it
125
+ // Fast path: registry hit on the caller — skip SQL altogether.
107
126
  if (registry.isTeamSession(sessionId)) return
108
127
 
109
- // Check if this session is a descendant of any team member
110
- const allTeamSessions = registry.allSessionIds()
128
+ // Collect every session ID that is a teammate, from the registry first
129
+ // (fast path) and SQLite second (covers multi-instance / cross-plugin state).
130
+ const teammateSessionIds = new Set(registry.allSessionIds())
131
+ if (db) {
132
+ const dbRows = db.query(
133
+ `SELECT tm.session_id FROM team_member tm
134
+ JOIN team t ON tm.team_id = t.id
135
+ WHERE t.status = 'active' AND tm.status NOT IN ('shutdown', 'error')`
136
+ ).all() as Array<{ session_id: string }>
137
+ for (const row of dbRows) teammateSessionIds.add(row.session_id)
138
+ }
139
+
140
+ // The caller may be a teammate registered in another Plugin instance — allow.
141
+ if (teammateSessionIds.has(sessionId)) return
111
142
 
112
- if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
143
+ if (teammateSessionIds.size > 0 && tracker.isDescendantOf(sessionId, teammateSessionIds)) {
113
144
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
114
145
  }
115
146
  }
@@ -127,3 +158,39 @@ export function shouldNudgeIdleMember(db: Database, teamId: string, memberName:
127
158
  .get(teamId, memberName) as { id: string } | null
128
159
  return !msg
129
160
  }
161
+
162
+ /** Shape of an error attached to a session.error event. Subset of the SDK's union. */
163
+ export interface SessionErrorPayload {
164
+ name?: string
165
+ data?: { message?: string }
166
+ }
167
+
168
+ /**
169
+ * Handle a session.error event. Surfaces tool/model failures from a teammate
170
+ * as a system message to the lead, so otherwise-silent failures are visible.
171
+ *
172
+ * Ignored when:
173
+ * - sessionID is undefined
174
+ * - the session is not a registered teammate (leads are not in the registry)
175
+ */
176
+ export function handleSessionErrorEvent(
177
+ db: Database,
178
+ registry: MemberRegistry,
179
+ sessionId: string | undefined,
180
+ error: SessionErrorPayload | undefined,
181
+ ): void {
182
+ if (!sessionId) return
183
+ // Use findTeamBySession so the SQLite fallback fires when this Plugin
184
+ // instance's in-memory registry doesn't have the teammate (multi-instance
185
+ // scenario — see findTeamBySession in src/types.ts).
186
+ const teamInfo = findTeamBySession(db, registry, sessionId)
187
+ if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName) return
188
+
189
+ const errMsg = error?.data?.message ?? error?.name ?? "unknown error"
190
+ sendMessage(db, {
191
+ teamId: teamInfo.teamId,
192
+ from: "system",
193
+ to: "lead",
194
+ content: `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
195
+ })
196
+ }
package/src/index.ts CHANGED
@@ -5,10 +5,10 @@ import path from "node:path"
5
5
  import { mkdirSync } from "node:fs"
6
6
  import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
- import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery"
8
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, rehydrateRegistry } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
- import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
11
+ import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember, handleSessionErrorEvent } from "./hooks"
12
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
13
  import { sendMessage, hasReportedCompletion } from "./messaging"
14
14
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
@@ -45,7 +45,7 @@ const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
45
45
  * peer-to-peer communication, shared task management, and coordinated execution.
46
46
  */
47
47
  const plugin: Plugin = async (input) => {
48
- // Initialize SQLite database in the global OpenCode config directory
48
+ // Initialize SQLite database in the global OpenCode config directory.
49
49
  const dbPath = getDbPath()
50
50
  mkdirSync(path.dirname(dbPath), { recursive: true })
51
51
  const db = createDb(dbPath)
@@ -80,14 +80,17 @@ const plugin: Plugin = async (input) => {
80
80
  const recovery = await recoverStaleMembers(db, client, input.directory)
81
81
  if (recovery.interrupted > 0) {
82
82
  log(`init:recovery:interrupted=${recovery.interrupted}`)
83
- const members = db.query(
84
- "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'"
85
- ).all() as Array<{ team_id: string; name: string; session_id: string }>
86
- for (const m of members) {
87
- registry.register(m.team_id, m.name, m.session_id)
88
- }
89
83
  }
90
84
 
85
+ // Always rehydrate the in-memory registry from SQLite. The registry is
86
+ // in-memory only and is wiped on every plugin restart. Without this,
87
+ // teammates from a previous lifetime become invisible — every team_*
88
+ // tool call from them throws "This session is not in a team." This is
89
+ // the bug that surfaced on Desktop, where the Electron sidecar restarts
90
+ // far more often than the CLI.
91
+ const rehydrated = rehydrateRegistry(db, registry)
92
+ if (rehydrated > 0) log(`init:registry:rehydrated members=${rehydrated}`)
93
+
91
94
  recoverUndeliveredMessages(db, client, registry).catch((err) => {
92
95
  log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`)
93
96
  })
@@ -209,16 +212,14 @@ const plugin: Plugin = async (input) => {
209
212
  "SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
210
213
  ).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
211
214
  if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
212
- const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id) as { name: string } | null
213
- if (teamRow) {
214
- const { preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
215
- const safeBranch = branchName(teamRow.name, member.name)
216
- const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
217
- if (ok) {
218
- deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
219
- [safeBranch, member.team_id, member.name])
220
- log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
221
- }
215
+ const { getTeamResourceParts, preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
216
+ const resource = getTeamResourceParts(deps.db, member.team_id)
217
+ const safeBranch = branchName(resource.projectName, resource.teamName, resource.teamId, member.name)
218
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
219
+ if (ok) {
220
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
221
+ [safeBranch, member.team_id, member.name])
222
+ log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
222
223
  }
223
224
  }
224
225
  try {
@@ -285,6 +286,15 @@ const plugin: Plugin = async (input) => {
285
286
  }
286
287
  }
287
288
 
289
+ // Surface teammate session errors as system messages to the lead.
290
+ // Without this, errors during a teammate's prompt loop (auth failure,
291
+ // tool failure, model error, etc.) are invisible to the lead — the
292
+ // teammate just appears stuck.
293
+ if (event.type === "session.error") {
294
+ const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
295
+ handleSessionErrorEvent(db, registry, props.sessionID, props.error)
296
+ }
297
+
288
298
  // Track per-step output tokens for stall detection
289
299
  if (event.type === "message.part.updated") {
290
300
  const part = (event.properties as { part?: { type?: string; sessionID?: string; tokens?: { output?: number } } }).part
@@ -298,7 +308,7 @@ const plugin: Plugin = async (input) => {
298
308
 
299
309
  // Sub-agent isolation + rate limiting hook
300
310
  "tool.execute.before": async (input, _output) => {
301
- checkToolIsolation(registry, tracker, input.tool, input.sessionID)
311
+ checkToolIsolation(registry, tracker, input.tool, input.sessionID, db)
302
312
  // Rate limit team tools that trigger LLM inference
303
313
  if (input.tool.startsWith("team_")) {
304
314
  if (!rateLimiter.tryConsume()) {
@@ -360,6 +370,7 @@ const plugin: Plugin = async (input) => {
360
370
  description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
361
371
  args: {
362
372
  name: tool.schema.string().describe("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
373
+ project_name: tool.schema.string().optional().describe("Project display name for first use of this working directory. If omitted, a short random name is generated."),
363
374
  },
364
375
  async execute(args, ctx) {
365
376
  const result = await executeTeamCreate(deps, args, ctx.sessionID)
package/src/messaging.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import { generateId } from "./util"
3
3
 
4
4
  const MAX_CONTENT_BYTES = 10 * 1024 // 10KB
package/src/notify.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
 
4
4
  type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
package/src/process.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { Buffer } from "node:buffer"
2
+ import { spawn } from "node:child_process"
3
+ import type { Readable } from "node:stream"
4
+
5
+ export interface CommandResult {
6
+ exitCode: number
7
+ stdout: string
8
+ stderr: string
9
+ }
10
+
11
+ export interface CommandOptions {
12
+ cwd?: string
13
+ }
14
+
15
+ function collect(stream: Readable | null, chunks: Buffer[]): void {
16
+ stream?.on("data", (chunk: Buffer | string) => {
17
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
18
+ })
19
+ }
20
+
21
+ /** Run a subprocess with Node-compatible APIs and capture stdout/stderr. */
22
+ export function runCommand(args: string[], options: CommandOptions = {}): Promise<CommandResult> {
23
+ const [command, ...commandArgs] = args
24
+ if (!command) return Promise.resolve({ exitCode: 1, stdout: "", stderr: "No command provided" })
25
+
26
+ return new Promise((resolve) => {
27
+ const stdout: Buffer[] = []
28
+ const stderr: Buffer[] = []
29
+ let settled = false
30
+ const finish = (result: CommandResult) => {
31
+ if (settled) return
32
+ settled = true
33
+ resolve(result)
34
+ }
35
+
36
+ const child = spawn(command, commandArgs, {
37
+ cwd: options.cwd,
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ })
40
+
41
+ collect(child.stdout, stdout)
42
+ collect(child.stderr, stderr)
43
+
44
+ child.on("error", (error) => {
45
+ finish({ exitCode: 1, stdout: Buffer.concat(stdout).toString("utf8"), stderr: error.message })
46
+ })
47
+ child.on("close", (code) => {
48
+ finish({
49
+ exitCode: code ?? 1,
50
+ stdout: Buffer.concat(stdout).toString("utf8"),
51
+ stderr: Buffer.concat(stderr).toString("utf8"),
52
+ })
53
+ })
54
+ })
55
+ }
package/src/recovery.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
5
- import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
5
+ import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
6
6
  import { log } from "./log"
7
+ import { runCommand } from "./process"
7
8
 
8
9
  /**
9
10
  * Scan for team members stuck in 'busy' status (stale from a crash)
@@ -15,17 +16,19 @@ import { log } from "./log"
15
16
  export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
16
17
  // Find stale members with branch info so we can preserve before aborting
17
18
  const stale = db.query(
18
- `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
19
- FROM team_member tm
20
- JOIN team t ON tm.team_id = t.id
21
- WHERE tm.status = 'busy' AND t.status = 'active'`
22
- ).all() as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string }>
19
+ `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
20
+ FROM team_member tm
21
+ JOIN team t ON tm.team_id = t.id
22
+ JOIN project p ON t.project_id = p.id
23
+ WHERE tm.status = 'busy' AND t.status = 'active'
24
+ AND (? IS NULL OR t.project_id = ? OR t.project_id = 'default')`
25
+ ).all(cwd ?? null, cwd ?? null) as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string; project_name: string }>
23
26
 
24
27
  const result = db.run(
25
28
  `UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
26
- WHERE status = 'busy'
27
- AND team_id IN (SELECT id FROM team WHERE status = 'active')`,
28
- [Date.now()]
29
+ WHERE status = 'busy'
30
+ AND team_id IN (SELECT id FROM team WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default'))`,
31
+ [Date.now(), cwd ?? null, cwd ?? null]
29
32
  )
30
33
 
31
34
  // Preserve branches then abort orphaned sessions
@@ -33,7 +36,7 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
33
36
  for (const member of stale) {
34
37
  // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
35
38
  if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
36
- const safeBranch = preservedBranchName(member.team_name, member.name)
39
+ const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
37
40
  const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd)
38
41
  if (ok) {
39
42
  db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
@@ -145,6 +148,30 @@ export async function recoverUndeliveredMessages(
145
148
  return { redelivered }
146
149
  }
147
150
 
151
+ /**
152
+ * Repopulate the in-memory MemberRegistry from SQLite for all active members.
153
+ * MUST be called on every plugin init — the registry is in-memory only,
154
+ * and without rehydration, every team_* tool call from an existing
155
+ * teammate fails with "This session is not in a team." after a plugin restart.
156
+ *
157
+ * Skips members in terminal states (shutdown, error) — they should not
158
+ * receive future messages.
159
+ *
160
+ * Returns the number of members rehydrated.
161
+ */
162
+ export function rehydrateRegistry(db: Database, registry: MemberRegistry): number {
163
+ const members = db.query(
164
+ `SELECT tm.team_id, tm.name, tm.session_id
165
+ FROM team_member tm
166
+ JOIN team t ON tm.team_id = t.id
167
+ WHERE t.status = 'active' AND tm.status NOT IN ('shutdown', 'error')`
168
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
169
+ for (const m of members) {
170
+ registry.register(m.team_id, m.name, m.session_id)
171
+ }
172
+ return members.length
173
+ }
174
+
148
175
  /**
149
176
  * Clean up orphaned ensemble/preserved/* branches that belong to archived teams
150
177
  * with no active members. Scoped carefully to avoid interfering with other
@@ -153,39 +180,37 @@ export async function recoverUndeliveredMessages(
153
180
  export async function recoverOrphanedBranches(db: Database, cwd: string): Promise<{ removed: number }> {
154
181
  let removed = 0
155
182
 
156
- // Get archived team names that have NO active members
183
+ // Get archived team namespaces for this project that have NO active members.
184
+ // The team id namespace is current; team names are kept for legacy preserved branches.
157
185
  const archivedTeams = db.query(
158
- `SELECT t.name FROM team t
186
+ `SELECT t.id, t.name, p.name as project_name FROM team t
187
+ JOIN project p ON t.project_id = p.id
159
188
  WHERE t.status = 'archived'
189
+ AND t.project_id = ?
160
190
  AND NOT EXISTS (
161
- SELECT 1 FROM team_member tm
162
- WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
163
- )`
164
- ).all() as Array<{ name: string }>
191
+ SELECT 1 FROM team_member tm
192
+ WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
193
+ )`
194
+ ).all(cwd) as Array<{ id: string; name: string; project_name: string }>
165
195
 
166
196
  if (archivedTeams.length === 0) return { removed: 0 }
167
197
 
168
- const archivedNames = new Set(archivedTeams.map(t => t.name))
198
+ const archivedPrefixes = archivedTeams.flatMap(t => [
199
+ `ensemble/preserved/${t.project_name}/${teamResourceSegment(t.name, t.id)}/`,
200
+ `ensemble/preserved/${t.name}/`,
201
+ ])
169
202
 
170
203
  // List all local branches matching ensemble/preserved/*
171
- const proc = Bun.spawn(["git", "branch", "--list", "ensemble/preserved/*"], { cwd, stdout: "pipe", stderr: "pipe" })
172
- const stdoutPromise = new Response(proc.stdout).text()
173
- await proc.exited
174
- const stdout = await stdoutPromise
204
+ const result = await runCommand(["git", "branch", "--list", "ensemble/preserved/*"], { cwd })
175
205
 
176
- const branches = stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
206
+ const branches = result.stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
177
207
 
178
208
  for (const branch of branches) {
179
- // Parse team name from branch: ensemble/preserved/{teamName}/{memberName}
180
- const parts = branch.split("/")
181
- if (parts.length < 4) continue
182
- const teamName = parts[2]
183
- if (!teamName || !archivedNames.has(teamName)) continue
209
+ if (!archivedPrefixes.some(prefix => branch.startsWith(prefix))) continue
184
210
 
185
211
  try {
186
- const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
187
- const exitCode = await del.exited
188
- if (exitCode === 0) {
212
+ const deleteResult = await runCommand(["git", "branch", "-D", branch], { cwd })
213
+ if (deleteResult.exitCode === 0) {
189
214
  removed++
190
215
  log(`recovery:branch:deleted branch=${branch}`)
191
216
  }
package/src/schema.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
 
3
3
  /** Migration SQL statements, applied in order. Version = index + 1. */
4
4
  export const MIGRATIONS: string[] = [
@@ -82,6 +82,110 @@ export const MIGRATIONS: string[] = [
82
82
  `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
83
83
  // Migration 7: Track whether teammate has reported to lead (completion loop prevention, issue #3)
84
84
  `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`,
85
+ // Migration 8: Add project as the dashboard grouping level. For this initial
86
+ // project-first step, project_id is the team lead's working directory.
87
+ `PRAGMA foreign_keys=OFF;
88
+ ALTER TABLE team RENAME TO team_old_m8;
89
+ CREATE TABLE IF NOT EXISTS project (
90
+ id TEXT PRIMARY KEY,
91
+ name TEXT NOT NULL,
92
+ path TEXT NOT NULL,
93
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
94
+ time_created INTEGER NOT NULL,
95
+ time_updated INTEGER NOT NULL
96
+ );
97
+ INSERT OR IGNORE INTO project (id, name, path, status, time_created, time_updated)
98
+ VALUES ('default', 'Default Project', '', 'active', strftime('%s','now') * 1000, strftime('%s','now') * 1000);
99
+ CREATE TABLE team (
100
+ id TEXT PRIMARY KEY,
101
+ name TEXT NOT NULL,
102
+ project_id TEXT NOT NULL DEFAULT 'default' REFERENCES project(id) ON DELETE CASCADE,
103
+ lead_session_id TEXT NOT NULL,
104
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
105
+ delegate INTEGER NOT NULL DEFAULT 0,
106
+ time_created INTEGER NOT NULL,
107
+ time_updated INTEGER NOT NULL,
108
+ lead_agent TEXT
109
+ );
110
+ INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated, lead_agent)
111
+ SELECT id, name, 'default', lead_session_id, status, delegate, time_created, time_updated, lead_agent FROM team_old_m8;
112
+ DROP TABLE team_old_m8;
113
+
114
+ ALTER TABLE team_member RENAME TO team_member_old_m8;
115
+ CREATE TABLE team_member (
116
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
117
+ name TEXT NOT NULL,
118
+ session_id TEXT NOT NULL,
119
+ agent TEXT NOT NULL,
120
+ status TEXT NOT NULL DEFAULT 'ready'
121
+ CHECK(status IN ('ready', 'busy', 'shutdown_requested', 'shutdown', 'error')),
122
+ execution_status TEXT NOT NULL DEFAULT 'idle'
123
+ CHECK(execution_status IN ('idle', 'starting', 'running',
124
+ 'cancel_requested', 'cancelling', 'cancelled',
125
+ 'completing', 'completed', 'failed', 'timed_out')),
126
+ model TEXT,
127
+ prompt TEXT,
128
+ time_created INTEGER NOT NULL,
129
+ time_updated INTEGER NOT NULL,
130
+ worktree_dir TEXT,
131
+ worktree_branch TEXT,
132
+ plan_approval TEXT NOT NULL DEFAULT 'none'
133
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected')),
134
+ workspace_id TEXT,
135
+ reported_to_lead INTEGER NOT NULL DEFAULT 0,
136
+ PRIMARY KEY (team_id, name)
137
+ );
138
+ INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead)
139
+ SELECT team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated, worktree_dir, worktree_branch, plan_approval, workspace_id, reported_to_lead FROM team_member_old_m8;
140
+ DROP TABLE team_member_old_m8;
141
+
142
+ ALTER TABLE team_task RENAME TO team_task_old_m8;
143
+ CREATE TABLE team_task (
144
+ id TEXT PRIMARY KEY,
145
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
146
+ content TEXT NOT NULL,
147
+ status TEXT NOT NULL DEFAULT 'pending'
148
+ CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled', 'blocked')),
149
+ priority TEXT NOT NULL DEFAULT 'medium'
150
+ CHECK(priority IN ('high', 'medium', 'low')),
151
+ assignee TEXT,
152
+ depends_on TEXT,
153
+ time_created INTEGER NOT NULL,
154
+ time_updated INTEGER NOT NULL
155
+ );
156
+ INSERT INTO team_task (id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated)
157
+ SELECT id, team_id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task_old_m8;
158
+ DROP TABLE team_task_old_m8;
159
+
160
+ ALTER TABLE team_message RENAME TO team_message_old_m8;
161
+ CREATE TABLE team_message (
162
+ id TEXT PRIMARY KEY,
163
+ team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
164
+ from_name TEXT NOT NULL,
165
+ to_name TEXT,
166
+ content TEXT NOT NULL,
167
+ delivered INTEGER NOT NULL DEFAULT 0,
168
+ time_created INTEGER NOT NULL,
169
+ read INTEGER NOT NULL DEFAULT 0
170
+ );
171
+ INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created, read)
172
+ SELECT id, team_id, from_name, to_name, content, delivered, time_created, read FROM team_message_old_m8;
173
+ DROP TABLE team_message_old_m8;
174
+
175
+ CREATE INDEX IF NOT EXISTS team_lead_idx ON team(lead_session_id);
176
+ CREATE INDEX IF NOT EXISTS team_status_idx ON team(status);
177
+ CREATE INDEX IF NOT EXISTS team_project_idx ON team(project_id, status);
178
+ CREATE UNIQUE INDEX IF NOT EXISTS team_active_project_name_idx ON team(project_id, name) WHERE status = 'active';
179
+ CREATE INDEX IF NOT EXISTS team_member_session_idx ON team_member(session_id);
180
+ CREATE INDEX IF NOT EXISTS team_member_status_idx ON team_member(team_id, status);
181
+ CREATE INDEX IF NOT EXISTS team_task_team_idx ON team_task(team_id);
182
+ CREATE INDEX IF NOT EXISTS team_task_assignee_idx ON team_task(assignee);
183
+ CREATE INDEX IF NOT EXISTS team_task_status_idx ON team_task(team_id, status);
184
+ CREATE INDEX IF NOT EXISTS team_message_team_idx ON team_message(team_id);
185
+ CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
186
+ CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
187
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
188
+ PRAGMA foreign_keys=ON;`,
85
189
  ]
86
190
 
87
191
  /**
@@ -91,9 +195,25 @@ export const MIGRATIONS: string[] = [
91
195
  export function applyMigrations(db: Database): void {
92
196
  const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
93
197
 
198
+ if (current > MIGRATIONS.length) {
199
+ throw new Error(`Database schema version ${current} is newer than this plugin supports (${MIGRATIONS.length}). Upgrade opencode-ensemble before continuing.`)
200
+ }
201
+
94
202
  for (let i = current; i < MIGRATIONS.length; i++) {
95
203
  const migration = MIGRATIONS[i]
96
- if (migration) db.exec(migration)
97
- db.exec(`PRAGMA user_version = ${i + 1}`)
204
+ const { foreign_keys: foreignKeys } = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number }
205
+
206
+ db.exec("PRAGMA foreign_keys = OFF")
207
+ db.exec("BEGIN IMMEDIATE")
208
+ try {
209
+ if (migration) db.exec(migration)
210
+ db.exec(`PRAGMA user_version = ${i + 1}`)
211
+ db.exec("COMMIT")
212
+ } catch (err) {
213
+ db.exec("ROLLBACK")
214
+ throw err
215
+ } finally {
216
+ db.exec(`PRAGMA foreign_keys = ${foreignKeys ? "ON" : "OFF"}`)
217
+ }
98
218
  }
99
219
  }
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import { markDelivered } from "./messaging"
3
3
  import { parseTaskResult, formatTaskResult } from "./result-parser"
4
4
  import type { EnsembleConfig } from "./config"