@hueyexe/opencode-ensemble 0.16.1 → 0.17.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/dist/index.js +356 -140
- package/package.json +1 -1
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +64 -17
- package/src/member-model.ts +30 -0
- package/src/notify.ts +44 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- 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 +54 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +16 -15
- package/src/tools/team-status.ts +38 -4
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
|
@@ -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
|
@@ -5,12 +5,13 @@ 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, rehydrateRegistry } from "./recovery"
|
|
8
|
+
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, recoverOrphanedTeams, rehydrateRegistry } from "./recovery"
|
|
9
9
|
import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
|
|
10
10
|
import { isWorktreeInstance } from "./util"
|
|
11
11
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember, handleSessionErrorEvent } from "./hooks"
|
|
12
|
-
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
13
|
-
import {
|
|
12
|
+
import { notifyTeamEvent, notifyWorkingProgress, notifyLead } from "./notify"
|
|
13
|
+
import { hasReportedCompletion } from "./messaging"
|
|
14
|
+
import { getMemberModel } from "./member-model"
|
|
14
15
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
15
16
|
import { log, initLog } from "./log"
|
|
16
17
|
import { findTeamBySession } from "./types"
|
|
@@ -72,13 +73,35 @@ const plugin: Plugin = async (input) => {
|
|
|
72
73
|
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
73
74
|
initLog(rawClient)
|
|
74
75
|
const client = wrapThrowingClient(rawClient)
|
|
75
|
-
const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }
|
|
76
|
+
const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config, progressTracker }
|
|
76
77
|
|
|
77
78
|
// Recovery only runs for the main project instance — NOT for teammate worktree instances.
|
|
78
79
|
// Worktree instances are created during session.create. Running recovery there makes HTTP
|
|
79
80
|
// calls back to the server, which deadlocks because the server is still handling session.create.
|
|
80
81
|
if (!isWorktreeInstance(input.directory)) {
|
|
81
82
|
log("init:recovery:start (main instance)")
|
|
83
|
+
|
|
84
|
+
// Reconciles teams whose lead session was deleted externally (not via
|
|
85
|
+
// team_cleanup) -- otherwise they stay 'active' forever, blocking
|
|
86
|
+
// team_create/team_cleanup for that name across restarts. See
|
|
87
|
+
// recoverOrphanedTeams' own doc comment for the full mechanism.
|
|
88
|
+
//
|
|
89
|
+
// Fire-and-forget, NOT awaited: isSessionAlive() calls client.session.get(),
|
|
90
|
+
// an HTTP call back to this same server. Awaiting it synchronously here --
|
|
91
|
+
// before the server has finished bootstrapping -- reproduced a real,
|
|
92
|
+
// confirmed deadlock (server never responds to ANY request, including
|
|
93
|
+
// unrelated ones like /config) when a stale team from a prior run exists
|
|
94
|
+
// for this project. Matches the existing pattern for the other three
|
|
95
|
+
// non-critical recovery passes below, and the documented reason
|
|
96
|
+
// recoverStaleMembers is skipped entirely for worktree instances two lines
|
|
97
|
+
// up ("makes HTTP calls back to the server, which deadlocks"). isSessionAlive
|
|
98
|
+
// itself also carries a bounded timeout as defense in depth.
|
|
99
|
+
recoverOrphanedTeams(db, client, input.directory, registry).then((result) => {
|
|
100
|
+
if (result.archived > 0) log(`init:recovery:orphaned-teams-archived=${result.archived}`)
|
|
101
|
+
}).catch((err) => {
|
|
102
|
+
log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
103
|
+
})
|
|
104
|
+
|
|
82
105
|
const recovery = await recoverStaleMembers(db, client, input.directory)
|
|
83
106
|
if (recovery.interrupted > 0) {
|
|
84
107
|
log(`init:recovery:interrupted=${recovery.interrupted}`)
|
|
@@ -142,7 +165,10 @@ const plugin: Plugin = async (input) => {
|
|
|
142
165
|
if (event.type === "session.status") {
|
|
143
166
|
const { sessionID, status } = event.properties
|
|
144
167
|
const statusType = status.type as "idle" | "busy" | "retry"
|
|
145
|
-
const
|
|
168
|
+
const retryPayload = statusType === "retry"
|
|
169
|
+
? (status as { attempt: number; message: string; action?: { reason: string; provider: string; title: string; message: string; label: string; link?: string }; next: number })
|
|
170
|
+
: undefined
|
|
171
|
+
const transition = handleSessionStatusEvent(db, registry, sessionID, statusType, retryPayload)
|
|
146
172
|
|
|
147
173
|
// Fire toast notifications for meaningful transitions
|
|
148
174
|
if (transition) {
|
|
@@ -167,12 +193,12 @@ const plugin: Plugin = async (input) => {
|
|
|
167
193
|
nudgedMembers.add(fastIdleKey)
|
|
168
194
|
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : ""
|
|
169
195
|
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
196
|
+
notifyLead(
|
|
197
|
+
client,
|
|
198
|
+
db,
|
|
199
|
+
transition.teamId,
|
|
200
|
+
`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.`,
|
|
201
|
+
)
|
|
176
202
|
client.tui.showToast({
|
|
177
203
|
title: "Team",
|
|
178
204
|
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
@@ -189,13 +215,23 @@ const plugin: Plugin = async (input) => {
|
|
|
189
215
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
|
|
190
216
|
nudgedMembers.add(nudgeKey)
|
|
191
217
|
log(`nudge:idle-without-report name=${transition.memberName}`)
|
|
218
|
+
const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName)
|
|
192
219
|
client.session.promptAsync({
|
|
193
220
|
sessionID,
|
|
194
221
|
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
|
-
|
|
222
|
+
...(nudgeModel ? { model: nudgeModel } : {}),
|
|
223
|
+
}).catch((err) => {
|
|
224
|
+
log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
225
|
+
})
|
|
196
226
|
}
|
|
197
227
|
} else if (transition.to === "error") {
|
|
198
228
|
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
229
|
+
} else if (transition.to === "busy") {
|
|
230
|
+
// Fresh busy period (ready/error -> busy) — give checkStalled a baseline
|
|
231
|
+
// that doesn't depend on a step-finish event having landed yet. Without
|
|
232
|
+
// this, a member whose first action is one long-running tool call is
|
|
233
|
+
// never detected as stalled until that call itself returns.
|
|
234
|
+
progressTracker.recordBusyStart(sessionID)
|
|
199
235
|
} else if (transition.to === "retry") {
|
|
200
236
|
// Teammate is being rate-limited — notify user
|
|
201
237
|
try {
|
|
@@ -270,9 +306,11 @@ const plugin: Plugin = async (input) => {
|
|
|
270
306
|
).get(member.team_id, member.name, staleThreshold) as { c: number }
|
|
271
307
|
if (peerMsgs.c > 0) {
|
|
272
308
|
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
|
|
309
|
+
const peerModel = getMemberModel(db, member.team_id, member.name)
|
|
273
310
|
client.session.promptAsync({
|
|
274
311
|
sessionID,
|
|
275
312
|
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
313
|
+
...(peerModel ? { model: peerModel } : {}),
|
|
276
314
|
}).catch((err) => {
|
|
277
315
|
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
278
316
|
})
|
|
@@ -294,7 +332,7 @@ const plugin: Plugin = async (input) => {
|
|
|
294
332
|
// teammate just appears stuck.
|
|
295
333
|
if (event.type === "session.error") {
|
|
296
334
|
const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
|
|
297
|
-
handleSessionErrorEvent(db, registry, props.sessionID, props.error)
|
|
335
|
+
handleSessionErrorEvent(db, registry, client, props.sessionID, props.error)
|
|
298
336
|
}
|
|
299
337
|
|
|
300
338
|
// Track per-step output tokens for stall detection
|
|
@@ -415,12 +453,16 @@ const plugin: Plugin = async (input) => {
|
|
|
415
453
|
}),
|
|
416
454
|
|
|
417
455
|
team_message: tool({
|
|
418
|
-
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead."
|
|
456
|
+
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
|
|
457
|
+
"Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " +
|
|
458
|
+
"When updating a model, 'text' is optional; omit it to change the model without sending a message.",
|
|
419
459
|
args: {
|
|
420
460
|
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
421
|
-
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
461
|
+
text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
|
|
422
462
|
approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
|
|
423
463
|
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
|
|
464
|
+
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."),
|
|
465
|
+
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
466
|
},
|
|
425
467
|
async execute(args, ctx) {
|
|
426
468
|
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
@@ -428,7 +470,7 @@ const plugin: Plugin = async (input) => {
|
|
|
428
470
|
progressTracker.recordMessage(ctx.sessionID)
|
|
429
471
|
// Track peer messages for chatty detection
|
|
430
472
|
if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
|
|
431
|
-
ctx.metadata({ title: `Message → ${args.to}` })
|
|
473
|
+
ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` })
|
|
432
474
|
return result
|
|
433
475
|
},
|
|
434
476
|
}),
|
|
@@ -440,8 +482,13 @@ const plugin: Plugin = async (input) => {
|
|
|
440
482
|
},
|
|
441
483
|
async execute(args, ctx) {
|
|
442
484
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
|
|
443
|
-
// Track broadcast activity for stall detection
|
|
485
|
+
// Track broadcast activity for stall detection AND chatty detection --
|
|
486
|
+
// a broadcast reaches every teammate at once, arguably the most
|
|
487
|
+
// "chatty" action possible, and was previously invisible to
|
|
488
|
+
// checkChatty()'s peer-message rate limit (recordPeerMessage was never
|
|
489
|
+
// called here, only recordMessage).
|
|
444
490
|
progressTracker.recordMessage(ctx.sessionID)
|
|
491
|
+
progressTracker.recordPeerMessage(ctx.sessionID)
|
|
445
492
|
ctx.metadata({ title: "Broadcast to team" })
|
|
446
493
|
return result
|
|
447
494
|
},
|
|
@@ -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
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Database } from "./db"
|
|
2
2
|
import type { PluginClient } from "./types"
|
|
3
|
+
import { sendMessage } from "./messaging"
|
|
4
|
+
import { log } from "./log"
|
|
3
5
|
|
|
4
6
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
5
7
|
|
|
@@ -82,3 +84,45 @@ export async function notifyWorkingProgress(
|
|
|
82
84
|
// TUI may not be available — silently ignore
|
|
83
85
|
}
|
|
84
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Persist a system-generated message to the lead AND actively wake the lead
|
|
90
|
+
* so the message is delivered on the lead's next turn.
|
|
91
|
+
*
|
|
92
|
+
* This is the delivery path for system events (teammate errors, timeouts,
|
|
93
|
+
* stalls, spawn failures). Unlike the passive wake-lead path in the event
|
|
94
|
+
* hook, this ALWAYS fires a promptAsync regardless of whether teammates are
|
|
95
|
+
* still busy — an errored/aborted teammate is precisely the case where the
|
|
96
|
+
* "all teammates done" gate would otherwise suppress the wake and strand the
|
|
97
|
+
* message in the DB with delivered=0.
|
|
98
|
+
*
|
|
99
|
+
* The message is persisted first; the promptAsync wake is fire-and-forget
|
|
100
|
+
* (never awaited, per the plugin's promptAsync invariant). If the wake fails,
|
|
101
|
+
* the message is still in the DB and the passive backstop / recovery paths
|
|
102
|
+
* remain available.
|
|
103
|
+
*
|
|
104
|
+
* Returns the persisted message id.
|
|
105
|
+
*/
|
|
106
|
+
export function notifyLead(
|
|
107
|
+
client: PluginClient,
|
|
108
|
+
db: Database,
|
|
109
|
+
teamId: string,
|
|
110
|
+
content: string,
|
|
111
|
+
): string {
|
|
112
|
+
const id = sendMessage(db, { teamId, from: "system", to: "lead", content })
|
|
113
|
+
|
|
114
|
+
const team = db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
115
|
+
.get(teamId) as { lead_session_id: string } | null
|
|
116
|
+
if (!team?.lead_session_id) return id
|
|
117
|
+
|
|
118
|
+
// Fire-and-forget wake — the system prompt transform delivers the actual
|
|
119
|
+
// content on the lead's next turn.
|
|
120
|
+
client.session.promptAsync({
|
|
121
|
+
sessionID: team.lead_session_id,
|
|
122
|
+
parts: [{ type: "text", text: "[System: New team message from system]" }],
|
|
123
|
+
}).catch((err) => {
|
|
124
|
+
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return id
|
|
128
|
+
}
|
package/src/progress.ts
CHANGED
|
@@ -12,6 +12,7 @@ export class ProgressTracker {
|
|
|
12
12
|
private steps = new Map<string, StepRecord[]>()
|
|
13
13
|
private lastMessageAt = new Map<string, number>()
|
|
14
14
|
private lastTaskAt = new Map<string, number>()
|
|
15
|
+
private busySince = new Map<string, number>()
|
|
15
16
|
private peerMessages = new Map<string, number[]>()
|
|
16
17
|
private reported = new Set<string>()
|
|
17
18
|
private chattyReported = new Set<string>()
|
|
@@ -83,15 +84,32 @@ export class ProgressTracker {
|
|
|
83
84
|
return recent.every(r => r.outputTokens < threshold)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Record that this member transitioned to busy. Gives isTimeStalled a baseline
|
|
89
|
+
* independent of step records, so a member whose first (or only) action is one
|
|
90
|
+
* long-running tool call is still subject to time-based stall detection before
|
|
91
|
+
* any step-finish event has landed.
|
|
92
|
+
*/
|
|
93
|
+
recordBusyStart(sessionId: string): void {
|
|
94
|
+
this.busySince.set(sessionId, Date.now())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Latest activity timestamp across all tracked signals (0 when nothing recorded). */
|
|
98
|
+
lastActivityAt(sessionId: string): number {
|
|
88
99
|
const records = this.steps.get(sessionId)
|
|
89
|
-
|
|
100
|
+
const lastStepAt = records && records.length > 0 ? records[records.length - 1]!.timestamp : 0
|
|
101
|
+
return Math.max(
|
|
102
|
+
this.lastMessageAt.get(sessionId) ?? 0,
|
|
103
|
+
this.lastTaskAt.get(sessionId) ?? 0,
|
|
104
|
+
lastStepAt,
|
|
105
|
+
this.busySince.get(sessionId) ?? 0,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
90
108
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
109
|
+
/** Time-based stall: no message, task completion, step, or busy-transition within `thresholdMs` of now. */
|
|
110
|
+
isTimeStalled(sessionId: string, thresholdMs: number): boolean {
|
|
111
|
+
const baseline = this.lastActivityAt(sessionId)
|
|
112
|
+
if (baseline === 0) return false // no activity or busy signal recorded at all yet
|
|
95
113
|
|
|
96
114
|
return Date.now() - baseline >= thresholdMs
|
|
97
115
|
}
|
|
@@ -116,6 +134,7 @@ export class ProgressTracker {
|
|
|
116
134
|
this.steps.delete(sessionId)
|
|
117
135
|
this.lastMessageAt.delete(sessionId)
|
|
118
136
|
this.lastTaskAt.delete(sessionId)
|
|
137
|
+
this.busySince.delete(sessionId)
|
|
119
138
|
this.peerMessages.delete(sessionId)
|
|
120
139
|
this.reported.delete(sessionId)
|
|
121
140
|
this.chattyReported.delete(sessionId)
|