@hueyexe/opencode-ensemble 0.10.0 → 0.11.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 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-460%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-471%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)
@@ -126,7 +126,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
126
126
 
127
127
  ```json
128
128
  {
129
- "plugin": ["@hueyexe/opencode-ensemble@0.10.0"]
129
+ "plugin": ["@hueyexe/opencode-ensemble@0.11.0"]
130
130
  }
131
131
  ```
132
132
 
@@ -134,7 +134,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
134
134
 
135
135
  ```json
136
136
  {
137
- "plugin": ["@hueyexe/opencode-ensemble@0.10.0"]
137
+ "plugin": ["@hueyexe/opencode-ensemble@0.11.0"]
138
138
  }
139
139
  ```
140
140
 
@@ -242,6 +242,7 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
242
242
  - **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
243
243
  - **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
244
244
  - **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
245
+ - **Peer-to-peer communication**: teammates can message each other directly, with idle-flush delivery and chatty agent detection
245
246
  - **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
246
247
  - **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
247
248
  - **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
@@ -336,7 +337,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
336
337
  ```bash
337
338
  bun install
338
339
  bun run typecheck
339
- bun test # 460 tests
340
+ bun test # 471 tests
340
341
  bun run build
341
342
  ```
342
343
 
package/dist/index.js CHANGED
@@ -15987,7 +15987,28 @@ function buildTeammateSystemPrompt(db, teamId, memberName) {
15987
15987
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
15988
15988
  if (!team)
15989
15989
  return "";
15990
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`;
15990
+ const lines = [
15991
+ `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`
15992
+ ];
15993
+ const pendingMessages = db.query("SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC").all(teamId, memberName);
15994
+ if (pendingMessages.length > 0) {
15995
+ lines.push("", "--- Messages for you ---");
15996
+ const MAX_MSG = 500;
15997
+ for (const msg of pendingMessages) {
15998
+ const parsed = parseTaskResult(msg.content);
15999
+ if (parsed) {
16000
+ lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }));
16001
+ } else if (msg.content.length > MAX_MSG) {
16002
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`);
16003
+ } else {
16004
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`);
16005
+ }
16006
+ markDelivered(db, msg.id);
16007
+ }
16008
+ lines.push("--- End Messages ---");
16009
+ }
16010
+ return lines.join(`
16011
+ `);
15991
16012
  }
15992
16013
  function buildTeamCompactionContext(db, teamId, role, memberName) {
15993
16014
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
@@ -16070,7 +16091,9 @@ var DEFAULT_CONFIG = {
16070
16091
  stallTokenThreshold: 500,
16071
16092
  timeoutMs: 30 * 60 * 1000,
16072
16093
  rateLimitCapacity: 10,
16073
- dashboardPort: 4747
16094
+ dashboardPort: 4747,
16095
+ peerMessageLimit: 5,
16096
+ peerMessageWindowMs: 300000
16074
16097
  };
16075
16098
  function readConfigFile(filePath) {
16076
16099
  try {
@@ -16091,6 +16114,10 @@ function readConfigFile(filePath) {
16091
16114
  result.rateLimitCapacity = raw.rateLimitCapacity;
16092
16115
  if (typeof raw.dashboardPort === "number")
16093
16116
  result.dashboardPort = raw.dashboardPort;
16117
+ if (typeof raw.peerMessageLimit === "number")
16118
+ result.peerMessageLimit = raw.peerMessageLimit;
16119
+ if (typeof raw.peerMessageWindowMs === "number")
16120
+ result.peerMessageWindowMs = raw.peerMessageWindowMs;
16094
16121
  return result;
16095
16122
  } catch (err) {
16096
16123
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
@@ -16123,7 +16150,9 @@ class ProgressTracker {
16123
16150
  steps = new Map;
16124
16151
  lastMessageAt = new Map;
16125
16152
  lastTaskAt = new Map;
16153
+ peerMessages = new Map;
16126
16154
  reported = new Set;
16155
+ chattyReported = new Set;
16127
16156
  maxSteps;
16128
16157
  constructor(maxSteps = 10) {
16129
16158
  this.maxSteps = maxSteps;
@@ -16135,6 +16164,31 @@ class ProgressTracker {
16135
16164
  records.shift();
16136
16165
  this.steps.set(sessionId, records);
16137
16166
  }
16167
+ recordPeerMessage(sessionId) {
16168
+ const timestamps = this.peerMessages.get(sessionId) ?? [];
16169
+ timestamps.push(Date.now());
16170
+ this.peerMessages.set(sessionId, timestamps);
16171
+ }
16172
+ isChatty(sessionId, limit, windowMs) {
16173
+ if (limit === 0)
16174
+ return false;
16175
+ const timestamps = this.peerMessages.get(sessionId);
16176
+ if (!timestamps)
16177
+ return false;
16178
+ const cutoff = Date.now() - windowMs;
16179
+ const recent = timestamps.filter((t) => t >= cutoff);
16180
+ this.peerMessages.set(sessionId, recent);
16181
+ return recent.length >= limit;
16182
+ }
16183
+ markChattyReported(sessionId) {
16184
+ this.chattyReported.add(sessionId);
16185
+ }
16186
+ isChattyReported(sessionId) {
16187
+ return this.chattyReported.has(sessionId);
16188
+ }
16189
+ clearChattyReport(sessionId) {
16190
+ this.chattyReported.delete(sessionId);
16191
+ }
16138
16192
  recordMessage(sessionId) {
16139
16193
  this.lastMessageAt.set(sessionId, Date.now());
16140
16194
  this.clearReport(sessionId);
@@ -16173,7 +16227,9 @@ class ProgressTracker {
16173
16227
  this.steps.delete(sessionId);
16174
16228
  this.lastMessageAt.delete(sessionId);
16175
16229
  this.lastTaskAt.delete(sessionId);
16230
+ this.peerMessages.delete(sessionId);
16176
16231
  this.reported.delete(sessionId);
16232
+ this.chattyReported.delete(sessionId);
16177
16233
  }
16178
16234
  }
16179
16235
 
@@ -16558,16 +16614,17 @@ function rActivity(t){
16558
16614
  const p=parseR(m.content);
16559
16615
  const deliv=m.delivered?(m.read?'\\u2713\\u2713':'\\u2713'):'';
16560
16616
  const isFromAgent=m.fromName!=='lead'&&m.fromName!=='system';
16617
+ const isPeer=isFromAgent&&m.toName&&m.toName!=='lead'&&m.toName!=='all';
16561
16618
  const align=isFromAgent?'mr-6':'ml-6';
16562
- const bubbleBg=isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
16619
+ const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
16563
16620
  const initial=m.fromName.charAt(0).toUpperCase();
16564
- const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
16621
+ const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
16565
16622
  html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
16566
16623
  html+='<div class="flex items-start gap-2">';
16567
16624
  html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
16568
16625
  html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
16569
16626
  html+='<div class="flex items-center gap-1.5 mb-1">';
16570
- html+='<span class="text-[11px] font-medium '+(isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
16627
+ html+='<span class="text-[11px] font-medium '+(isPeer?'text-violet-400':isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
16571
16628
  if(m.toName)html+=chip('\\u2192 '+E(m.toName),'muted');
16572
16629
  html+=chip(relT(m.timeCreated),'muted');
16573
16630
  if(deliv)html+='<span class="text-txt-500 text-[10px] ml-auto">'+deliv+'</span>';
@@ -16953,6 +17010,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
16953
17010
  } else {
16954
17011
  context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board");
16955
17012
  }
17013
+ if (otherMembers.length > 0) {
17014
+ context.push("", "Collaboration:", "- Check team_tasks_list to see what other teammates are working on.", "- If you need information another teammate has, message them directly via team_message.", "- If you discover something relevant to another teammate's task, share it with them.", "- Use team_broadcast for updates that affect the whole team.", "- Keep peer messages focused and actionable \u2014 coordinate, don't chat.");
17015
+ }
16956
17016
  context.push("", "When you finish your task:");
16957
17017
  if (!isReadOnly && worktreeBranch) {
16958
17018
  context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
@@ -17020,6 +17080,19 @@ async function executeTeamMessage(deps, args, sessionId) {
17020
17080
  const teamInfo = requireTeamMember(deps, sessionId);
17021
17081
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
17022
17082
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
17083
+ if (!recipientSessionId && args.to !== "lead") {
17084
+ if (args.approve || args.reject) {
17085
+ throw new Error(`Cannot approve/reject plan for "${args.to}" \u2014 they haven't been spawned yet.`);
17086
+ }
17087
+ sendMessage(deps.db, {
17088
+ teamId: teamInfo.teamId,
17089
+ from: senderName,
17090
+ to: args.to,
17091
+ content: args.text
17092
+ });
17093
+ log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`);
17094
+ return `Message queued for ${args.to} \u2014 they haven't been spawned yet. It will be delivered when they join the team.`;
17095
+ }
17023
17096
  if (!recipientSessionId)
17024
17097
  throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
17025
17098
  let messageText = args.text;
@@ -17580,6 +17653,8 @@ class Watchdog {
17580
17653
  stallMinSteps;
17581
17654
  stallTokenThreshold;
17582
17655
  cwd;
17656
+ peerMessageLimit;
17657
+ peerMessageWindowMs;
17583
17658
  timer;
17584
17659
  constructor(opts) {
17585
17660
  this.db = opts.db;
@@ -17592,6 +17667,8 @@ class Watchdog {
17592
17667
  this.stallMinSteps = opts.stallMinSteps ?? 3;
17593
17668
  this.stallTokenThreshold = opts.stallTokenThreshold ?? 500;
17594
17669
  this.cwd = opts.cwd;
17670
+ this.peerMessageLimit = opts.peerMessageLimit ?? 0;
17671
+ this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300000;
17595
17672
  }
17596
17673
  static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
17597
17674
  async cleanupStaleWorktrees() {
@@ -17649,9 +17726,36 @@ class Watchdog {
17649
17726
  } catch {}
17650
17727
  }
17651
17728
  }
17729
+ async checkChatty() {
17730
+ if (!this.progressTracker || this.peerMessageLimit === 0)
17731
+ return;
17732
+ const busy = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
17733
+ FROM team_member tm
17734
+ JOIN team t ON tm.team_id = t.id
17735
+ WHERE t.status = 'active' AND tm.status = 'busy'`).all();
17736
+ for (const member of busy) {
17737
+ if (this.progressTracker.isChattyReported(member.session_id))
17738
+ continue;
17739
+ if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs))
17740
+ continue;
17741
+ this.progressTracker.markChattyReported(member.session_id);
17742
+ this.client.session.promptAsync({
17743
+ sessionID: member.session_id,
17744
+ 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." }]
17745
+ }).catch(() => {});
17746
+ sendMessage(this.db, {
17747
+ teamId: member.team_id,
17748
+ from: "system",
17749
+ to: "lead",
17750
+ content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`
17751
+ });
17752
+ log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`);
17753
+ }
17754
+ }
17652
17755
  async check() {
17653
17756
  await this.cleanupStaleWorktrees();
17654
17757
  await this.checkStalled();
17758
+ await this.checkChatty();
17655
17759
  if (this.ttlMs === 0)
17656
17760
  return;
17657
17761
  const cutoff = Date.now() - this.ttlMs;
@@ -17761,7 +17865,9 @@ var plugin = async (input) => {
17761
17865
  stallThresholdMs: config2.stallThresholdMs,
17762
17866
  stallMinSteps: config2.stallMinSteps,
17763
17867
  stallTokenThreshold: config2.stallTokenThreshold,
17764
- cwd: input.directory
17868
+ cwd: input.directory,
17869
+ peerMessageLimit: config2.peerMessageLimit,
17870
+ peerMessageWindowMs: config2.peerMessageWindowMs
17765
17871
  });
17766
17872
  watchdog.start();
17767
17873
  return {
@@ -17829,6 +17935,22 @@ var plugin = async (input) => {
17829
17935
  });
17830
17936
  }
17831
17937
  }
17938
+ const member = db.query(`SELECT tm.team_id, tm.name FROM team_member tm
17939
+ JOIN team t ON tm.team_id = t.id
17940
+ WHERE tm.session_id = ? AND t.status = 'active'`).get(sessionID);
17941
+ if (member) {
17942
+ const staleThreshold = Date.now() - 5000;
17943
+ 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);
17944
+ if (peerMsgs.c > 0) {
17945
+ log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`);
17946
+ client3.session.promptAsync({
17947
+ sessionID,
17948
+ parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }]
17949
+ }).catch((err) => {
17950
+ log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`);
17951
+ });
17952
+ }
17953
+ }
17832
17954
  }
17833
17955
  }
17834
17956
  if (event.type === "session.created") {
@@ -17931,6 +18053,8 @@ var plugin = async (input) => {
17931
18053
  async execute(args, ctx) {
17932
18054
  const result = await executeTeamMessage(deps, args, ctx.sessionID);
17933
18055
  progressTracker.recordMessage(ctx.sessionID);
18056
+ if (args.to !== "lead")
18057
+ progressTracker.recordPeerMessage(ctx.sessionID);
17934
18058
  ctx.metadata({ title: `Message \u2192 ${args.to}` });
17935
18059
  return result;
17936
18060
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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/config.ts CHANGED
@@ -17,6 +17,10 @@ export interface EnsembleConfig {
17
17
  rateLimitCapacity?: number
18
18
  /** Dashboard server port (default: 4747, 0 to disable) */
19
19
  dashboardPort?: number
20
+ /** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */
21
+ peerMessageLimit?: number
22
+ /** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */
23
+ peerMessageWindowMs?: number
20
24
  }
21
25
 
22
26
  /** Default configuration values. */
@@ -28,6 +32,8 @@ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
28
32
  timeoutMs: 30 * 60 * 1000,
29
33
  rateLimitCapacity: 10,
30
34
  dashboardPort: 4747,
35
+ peerMessageLimit: 5,
36
+ peerMessageWindowMs: 300_000,
31
37
  }
32
38
 
33
39
  /** Read a JSON config file, returning an empty object on missing/invalid. */
@@ -44,6 +50,8 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
44
50
  if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
45
51
  if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
46
52
  if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort
53
+ if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit
54
+ if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs
47
55
  return result
48
56
  } catch (err) {
49
57
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
@@ -189,16 +189,17 @@ function rActivity(t){
189
189
  const p=parseR(m.content);
190
190
  const deliv=m.delivered?(m.read?'\\u2713\\u2713':'\\u2713'):'';
191
191
  const isFromAgent=m.fromName!=='lead'&&m.fromName!=='system';
192
+ const isPeer=isFromAgent&&m.toName&&m.toName!=='lead'&&m.toName!=='all';
192
193
  const align=isFromAgent?'mr-6':'ml-6';
193
- const bubbleBg=isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
194
+ const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
194
195
  const initial=m.fromName.charAt(0).toUpperCase();
195
- const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
196
+ const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
196
197
  html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
197
198
  html+='<div class="flex items-start gap-2">';
198
199
  html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
199
200
  html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
200
201
  html+='<div class="flex items-center gap-1.5 mb-1">';
201
- html+='<span class="text-[11px] font-medium '+(isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
202
+ html+='<span class="text-[11px] font-medium '+(isPeer?'text-violet-400':isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
202
203
  if(m.toName)html+=chip('\\u2192 '+E(m.toName),'muted');
203
204
  html+=chip(relT(m.timeCreated),'muted');
204
205
  if(deliv)html+='<span class="text-txt-500 text-[10px] ml-auto">'+deliv+'</span>';
package/src/index.ts CHANGED
@@ -122,6 +122,8 @@ const plugin: Plugin = async (input) => {
122
122
  stallMinSteps: config.stallMinSteps,
123
123
  stallTokenThreshold: config.stallTokenThreshold,
124
124
  cwd: input.directory,
125
+ peerMessageLimit: config.peerMessageLimit,
126
+ peerMessageWindowMs: config.peerMessageWindowMs,
125
127
  })
126
128
  watchdog.start()
127
129
 
@@ -206,6 +208,29 @@ const plugin: Plugin = async (input) => {
206
208
  })
207
209
  }
208
210
  }
211
+
212
+ // Also flush peer messages for teammates that just went idle
213
+ // Only flush messages older than 5s to avoid double-delivery with the direct promptAsync path
214
+ const member = db.query(
215
+ `SELECT tm.team_id, tm.name FROM team_member tm
216
+ JOIN team t ON tm.team_id = t.id
217
+ WHERE tm.session_id = ? AND t.status = 'active'`
218
+ ).get(sessionID) as { team_id: string; name: string } | null
219
+ if (member) {
220
+ const staleThreshold = Date.now() - 5000
221
+ const peerMsgs = db.query(
222
+ "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
223
+ ).get(member.team_id, member.name, staleThreshold) as { c: number }
224
+ if (peerMsgs.c > 0) {
225
+ log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
226
+ client.session.promptAsync({
227
+ sessionID,
228
+ parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
229
+ }).catch((err) => {
230
+ log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
231
+ })
232
+ }
233
+ }
209
234
  }
210
235
  }
211
236
 
@@ -325,6 +350,8 @@ const plugin: Plugin = async (input) => {
325
350
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
326
351
  // Track message activity for stall detection
327
352
  progressTracker.recordMessage(ctx.sessionID)
353
+ // Track peer messages for chatty detection
354
+ if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
328
355
  ctx.metadata({ title: `Message → ${args.to}` })
329
356
  return result
330
357
  },
package/src/progress.ts CHANGED
@@ -12,7 +12,9 @@ export class ProgressTracker {
12
12
  private steps = new Map<string, StepRecord[]>()
13
13
  private lastMessageAt = new Map<string, number>()
14
14
  private lastTaskAt = new Map<string, number>()
15
+ private peerMessages = new Map<string, number[]>()
15
16
  private reported = new Set<string>()
17
+ private chattyReported = new Set<string>()
16
18
  private readonly maxSteps: number
17
19
 
18
20
  constructor(maxSteps = 10) {
@@ -27,6 +29,40 @@ export class ProgressTracker {
27
29
  this.steps.set(sessionId, records)
28
30
  }
29
31
 
32
+ /** Record a peer message (not to lead). Used for chatty detection. */
33
+ recordPeerMessage(sessionId: string): void {
34
+ const timestamps = this.peerMessages.get(sessionId) ?? []
35
+ timestamps.push(Date.now())
36
+ this.peerMessages.set(sessionId, timestamps)
37
+ }
38
+
39
+ /** Check if agent is chatty: more than `limit` peer messages within `windowMs`. */
40
+ isChatty(sessionId: string, limit: number, windowMs: number): boolean {
41
+ if (limit === 0) return false
42
+ const timestamps = this.peerMessages.get(sessionId)
43
+ if (!timestamps) return false
44
+ const cutoff = Date.now() - windowMs
45
+ const recent = timestamps.filter(t => t >= cutoff)
46
+ // Prune old timestamps
47
+ this.peerMessages.set(sessionId, recent)
48
+ return recent.length >= limit
49
+ }
50
+
51
+ /** Mark chatty as reported (avoid spam). */
52
+ markChattyReported(sessionId: string): void {
53
+ this.chattyReported.add(sessionId)
54
+ }
55
+
56
+ /** Check if chatty already reported. */
57
+ isChattyReported(sessionId: string): boolean {
58
+ return this.chattyReported.has(sessionId)
59
+ }
60
+
61
+ /** Clear chatty report (e.g., when agent sends result to lead). */
62
+ clearChattyReport(sessionId: string): void {
63
+ this.chattyReported.delete(sessionId)
64
+ }
65
+
30
66
  /** Record that this member sent a team_message. Clears stall report. */
31
67
  recordMessage(sessionId: string): void {
32
68
  this.lastMessageAt.set(sessionId, Date.now())
@@ -80,6 +116,8 @@ export class ProgressTracker {
80
116
  this.steps.delete(sessionId)
81
117
  this.lastMessageAt.delete(sessionId)
82
118
  this.lastTaskAt.delete(sessionId)
119
+ this.peerMessages.delete(sessionId)
83
120
  this.reported.delete(sessionId)
121
+ this.chattyReported.delete(sessionId)
84
122
  }
85
123
  }
@@ -125,13 +125,39 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
125
125
 
126
126
  /**
127
127
  * Build the system prompt injected into a teammate's session.
128
- * Short role reminder with name, team name, and communication tool.
128
+ * Includes role reminder and delivers any pending peer messages.
129
129
  */
130
130
  export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
131
131
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
132
132
  if (!team) return ""
133
133
 
134
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`
134
+ const lines = [
135
+ `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`,
136
+ ]
137
+
138
+ // Deliver pending peer messages addressed to this teammate
139
+ const pendingMessages = db.query(
140
+ "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
141
+ ).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
142
+
143
+ if (pendingMessages.length > 0) {
144
+ lines.push("", "--- Messages for you ---")
145
+ const MAX_MSG = 500
146
+ for (const msg of pendingMessages) {
147
+ const parsed = parseTaskResult(msg.content)
148
+ if (parsed) {
149
+ lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
150
+ } else if (msg.content.length > MAX_MSG) {
151
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
152
+ } else {
153
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`)
154
+ }
155
+ markDelivered(db, msg.id)
156
+ }
157
+ lines.push("--- End Messages ---")
158
+ }
159
+
160
+ return lines.join("\n")
135
161
  }
136
162
 
137
163
  /**
@@ -18,6 +18,22 @@ export async function executeTeamMessage(
18
18
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
19
19
 
20
20
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
21
+
22
+ // If recipient not found, store the message for later delivery (they may not be spawned yet)
23
+ // Reject approve/reject flags for unspawned recipients — plan approval requires the member to exist
24
+ if (!recipientSessionId && args.to !== "lead") {
25
+ if (args.approve || args.reject) {
26
+ throw new Error(`Cannot approve/reject plan for "${args.to}" — they haven't been spawned yet.`)
27
+ }
28
+ sendMessage(deps.db, {
29
+ teamId: teamInfo.teamId,
30
+ from: senderName,
31
+ to: args.to,
32
+ content: args.text,
33
+ })
34
+ log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
35
+ return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
36
+ }
21
37
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
22
38
 
23
39
  // Handle plan approval/rejection
@@ -253,6 +253,19 @@ export async function executeTeamSpawn(
253
253
  )
254
254
  }
255
255
 
256
+ // Collaboration guidance for peer-to-peer communication
257
+ if (otherMembers.length > 0) {
258
+ context.push(
259
+ "",
260
+ "Collaboration:",
261
+ "- Check team_tasks_list to see what other teammates are working on.",
262
+ "- If you need information another teammate has, message them directly via team_message.",
263
+ "- If you discover something relevant to another teammate's task, share it with them.",
264
+ "- Use team_broadcast for updates that affect the whole team.",
265
+ "- Keep peer messages focused and actionable — coordinate, don't chat.",
266
+ )
267
+ }
268
+
256
269
  context.push("", "When you finish your task:")
257
270
  if (!isReadOnly && worktreeBranch) {
258
271
  context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
package/src/watchdog.ts CHANGED
@@ -24,6 +24,10 @@ interface WatchdogOpts {
24
24
  stallTokenThreshold?: number
25
25
  /** Project directory for git operations. */
26
26
  cwd?: string
27
+ /** Max peer messages per agent per window before nudge. 0 disables. */
28
+ peerMessageLimit?: number
29
+ /** Time window for peer message rate limiting in ms. */
30
+ peerMessageWindowMs?: number
27
31
  }
28
32
 
29
33
  /**
@@ -41,6 +45,8 @@ export class Watchdog {
41
45
  private readonly stallMinSteps: number
42
46
  private readonly stallTokenThreshold: number
43
47
  private readonly cwd?: string
48
+ private readonly peerMessageLimit: number
49
+ private readonly peerMessageWindowMs: number
44
50
  private timer: ReturnType<typeof setInterval> | undefined
45
51
 
46
52
  constructor(opts: WatchdogOpts) {
@@ -54,6 +60,8 @@ export class Watchdog {
54
60
  this.stallMinSteps = opts.stallMinSteps ?? 3
55
61
  this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
56
62
  this.cwd = opts.cwd
63
+ this.peerMessageLimit = opts.peerMessageLimit ?? 0
64
+ this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300_000
57
65
  }
58
66
 
59
67
  private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
@@ -133,10 +141,46 @@ export class Watchdog {
133
141
  }
134
142
  }
135
143
 
144
+ /** Check for chatty agents sending too many peer messages. */
145
+ async checkChatty(): Promise<void> {
146
+ if (!this.progressTracker || this.peerMessageLimit === 0) return
147
+
148
+ const busy = this.db.query(
149
+ `SELECT tm.team_id, tm.name, tm.session_id
150
+ FROM team_member tm
151
+ JOIN team t ON tm.team_id = t.id
152
+ WHERE t.status = 'active' AND tm.status = 'busy'`
153
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
154
+
155
+ for (const member of busy) {
156
+ if (this.progressTracker.isChattyReported(member.session_id)) continue
157
+ if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
158
+
159
+ this.progressTracker.markChattyReported(member.session_id)
160
+
161
+ // Nudge the agent
162
+ this.client.session.promptAsync({
163
+ sessionID: member.session_id,
164
+ 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 */ })
166
+
167
+ // 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
+ })
174
+
175
+ log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
176
+ }
177
+ }
178
+
136
179
  /** Run a single check for stale busy members. */
137
180
  async check(): Promise<void> {
138
181
  await this.cleanupStaleWorktrees()
139
182
  await this.checkStalled()
183
+ await this.checkChatty()
140
184
  if (this.ttlMs === 0) return
141
185
 
142
186
  const cutoff = Date.now() - this.ttlMs