@hueyexe/opencode-ensemble 0.13.0 → 0.13.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-482%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-492%20passing-brightgreen.svg)]()
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
7
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
@@ -15,7 +15,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
18
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
19
19
  }
20
20
  ```
21
21
 
@@ -136,7 +136,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
136
136
 
137
137
  ```json
138
138
  {
139
- "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
139
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
140
140
  }
141
141
  ```
142
142
 
@@ -144,7 +144,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
144
144
 
145
145
  ```json
146
146
  {
147
- "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
147
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
148
148
  }
149
149
  ```
150
150
 
@@ -406,7 +406,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
406
406
  ```bash
407
407
  bun install
408
408
  bun run typecheck
409
- bun test # 482 tests
409
+ bun test # 492 tests
410
410
  bun run build
411
411
  ```
412
412
 
package/dist/index.js CHANGED
@@ -15454,7 +15454,8 @@ var MIGRATIONS = [
15454
15454
  `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
15455
15455
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
15456
15456
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
15457
- `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
15457
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
15458
+ `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`
15458
15459
  ];
15459
15460
  function applyMigrations(db) {
15460
15461
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -15576,6 +15577,10 @@ function getUndeliveredMessages(db, teamId) {
15576
15577
  function markDelivered(db, messageId) {
15577
15578
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15578
15579
  }
15580
+ function hasReportedCompletion(db, teamId, memberName) {
15581
+ const row = db.query("SELECT reported_to_lead FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
15582
+ return row?.reported_to_lead === 1;
15583
+ }
15579
15584
 
15580
15585
  // src/recovery.ts
15581
15586
  init_merge_helper();
@@ -15643,6 +15648,10 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15643
15648
  }
15644
15649
  if (!recipientSessionId)
15645
15650
  continue;
15651
+ if (hasReportedCompletion(db, team.id, msg.to_name)) {
15652
+ markDelivered(db, msg.id);
15653
+ continue;
15654
+ }
15646
15655
  try {
15647
15656
  await client3.session.promptAsync({
15648
15657
  sessionID: recipientSessionId,
@@ -15778,10 +15787,16 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
15778
15787
  if (member.status === newStatus)
15779
15788
  return;
15780
15789
  db.run("UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [newStatus, Date.now(), entry.teamId, entry.memberName]);
15790
+ if (member.status === "busy" && newStatus === "ready") {
15791
+ const leadMsgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'").get(entry.teamId, entry.memberName).c;
15792
+ if (leadMsgCount > 0) {
15793
+ db.run("UPDATE team_member SET reported_to_lead = 1 WHERE team_id = ? AND name = ?", [entry.teamId, entry.memberName]);
15794
+ }
15795
+ }
15781
15796
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: newStatus };
15782
15797
  } else if (status === "busy") {
15783
15798
  if (member.status === "ready" || member.status === "error") {
15784
- db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
15799
+ db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
15785
15800
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
15786
15801
  }
15787
15802
  if (member.status === "shutdown_requested") {
@@ -17189,6 +17204,9 @@ async function executeTeamMessage(deps, args, sessionId) {
17189
17204
  });
17190
17205
  return `Message sent to ${args.to}.`;
17191
17206
  }
17207
+ if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
17208
+ return `Message stored for ${args.to} (teammate has completed their task \u2014 message will not wake them).`;
17209
+ }
17192
17210
  const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
17193
17211
  deps.client.session.promptAsync({
17194
17212
  sessionID: recipientSessionId,
@@ -17223,7 +17241,12 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17223
17241
  }
17224
17242
  }
17225
17243
  let delivered = 0;
17244
+ let skipped = 0;
17226
17245
  for (const recipient of recipients) {
17246
+ if (recipient.name !== "lead" && hasReportedCompletion(deps.db, teamInfo.teamId, recipient.name)) {
17247
+ skipped++;
17248
+ continue;
17249
+ }
17227
17250
  deps.client.session.promptAsync({
17228
17251
  sessionID: recipient.sessionId,
17229
17252
  parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
@@ -17235,7 +17258,8 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17235
17258
  log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
17236
17259
  });
17237
17260
  }
17238
- return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
17261
+ const sent = recipients.length - skipped;
17262
+ return `Broadcast sent to ${sent} recipient${sent !== 1 ? "s" : ""}.`;
17239
17263
  }
17240
17264
 
17241
17265
  // src/tools/team-shutdown.ts
@@ -17930,6 +17954,8 @@ var plugin = async (input) => {
17930
17954
  const tracker = new DescendantTracker;
17931
17955
  const nudgedMembers = new Set;
17932
17956
  const progressTracker = new ProgressTracker;
17957
+ const wakeLeadTimestamps = new Map;
17958
+ const WAKE_LEAD_COOLDOWN_MS = 5000;
17933
17959
  const pluginTransport = input.client._client;
17934
17960
  const rawClient = new OpencodeClient({ client: pluginTransport });
17935
17961
  initLog(rawClient);
@@ -18020,7 +18046,7 @@ var plugin = async (input) => {
18020
18046
  }
18021
18047
  }
18022
18048
  const nudgeKey = `${transition.teamId}:${transition.memberName}`;
18023
- if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
18049
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
18024
18050
  nudgedMembers.add(nudgeKey);
18025
18051
  log(`nudge:idle-without-report name=${transition.memberName}`);
18026
18052
  client3.session.promptAsync({
@@ -18063,7 +18089,10 @@ var plugin = async (input) => {
18063
18089
  const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
18064
18090
  if (team) {
18065
18091
  const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
18066
- if (pending.c > 0) {
18092
+ const allDone = db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ? AND status NOT IN ('ready', 'shutdown', 'error')").get(team.id).c === 0;
18093
+ const lastWake = wakeLeadTimestamps.get(team.id) ?? 0;
18094
+ if (pending.c > 0 && !allDone && Date.now() - lastWake > WAKE_LEAD_COOLDOWN_MS) {
18095
+ wakeLeadTimestamps.set(team.id, Date.now());
18067
18096
  log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
18068
18097
  client3.session.promptAsync({
18069
18098
  sessionID,
@@ -18076,7 +18105,7 @@ var plugin = async (input) => {
18076
18105
  const member = db.query(`SELECT tm.team_id, tm.name FROM team_member tm
18077
18106
  JOIN team t ON tm.team_id = t.id
18078
18107
  WHERE tm.session_id = ? AND t.status = 'active'`).get(sessionID);
18079
- if (member) {
18108
+ if (member && !hasReportedCompletion(db, member.team_id, member.name)) {
18080
18109
  const staleThreshold = Date.now() - 5000;
18081
18110
  const peerMsgs = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?").get(member.team_id, member.name, staleThreshold);
18082
18111
  if (peerMsgs.c > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
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",
package/src/hooks.ts CHANGED
@@ -41,11 +41,26 @@ export function handleSessionStatusEvent(
41
41
  "UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
42
42
  [newStatus, Date.now(), entry.teamId, entry.memberName]
43
43
  )
44
+ // Mark teammate as having reported if they sent at least one message to lead (issue #3).
45
+ // Set on busy→ready transition so Q&A messages during work don't prematurely block delivery.
46
+ if (member.status === "busy" && newStatus === "ready") {
47
+ const leadMsgCount = (db.query(
48
+ "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'"
49
+ ).get(entry.teamId, entry.memberName) as { c: number }).c
50
+ if (leadMsgCount > 0) {
51
+ db.run(
52
+ "UPDATE team_member SET reported_to_lead = 1 WHERE team_id = ? AND name = ?",
53
+ [entry.teamId, entry.memberName]
54
+ )
55
+ }
56
+ }
44
57
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: newStatus }
45
58
  } else if (status === "busy") {
46
59
  if (member.status === "ready" || member.status === "error") {
60
+ // Reset reported_to_lead so re-activated teammates can receive messages again (issue #3).
61
+ // INVARIANT: every promptAsync delivery path must check hasReportedCompletion() to prevent loops.
47
62
  db.run(
48
- "UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
63
+ "UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?",
49
64
  [Date.now(), entry.teamId, entry.memberName]
50
65
  )
51
66
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { MemberRegistry, DescendantTracker } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
12
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
- import { sendMessage } from "./messaging"
13
+ import { sendMessage, hasReportedCompletion } from "./messaging"
14
14
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
15
15
  import { log, initLog } from "./log"
16
16
  import { findTeamBySession } from "./types"
@@ -58,6 +58,8 @@ const plugin: Plugin = async (input) => {
58
58
  const tracker = new DescendantTracker()
59
59
  const nudgedMembers = new Set<string>()
60
60
  const progressTracker = new ProgressTracker()
61
+ const wakeLeadTimestamps = new Map<string, number>()
62
+ const WAKE_LEAD_COOLDOWN_MS = 5000
61
63
 
62
64
  // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
63
65
  // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
@@ -176,8 +178,9 @@ const plugin: Plugin = async (input) => {
176
178
  }
177
179
 
178
180
  // Nudge teammate if they went idle without reporting to the lead (once only)
181
+ // Skip if they already reported completion (issue #3 — prevents re-waking completed teammates)
179
182
  const nudgeKey = `${transition.teamId}:${transition.memberName}`
180
- if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
183
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
181
184
  nudgedMembers.add(nudgeKey)
182
185
  log(`nudge:idle-without-report name=${transition.memberName}`)
183
186
  client.session.promptAsync({
@@ -232,7 +235,13 @@ const plugin: Plugin = async (input) => {
232
235
  const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID) as { id: string } | null
233
236
  if (team) {
234
237
  const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id) as { c: number }
235
- if (pending.c > 0) {
238
+ // Skip wake if all teammates are done or if we woke recently (issue #3 — breaks completion loop)
239
+ const allDone = (db.query(
240
+ "SELECT COUNT(*) as c FROM team_member WHERE team_id = ? AND status NOT IN ('ready', 'shutdown', 'error')"
241
+ ).get(team.id) as { c: number }).c === 0
242
+ const lastWake = wakeLeadTimestamps.get(team.id) ?? 0
243
+ if (pending.c > 0 && !allDone && Date.now() - lastWake > WAKE_LEAD_COOLDOWN_MS) {
244
+ wakeLeadTimestamps.set(team.id, Date.now())
236
245
  log(`wake-lead: ${pending.c} pending messages, sending promptAsync`)
237
246
  client.session.promptAsync({
238
247
  sessionID,
@@ -250,7 +259,7 @@ const plugin: Plugin = async (input) => {
250
259
  JOIN team t ON tm.team_id = t.id
251
260
  WHERE tm.session_id = ? AND t.status = 'active'`
252
261
  ).get(sessionID) as { team_id: string; name: string } | null
253
- if (member) {
262
+ if (member && !hasReportedCompletion(db, member.team_id, member.name)) {
254
263
  const staleThreshold = Date.now() - 5000
255
264
  const peerMsgs = db.query(
256
265
  "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
package/src/messaging.ts CHANGED
@@ -76,3 +76,14 @@ export function getUndeliveredMessages(db: Database, teamId: string): MessageRow
76
76
  export function markDelivered(db: Database, messageId: string): void {
77
77
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
78
78
  }
79
+
80
+ /**
81
+ * Check if a teammate has reported completion to the lead.
82
+ * Returns true if the reported_to_lead flag is set on the member.
83
+ */
84
+ export function hasReportedCompletion(db: Database, teamId: string, memberName: string): boolean {
85
+ const row = db.query(
86
+ "SELECT reported_to_lead FROM team_member WHERE team_id = ? AND name = ?"
87
+ ).get(teamId, memberName) as { reported_to_lead: number } | null
88
+ return row?.reported_to_lead === 1
89
+ }
package/src/recovery.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
- import { getUndeliveredMessages, markDelivered } from "./messaging"
4
+ import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
5
5
  import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
6
  import { log } from "./log"
7
7
 
@@ -123,6 +123,12 @@ export async function recoverUndeliveredMessages(
123
123
 
124
124
  if (!recipientSessionId) continue
125
125
 
126
+ // Skip delivery to teammates who have already reported completion (issue #3)
127
+ if (hasReportedCompletion(db, team.id, msg.to_name!)) {
128
+ markDelivered(db, msg.id)
129
+ continue
130
+ }
131
+
126
132
  try {
127
133
  await client.session.promptAsync({
128
134
  sessionID: recipientSessionId,
package/src/schema.ts CHANGED
@@ -80,6 +80,8 @@ export const MIGRATIONS: string[] = [
80
80
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
81
81
  // Migration 6: Track workspace ID for worktree-session binding
82
82
  `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
83
+ // Migration 7: Track whether teammate has reported to lead (completion loop prevention, issue #3)
84
+ `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`,
83
85
  ]
84
86
 
85
87
  /**
@@ -1,6 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
- import { broadcastMessage, markDelivered } from "../messaging"
3
+ import { broadcastMessage, markDelivered, hasReportedCompletion } from "../messaging"
4
4
  import { log } from "../log"
5
5
 
6
6
  /**
@@ -40,8 +40,14 @@ export async function executeTeamBroadcast(
40
40
  }
41
41
 
42
42
  // Fire-and-forget: deliver to all recipients. Message is already persisted in DB.
43
+ // Skip completed teammates to prevent re-waking them (issue #3).
43
44
  let delivered = 0
45
+ let skipped = 0
44
46
  for (const recipient of recipients) {
47
+ if (recipient.name !== "lead" && hasReportedCompletion(deps.db, teamInfo.teamId, recipient.name)) {
48
+ skipped++
49
+ continue
50
+ }
45
51
  deps.client.session.promptAsync({
46
52
  sessionID: recipient.sessionId,
47
53
  parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
@@ -53,5 +59,6 @@ export async function executeTeamBroadcast(
53
59
  })
54
60
  }
55
61
 
56
- return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
62
+ const sent = recipients.length - skipped
63
+ return `Broadcast sent to ${sent} recipient${sent !== 1 ? "s" : ""}.`
57
64
  }
@@ -1,7 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { resolveRecipientSession } from "../types"
3
3
  import { requireTeamMember } from "./shared"
4
- import { sendMessage, markDelivered } from "../messaging"
4
+ import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
5
5
  import { log } from "../log"
6
6
 
7
7
  /**
@@ -90,6 +90,11 @@ export async function executeTeamMessage(
90
90
  return `Message sent to ${args.to}.`
91
91
  }
92
92
 
93
+ // Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
94
+ if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
95
+ return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`
96
+ }
97
+
93
98
  // For member-to-member messages, fire-and-forget delivery is safe.
94
99
  const deliveryText = `[Team message from ${senderName}]: ${messageText}`
95
100
  deps.client.session.promptAsync({