@hueyexe/opencode-ensemble 0.15.2 → 0.16.1

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/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { log, initLog } from "./log"
16
16
  import { findTeamBySession } from "./types"
17
17
  import { loadConfig } from "./config"
18
18
  import { ProgressTracker } from "./progress"
19
+ import { ActivityBuffer, recordFromV2Event, recordFromToolBefore, recordFromToolAfter } from "./activity"
19
20
  import { startDashboard } from "./dashboard"
20
21
  import { executeTeamCreate } from "./tools/team-create"
21
22
  import { executeTeamSpawn } from "./tools/team-spawn"
@@ -59,6 +60,7 @@ const plugin: Plugin = async (input) => {
59
60
  const purgeApprovals = new PendingPurgeApprovals()
60
61
  const nudgedMembers = new Set<string>()
61
62
  const progressTracker = new ProgressTracker()
63
+ const activityBuffer = new ActivityBuffer()
62
64
  const wakeLeadTimestamps = new Map<string, number>()
63
65
  const WAKE_LEAD_COOLDOWN_MS = 5000
64
66
 
@@ -104,7 +106,7 @@ const plugin: Plugin = async (input) => {
104
106
 
105
107
  // Start dashboard server (main instance only, not worktree instances)
106
108
  if (config.dashboardPort !== 0) {
107
- startDashboard(db, config.dashboardPort).catch((err) => {
109
+ startDashboard(db, config.dashboardPort, { activityBuffer, client }).catch((err) => {
108
110
  log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
109
111
  })
110
112
  }
@@ -304,6 +306,15 @@ const plugin: Plugin = async (input) => {
304
306
  }
305
307
  }
306
308
  }
309
+
310
+ // Capture activity for dashboard verbose view (best-effort, v2 event types)
311
+ // Only shell and step events are recorded here — tool calls/results are
312
+ // recorded via tool.execute.before/after to avoid duplicate entries.
313
+ recordFromV2Event(
314
+ event as unknown as { type: string; properties: { sessionID?: string; tool?: string; input?: string; content?: string; title?: string; error?: string; command?: string; exitCode?: number; cost?: number; tokens?: { input?: number; output?: number } } },
315
+ registry,
316
+ activityBuffer,
317
+ )
307
318
  },
308
319
 
309
320
  // Sub-agent isolation + rate limiting hook
@@ -315,12 +326,16 @@ const plugin: Plugin = async (input) => {
315
326
  await rateLimiter.waitForToken()
316
327
  }
317
328
  }
329
+ // Record tool call activity for team member sessions
330
+ recordFromToolBefore(input, registry, activityBuffer)
318
331
  },
319
332
 
320
333
  "tool.execute.after": async (input, output) => {
321
334
  if (input.tool === "question") {
322
335
  purgeApprovals.recordQuestionAnswer(input.sessionID, output.output, input.args)
323
336
  }
337
+ // Record tool result activity for team member sessions
338
+ recordFromToolAfter(input, output, registry, activityBuffer)
324
339
  },
325
340
 
326
341
  // System prompt injection — keeps lead aware of team state, reminds teammates of role
@@ -508,7 +523,10 @@ const plugin: Plugin = async (input) => {
508
523
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
509
524
  // Clean up progress tracking for the shut-down member
510
525
  const member = deps.db.query("SELECT session_id FROM team_member WHERE name = ? AND status IN ('shutdown', 'shutdown_requested')").get(args.member) as { session_id: string } | null
511
- if (member) progressTracker.remove(member.session_id)
526
+ if (member) {
527
+ progressTracker.remove(member.session_id)
528
+ activityBuffer.remove(member.session_id)
529
+ }
512
530
  const hasWarning = result.includes("uncommitted")
513
531
  ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
514
532
  return result
@@ -542,7 +560,16 @@ const plugin: Plugin = async (input) => {
542
560
  })
543
561
  }
544
562
  : undefined
563
+ // Collect member session IDs before cleanup so we can clean up activity buffers after
564
+ const teamInfoForCleanup = findTeamBySession(db, registry, ctx.sessionID)
565
+ const memberSessions = teamInfoForCleanup
566
+ ? (db.query("SELECT session_id FROM team_member WHERE team_id = ?").all(teamInfoForCleanup.teamId) as Array<{ session_id: string }>).map(m => m.session_id)
567
+ : []
545
568
  const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup, undefined, approvePurge)
569
+ // Clean up activity buffers for team members after successful cleanup
570
+ if (!result.includes("uncommitted") && !args.purge) {
571
+ for (const sid of memberSessions) activityBuffer.remove(sid)
572
+ }
546
573
  const blocked = result.includes("uncommitted")
547
574
  const title = args.purge
548
575
  ? result.startsWith("No archived teams") ? "No archived teams to purge" : result.startsWith("Purge preview") ? "Purge confirmation required" : "Archived teams purged"
@@ -1,6 +1,37 @@
1
+ import type { Database } from "../db"
1
2
  import type { ToolDeps } from "../types"
2
3
  import { requireTeamMember } from "./shared"
3
4
 
5
+ /**
6
+ * Atomically claim a pending task for an assignee. Sets status to
7
+ * 'in_progress' and records the assignee. Throws if the task is missing,
8
+ * blocked, already claimed, or no longer pending. Returns the task content
9
+ * on success.
10
+ *
11
+ * Shared by team_claim and team_spawn's claim_task auto-claim so both paths
12
+ * enforce the same atomic claim invariant.
13
+ */
14
+ export function claimTask(db: Database, teamId: string, taskId: string, assignee: string): string {
15
+ const task = db.query("SELECT content, status, assignee FROM team_task WHERE id = ? AND team_id = ?")
16
+ .get(taskId, teamId) as { content: string; status: string; assignee: string | null } | null
17
+ if (!task) throw new Error(`Task "${taskId}" not found`)
18
+ if (task.status === "blocked") throw new Error(`Task "${taskId}" is blocked by unresolved dependencies`)
19
+ if (task.status !== "pending") throw new Error(`Task "${taskId}" is not pending (status: ${task.status})`)
20
+ if (task.assignee) throw new Error(`Task "${taskId}" is already claimed by ${task.assignee}`)
21
+
22
+ // Atomic claim: UPDATE only if still pending and unassigned
23
+ const result = db.run(
24
+ "UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL",
25
+ [assignee, Date.now(), taskId]
26
+ )
27
+
28
+ if (result.changes === 0) {
29
+ throw new Error(`Task "${taskId}" is already claimed (race condition)`)
30
+ }
31
+
32
+ return task.content
33
+ }
34
+
4
35
  /**
5
36
  * Execute the team_claim tool. Atomically claims a pending task.
6
37
  * Rejects if the task is already claimed, blocked, or not pending.
@@ -13,23 +44,7 @@ export async function executeTeamClaim(
13
44
  const teamInfo = requireTeamMember(deps, sessionId)
14
45
 
15
46
  const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
47
+ const content = claimTask(deps.db, teamInfo.teamId, args.task_id, claimerName)
16
48
 
17
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
18
- .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
19
- if (!task) throw new Error(`Task "${args.task_id}" not found`)
20
- if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
21
- if (task.status !== "pending") throw new Error(`Task "${args.task_id}" is not pending (status: ${task.status})`)
22
- if (task.assignee) throw new Error(`Task "${args.task_id}" is already claimed by ${task.assignee}`)
23
-
24
- // Atomic claim: UPDATE only if still pending and unassigned
25
- const result = deps.db.run(
26
- "UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL",
27
- [claimerName, Date.now(), args.task_id]
28
- )
29
-
30
- if (result.changes === 0) {
31
- throw new Error(`Task "${args.task_id}" is already claimed (race condition)`)
32
- }
33
-
34
- return `Claimed task: ${task.content}`
49
+ return `Claimed task: ${content}`
35
50
  }
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
3
  import { requireLead } from "./shared"
4
+ import { claimTask } from "./team-claim"
4
5
  import { sendMessage } from "../messaging"
5
6
  import { log } from "../log"
6
7
  import type { EnsembleConfig } from "../config"
@@ -67,9 +68,14 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
67
68
  */
68
69
  export async function executeTeamSpawn(
69
70
  deps: ToolDeps,
70
- args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
71
+ args: { name: string; agent?: string | null; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
71
72
  sessionId: string,
72
73
  ): Promise<string> {
74
+ // Normalize agent: the tool schema defaults to "build", but Zod's .default()
75
+ // only fires on undefined — an explicit null slips through and would violate
76
+ // the team_member.agent NOT NULL constraint (issue #28).
77
+ const agent = args.agent ?? "build"
78
+
73
79
  const nameError = validateMemberName(args.name)
74
80
  if (nameError) throw new Error(nameError)
75
81
 
@@ -86,11 +92,11 @@ export async function executeTeamSpawn(
86
92
  .get(teamInfo.teamId, args.name)
87
93
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
88
94
 
89
- const isReadOnly = args.agent === "plan" || args.agent === "explore"
95
+ const isReadOnly = agent === "plan" || agent === "explore"
90
96
  const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
91
97
  const usePlanApproval = args.plan_approval === true
92
98
 
93
- log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
99
+ log(`spawn:start name=${args.name} agent=${agent} worktree=${useWorktree}`)
94
100
 
95
101
  // Create worktree if enabled
96
102
  let worktreeDir: string | null = null
@@ -177,7 +183,7 @@ export async function executeTeamSpawn(
177
183
  const createResult = await withTimeout(
178
184
  deps.client.session.create({
179
185
  parentID: sessionId,
180
- title: `${args.name} (@${args.agent} teammate)`,
186
+ title: `${args.name} (@${agent} teammate)`,
181
187
  permission,
182
188
  ...(workspaceId ? { workspaceID: workspaceId } : {}),
183
189
  }),
@@ -216,22 +222,37 @@ export async function executeTeamSpawn(
216
222
  const now = Date.now()
217
223
  // Resolve model before DB insert so the stored value matches what promptAsync uses
218
224
  const memberCount = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId) as { c: number }).c
219
- const resolvedModel = resolveModel(args.model, args.agent, memberCount, deps.config)
225
+ const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
220
226
  if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
221
227
 
222
228
  deps.db.run(
223
229
  `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
224
230
  VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
225
- [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
231
+ [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
226
232
  )
227
233
 
228
234
  // Register in memory
229
235
  deps.registry.register(teamInfo.teamId, args.name, childSessionId)
230
236
 
237
+ // Auto-claim a task for this teammate when claim_task is provided (issue #27).
238
+ // Claims atomically so a task is never double-assigned when the lead hands out
239
+ // more tasks than there are teammates. Read-only agents cannot claim tasks.
240
+ let claimedTaskContent: string | null = null
241
+ let claimWarning: string | null = null
242
+ if (args.claim_task && !isReadOnly) {
243
+ try {
244
+ claimedTaskContent = claimTask(deps.db, teamInfo.teamId, args.claim_task, args.name)
245
+ log(`spawn:claim:ok name=${args.name} task=${args.claim_task}`)
246
+ } catch (err) {
247
+ claimWarning = err instanceof Error ? err.message : String(err)
248
+ log(`spawn:claim:failed name=${args.name} task=${args.claim_task} err=${claimWarning}`)
249
+ }
250
+ }
251
+
231
252
  // Build teammate context message
232
253
  const context = [
233
254
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
234
- `Your agent type is "${args.agent}".`,
255
+ `Your agent type is "${agent}".`,
235
256
  ]
236
257
 
237
258
  // Show other teammates so this agent knows who to message
@@ -347,7 +368,7 @@ export async function executeTeamSpawn(
347
368
  args.prompt,
348
369
  )
349
370
 
350
- if (args.claim_task) {
371
+ if (claimedTaskContent) {
351
372
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
352
373
  }
353
374
 
@@ -364,7 +385,7 @@ export async function executeTeamSpawn(
364
385
  deps.client.session.promptAsync({
365
386
  sessionID: childSessionId,
366
387
  parts: [{ type: "text", text: contextStr }],
367
- agent: args.agent,
388
+ agent,
368
389
  ...(modelParam ? { model: modelParam } : {}),
369
390
  }).catch((err) => {
370
391
  const errMsg = err instanceof Error ? err.message : String(err)
@@ -372,6 +393,13 @@ export async function executeTeamSpawn(
372
393
  try {
373
394
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
374
395
  deps.registry.unregister(childSessionId)
396
+ // Release any task auto-claimed for this teammate so it returns to the pool.
397
+ if (claimedTaskContent) {
398
+ deps.db.run(
399
+ "UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?",
400
+ [Date.now(), args.claim_task, args.name]
401
+ )
402
+ }
375
403
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
376
404
  if (workspaceId) {
377
405
  deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
@@ -397,8 +425,11 @@ export async function executeTeamSpawn(
397
425
 
398
426
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
399
427
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
428
+ const claimInfo = claimedTaskContent
429
+ ? ` (claimed task: ${args.claim_task})`
430
+ : claimWarning ? ` (could not claim task: ${claimWarning})` : ""
400
431
  // Reset circuit breaker on success
401
432
  spawnFailures.delete(teamInfo.teamId)
402
433
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
403
- return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
434
+ return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
404
435
  }
@@ -5,6 +5,12 @@ import { log } from "../log"
5
5
  /**
6
6
  * Execute the team_tasks_complete tool. Marks a task as completed
7
7
  * and unblocks any dependent tasks.
8
+ *
9
+ * Enforces task-board accuracy (issue #27):
10
+ * - Rejects completing an already-completed or cancelled task.
11
+ * - Rejects completing a blocked task (dependencies must resolve first).
12
+ * - Only the assignee (or the lead) may complete a claimed task.
13
+ * - Completing an unclaimed task attributes it to the caller atomically.
8
14
  */
9
15
  export async function executeTeamTasksComplete(
10
16
  deps: ToolDeps,
@@ -12,13 +18,31 @@ export async function executeTeamTasksComplete(
12
18
  sessionId: string,
13
19
  ): Promise<string> {
14
20
  const teamInfo = requireTeamMember(deps, sessionId)
21
+ const caller = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
15
22
 
16
- const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
17
- .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
23
+ const task = deps.db.query("SELECT id, content, status, assignee FROM team_task WHERE id = ? AND team_id = ?")
24
+ .get(args.task_id, teamInfo.teamId) as { id: string; content: string; status: string; assignee: string | null } | null
18
25
  if (!task) throw new Error(`Task "${args.task_id}" not found`)
26
+ if (task.status === "completed") throw new Error(`Task "${args.task_id}" is already completed`)
27
+ if (task.status === "cancelled") throw new Error(`Task "${args.task_id}" was cancelled`)
28
+ if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
29
+ if (task.status === "in_progress" && task.assignee && teamInfo.role !== "lead" && caller !== task.assignee) {
30
+ throw new Error(`Task "${args.task_id}" is claimed by ${task.assignee}`)
31
+ }
19
32
 
20
33
  const now = Date.now()
21
- deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
34
+ if (task.assignee) {
35
+ deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id])
36
+ } else {
37
+ // Attribute an unclaimed task to the caller so the board reflects who did it.
38
+ const result = deps.db.run(
39
+ "UPDATE team_task SET status = 'completed', assignee = ?, time_updated = ? WHERE id = ? AND assignee IS NULL",
40
+ [caller, now, args.task_id]
41
+ )
42
+ if (result.changes === 0) {
43
+ throw new Error(`Task "${args.task_id}" was just completed by another teammate`)
44
+ }
45
+ }
22
46
 
23
47
  // Unblock dependent tasks
24
48
  const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?")
package/src/types.ts CHANGED
@@ -50,6 +50,8 @@ export interface PluginClient {
50
50
  }): Promise<unknown>
51
51
  abort(options: { sessionID: string }): Promise<unknown>
52
52
  status(): Promise<{ data?: Record<string, { type: string }> }>
53
+ messages(options: { sessionID: string; limit?: number }): Promise<{ data?: Array<{ info: unknown; parts: unknown[] }> }>
54
+ get(options: { sessionID: string }): Promise<{ data?: unknown }>
53
55
  }
54
56
  tui: {
55
57
  showToast(options: {