@hueyexe/opencode-ensemble 0.18.0 → 0.21.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 +45 -24
- package/dist/index.js +17224 -30767
- package/package.json +2 -2
- package/src/config.ts +33 -0
- package/src/dashboard-html.ts +34 -1
- package/src/dashboard-js-events.ts +9 -0
- package/src/dashboard-js-graph-layout.ts +40 -0
- package/src/dashboard-js-graph.ts +99 -0
- package/src/dashboard.ts +8 -19
- package/src/hooks.ts +3 -3
- package/src/index.ts +11 -23
- package/src/member-model.ts +107 -3
- package/src/messaging.ts +4 -2
- package/src/notify.ts +9 -18
- package/src/recovery.ts +23 -16
- package/src/schema.ts +4 -0
- package/src/system-prompt.ts +79 -50
- package/src/task-graph.ts +116 -0
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-broadcast.ts +21 -3
- package/src/tools/team-message.ts +4 -5
- package/src/tools/team-results.ts +15 -5
- package/src/tools/team-spawn.ts +68 -16
- package/src/tools/team-tasks-add.ts +56 -3
- package/src/tools/team-tasks-complete.ts +2 -1
- package/src/types.ts +5 -1
- package/src/v2-client.ts +103 -14
- package/src/v2-setup.ts +16 -3
- package/src/wake.ts +198 -0
package/src/wake.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Database } from "./db"
|
|
2
|
+
import type { PluginClient } from "./types"
|
|
3
|
+
import { getSessionSelection } from "./member-model"
|
|
4
|
+
import { log } from "./log"
|
|
5
|
+
|
|
6
|
+
/** Options for a lead wake. */
|
|
7
|
+
export interface WakeLeadOptions {
|
|
8
|
+
/** Send even when the lead session is busy, and skip the cooldown (system/error notifications). */
|
|
9
|
+
force?: boolean
|
|
10
|
+
/** Coalescing window override (tests); defaults to {@link WAKE_COOLDOWN_MS}. */
|
|
11
|
+
cooldownMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Per-database coalescing state, so separate plugin instances/DBs don't share it. */
|
|
15
|
+
interface WakeState {
|
|
16
|
+
/** Teams with a wake currently being resolved/sent. */
|
|
17
|
+
inFlight: Set<string>
|
|
18
|
+
/** Trailing retries scheduled after a cooldown skip. */
|
|
19
|
+
timers: Map<string, ReturnType<typeof setTimeout>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const states = new WeakMap<Database, WakeState>()
|
|
23
|
+
const WAKE_COOLDOWN_MS = 5000
|
|
24
|
+
|
|
25
|
+
function stateFor(db: Database): WakeState {
|
|
26
|
+
let state = states.get(db)
|
|
27
|
+
if (!state) {
|
|
28
|
+
state = { inFlight: new Set(), timers: new Map() }
|
|
29
|
+
states.set(db, state)
|
|
30
|
+
}
|
|
31
|
+
return state
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PendingLeadWake {
|
|
35
|
+
leadSessionID: string
|
|
36
|
+
count: number
|
|
37
|
+
senders: string[]
|
|
38
|
+
lastWakeAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve whether a team still has messages the lead has neither seen in the
|
|
43
|
+
* system prompt (delivered) nor read via team_results (read). Resolving from
|
|
44
|
+
* the DB at fire time is what prevents wakes from landing in an archived or
|
|
45
|
+
* purged team (#48) or after the inbox was drained (#57).
|
|
46
|
+
*/
|
|
47
|
+
function pendingLeadWake(db: Database, teamId: string): PendingLeadWake | undefined {
|
|
48
|
+
const team = db.query("SELECT lead_session_id, last_wake_at FROM team WHERE id = ? AND status = 'active'")
|
|
49
|
+
.get(teamId) as { lead_session_id: string; last_wake_at: number } | null
|
|
50
|
+
if (!team?.lead_session_id) return undefined
|
|
51
|
+
|
|
52
|
+
const row = db.query(
|
|
53
|
+
`SELECT COUNT(*) as c, GROUP_CONCAT(DISTINCT from_name) as senders
|
|
54
|
+
FROM team_message
|
|
55
|
+
WHERE team_id = ? AND to_name = 'lead' AND read = 0 AND delivered = 0`,
|
|
56
|
+
).get(teamId) as { c: number; senders: string | null }
|
|
57
|
+
if (row.c === 0) return undefined
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
leadSessionID: team.lead_session_id,
|
|
61
|
+
count: row.c,
|
|
62
|
+
senders: row.senders ? row.senders.split(",").filter(Boolean) : [],
|
|
63
|
+
lastWakeAt: team.last_wake_at,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Schedule one trailing re-check after a cooldown skip. */
|
|
68
|
+
function scheduleCooldownRetry(
|
|
69
|
+
client: PluginClient,
|
|
70
|
+
db: Database,
|
|
71
|
+
teamId: string,
|
|
72
|
+
options: WakeLeadOptions,
|
|
73
|
+
delayMs: number,
|
|
74
|
+
): void {
|
|
75
|
+
const state = stateFor(db)
|
|
76
|
+
if (state.timers.has(teamId)) return
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
state.timers.delete(teamId)
|
|
79
|
+
wakeLeadIfPending(client, db, teamId, options).catch((err) => {
|
|
80
|
+
log(`wake:retry:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
81
|
+
})
|
|
82
|
+
}, Math.max(0, delayMs))
|
|
83
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
84
|
+
state.timers.set(teamId, timer)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Best-effort busy check. Unknown status is treated as idle so wakes are not lost. */
|
|
88
|
+
async function isSessionBusy(client: PluginClient, sessionID: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
const status = await client.session.status()
|
|
91
|
+
const entry = status.data?.[sessionID]
|
|
92
|
+
return entry?.type === "busy" || entry?.type === "retry"
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wake a team's lead only when the team is still active and has genuinely
|
|
100
|
+
* unseen messages. The team and pending count are resolved at fire time, so a
|
|
101
|
+
* wake scheduled before team_cleanup cannot wake the lead into a dead team.
|
|
102
|
+
*
|
|
103
|
+
* Coalesces bursts: concurrent calls for the same team collapse into one, and
|
|
104
|
+
* a successful wake suppresses further wakes for {@link WAKE_COOLDOWN_MS}.
|
|
105
|
+
* A busy lead is left alone (the idle backstop will wake it) unless
|
|
106
|
+
* `force` is set for system/error notifications.
|
|
107
|
+
*
|
|
108
|
+
* Fire-and-forget by design: callers should not await this in delivery paths.
|
|
109
|
+
* Returns true when a wake was actually sent.
|
|
110
|
+
*/
|
|
111
|
+
export async function wakeLeadIfPending(
|
|
112
|
+
client: PluginClient,
|
|
113
|
+
db: Database,
|
|
114
|
+
teamId: string,
|
|
115
|
+
options: WakeLeadOptions = {},
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const state = stateFor(db)
|
|
118
|
+
if (state.inFlight.has(teamId)) return false
|
|
119
|
+
|
|
120
|
+
const before = pendingLeadWake(db, teamId)
|
|
121
|
+
if (!before) {
|
|
122
|
+
log(`wake:skip team=${teamId} reason=no-unseen`)
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cooldownMs = options.cooldownMs ?? WAKE_COOLDOWN_MS
|
|
127
|
+
const now = Date.now()
|
|
128
|
+
if (!options.force && now - before.lastWakeAt < cooldownMs) {
|
|
129
|
+
const remaining = cooldownMs - (now - before.lastWakeAt)
|
|
130
|
+
scheduleCooldownRetry(client, db, teamId, options, remaining)
|
|
131
|
+
log(`wake:skip team=${teamId} reason=cooldown retryIn=${remaining}ms`)
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
state.inFlight.add(teamId)
|
|
136
|
+
try {
|
|
137
|
+
// V2 synthetic wakes use queue delivery and are designed to wait for a busy
|
|
138
|
+
// session; V2 also has no lead-idle backstop, so skipping here would strand
|
|
139
|
+
// the message. On V1 the passive idle path retries, so a busy lead is left
|
|
140
|
+
// alone unless the caller forces the wake (system/error notifications).
|
|
141
|
+
const isV2 = Boolean(client.agent)
|
|
142
|
+
if (!options.force && !isV2 && await isSessionBusy(client, before.leadSessionID)) {
|
|
143
|
+
log(`wake:skip team=${teamId} reason=lead-busy`)
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// On V1 a wake is a normal user turn, so pass the lead's current agent and
|
|
148
|
+
// model explicitly to stop OpenCode falling back to Build/default (#37).
|
|
149
|
+
// V2 wakes are synthetic and ignore both, so skip the lookup there.
|
|
150
|
+
const selection = client.agent
|
|
151
|
+
? {}
|
|
152
|
+
: await getSessionSelection(client, before.leadSessionID).catch(() => ({}))
|
|
153
|
+
|
|
154
|
+
// Re-check after the async lookups: the team may have been archived or
|
|
155
|
+
// purged, or the lead may have drained the inbox in the meantime.
|
|
156
|
+
const after = pendingLeadWake(db, teamId)
|
|
157
|
+
if (!after) {
|
|
158
|
+
log(`wake:skip team=${teamId} reason=drained`)
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pendingTimer = state.timers.get(teamId)
|
|
163
|
+
if (pendingTimer) {
|
|
164
|
+
clearTimeout(pendingTimer)
|
|
165
|
+
state.timers.delete(teamId)
|
|
166
|
+
}
|
|
167
|
+
// Persisted, not in-memory: worktree teammate instances share this DB.
|
|
168
|
+
db.run("UPDATE team SET last_wake_at = ? WHERE id = ? AND status = 'active'", [Date.now(), teamId])
|
|
169
|
+
const senderText = after.senders.length > 0 ? ` from ${after.senders.join(", ")}` : ""
|
|
170
|
+
client.session.promptAsync({
|
|
171
|
+
sessionID: after.leadSessionID,
|
|
172
|
+
parts: [{ type: "text", text: `[System: ${after.count} new team message(s)${senderText}] — use team_results to read them` }],
|
|
173
|
+
synthetic: true,
|
|
174
|
+
...(selection.agent ? { agent: selection.agent } : {}),
|
|
175
|
+
...(selection.model ? { model: selection.model } : {}),
|
|
176
|
+
}).catch((err) => {
|
|
177
|
+
log(`wake:send:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
|
|
178
|
+
})
|
|
179
|
+
log(`wake:sent team=${teamId} count=${after.count}`)
|
|
180
|
+
return true
|
|
181
|
+
} finally {
|
|
182
|
+
state.inFlight.delete(teamId)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Clear wake coalescing state for a database (tests). */
|
|
187
|
+
export function resetWakeState(db: Database): void {
|
|
188
|
+
const state = states.get(db)
|
|
189
|
+
if (state) {
|
|
190
|
+
for (const timer of state.timers.values()) clearTimeout(timer)
|
|
191
|
+
states.delete(db)
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
db.run("UPDATE team SET last_wake_at = 0")
|
|
195
|
+
} catch {
|
|
196
|
+
// The table may not exist yet in a partially initialized test DB.
|
|
197
|
+
}
|
|
198
|
+
}
|