@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 +5 -4
- package/dist/index.js +130 -6
- package/package.json +1 -1
- package/src/config.ts +8 -0
- package/src/dashboard-js-part2.ts +4 -3
- package/src/index.ts +27 -0
- package/src/progress.ts +38 -0
- package/src/system-prompt.ts +28 -2
- package/src/tools/team-message.ts +16 -0
- package/src/tools/team-spawn.ts +13 -0
- package/src/watchdog.ts +44 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
4
4
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
7
7
|
[]()
|
|
8
8
|
[](./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.
|
|
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.
|
|
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 #
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -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
|