@hueyexe/opencode-ensemble 0.14.0 → 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/README.md +105 -63
- package/dist/index.js +1606 -1459
- package/package.json +8 -4
- package/src/dashboard.ts +92 -57
- package/src/db.ts +86 -2
- package/src/hooks.ts +72 -5
- package/src/index.ts +21 -9
- package/src/messaging.ts +1 -1
- package/src/notify.ts +1 -1
- package/src/process.ts +55 -0
- package/src/recovery.ts +30 -9
- package/src/schema.ts +1 -1
- package/src/system-prompt.ts +1 -1
- package/src/tools/merge-helper.ts +16 -24
- package/src/tools/shared.ts +7 -10
- package/src/tools/team-cleanup.ts +9 -14
- package/src/types.ts +51 -5
- package/src/watchdog.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.14.
|
|
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
|
|
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.
|
|
45
|
-
"@opencode-ai/sdk": "^1.
|
|
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
|
|
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<
|
|
132
|
-
|
|
133
|
-
const server =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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 =
|
|
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 "
|
|
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
|
-
//
|
|
125
|
+
// Fast path: registry hit on the caller — skip SQL altogether.
|
|
107
126
|
if (registry.isTeamSession(sessionId)) return
|
|
108
127
|
|
|
109
|
-
//
|
|
110
|
-
|
|
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 (
|
|
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
package/src/notify.ts
CHANGED
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 "
|
|
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
|
|
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
|
|
187
|
-
|
|
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
package/src/system-prompt.ts
CHANGED