@hueyexe/opencode-ensemble 0.14.1 → 0.14.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -30,9 +30,13 @@
30
30
  "lint": "bunx biome lint src/",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "test": "bun test",
33
- "build": "bun build src/index.ts --outdir dist --target bun",
33
+ "build": "bun build src/index.ts --outdir dist --target node",
34
34
  "prepublishOnly": "bun run typecheck && bun test && bun run build"
35
35
  },
36
+ "engines": {
37
+ "node": ">=24",
38
+ "bun": ">=1.0"
39
+ },
36
40
  "devDependencies": {
37
41
  "@biomejs/biome": "^2.4.9",
38
42
  "@types/bun": "latest"
@@ -41,7 +45,7 @@
41
45
  "typescript": "^5"
42
46
  },
43
47
  "dependencies": {
44
- "@opencode-ai/plugin": "^1.14.45",
45
- "@opencode-ai/sdk": "^1.14.45"
48
+ "@opencode-ai/plugin": "^1.15.1",
49
+ "@opencode-ai/sdk": "^1.15.1"
46
50
  }
47
51
  }
package/src/dashboard.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { Database } from "bun:sqlite"
1
+ import { createServer } from "node:http"
2
+ import type { IncomingMessage, Server, ServerResponse } from "node:http"
3
+ import type { Database } from "./db"
2
4
  import { DASHBOARD_HEAD } from "./dashboard-html"
3
5
  import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
4
6
  import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
@@ -51,13 +53,6 @@ interface MessageRow {
51
53
  time_created: number
52
54
  }
53
55
 
54
- function jsonResponse(data: unknown, status = 200): Response {
55
- return new Response(JSON.stringify(data), {
56
- status,
57
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
58
- })
59
- }
60
-
61
56
  function parseDependsOn(value: string | null): string[] {
62
57
  if (!value) return []
63
58
 
@@ -122,60 +117,100 @@ function buildState(db: Database): { teams: unknown[] } {
122
117
  }
123
118
  }
124
119
 
120
+ /** Dashboard server handle returned by startDashboard. */
121
+ export interface DashboardServer {
122
+ stop(force?: boolean): void
123
+ }
124
+
125
+ function sendJson(res: ServerResponse, data: unknown): void {
126
+ res.writeHead(200, {
127
+ "Content-Type": "application/json",
128
+ "Access-Control-Allow-Origin": "*",
129
+ })
130
+ res.end(JSON.stringify(data))
131
+ }
132
+
133
+ function handleDashboardRequest(db: Database, port: number, req: IncomingMessage, res: ServerResponse): void {
134
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `localhost:${port}`}`)
135
+
136
+ if (url.pathname === "/api/health") {
137
+ sendJson(res, { ensemble: true, pid: process.pid })
138
+ return
139
+ }
140
+
141
+ if (url.pathname === "/api/state") {
142
+ sendJson(res, buildState(db))
143
+ return
144
+ }
145
+
146
+ if (url.pathname === "/") {
147
+ res.writeHead(200, { "Content-Type": "text/html" })
148
+ res.end(DASHBOARD_HTML)
149
+ return
150
+ }
151
+
152
+ res.writeHead(404, { "Content-Type": "text/plain" })
153
+ res.end("Not Found")
154
+ }
155
+
156
+ function toDashboardServer(server: Server): DashboardServer {
157
+ return {
158
+ stop(force?: boolean) {
159
+ server.close()
160
+ // server.close() only stops accepting new connections — under Node's
161
+ // node:http, idle keep-alive sockets keep the listener busy until the
162
+ // keep-alive timeout. closeAllConnections() (Node ≥ 18.2) terminates
163
+ // them promptly, matching the behaviour Bun.serve().stop(true) had.
164
+ if (force) {
165
+ const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections
166
+ if (typeof closeAll === "function") closeAll.call(server)
167
+ }
168
+ },
169
+ }
170
+ }
171
+
125
172
  /**
126
173
  * Start the dashboard HTTP server.
127
174
  * Serves a JSON API for team state and the dashboard HTML.
128
175
  * Singleton: if the port is already in use by another ensemble instance, skips silently.
129
176
  * Returns the server instance, or null if skipped.
130
177
  */
131
- export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
132
- try {
133
- const server = Bun.serve({
134
- port,
135
- fetch(req) {
136
- const url = new URL(req.url)
137
-
138
- if (url.pathname === "/api/health") {
139
- return jsonResponse({ ensemble: true, pid: process.pid })
140
- }
141
-
142
- if (url.pathname === "/api/state") {
143
- return jsonResponse(buildState(db))
144
- }
145
-
146
- if (url.pathname === "/") {
147
- return new Response(DASHBOARD_HTML, {
148
- headers: { "Content-Type": "text/html" },
149
- })
150
- }
151
-
152
- return new Response("Not Found", { status: 404 })
153
- },
154
- })
155
- log(`dashboard:started port=${port} url=http://localhost:${port}`)
156
- return server
157
- } catch (err) {
158
- if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
159
- try {
160
- const res = await fetch(`http://localhost:${port}/api/health`)
161
- const data = await res.json() as { ensemble?: boolean; pid?: number }
162
- if (data.ensemble && data.pid) {
163
- // Check if the other process is still alive
164
- let alive = false
165
- try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
166
- if (alive && data.pid !== process.pid) {
167
- log(`dashboard:already-running port=${port} pid=${data.pid}`)
168
- return null
178
+ export async function startDashboard(db: Database, port: number): Promise<DashboardServer | null> {
179
+ return new Promise((resolve) => {
180
+ const server = createServer((req, res) => handleDashboardRequest(db, port, req, res))
181
+
182
+ server.once("error", async (err: NodeJS.ErrnoException) => {
183
+ if (err.code === "EADDRINUSE") {
184
+ try {
185
+ const res = await fetch(`http://localhost:${port}/api/health`)
186
+ const data = await res.json() as { ensemble?: boolean; pid?: number }
187
+ if (data.ensemble && data.pid) {
188
+ // Check if the other process is still alive
189
+ let alive = false
190
+ try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
191
+ if (alive && data.pid !== process.pid) {
192
+ log(`dashboard:already-running port=${port} pid=${data.pid}`)
193
+ resolve(null)
194
+ return
195
+ }
196
+ // Stale server from a dead process — warn the user
197
+ log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
198
+ resolve(null)
199
+ return
169
200
  }
170
- // Stale server from a dead process — warn the user
171
- log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
172
- return null
173
- }
174
- } catch { /* health check failed — port held by something else */ }
175
- log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
176
- return null
177
- }
178
- log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
179
- return null
180
- }
201
+ } catch { /* health check failed — port held by something else */ }
202
+ log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
203
+ resolve(null)
204
+ return
205
+ }
206
+
207
+ log(`dashboard:failed err=${err.message}`)
208
+ resolve(null)
209
+ })
210
+
211
+ server.listen(port, () => {
212
+ log(`dashboard:started port=${port} url=http://localhost:${port}`)
213
+ resolve(toDashboardServer(server))
214
+ })
215
+ })
181
216
  }
package/src/db.ts CHANGED
@@ -1,9 +1,93 @@
1
- import { Database } from "bun:sqlite"
1
+ import { createRequire } from "node:module"
2
2
  import path from "node:path"
3
3
  import { applyMigrations } from "./schema"
4
4
 
5
+ interface SqliteStatement {
6
+ get(...params: unknown[]): unknown
7
+ all(...params: unknown[]): unknown[]
8
+ run(...params: unknown[]): unknown
9
+ }
10
+
11
+ interface SqliteRunResult {
12
+ changes: number
13
+ }
14
+
15
+ /** Minimal synchronous SQLite database interface used by Ensemble. */
16
+ export interface Database {
17
+ exec(sql: string): void
18
+ query(sql: string): SqliteStatement
19
+ run(sql: string, ...params: unknown[]): SqliteRunResult
20
+ transaction<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult): (...args: TArgs) => TResult
21
+ close(): void
22
+ }
23
+
24
+ interface BunSqliteModule {
25
+ Database: new (filename: string) => Database
26
+ }
27
+
28
+ interface NodeSqliteDatabase {
29
+ exec(sql: string): void
30
+ prepare(sql: string): SqliteStatement
31
+ close(): void
32
+ }
33
+
34
+ interface NodeSqliteModule {
35
+ DatabaseSync: new (filename: string) => NodeSqliteDatabase
36
+ }
37
+
38
+ const requireRuntimeModule = createRequire(import.meta.url)
39
+
5
40
  let instance: Database | undefined
6
41
 
42
+ class NodeSqliteAdapter implements Database {
43
+ private readonly db: NodeSqliteDatabase
44
+
45
+ constructor(filename: string) {
46
+ const sqlite = requireRuntimeModule("node:sqlite") as NodeSqliteModule
47
+ this.db = new sqlite.DatabaseSync(filename)
48
+ }
49
+
50
+ exec(sql: string): void {
51
+ this.db.exec(sql)
52
+ }
53
+
54
+ query(sql: string): SqliteStatement {
55
+ return this.db.prepare(sql)
56
+ }
57
+
58
+ run(sql: string, ...params: unknown[]): SqliteRunResult {
59
+ const bindings = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
60
+ return this.db.prepare(sql).run(...bindings) as SqliteRunResult
61
+ }
62
+
63
+ transaction<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => TResult): (...args: TArgs) => TResult {
64
+ return (...args: TArgs): TResult => {
65
+ this.exec("BEGIN")
66
+ try {
67
+ const result = fn(...args)
68
+ this.exec("COMMIT")
69
+ return result
70
+ } catch (err) {
71
+ this.exec("ROLLBACK")
72
+ throw err
73
+ }
74
+ }
75
+ }
76
+
77
+ close(): void {
78
+ this.db.close()
79
+ }
80
+ }
81
+
82
+ function openDatabase(filename: string): Database {
83
+ if (typeof process.versions.bun === "string") {
84
+ const sqlite = requireRuntimeModule(["bun", "sqlite"].join(":")) as BunSqliteModule
85
+ return new sqlite.Database(filename)
86
+ }
87
+
88
+ return new NodeSqliteAdapter(filename)
89
+ }
90
+
7
91
  /**
8
92
  * Resolve the path for the ensemble SQLite database.
9
93
  * Always uses the global ~/.config/opencode/ directory, never the project directory.
@@ -19,7 +103,7 @@ export function getDbPath(env: Record<string, string | undefined> = process.env)
19
103
  * Applies all pending migrations and enables WAL mode.
20
104
  */
21
105
  export function createDb(path: string): Database {
22
- const db = new Database(path)
106
+ const db = openDatabase(path)
23
107
  db.exec("PRAGMA journal_mode=WAL")
24
108
  db.exec("PRAGMA foreign_keys=ON")
25
109
  applyMigrations(db)
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"
@@ -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
  })
@@ -285,6 +288,15 @@ const plugin: Plugin = async (input) => {
285
288
  }
286
289
  }
287
290
 
291
+ // Surface teammate session errors as system messages to the lead.
292
+ // Without this, errors during a teammate's prompt loop (auth failure,
293
+ // tool failure, model error, etc.) are invisible to the lead — the
294
+ // teammate just appears stuck.
295
+ if (event.type === "session.error") {
296
+ const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
297
+ handleSessionErrorEvent(db, registry, props.sessionID, props.error)
298
+ }
299
+
288
300
  // Track per-step output tokens for stall detection
289
301
  if (event.type === "message.part.updated") {
290
302
  const part = (event.properties as { part?: { type?: string; sessionID?: string; tokens?: { output?: number } } }).part
@@ -298,7 +310,7 @@ const plugin: Plugin = async (input) => {
298
310
 
299
311
  // Sub-agent isolation + rate limiting hook
300
312
  "tool.execute.before": async (input, _output) => {
301
- checkToolIsolation(registry, tracker, input.tool, input.sessionID)
313
+ checkToolIsolation(registry, tracker, input.tool, input.sessionID, db)
302
314
  // Rate limit team tools that trigger LLM inference
303
315
  if (input.tool.startsWith("team_")) {
304
316
  if (!rateLimiter.tryConsume()) {
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
5
  import { preserveBranch, preservedBranchName } 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)
@@ -145,6 +146,30 @@ export async function recoverUndeliveredMessages(
145
146
  return { redelivered }
146
147
  }
147
148
 
149
+ /**
150
+ * Repopulate the in-memory MemberRegistry from SQLite for all active members.
151
+ * MUST be called on every plugin init — the registry is in-memory only,
152
+ * and without rehydration, every team_* tool call from an existing
153
+ * teammate fails with "This session is not in a team." after a plugin restart.
154
+ *
155
+ * Skips members in terminal states (shutdown, error) — they should not
156
+ * receive future messages.
157
+ *
158
+ * Returns the number of members rehydrated.
159
+ */
160
+ export function rehydrateRegistry(db: Database, registry: MemberRegistry): number {
161
+ const members = db.query(
162
+ `SELECT tm.team_id, tm.name, tm.session_id
163
+ FROM team_member tm
164
+ JOIN team t ON tm.team_id = t.id
165
+ WHERE t.status = 'active' AND tm.status NOT IN ('shutdown', 'error')`
166
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
167
+ for (const m of members) {
168
+ registry.register(m.team_id, m.name, m.session_id)
169
+ }
170
+ return members.length
171
+ }
172
+
148
173
  /**
149
174
  * Clean up orphaned ensemble/preserved/* branches that belong to archived teams
150
175
  * with no active members. Scoped carefully to avoid interfering with other
@@ -168,12 +193,9 @@ export async function recoverOrphanedBranches(db: Database, cwd: string): Promis
168
193
  const archivedNames = new Set(archivedTeams.map(t => t.name))
169
194
 
170
195
  // 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
196
+ const result = await runCommand(["git", "branch", "--list", "ensemble/preserved/*"], { cwd })
175
197
 
176
- const branches = stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
198
+ const branches = result.stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
177
199
 
178
200
  for (const branch of branches) {
179
201
  // Parse team name from branch: ensemble/preserved/{teamName}/{memberName}
@@ -183,9 +205,8 @@ export async function recoverOrphanedBranches(db: Database, cwd: string): Promis
183
205
  if (!teamName || !archivedNames.has(teamName)) continue
184
206
 
185
207
  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) {
208
+ const deleteResult = await runCommand(["git", "branch", "-D", branch], { cwd })
209
+ if (deleteResult.exitCode === 0) {
189
210
  removed++
190
211
  log(`recovery:branch:deleted branch=${branch}`)
191
212
  }
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[] = [
@@ -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"