@hueyexe/opencode-ensemble 0.16.1 → 0.18.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/README.md +42 -28
- package/dist/index.js +43016 -19579
- package/package.json +18 -5
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +90 -20
- package/src/log.ts +9 -0
- package/src/member-model.ts +30 -0
- package/src/notify.ts +46 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/rpc.ts +99 -0
- package/src/schema.ts +18 -0
- package/src/tasks.ts +19 -0
- package/src/tools/team-create.ts +15 -2
- package/src/tools/team-merge.ts +30 -3
- package/src/tools/team-message.ts +55 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +37 -16
- package/src/tools/team-status.ts +38 -4
- package/src/tools/team-view.ts +10 -4
- package/src/tui-view.ts +50 -0
- package/src/tui.tsx +101 -0
- package/src/types.ts +5 -0
- package/src/v2-client.ts +227 -0
- package/src/v2-events.ts +73 -0
- package/src/v2-rpc.ts +24 -0
- package/src/v2-session.ts +56 -0
- package/src/v2-setup.ts +349 -0
- package/src/v2-tools.ts +349 -0
- package/src/watchdog.ts +91 -17
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./rpc": "./src/rpc.ts",
|
|
10
|
+
"./tui": "./src/tui.tsx"
|
|
11
|
+
},
|
|
7
12
|
"type": "module",
|
|
8
13
|
"license": "MIT",
|
|
9
14
|
"repository": {
|
|
@@ -30,6 +35,7 @@
|
|
|
30
35
|
"lint": "bunx biome lint src/",
|
|
31
36
|
"typecheck": "tsc --noEmit",
|
|
32
37
|
"test": "bun test",
|
|
38
|
+
"eval:smoke": "bun eval/smoke.ts",
|
|
33
39
|
"build": "bun build src/index.ts --outdir dist --target node",
|
|
34
40
|
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
|
35
41
|
},
|
|
@@ -39,13 +45,20 @@
|
|
|
39
45
|
},
|
|
40
46
|
"devDependencies": {
|
|
41
47
|
"@biomejs/biome": "^2.4.9",
|
|
42
|
-
"@
|
|
48
|
+
"@opentui/core": "0.5.10",
|
|
49
|
+
"@opentui/solid": "0.5.10",
|
|
50
|
+
"@types/bun": "latest",
|
|
51
|
+
"solid-js": "1.9.0"
|
|
43
52
|
},
|
|
44
53
|
"peerDependencies": {
|
|
45
|
-
"typescript": "^5"
|
|
54
|
+
"typescript": "^5",
|
|
55
|
+
"@opentui/core": ">=0.5.10",
|
|
56
|
+
"@opentui/solid": ">=0.5.10",
|
|
57
|
+
"solid-js": ">=1.9.0"
|
|
46
58
|
},
|
|
47
59
|
"dependencies": {
|
|
48
60
|
"@opencode-ai/plugin": "^1.17.18",
|
|
49
|
-
"@opencode-ai/sdk": "^1.17.18"
|
|
61
|
+
"@opencode-ai/sdk": "^1.17.18",
|
|
62
|
+
"@opencode/plugin": "2.0.3"
|
|
50
63
|
}
|
|
51
|
-
}
|
|
64
|
+
}
|
|
@@ -81,7 +81,7 @@ function rAgents(t){
|
|
|
81
81
|
'<div class="flex items-center gap-2">'+
|
|
82
82
|
'<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
|
|
83
83
|
'<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
|
|
84
|
-
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
84
|
+
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+(m.lastNudgedAt?', nudged '+relT(m.lastNudgedAt):'')+'</span>'+
|
|
85
85
|
spark+
|
|
86
86
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
87
87
|
(m.model?chip(E(m.model),'muted'):'')+
|
|
@@ -90,6 +90,7 @@ function rAgents(t){
|
|
|
90
90
|
(mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
|
|
91
91
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
|
92
92
|
chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
|
|
93
|
+
(m.isRetrying?chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':''),'amber'):'')+
|
|
93
94
|
(m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
|
|
94
95
|
'</div></button>';
|
|
95
96
|
}).join('');
|
|
@@ -113,6 +114,8 @@ function openDrawer(name){
|
|
|
113
114
|
meta.push(chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted'));
|
|
114
115
|
if(m.planApproval&&m.planApproval!=='none')meta.push(chip(E(m.planApproval),m.planApproval==='approved'?'green':m.planApproval==='rejected'?'red':'amber'));
|
|
115
116
|
meta.push(chip('spawned '+relT(m.timeCreated),'muted'));
|
|
117
|
+
if(m.lastNudgedAt)meta.push(chip('nudged '+relT(m.lastNudgedAt),'amber'));
|
|
118
|
+
if(m.isRetrying)meta.push(chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':'')+(m.retryMessage?': '+E(m.retryMessage):''),'amber'));
|
|
116
119
|
if(m.worktreeBranch)meta.push(chip(E(m.worktreeBranch),'muted'));
|
|
117
120
|
h+='<div class="flex flex-wrap gap-1.5 mb-4 pb-4 border-b border-base-800/50">'+meta.join('')+'</div>';
|
|
118
121
|
// Prompt
|
|
@@ -180,7 +183,7 @@ function rDrawerActivityUpdate(){
|
|
|
180
183
|
el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
|
|
181
184
|
return;
|
|
182
185
|
}
|
|
183
|
-
var html=drawerActivity.map(function(a){
|
|
186
|
+
var html=drawerActivity.slice().reverse().map(function(a){
|
|
184
187
|
var icon='\\u25CF',color='text-txt-500',label=a.type;
|
|
185
188
|
if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
|
|
186
189
|
if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
|
package/src/dashboard.ts
CHANGED
|
@@ -16,6 +16,7 @@ interface TeamRow {
|
|
|
16
16
|
id: string
|
|
17
17
|
name: string
|
|
18
18
|
project_id: string
|
|
19
|
+
lead_session_id: string
|
|
19
20
|
status: string
|
|
20
21
|
lead_agent: string | null
|
|
21
22
|
time_created: number
|
|
@@ -43,6 +44,11 @@ interface MemberRow {
|
|
|
43
44
|
plan_approval: string
|
|
44
45
|
time_created: number
|
|
45
46
|
time_updated: number
|
|
47
|
+
last_nudged_at: number | null
|
|
48
|
+
retry_until: number | null
|
|
49
|
+
retry_attempt: number | null
|
|
50
|
+
retry_provider: string | null
|
|
51
|
+
retry_message: string | null
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
interface TaskRow {
|
|
@@ -81,10 +87,31 @@ function parseDependsOn(value: string | null): string[] {
|
|
|
81
87
|
return []
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Bare-integer version of the `/api/state` response shape. Bump on any
|
|
92
|
+
* breaking change to the payload (field removed/renamed/retyped, not
|
|
93
|
+
* additive fields). Consumers outside this repo compare this field with
|
|
94
|
+
* strict equality — no semver ranges. Currently consumed externally by the
|
|
95
|
+
* OpenCode sidebar TUI plugin that renders Team status from this endpoint.
|
|
96
|
+
*/
|
|
97
|
+
export const ENSEMBLE_STATE_VERSION = 1
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `/api/state` response shape. Exported and versioned because at least one
|
|
101
|
+
* consumer outside this repo (an OpenCode sidebar TUI plugin) polls this
|
|
102
|
+
* endpoint and depends on its shape. Treat additive-only changes as safe;
|
|
103
|
+
* bump {@link ENSEMBLE_STATE_VERSION} for anything else.
|
|
104
|
+
*/
|
|
105
|
+
export interface EnsembleDashboardState {
|
|
106
|
+
version: number
|
|
107
|
+
projects: unknown[]
|
|
108
|
+
teams: unknown[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildState(db: Database): EnsembleDashboardState {
|
|
85
112
|
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
|
|
86
|
-
const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
|
|
87
|
-
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
|
|
113
|
+
const teams = db.query("SELECT id, name, project_id, lead_session_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
|
|
114
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated, last_nudged_at, retry_until, retry_attempt, retry_provider, retry_message FROM team_member WHERE team_id = ?")
|
|
88
115
|
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
|
|
89
116
|
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
|
|
90
117
|
|
|
@@ -101,11 +128,21 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
|
|
|
101
128
|
planApproval: m.plan_approval,
|
|
102
129
|
timeCreated: m.time_created,
|
|
103
130
|
timeUpdated: m.time_updated,
|
|
131
|
+
lastNudgedAt: m.last_nudged_at,
|
|
132
|
+
// Fix 4: derived, read-time TTL boolean (Fix 3) — never a stored enum.
|
|
133
|
+
// Additive fields; existing consumers that don't know about them simply
|
|
134
|
+
// don't render them.
|
|
135
|
+
isRetrying: m.retry_until !== null && m.retry_until > Date.now(),
|
|
136
|
+
retryUntil: m.retry_until,
|
|
137
|
+
retryAttempt: m.retry_attempt,
|
|
138
|
+
retryProvider: m.retry_provider,
|
|
139
|
+
retryMessage: m.retry_message,
|
|
104
140
|
}))
|
|
105
141
|
return {
|
|
106
142
|
id: t.id,
|
|
107
143
|
name: t.name,
|
|
108
144
|
projectId: t.project_id,
|
|
145
|
+
leadSessionId: t.lead_session_id,
|
|
109
146
|
status: t.status,
|
|
110
147
|
leadAgent: t.lead_agent,
|
|
111
148
|
timeCreated: t.time_created,
|
|
@@ -140,6 +177,7 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
|
|
|
140
177
|
})
|
|
141
178
|
|
|
142
179
|
return {
|
|
180
|
+
version: ENSEMBLE_STATE_VERSION,
|
|
143
181
|
projects: projects.flatMap(project => {
|
|
144
182
|
const projectTeams = teamsByProject.get(project.id) ?? []
|
|
145
183
|
if (projectTeams.length === 0) return []
|
|
@@ -183,11 +221,30 @@ function sendJson(res: ServerResponse, data: unknown): void {
|
|
|
183
221
|
res.end(JSON.stringify(data))
|
|
184
222
|
}
|
|
185
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Resolve a message's creation timestamp (Unix ms) from an SDK message's
|
|
226
|
+
* `info.time`. The SDK shape is an object `{ created: number }`; older/other
|
|
227
|
+
* shapes (a numeric epoch or an ISO string) are tolerated. Falls back to
|
|
228
|
+
* `Date.now()` when the value is missing or unparseable — never returns NaN.
|
|
229
|
+
*/
|
|
230
|
+
export function parseMessageTime(time: unknown): number {
|
|
231
|
+
if (typeof time === "object" && time !== null) {
|
|
232
|
+
const created = (time as { created?: unknown }).created
|
|
233
|
+
if (typeof created === "number" && Number.isFinite(created)) return created
|
|
234
|
+
}
|
|
235
|
+
if (typeof time === "number" && Number.isFinite(time)) return time
|
|
236
|
+
if (typeof time === "string") {
|
|
237
|
+
const ms = new Date(time).getTime()
|
|
238
|
+
if (!Number.isNaN(ms)) return ms
|
|
239
|
+
}
|
|
240
|
+
return Date.now()
|
|
241
|
+
}
|
|
242
|
+
|
|
186
243
|
/** Parse SDK message parts into ActivityEntry format for the fallback path. */
|
|
187
244
|
export function parseMessageParts(parts: unknown[], msgInfo: unknown): ActivityEntry[] {
|
|
188
245
|
const entries: ActivityEntry[] = []
|
|
189
|
-
const info = (msgInfo ?? {}) as { time?:
|
|
190
|
-
const timestamp =
|
|
246
|
+
const info = (msgInfo ?? {}) as { time?: unknown; role?: string; tokens?: { input?: number; output?: number } }
|
|
247
|
+
const timestamp = parseMessageTime(info.time)
|
|
191
248
|
|
|
192
249
|
for (const part of parts) {
|
|
193
250
|
if (typeof part !== "object" || part === null) continue
|
package/src/hooks.ts
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import type { Database } from "./db"
|
|
2
2
|
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
3
|
-
import {
|
|
3
|
+
import type { PluginClient } from "./types"
|
|
4
|
+
import { notifyLead } from "./notify"
|
|
5
|
+
import { releaseMemberTasks } from "./tasks"
|
|
4
6
|
import { findTeamBySession } from "./types"
|
|
5
7
|
|
|
6
8
|
const TEAM_TOOL_PREFIX = "team_"
|
|
7
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Retry-specific payload carried by a `session.status` event when
|
|
12
|
+
* `status === "retry"`. Mirrors the SDK's `SessionStatus` retry variant.
|
|
13
|
+
* `action` is optional and free-form (not rate-limit-specific) — see Fix 2
|
|
14
|
+
* in the retry-observability spec.
|
|
15
|
+
*/
|
|
16
|
+
export interface RetryStatusPayload {
|
|
17
|
+
attempt: number
|
|
18
|
+
message: string
|
|
19
|
+
action?: {
|
|
20
|
+
reason: string
|
|
21
|
+
provider: string
|
|
22
|
+
title: string
|
|
23
|
+
message: string
|
|
24
|
+
label: string
|
|
25
|
+
link?: string
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Absolute epoch-ms timestamp of the next retry attempt — NOT a relative
|
|
29
|
+
* delta. Confirmed against the opencode server/TUI source (session.retry.scheduled
|
|
30
|
+
* emits `{attempt, at, error}` mapped straight onto `next`, and the built-in
|
|
31
|
+
* TUI computes remaining seconds as `Math.round((next - Date.now()) / 1000)`).
|
|
32
|
+
*/
|
|
33
|
+
next: number
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/** Result of a session status event — tells the caller what transition happened. */
|
|
9
37
|
export interface StatusTransition {
|
|
10
38
|
memberName: string
|
|
@@ -18,12 +46,17 @@ export interface StatusTransition {
|
|
|
18
46
|
* in SQLite based on the new session status.
|
|
19
47
|
* Ignores events for unknown sessions or archived teams.
|
|
20
48
|
* Returns the transition if one occurred, for toast notifications.
|
|
49
|
+
*
|
|
50
|
+
* `retryPayload` is only consulted when `status === "retry"` — it persists
|
|
51
|
+
* the four additive retry_* columns (Fix 2) without changing `status`/
|
|
52
|
+
* `execution_status` at all (Fix 1's non-goal: no FSM change).
|
|
21
53
|
*/
|
|
22
54
|
export function handleSessionStatusEvent(
|
|
23
55
|
db: Database,
|
|
24
56
|
registry: MemberRegistry,
|
|
25
57
|
sessionId: string,
|
|
26
58
|
status: "idle" | "busy" | "retry",
|
|
59
|
+
retryPayload?: RetryStatusPayload,
|
|
27
60
|
): StatusTransition | undefined {
|
|
28
61
|
const entry = registry.getBySession(sessionId)
|
|
29
62
|
if (!entry) return undefined
|
|
@@ -43,6 +76,11 @@ export function handleSessionStatusEvent(
|
|
|
43
76
|
"UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
44
77
|
[newStatus, Date.now(), entry.teamId, entry.memberName]
|
|
45
78
|
)
|
|
79
|
+
// A gracefully shut-down member has actually stopped now — release any
|
|
80
|
+
// tasks they left in_progress back to the pool so nothing is stranded (issue #27).
|
|
81
|
+
if (newStatus === "shutdown") {
|
|
82
|
+
releaseMemberTasks(db, entry.teamId, entry.memberName)
|
|
83
|
+
}
|
|
46
84
|
// Mark teammate as having reported if they sent at least one message to lead (issue #3).
|
|
47
85
|
// Set on busy→ready transition so Q&A messages during work don't prematurely block delivery.
|
|
48
86
|
if (member.status === "busy" && newStatus === "ready") {
|
|
@@ -67,12 +105,46 @@ export function handleSessionStatusEvent(
|
|
|
67
105
|
)
|
|
68
106
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
|
|
69
107
|
}
|
|
108
|
+
// Freshly-spawned member: inserted as busy/starting before the session's
|
|
109
|
+
// first busy event. Advance execution_status to running and refresh
|
|
110
|
+
// time_updated so the dashboard shows accurate progress instead of a
|
|
111
|
+
// frozen "starting" state (do NOT reset reported_to_lead — status is
|
|
112
|
+
// already busy, so this is not a re-activation).
|
|
113
|
+
if (member.status === "busy" && member.execution_status === "starting") {
|
|
114
|
+
db.run(
|
|
115
|
+
"UPDATE team_member SET execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
116
|
+
[Date.now(), entry.teamId, entry.memberName]
|
|
117
|
+
)
|
|
118
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
|
|
119
|
+
}
|
|
70
120
|
// Session went busy while shutdown was requested — signal for re-abort
|
|
71
121
|
if (member.status === "shutdown_requested") {
|
|
72
122
|
return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" }
|
|
73
123
|
}
|
|
74
124
|
} else if (status === "retry") {
|
|
75
|
-
// Session is being rate-limited — signal for toast but don't change state
|
|
125
|
+
// Session is being rate-limited — signal for toast but don't change state.
|
|
126
|
+
// Persist the retry_* columns so the signal survives past the toast (Fix 2)
|
|
127
|
+
// — status/execution_status are untouched, and there is no "provider
|
|
128
|
+
// throttled" language baked in here: the SDK's retry status is generic,
|
|
129
|
+
// so we surface whatever action.message/status.message actually says.
|
|
130
|
+
if (retryPayload) {
|
|
131
|
+
// Coerce before binding: older server versions may emit a partial retry
|
|
132
|
+
// payload, and an undefined bind throws synchronously inside this event
|
|
133
|
+
// handler, discarding the event's remaining processing.
|
|
134
|
+
const next = typeof retryPayload.next === "number" && Number.isFinite(retryPayload.next) ? retryPayload.next : null
|
|
135
|
+
const attempt = typeof retryPayload.attempt === "number" && Number.isFinite(retryPayload.attempt) ? retryPayload.attempt : null
|
|
136
|
+
db.run(
|
|
137
|
+
"UPDATE team_member SET retry_until = ?, retry_attempt = ?, retry_provider = ?, retry_message = ? WHERE team_id = ? AND name = ?",
|
|
138
|
+
[
|
|
139
|
+
next,
|
|
140
|
+
attempt,
|
|
141
|
+
retryPayload.action?.provider ?? null,
|
|
142
|
+
retryPayload.action?.message ?? retryPayload.message ?? null,
|
|
143
|
+
entry.teamId,
|
|
144
|
+
entry.memberName,
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
}
|
|
76
148
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" }
|
|
77
149
|
}
|
|
78
150
|
return undefined
|
|
@@ -169,6 +241,11 @@ export interface SessionErrorPayload {
|
|
|
169
241
|
* Handle a session.error event. Surfaces tool/model failures from a teammate
|
|
170
242
|
* as a system message to the lead, so otherwise-silent failures are visible.
|
|
171
243
|
*
|
|
244
|
+
* The lead is actively woken via notifyLead so the message is delivered even
|
|
245
|
+
* when the errored teammate was the last busy member (the "all done" gate in
|
|
246
|
+
* the passive wake path would otherwise strand the message). The teammate
|
|
247
|
+
* itself is NOT auto-resumed — recovery is left to the lead's judgement.
|
|
248
|
+
*
|
|
172
249
|
* Ignored when:
|
|
173
250
|
* - sessionID is undefined
|
|
174
251
|
* - the session is not a registered teammate (leads are not in the registry)
|
|
@@ -176,6 +253,7 @@ export interface SessionErrorPayload {
|
|
|
176
253
|
export function handleSessionErrorEvent(
|
|
177
254
|
db: Database,
|
|
178
255
|
registry: MemberRegistry,
|
|
256
|
+
client: PluginClient,
|
|
179
257
|
sessionId: string | undefined,
|
|
180
258
|
error: SessionErrorPayload | undefined,
|
|
181
259
|
): void {
|
|
@@ -187,10 +265,10 @@ export function handleSessionErrorEvent(
|
|
|
187
265
|
if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName) return
|
|
188
266
|
|
|
189
267
|
const errMsg = error?.data?.message ?? error?.name ?? "unknown error"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
268
|
+
notifyLead(
|
|
269
|
+
client,
|
|
270
|
+
db,
|
|
271
|
+
teamInfo.teamId,
|
|
272
|
+
`Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
|
|
273
|
+
)
|
|
196
274
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { Plugin as PluginV2 } from "@opencode/plugin"
|
|
3
|
+
import { setupEnsemble, type V2SetupContext } from "./v2-setup"
|
|
2
4
|
import { tool } from "@opencode-ai/plugin"
|
|
3
5
|
import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
4
6
|
import path from "node:path"
|
|
5
7
|
import { mkdirSync } from "node:fs"
|
|
6
8
|
import { createDb, getDbPath } from "./db"
|
|
7
9
|
import { wrapThrowingClient } from "./client"
|
|
8
|
-
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, rehydrateRegistry } from "./recovery"
|
|
10
|
+
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, recoverOrphanedTeams, rehydrateRegistry } from "./recovery"
|
|
9
11
|
import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
|
|
10
12
|
import { isWorktreeInstance } from "./util"
|
|
11
13
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember, handleSessionErrorEvent } from "./hooks"
|
|
12
|
-
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
13
|
-
import {
|
|
14
|
+
import { notifyTeamEvent, notifyWorkingProgress, notifyLead } from "./notify"
|
|
15
|
+
import { hasReportedCompletion } from "./messaging"
|
|
16
|
+
import { getMemberModel } from "./member-model"
|
|
14
17
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
15
18
|
import { log, initLog } from "./log"
|
|
16
19
|
import { findTeamBySession } from "./types"
|
|
@@ -72,13 +75,35 @@ const plugin: Plugin = async (input) => {
|
|
|
72
75
|
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
73
76
|
initLog(rawClient)
|
|
74
77
|
const client = wrapThrowingClient(rawClient)
|
|
75
|
-
const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }
|
|
78
|
+
const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config, progressTracker }
|
|
76
79
|
|
|
77
80
|
// Recovery only runs for the main project instance — NOT for teammate worktree instances.
|
|
78
81
|
// Worktree instances are created during session.create. Running recovery there makes HTTP
|
|
79
82
|
// calls back to the server, which deadlocks because the server is still handling session.create.
|
|
80
83
|
if (!isWorktreeInstance(input.directory)) {
|
|
81
84
|
log("init:recovery:start (main instance)")
|
|
85
|
+
|
|
86
|
+
// Reconciles teams whose lead session was deleted externally (not via
|
|
87
|
+
// team_cleanup) -- otherwise they stay 'active' forever, blocking
|
|
88
|
+
// team_create/team_cleanup for that name across restarts. See
|
|
89
|
+
// recoverOrphanedTeams' own doc comment for the full mechanism.
|
|
90
|
+
//
|
|
91
|
+
// Fire-and-forget, NOT awaited: isSessionAlive() calls client.session.get(),
|
|
92
|
+
// an HTTP call back to this same server. Awaiting it synchronously here --
|
|
93
|
+
// before the server has finished bootstrapping -- reproduced a real,
|
|
94
|
+
// confirmed deadlock (server never responds to ANY request, including
|
|
95
|
+
// unrelated ones like /config) when a stale team from a prior run exists
|
|
96
|
+
// for this project. Matches the existing pattern for the other three
|
|
97
|
+
// non-critical recovery passes below, and the documented reason
|
|
98
|
+
// recoverStaleMembers is skipped entirely for worktree instances two lines
|
|
99
|
+
// up ("makes HTTP calls back to the server, which deadlocks"). isSessionAlive
|
|
100
|
+
// itself also carries a bounded timeout as defense in depth.
|
|
101
|
+
recoverOrphanedTeams(db, client, input.directory, registry).then((result) => {
|
|
102
|
+
if (result.archived > 0) log(`init:recovery:orphaned-teams-archived=${result.archived}`)
|
|
103
|
+
}).catch((err) => {
|
|
104
|
+
log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
105
|
+
})
|
|
106
|
+
|
|
82
107
|
const recovery = await recoverStaleMembers(db, client, input.directory)
|
|
83
108
|
if (recovery.interrupted > 0) {
|
|
84
109
|
log(`init:recovery:interrupted=${recovery.interrupted}`)
|
|
@@ -142,7 +167,10 @@ const plugin: Plugin = async (input) => {
|
|
|
142
167
|
if (event.type === "session.status") {
|
|
143
168
|
const { sessionID, status } = event.properties
|
|
144
169
|
const statusType = status.type as "idle" | "busy" | "retry"
|
|
145
|
-
const
|
|
170
|
+
const retryPayload = statusType === "retry"
|
|
171
|
+
? (status as { attempt: number; message: string; action?: { reason: string; provider: string; title: string; message: string; label: string; link?: string }; next: number })
|
|
172
|
+
: undefined
|
|
173
|
+
const transition = handleSessionStatusEvent(db, registry, sessionID, statusType, retryPayload)
|
|
146
174
|
|
|
147
175
|
// Fire toast notifications for meaningful transitions
|
|
148
176
|
if (transition) {
|
|
@@ -167,12 +195,12 @@ const plugin: Plugin = async (input) => {
|
|
|
167
195
|
nudgedMembers.add(fastIdleKey)
|
|
168
196
|
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : ""
|
|
169
197
|
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
198
|
+
notifyLead(
|
|
199
|
+
client,
|
|
200
|
+
db,
|
|
201
|
+
transition.teamId,
|
|
202
|
+
`Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`,
|
|
203
|
+
)
|
|
176
204
|
client.tui.showToast({
|
|
177
205
|
title: "Team",
|
|
178
206
|
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
@@ -189,13 +217,23 @@ const plugin: Plugin = async (input) => {
|
|
|
189
217
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
|
|
190
218
|
nudgedMembers.add(nudgeKey)
|
|
191
219
|
log(`nudge:idle-without-report name=${transition.memberName}`)
|
|
220
|
+
const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName)
|
|
192
221
|
client.session.promptAsync({
|
|
193
222
|
sessionID,
|
|
194
223
|
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
|
|
195
|
-
|
|
224
|
+
...(nudgeModel ? { model: nudgeModel } : {}),
|
|
225
|
+
}).catch((err) => {
|
|
226
|
+
log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
227
|
+
})
|
|
196
228
|
}
|
|
197
229
|
} else if (transition.to === "error") {
|
|
198
230
|
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
231
|
+
} else if (transition.to === "busy") {
|
|
232
|
+
// Fresh busy period (ready/error -> busy) — give checkStalled a baseline
|
|
233
|
+
// that doesn't depend on a step-finish event having landed yet. Without
|
|
234
|
+
// this, a member whose first action is one long-running tool call is
|
|
235
|
+
// never detected as stalled until that call itself returns.
|
|
236
|
+
progressTracker.recordBusyStart(sessionID)
|
|
199
237
|
} else if (transition.to === "retry") {
|
|
200
238
|
// Teammate is being rate-limited — notify user
|
|
201
239
|
try {
|
|
@@ -270,9 +308,11 @@ const plugin: Plugin = async (input) => {
|
|
|
270
308
|
).get(member.team_id, member.name, staleThreshold) as { c: number }
|
|
271
309
|
if (peerMsgs.c > 0) {
|
|
272
310
|
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
|
|
311
|
+
const peerModel = getMemberModel(db, member.team_id, member.name)
|
|
273
312
|
client.session.promptAsync({
|
|
274
313
|
sessionID,
|
|
275
314
|
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
315
|
+
...(peerModel ? { model: peerModel } : {}),
|
|
276
316
|
}).catch((err) => {
|
|
277
317
|
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
278
318
|
})
|
|
@@ -294,7 +334,7 @@ const plugin: Plugin = async (input) => {
|
|
|
294
334
|
// teammate just appears stuck.
|
|
295
335
|
if (event.type === "session.error") {
|
|
296
336
|
const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
|
|
297
|
-
handleSessionErrorEvent(db, registry, props.sessionID, props.error)
|
|
337
|
+
handleSessionErrorEvent(db, registry, client, props.sessionID, props.error)
|
|
298
338
|
}
|
|
299
339
|
|
|
300
340
|
// Track per-step output tokens for stall detection
|
|
@@ -415,12 +455,16 @@ const plugin: Plugin = async (input) => {
|
|
|
415
455
|
}),
|
|
416
456
|
|
|
417
457
|
team_message: tool({
|
|
418
|
-
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead."
|
|
458
|
+
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
|
|
459
|
+
"Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " +
|
|
460
|
+
"When updating a model, 'text' is optional; omit it to change the model without sending a message.",
|
|
419
461
|
args: {
|
|
420
462
|
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
421
|
-
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
463
|
+
text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
|
|
422
464
|
approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
|
|
423
465
|
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
|
|
466
|
+
force: tool.schema.boolean().optional().describe("Lead only. Re-activate a teammate who already reported task completion (normally their session won't be woken again). Use for legitimate follow-on work — e.g. the next round of a multi-round debate — not for courtesy replies."),
|
|
467
|
+
model: tool.schema.string().optional().describe("Lead only: update the recipient teammate's model in-place, in 'provider/model' format (e.g. 'anthropic/claude-sonnet')."),
|
|
424
468
|
},
|
|
425
469
|
async execute(args, ctx) {
|
|
426
470
|
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
@@ -428,7 +472,7 @@ const plugin: Plugin = async (input) => {
|
|
|
428
472
|
progressTracker.recordMessage(ctx.sessionID)
|
|
429
473
|
// Track peer messages for chatty detection
|
|
430
474
|
if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
|
|
431
|
-
ctx.metadata({ title: `Message → ${args.to}` })
|
|
475
|
+
ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` })
|
|
432
476
|
return result
|
|
433
477
|
},
|
|
434
478
|
}),
|
|
@@ -440,8 +484,13 @@ const plugin: Plugin = async (input) => {
|
|
|
440
484
|
},
|
|
441
485
|
async execute(args, ctx) {
|
|
442
486
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
|
|
443
|
-
// Track broadcast activity for stall detection
|
|
487
|
+
// Track broadcast activity for stall detection AND chatty detection --
|
|
488
|
+
// a broadcast reaches every teammate at once, arguably the most
|
|
489
|
+
// "chatty" action possible, and was previously invisible to
|
|
490
|
+
// checkChatty()'s peer-message rate limit (recordPeerMessage was never
|
|
491
|
+
// called here, only recordMessage).
|
|
444
492
|
progressTracker.recordMessage(ctx.sessionID)
|
|
493
|
+
progressTracker.recordPeerMessage(ctx.sessionID)
|
|
445
494
|
ctx.metadata({ title: "Broadcast to team" })
|
|
446
495
|
return result
|
|
447
496
|
},
|
|
@@ -609,10 +658,11 @@ const plugin: Plugin = async (input) => {
|
|
|
609
658
|
}),
|
|
610
659
|
|
|
611
660
|
team_view: tool({
|
|
612
|
-
description: "
|
|
613
|
-
"
|
|
661
|
+
description: "Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
|
|
662
|
+
"Pass navigate: true ONLY when the user explicitly asked to view the teammate's session — it switches their client to it. Return via the session picker (ctrl+p).",
|
|
614
663
|
args: {
|
|
615
664
|
member: tool.schema.string().describe("Teammate name to view"),
|
|
665
|
+
navigate: tool.schema.boolean().default(false).describe("Switch the user's client to this session. Only when the user explicitly asked."),
|
|
616
666
|
},
|
|
617
667
|
async execute(args, ctx) {
|
|
618
668
|
const result = await executeTeamView(deps, args, ctx.sessionID)
|
|
@@ -624,4 +674,24 @@ const plugin: Plugin = async (input) => {
|
|
|
624
674
|
}
|
|
625
675
|
}
|
|
626
676
|
|
|
627
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Dual V1/V2 export (issue #36).
|
|
679
|
+
* - V2 reads `id` + `setup()` and ignores `server()`.
|
|
680
|
+
* - V1 (>=1.18.29) calls `server()` and ignores the V2 fields.
|
|
681
|
+
* The V2 setup body lands in later phases; the V1 implementation below is
|
|
682
|
+
* unchanged so current users keep working during the port.
|
|
683
|
+
*/
|
|
684
|
+
export default {
|
|
685
|
+
...PluginV2.define({
|
|
686
|
+
id: "ensemble",
|
|
687
|
+
async setup(ctx) {
|
|
688
|
+
// Dual support (issue #36): V2 initializes here; the V1 server()
|
|
689
|
+
// below keeps serving V1 hosts. Tool registration lands next slice.
|
|
690
|
+
const handle = await setupEnsemble(ctx as unknown as V2SetupContext)
|
|
691
|
+
return () => {
|
|
692
|
+
void handle.dispose()
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
server: plugin,
|
|
697
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -11,3 +11,12 @@ export function log(msg: string): void {
|
|
|
11
11
|
if (!_client) return
|
|
12
12
|
_client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {})
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* V2 fallback logger. The V2 plugin context exposes no app.log, so server
|
|
17
|
+
* log lines go to stdout with a prefix (the host captures plugin stdout
|
|
18
|
+
* into its own log). Used only by V2 setup paths.
|
|
19
|
+
*/
|
|
20
|
+
export function vlog(msg: string): void {
|
|
21
|
+
console.log(`[ensemble] ${msg}`)
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Database } from "./db"
|
|
2
|
+
|
|
3
|
+
/** A parsed provider/model pair, as accepted by session.promptAsync. */
|
|
4
|
+
export interface ParsedModel {
|
|
5
|
+
providerID: string
|
|
6
|
+
modelID: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a "provider/model" string into { providerID, modelID } for the SDK.
|
|
11
|
+
* Returns undefined if the string is not in "provider/model" form (empty
|
|
12
|
+
* provider or empty model). The model portion may itself contain slashes.
|
|
13
|
+
*/
|
|
14
|
+
export function parseModelId(model: string): ParsedModel | undefined {
|
|
15
|
+
const slash = model.indexOf("/")
|
|
16
|
+
if (slash <= 0 || slash === model.length - 1) return undefined
|
|
17
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a team member's stored model and parse it for session.promptAsync.
|
|
22
|
+
* Returns undefined when the member is unknown, has no model set, or the
|
|
23
|
+
* stored value is malformed. Never throws — safe to call from delivery paths.
|
|
24
|
+
*/
|
|
25
|
+
export function getMemberModel(db: Database, teamId: string, memberName: string): ParsedModel | undefined {
|
|
26
|
+
const row = db.query("SELECT model FROM team_member WHERE team_id = ? AND name = ?")
|
|
27
|
+
.get(teamId, memberName) as { model: string | null } | null
|
|
28
|
+
if (!row?.model) return undefined
|
|
29
|
+
return parseModelId(row.model)
|
|
30
|
+
}
|