@hueyexe/opencode-ensemble 0.7.1 → 0.9.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.
@@ -6,6 +6,13 @@ export const lastCallTime = new Map<string, number>()
6
6
 
7
7
  const RATE_LIMIT_MS = 30_000
8
8
 
9
+ /** Format a duration in ms to a human-readable string. */
10
+ export function formatDuration(ms: number): string {
11
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
12
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
13
+ return `${Math.floor(ms / 3_600_000)}h`
14
+ }
15
+
9
16
  /**
10
17
  * Execute the team_status tool. Shows team overview with member statuses and task summary.
11
18
  */
@@ -23,9 +30,9 @@ export async function executeTeamStatus(
23
30
  lastCallTime.set(teamInfo.teamId, now)
24
31
 
25
32
  const members = deps.db.query(
26
- "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
33
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
27
34
  ).all(teamInfo.teamId) as Array<{
28
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
35
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number
29
36
  }>
30
37
 
31
38
  const tasks = deps.db.query(
@@ -42,9 +49,24 @@ export async function executeTeamStatus(
42
49
  lines.push("Members:")
43
50
  for (const m of members) {
44
51
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
52
+ const duration = formatDuration(now - m.time_updated)
45
53
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
46
54
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
47
- lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
55
+
56
+ // Last message time
57
+ const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?")
58
+ .get(teamInfo.teamId, m.name) as { last_msg: number | null } | null
59
+ const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet"
60
+
61
+ lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`)
62
+
63
+ // Current task
64
+ const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1")
65
+ .get(teamInfo.teamId, m.name) as { content: string } | null
66
+ if (task) {
67
+ const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content
68
+ lines.push(` task: ${truncated}`)
69
+ }
48
70
  if (m.worktree_dir) {
49
71
  lines.push(` worktree: ${m.worktree_dir}`)
50
72
  }
package/src/watchdog.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
+ import type { ProgressTracker } from "./progress"
5
+ import { sendMessage } from "./messaging"
4
6
 
5
7
  interface WatchdogOpts {
6
8
  db: Database
@@ -10,6 +12,14 @@ interface WatchdogOpts {
10
12
  ttlMs: number
11
13
  /** How often to run the check. Defaults to 60s. */
12
14
  checkIntervalMs?: number
15
+ /** Progress tracker for stall detection. */
16
+ progressTracker?: ProgressTracker
17
+ /** Stall detection threshold in ms. 0 disables. */
18
+ stallThresholdMs?: number
19
+ /** Min steps before token-based stall check. */
20
+ stallMinSteps?: number
21
+ /** Output token threshold for stall detection. */
22
+ stallTokenThreshold?: number
13
23
  }
14
24
 
15
25
  /**
@@ -22,6 +32,10 @@ export class Watchdog {
22
32
  private readonly registry: MemberRegistry
23
33
  private readonly ttlMs: number
24
34
  private readonly checkIntervalMs: number
35
+ private readonly progressTracker?: ProgressTracker
36
+ private readonly stallThresholdMs: number
37
+ private readonly stallMinSteps: number
38
+ private readonly stallTokenThreshold: number
25
39
  private timer: ReturnType<typeof setInterval> | undefined
26
40
 
27
41
  constructor(opts: WatchdogOpts) {
@@ -30,6 +44,10 @@ export class Watchdog {
30
44
  this.registry = opts.registry
31
45
  this.ttlMs = opts.ttlMs
32
46
  this.checkIntervalMs = opts.checkIntervalMs ?? 60_000
47
+ this.progressTracker = opts.progressTracker
48
+ this.stallThresholdMs = opts.stallThresholdMs ?? 0
49
+ this.stallMinSteps = opts.stallMinSteps ?? 3
50
+ this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
33
51
  }
34
52
 
35
53
  private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
@@ -61,9 +79,58 @@ export class Watchdog {
61
79
  }
62
80
  }
63
81
 
82
+ /** Check for stalled busy members and escalate to lead + nudge teammate. */
83
+ async checkStalled(): Promise<void> {
84
+ if (!this.progressTracker || this.stallThresholdMs === 0) return
85
+
86
+ const busy = this.db.query(
87
+ `SELECT tm.team_id, tm.name, tm.session_id
88
+ FROM team_member tm
89
+ JOIN team t ON tm.team_id = t.id
90
+ WHERE t.status = 'active' AND tm.status = 'busy'`
91
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
92
+
93
+ for (const member of busy) {
94
+ if (this.progressTracker.isReported(member.session_id)) continue
95
+
96
+ const tokenStalled = this.progressTracker.isTokenStalled(member.session_id, this.stallMinSteps, this.stallTokenThreshold)
97
+ const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs)
98
+
99
+ if (!tokenStalled && !timeStalled) continue
100
+
101
+ this.progressTracker.markReported(member.session_id)
102
+ const reason = tokenStalled ? "low output tokens" : "no communication"
103
+
104
+ // Nudge the teammate directly
105
+ this.client.session.promptAsync({
106
+ sessionID: member.session_id,
107
+ 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." }],
108
+ }).catch(() => { /* best effort */ })
109
+
110
+ // Notify the lead
111
+ sendMessage(this.db, {
112
+ teamId: member.team_id,
113
+ from: "system",
114
+ to: "lead",
115
+ content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`,
116
+ })
117
+
118
+ // Toast for the user
119
+ try {
120
+ await this.client.tui.showToast({
121
+ title: "Team",
122
+ message: `${member.name} appears stalled`,
123
+ variant: "warning",
124
+ duration: 5000,
125
+ })
126
+ } catch { /* TUI may not be available */ }
127
+ }
128
+ }
129
+
64
130
  /** Run a single check for stale busy members. */
65
131
  async check(): Promise<void> {
66
132
  await this.cleanupStaleWorktrees()
133
+ await this.checkStalled()
67
134
  if (this.ttlMs === 0) return
68
135
 
69
136
  const cutoff = Date.now() - this.ttlMs