@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.
@@ -0,0 +1,349 @@
1
+ import type { ToolDeps } from "./types"
2
+ import { executeTeamCreate } from "./tools/team-create"
3
+ import { executeTeamSpawn } from "./tools/team-spawn"
4
+ import { executeTeamMessage } from "./tools/team-message"
5
+ import { executeTeamBroadcast } from "./tools/team-broadcast"
6
+ import { executeTeamTasksList } from "./tools/team-tasks-list"
7
+ import { executeTeamTasksAdd } from "./tools/team-tasks-add"
8
+ import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
9
+ import { executeTeamClaim } from "./tools/team-claim"
10
+ import { executeTeamResults } from "./tools/team-results"
11
+ import { executeTeamShutdown } from "./tools/team-shutdown"
12
+ import { executeTeamCleanup } from "./tools/team-cleanup"
13
+ import { executeTeamMerge } from "./tools/team-merge"
14
+ import { executeTeamStatus } from "./tools/team-status"
15
+ import { executeTeamView } from "./tools/team-view"
16
+
17
+ /** V2 tool definition (JSON Schema input, structured content output). */
18
+ export interface V2ToolDef {
19
+ name: string
20
+ description: string
21
+ input: Record<string, unknown>
22
+ execute(input: unknown, context: { sessionID: string }): Promise<{ content: string }>
23
+ }
24
+
25
+ /** Minimal structural subset of the V2 tool domain. */
26
+ export interface V2ToolDomain {
27
+ transform(cb: (editor: { add(def: V2ToolDef): void }) => void): Promise<{ dispose(): Promise<void> }>
28
+ }
29
+
30
+ /** JSON Schema string property. */
31
+ function str(description: string): Record<string, unknown> {
32
+ return { type: "string", description }
33
+ }
34
+
35
+ /** JSON Schema boolean property with default. */
36
+ function bool(description: string, fallback: boolean): Record<string, unknown> {
37
+ return { type: "boolean", description, default: fallback }
38
+ }
39
+
40
+ /** Task priority enum shared by the schema and the runtime validator. */
41
+ const TASK_PRIORITIES = ["high", "medium", "low"] as const
42
+ type TaskPriority = (typeof TASK_PRIORITIES)[number]
43
+
44
+ /** JSON Schema enum property for task priority. */
45
+ function priority(): Record<string, unknown> {
46
+ return { type: "string", description: "Task priority", enum: [...TASK_PRIORITIES], default: "medium" }
47
+ }
48
+
49
+ /** Throw on unknown priority — fail fast instead of writing garbage to SQLite. */
50
+ export function normalizePriority(value: string | undefined): TaskPriority {
51
+ if (value === undefined) return "medium"
52
+ if ((TASK_PRIORITIES as readonly string[]).includes(value)) return value as TaskPriority
53
+ throw new Error(`Invalid priority "${value}" — expected one of: high, medium, low.`)
54
+ }
55
+
56
+ /**
57
+ * Register all 14 team tools on a V2 context. Each execute delegates to the
58
+ * same implementation V1 uses (DRY) — only arg transport changes from Zod to
59
+ * JSON Schema. V1 applied schema defaults before execute; V2 may not, so
60
+ * priority is normalized here (team_tasks_add writes it straight to SQLite).
61
+ *
62
+ * Purge approval (team_cleanup) has no approver on V2 yet — the question-tool
63
+ * flow it used is TUI-side. Preview works; confirmed purge needs the forms
64
+ * slice. team_view resolves without navigating (no server-side TUI select).
65
+ */
66
+ export async function registerV2Tools(domain: V2ToolDomain, deps: ToolDeps): Promise<void> {
67
+ const run = <A>(
68
+ fn: (deps: ToolDeps, args: A, sessionID: string) => Promise<string>,
69
+ track?: (sessionID: string, args: A) => void,
70
+ ) => async (input: unknown, context: { sessionID: string }) => {
71
+ const args = input as A
72
+ track?.(context.sessionID, args)
73
+ return { content: await fn(deps, args, context.sessionID) }
74
+ }
75
+ const runSession = (fn: (deps: ToolDeps, sessionID: string) => Promise<string>) => async (
76
+ _input: unknown,
77
+ context: { sessionID: string },
78
+ ) => ({ content: await fn(deps, context.sessionID) })
79
+
80
+ await domain.transform((editor) => {
81
+ editor.add({
82
+ name: "team_create",
83
+ description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
84
+ input: {
85
+ type: "object",
86
+ properties: {
87
+ name: str("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
88
+ project_name: str("Project display name for first use of this working directory."),
89
+ },
90
+ required: ["name"],
91
+ additionalProperties: false,
92
+ },
93
+ execute: run(executeTeamCreate),
94
+ })
95
+
96
+ editor.add({
97
+ name: "team_spawn",
98
+ description:
99
+ "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
100
+ "Each teammate gets their own git worktree for file isolation. " +
101
+ "Teammates work asynchronously and will message you when done. Do not poll for their status.",
102
+ input: {
103
+ type: "object",
104
+ properties: {
105
+ name: str("Teammate name (lowercase alphanumeric with hyphens)"),
106
+ agent: str("Agent type (e.g. 'build', 'plan', 'explore')"),
107
+ prompt: str("Task instructions for the teammate"),
108
+ model: str("Model in provider/model format (optional, uses default)"),
109
+ claim_task: str("Task ID to auto-claim for this teammate (optional)"),
110
+ worktree: bool("Create a git worktree for file isolation (default: true, set false for read-only agents)", true),
111
+ plan_approval: bool("Require teammate to send a plan for approval before writing files (default: false)", false),
112
+ },
113
+ required: ["name", "prompt"],
114
+ additionalProperties: false,
115
+ },
116
+ execute: run(executeTeamSpawn),
117
+ })
118
+
119
+ editor.add({
120
+ name: "team_message",
121
+ description:
122
+ "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
123
+ "Lead only: pass 'model' (provider/model) to update a teammate's model in-place.",
124
+ input: {
125
+ type: "object",
126
+ properties: {
127
+ to: str("Recipient name ('lead' or teammate name)"),
128
+ text: str("Message content (max 10KB). Optional only when 'model' is provided."),
129
+ approve: bool("Approve a teammate's plan (only when recipient has plan_approval='pending')", false),
130
+ reject: str("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
131
+ force: bool("Lead only. Re-activate a teammate who already reported task completion.", false),
132
+ model: str("Lead only: update the recipient teammate's model in-place, in 'provider/model' format."),
133
+ },
134
+ required: ["to"],
135
+ additionalProperties: false,
136
+ },
137
+ execute: run(executeTeamMessage, (sessionID, args) => {
138
+ const to = args.to ?? ""
139
+ deps.progressTracker.recordMessage(sessionID)
140
+ if (to !== "lead") deps.progressTracker.recordPeerMessage(sessionID)
141
+ }),
142
+ })
143
+
144
+ editor.add({
145
+ name: "team_broadcast",
146
+ description: "Send a message to all teammates and the lead (excluding yourself).",
147
+ input: {
148
+ type: "object",
149
+ properties: { text: str("Message content (max 10KB)") },
150
+ required: ["text"],
151
+ additionalProperties: false,
152
+ },
153
+ execute: run(executeTeamBroadcast, (sessionID) => {
154
+ deps.progressTracker.recordMessage(sessionID)
155
+ deps.progressTracker.recordPeerMessage(sessionID)
156
+ },
157
+ ),
158
+ })
159
+
160
+ editor.add({
161
+ name: "team_tasks_list",
162
+ description:
163
+ "View the shared team task board. Use this to check task status, not to wait for teammates. Teammates will message you when done.",
164
+ input: { type: "object", properties: {}, additionalProperties: false },
165
+ execute: runSession(executeTeamTasksList),
166
+ })
167
+
168
+ editor.add({
169
+ name: "team_tasks_add",
170
+ description: "Add tasks to the shared team task board so teammates can see what work is available and claim it.",
171
+ input: {
172
+ type: "object",
173
+ properties: {
174
+ tasks: {
175
+ type: "array",
176
+ description: "Tasks to add",
177
+ items: {
178
+ type: "object",
179
+ properties: {
180
+ content: str("Task description"),
181
+ priority: priority(),
182
+ depends_on: {
183
+ type: "array",
184
+ items: { type: "string" },
185
+ description: "Task IDs this depends on",
186
+ },
187
+ },
188
+ required: ["content"],
189
+ additionalProperties: false,
190
+ },
191
+ },
192
+ },
193
+ required: ["tasks"],
194
+ additionalProperties: false,
195
+ },
196
+ execute: async (input: unknown, context: { sessionID: string }) => {
197
+ const args = input as {
198
+ tasks: Array<{ content: string; priority?: string; depends_on?: string[] }>
199
+ }
200
+ const content = await executeTeamTasksAdd(
201
+ deps,
202
+ {
203
+ tasks: args.tasks.map((task) => ({
204
+ content: task.content,
205
+ priority: normalizePriority(task.priority),
206
+ ...(task.depends_on ? { depends_on: task.depends_on } : {}),
207
+ })),
208
+ },
209
+ context.sessionID,
210
+ )
211
+ return { content }
212
+ },
213
+ })
214
+
215
+ editor.add({
216
+ name: "team_tasks_complete",
217
+ description: "Mark a task as completed on the shared board. This unblocks any tasks that depend on it.",
218
+ input: {
219
+ type: "object",
220
+ properties: { task_id: str("ID of the task to mark complete") },
221
+ required: ["task_id"],
222
+ additionalProperties: false,
223
+ },
224
+ execute: run(executeTeamTasksComplete, (sessionID) =>
225
+ deps.progressTracker.recordTaskComplete(sessionID),
226
+ ),
227
+ })
228
+
229
+ editor.add({
230
+ name: "team_claim",
231
+ description: "Claim a pending task from the shared task list. Only unclaimed, unblocked tasks can be claimed.",
232
+ input: {
233
+ type: "object",
234
+ properties: { task_id: str("ID of the task to claim") },
235
+ required: ["task_id"],
236
+ additionalProperties: false,
237
+ },
238
+ execute: run(executeTeamClaim),
239
+ })
240
+
241
+ editor.add({
242
+ name: "team_results",
243
+ description:
244
+ "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
245
+ input: {
246
+ type: "object",
247
+ properties: { from: str("Filter messages by sender name (optional, returns all if omitted)") },
248
+ additionalProperties: false,
249
+ },
250
+ execute: run(executeTeamResults),
251
+ })
252
+
253
+ editor.add({
254
+ name: "team_shutdown",
255
+ description:
256
+ "Request a teammate to shut down. The teammate finishes current work then stops. Pass force: true to abort immediately without waiting.",
257
+ input: {
258
+ type: "object",
259
+ properties: {
260
+ member: str("Teammate name to shut down"),
261
+ force: bool("Force immediate abort without waiting for current work to finish", false),
262
+ },
263
+ required: ["member"],
264
+ additionalProperties: false,
265
+ },
266
+ execute: run(executeTeamShutdown),
267
+ })
268
+
269
+ editor.add({
270
+ name: "team_cleanup",
271
+ description:
272
+ "Clean up the current team, or purge archived teams after human approval. Omit purge for normal cleanup.",
273
+ input: {
274
+ type: "object",
275
+ properties: {
276
+ force: bool("Force cleanup even if members are active (will abort them)", false),
277
+ acknowledge_uncommitted: bool("Acknowledge uncommitted changes", false),
278
+ purge: {
279
+ type: "array",
280
+ items: { type: "string" },
281
+ description: "Archived team names to permanently delete, or ['*'] for all archived teams.",
282
+ },
283
+ confirm_purge: bool("Set true only after the user explicitly approves the purge preview.", false),
284
+ confirm_token: str("Confirmation token from the purge preview."),
285
+ },
286
+ additionalProperties: false,
287
+ },
288
+ execute: async (input: unknown, context: { sessionID: string }) => {
289
+ const args = input as {
290
+ force?: boolean
291
+ acknowledge_uncommitted?: boolean
292
+ purge?: string[]
293
+ confirm_purge?: boolean
294
+ confirm_token?: string
295
+ }
296
+ const content = await executeTeamCleanup(
297
+ deps,
298
+ {
299
+ force: args.force ?? false,
300
+ acknowledge_uncommitted: args.acknowledge_uncommitted,
301
+ purge: args.purge,
302
+ confirm_purge: args.confirm_purge,
303
+ confirm_token: args.confirm_token,
304
+ },
305
+ context.sessionID,
306
+ )
307
+ return { content }
308
+ },
309
+ })
310
+
311
+ editor.add({
312
+ name: "team_merge",
313
+ description:
314
+ "Merge a shutdown teammate's branch into the working directory as unstaged changes. Use this after team_shutdown to review and integrate a teammate's work. The teammate must be shut down first.",
315
+ input: {
316
+ type: "object",
317
+ properties: { member: str("Teammate name whose branch to merge") },
318
+ required: ["member"],
319
+ additionalProperties: false,
320
+ },
321
+ execute: run(executeTeamMerge),
322
+ })
323
+
324
+ editor.add({
325
+ name: "team_status",
326
+ description:
327
+ "View team members with their current status, agent type, and session IDs. Use this to check who is working, idle, or shut down. Includes a task summary.",
328
+ input: { type: "object", properties: {}, additionalProperties: false },
329
+ execute: runSession(executeTeamStatus),
330
+ })
331
+
332
+ editor.add({
333
+ name: "team_view",
334
+ description:
335
+ "Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
336
+ "navigate: true switches the user's client — ONLY when the user explicitly asked to view the teammate's session.",
337
+ input: {
338
+ type: "object",
339
+ properties: {
340
+ member: str("Teammate name to view"),
341
+ navigate: bool("Switch the user's client to this session. Only when the user explicitly asked.", false),
342
+ },
343
+ required: ["member"],
344
+ additionalProperties: false,
345
+ },
346
+ execute: run(executeTeamView),
347
+ })
348
+ })
349
+ }
package/src/watchdog.ts CHANGED
@@ -3,7 +3,10 @@ import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import type { ProgressTracker } from "./progress"
5
5
  import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
- import { sendMessage } from "./messaging"
6
+ import { releaseMemberTasks } from "./tasks"
7
+ import { hasReportedCompletion } from "./messaging"
8
+ import { getMemberModel } from "./member-model"
9
+ import { notifyLead } from "./notify"
7
10
  import { log } from "./log"
8
11
 
9
12
  interface WatchdogOpts {
@@ -112,22 +115,69 @@ export class Watchdog {
112
115
 
113
116
  if (!tokenStalled && !timeStalled) continue
114
117
 
115
- this.progressTracker.markReported(member.session_id)
118
+ // hasReportedCompletion() is the documented invariant every promptAsync delivery
119
+ // path must honor (see hooks.ts:63). The in-memory isReported() check above only
120
+ // catches this watchdog's own prior nudges — it doesn't know if the member reported
121
+ // completion by some other path while still transiently 'busy' in the DB.
122
+ if (hasReportedCompletion(this.db, member.team_id, member.name)) {
123
+ log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`)
124
+ continue
125
+ }
126
+
116
127
  const reason = tokenStalled ? "low output tokens" : "no communication"
117
128
 
118
- // Nudge the teammate directly
129
+ // Re-nudge suppression: markReported is deferred until promptAsync delivery
130
+ // succeeds, so a persistently failing nudge (session deleted mid-run while the
131
+ // DB row stays busy, bad stored model) would otherwise re-fire this whole
132
+ // block on every tick — a fresh last_nudged_at write, lead wake, and toast
133
+ // forever. Skip while the previous nudge is recent AND the member has shown
134
+ // no activity since it; genuine activity after a nudge makes them eligible
135
+ // for a fresh evaluation (and a fresh stall verdict) immediately.
136
+ const recentNudgeRow = this.db.query(
137
+ "SELECT last_nudged_at FROM team_member WHERE team_id = ? AND name = ?"
138
+ ).get(member.team_id, member.name) as { last_nudged_at: number | null } | null
139
+ if (recentNudgeRow?.last_nudged_at) {
140
+ const nudgedAt = recentNudgeRow.last_nudged_at
141
+ // >= because Date.now() has ms resolution: activity recorded in the same
142
+ // tick as the nudge write must count as post-nudge, or a member that went
143
+ // idle, worked, and stalled again would stay suppressed forever.
144
+ const activeSinceNudge = (this.progressTracker?.lastActivityAt(member.session_id) ?? 0) >= nudgedAt
145
+ if (!activeSinceNudge && Date.now() - nudgedAt < this.stallThresholdMs) {
146
+ log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=recently-nudged`)
147
+ continue
148
+ }
149
+ }
150
+
151
+ // Additive display-staleness signal (does not change team_member.status) — lets
152
+ // the dashboard/team_status annotate "busy, nudged Xs ago" so a soft nudge is
153
+ // visible without inventing a new status value.
154
+ this.db.run(
155
+ "UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?",
156
+ [Date.now(), member.team_id, member.name]
157
+ )
158
+
159
+ // Nudge the teammate directly. markReported() only fires once delivery is
160
+ // confirmed — marking it before delivery is known would permanently and silently
161
+ // orphan the stall state if promptAsync throws (aborted session, invalid ID),
162
+ // since ProgressTracker.reported is in-memory and only cleared by new activity.
163
+ const stallModel = getMemberModel(this.db, member.team_id, member.name)
119
164
  this.client.session.promptAsync({
120
165
  sessionID: member.session_id,
121
166
  parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }],
122
- }).catch(() => { /* best effort */ })
167
+ ...(stallModel ? { model: stallModel } : {}),
168
+ }).then(() => {
169
+ this.progressTracker!.markReported(member.session_id)
170
+ }).catch((err) => {
171
+ log(`watchdog:stall:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`)
172
+ })
123
173
 
124
174
  // Notify the lead
125
- sendMessage(this.db, {
126
- teamId: member.team_id,
127
- from: "system",
128
- to: "lead",
129
- content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
130
- })
175
+ notifyLead(
176
+ this.client,
177
+ this.db,
178
+ member.team_id,
179
+ `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
180
+ )
131
181
 
132
182
  // Toast for the user
133
183
  try {
@@ -156,21 +206,31 @@ export class Watchdog {
156
206
  if (this.progressTracker.isChattyReported(member.session_id)) continue
157
207
  if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
158
208
 
209
+ // hasReportedCompletion() guard — same invariant/rationale as checkStalled() above.
210
+ if (hasReportedCompletion(this.db, member.team_id, member.name)) {
211
+ log(`watchdog:chatty:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`)
212
+ continue
213
+ }
214
+
159
215
  this.progressTracker.markChattyReported(member.session_id)
160
216
 
161
217
  // Nudge the agent
218
+ const chattyModel = getMemberModel(this.db, member.team_id, member.name)
162
219
  this.client.session.promptAsync({
163
220
  sessionID: member.session_id,
164
221
  parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
165
- }).catch(() => { /* best effort */ })
222
+ ...(chattyModel ? { model: chattyModel } : {}),
223
+ }).catch((err) => {
224
+ log(`watchdog:chatty:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`)
225
+ })
166
226
 
167
227
  // Notify the lead
168
- sendMessage(this.db, {
169
- teamId: member.team_id,
170
- from: "system",
171
- to: "lead",
172
- content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
173
- })
228
+ notifyLead(
229
+ this.client,
230
+ this.db,
231
+ member.team_id,
232
+ `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
233
+ )
174
234
 
175
235
  log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
176
236
  }
@@ -212,6 +272,20 @@ export class Watchdog {
212
272
  [Date.now(), member.team_id, member.name]
213
273
  )
214
274
 
275
+ // Release the timed-out member's in_progress tasks back to the pool (issue #27)
276
+ const released = releaseMemberTasks(this.db, member.team_id, member.name)
277
+ if (released > 0) log(`watchdog:tasks:released name=${member.name} count=${released}`)
278
+
279
+ // Notify AND wake the lead — a timed-out teammate is otherwise a silent
280
+ // failure. This is the "leave recovery to the lead" path: we surface the
281
+ // timeout, we do not auto-resume the teammate.
282
+ notifyLead(
283
+ this.client,
284
+ this.db,
285
+ member.team_id,
286
+ `Teammate "${member.name}" timed out after exceeding the busy time limit and was aborted. Their in-progress work has been released. Review their session, then re-spawn or reassign the task if needed.`,
287
+ )
288
+
215
289
  // Abort session (best effort)
216
290
  try {
217
291
  await this.client.session.abort({ sessionID: member.session_id })