@hueyexe/opencode-ensemble 0.1.2 → 0.3.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 +14 -4
- package/dist/index.js +218 -47
- package/package.json +1 -1
- package/src/hooks.ts +8 -1
- package/src/index.ts +49 -14
- package/src/recovery.ts +19 -1
- package/src/schema.ts +3 -0
- package/src/tools/team-broadcast.ts +4 -1
- package/src/tools/team-create.ts +1 -1
- package/src/tools/team-message.ts +12 -2
- package/src/tools/team-results.ts +45 -0
- package/src/tools/team-shutdown.ts +14 -2
- package/src/tools/team-spawn.ts +15 -7
- package/src/tools/team-status.ts +13 -1
- package/src/tools/team-view.ts +9 -2
- package/src/watchdog.ts +92 -0
- package/src/tools/team-approve-plan.ts +0 -42
package/README.md
CHANGED
|
@@ -125,7 +125,6 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
|
125
125
|
| `team_spawn` | Start a new teammate with a task. |
|
|
126
126
|
| `team_shutdown` | Ask a teammate to stop. |
|
|
127
127
|
| `team_cleanup` | Remove the team when done. |
|
|
128
|
-
| `team_approve_plan` | Approve or reject a teammate's plan before they write code. |
|
|
129
128
|
| `team_status` | See all members, their status, and a task summary. |
|
|
130
129
|
| `team_view` | Switch the TUI to a teammate's session. |
|
|
131
130
|
|
|
@@ -135,6 +134,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
|
135
134
|
|------|-------------|
|
|
136
135
|
| `team_message` | Send a direct message to a teammate or the lead. |
|
|
137
136
|
| `team_broadcast` | Message everyone on the team. |
|
|
137
|
+
| `team_results` | Retrieve full message content (messages to lead are truncated on delivery). |
|
|
138
138
|
|
|
139
139
|
**Task board** (everyone)
|
|
140
140
|
|
|
@@ -151,10 +151,11 @@ The plugin works within OpenCode's existing TUI. There's no custom team panel (t
|
|
|
151
151
|
|
|
152
152
|
What you do get:
|
|
153
153
|
|
|
154
|
-
- **Toast notifications** when teammates spawn, finish, error, or
|
|
154
|
+
- **Toast notifications** when teammates spawn, finish, error, shut down, or get rate-limited
|
|
155
|
+
- **Working progress toasts** showing who's still active after every status change (e.g. "Working: alice, bob (2/3)")
|
|
155
156
|
- **Rich tool titles** in the sidebar (e.g. "Spawned alice (build)", "Message -> bob", "Task board (3 tasks)")
|
|
156
157
|
- **Session switching** via `team_view` to see any teammate's full chat log
|
|
157
|
-
- **Status checks** via `team_status` for a snapshot of the whole team
|
|
158
|
+
- **Status checks** via `team_status` for a snapshot of the whole team (also fires a toast)
|
|
158
159
|
|
|
159
160
|
Teammate messages arrive in the lead's session as `[Team message from alice]: ...` blocks. They look like user messages in the TUI because that's how `promptAsync` delivery works. The content is clearly labeled with the sender's name.
|
|
160
161
|
|
|
@@ -163,7 +164,10 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
|
|
|
163
164
|
- **SQLite** (`bun:sqlite`, WAL mode) for teams, members, tasks, and messages
|
|
164
165
|
- **promptAsync** for message delivery: injects a message and starts the prompt loop in one call
|
|
165
166
|
- **Sub-agent isolation**: teammates' sub-agents can't use team tools (parent chain tracking, max depth 10)
|
|
166
|
-
- **Crash recovery**: stale busy members marked as errored on restart, undelivered messages redelivered
|
|
167
|
+
- **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, undelivered messages redelivered
|
|
168
|
+
- **Spawn rollback**: if the initial prompt fails, the member is cleaned up and the session aborted
|
|
169
|
+
- **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
|
|
170
|
+
- **Shutdown resilience**: abort failures handled gracefully, busy-after-shutdown triggers re-abort
|
|
167
171
|
- **Rate limiting**: token bucket (configurable via `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
168
172
|
|
|
169
173
|
## Configuration
|
|
@@ -174,6 +178,12 @@ OPENCODE_ENSEMBLE_RATE_LIMIT=20
|
|
|
174
178
|
|
|
175
179
|
# Disable rate limiting
|
|
176
180
|
OPENCODE_ENSEMBLE_RATE_LIMIT=0
|
|
181
|
+
|
|
182
|
+
# Adjust teammate timeout (default: 1800000ms = 30 minutes)
|
|
183
|
+
OPENCODE_ENSEMBLE_TIMEOUT=3600000
|
|
184
|
+
|
|
185
|
+
# Disable timeout watchdog
|
|
186
|
+
OPENCODE_ENSEMBLE_TIMEOUT=0
|
|
177
187
|
```
|
|
178
188
|
|
|
179
189
|
## Development
|
package/dist/index.js
CHANGED
|
@@ -12407,7 +12407,9 @@ var MIGRATIONS = [
|
|
|
12407
12407
|
CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
|
|
12408
12408
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
|
|
12409
12409
|
WHERE delivered = 0;
|
|
12410
|
-
|
|
12410
|
+
`,
|
|
12411
|
+
`ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
|
|
12412
|
+
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`
|
|
12411
12413
|
];
|
|
12412
12414
|
function applyMigrations(db) {
|
|
12413
12415
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -12476,17 +12478,59 @@ function broadcastMessage(db, input) {
|
|
|
12476
12478
|
db.run("INSERT INTO team_message (id, team_id, from_name, to_name, content, delivered, time_created) VALUES (?, ?, ?, NULL, ?, 0, ?)", [id, input.teamId, input.from, input.content, Date.now()]);
|
|
12477
12479
|
return id;
|
|
12478
12480
|
}
|
|
12481
|
+
function getUndeliveredMessages(db, teamId) {
|
|
12482
|
+
return db.query("SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 ORDER BY time_created ASC").all(teamId);
|
|
12483
|
+
}
|
|
12479
12484
|
function markDelivered(db, messageId) {
|
|
12480
12485
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
|
|
12481
12486
|
}
|
|
12482
12487
|
|
|
12483
12488
|
// src/recovery.ts
|
|
12484
|
-
function recoverStaleMembers(db) {
|
|
12489
|
+
async function recoverStaleMembers(db, client) {
|
|
12490
|
+
const stale = db.query(`SELECT tm.session_id FROM team_member tm
|
|
12491
|
+
JOIN team t ON tm.team_id = t.id
|
|
12492
|
+
WHERE tm.status = 'busy' AND t.status = 'active'`).all();
|
|
12485
12493
|
const result = db.run(`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
12486
12494
|
WHERE status = 'busy'
|
|
12487
12495
|
AND team_id IN (SELECT id FROM team WHERE status = 'active')`, [Date.now()]);
|
|
12496
|
+
if (client) {
|
|
12497
|
+
for (const member of stale) {
|
|
12498
|
+
try {
|
|
12499
|
+
await client.session.abort({ path: { id: member.session_id } });
|
|
12500
|
+
} catch {}
|
|
12501
|
+
}
|
|
12502
|
+
}
|
|
12488
12503
|
return { interrupted: result.changes };
|
|
12489
12504
|
}
|
|
12505
|
+
async function recoverUndeliveredMessages(db, client, registry2) {
|
|
12506
|
+
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
|
|
12507
|
+
let redelivered = 0;
|
|
12508
|
+
for (const team of teams) {
|
|
12509
|
+
const messages = getUndeliveredMessages(db, team.id);
|
|
12510
|
+
for (const msg of messages) {
|
|
12511
|
+
let recipientSessionId;
|
|
12512
|
+
if (msg.to_name === "lead") {
|
|
12513
|
+
recipientSessionId = team.lead_session_id;
|
|
12514
|
+
} else if (msg.to_name) {
|
|
12515
|
+
const entry = registry2.getByName(team.id, msg.to_name);
|
|
12516
|
+
recipientSessionId = entry?.sessionId;
|
|
12517
|
+
} else {
|
|
12518
|
+
continue;
|
|
12519
|
+
}
|
|
12520
|
+
if (!recipientSessionId)
|
|
12521
|
+
continue;
|
|
12522
|
+
try {
|
|
12523
|
+
await client.session.promptAsync({
|
|
12524
|
+
path: { id: recipientSessionId },
|
|
12525
|
+
body: { parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }] }
|
|
12526
|
+
});
|
|
12527
|
+
markDelivered(db, msg.id);
|
|
12528
|
+
redelivered++;
|
|
12529
|
+
} catch {}
|
|
12530
|
+
}
|
|
12531
|
+
}
|
|
12532
|
+
return { redelivered };
|
|
12533
|
+
}
|
|
12490
12534
|
|
|
12491
12535
|
// src/state.ts
|
|
12492
12536
|
class MemberRegistry {
|
|
@@ -12578,6 +12622,11 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
|
12578
12622
|
db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
|
|
12579
12623
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
|
|
12580
12624
|
}
|
|
12625
|
+
if (member.status === "shutdown_requested") {
|
|
12626
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" };
|
|
12627
|
+
}
|
|
12628
|
+
} else if (status === "retry") {
|
|
12629
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" };
|
|
12581
12630
|
}
|
|
12582
12631
|
return;
|
|
12583
12632
|
}
|
|
@@ -12694,7 +12743,7 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
12694
12743
|
const id = generateId("team");
|
|
12695
12744
|
const now = Date.now();
|
|
12696
12745
|
deps.db.run("INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)", [id, args.name, sessionId, now, now]);
|
|
12697
|
-
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done \u2014 do not poll team_status repeatedly.`;
|
|
12746
|
+
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done \u2014 do not poll team_status repeatedly. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`;
|
|
12698
12747
|
}
|
|
12699
12748
|
|
|
12700
12749
|
// src/tools/team-spawn.ts
|
|
@@ -12749,11 +12798,20 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12749
12798
|
}
|
|
12750
12799
|
const contextStr = context.join(`
|
|
12751
12800
|
`);
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12801
|
+
try {
|
|
12802
|
+
await deps.client.session.promptAsync({
|
|
12803
|
+
path: { id: childSessionId },
|
|
12804
|
+
body: { parts: [{ type: "text", text: contextStr }] }
|
|
12805
|
+
});
|
|
12806
|
+
} catch (err) {
|
|
12807
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name]);
|
|
12808
|
+
deps.registry.unregister(childSessionId);
|
|
12809
|
+
try {
|
|
12810
|
+
await deps.client.session.abort({ path: { id: childSessionId } });
|
|
12811
|
+
} catch {}
|
|
12812
|
+
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12813
|
+
}
|
|
12814
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`;
|
|
12757
12815
|
}
|
|
12758
12816
|
|
|
12759
12817
|
// src/tools/team-message.ts
|
|
@@ -12771,9 +12829,18 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
12771
12829
|
to: args.to,
|
|
12772
12830
|
content: args.text
|
|
12773
12831
|
});
|
|
12832
|
+
const isToLead = args.to === "lead";
|
|
12833
|
+
const MAX_LEAD_MSG = 500;
|
|
12834
|
+
let deliveryText;
|
|
12835
|
+
if (isToLead && args.text.length > MAX_LEAD_MSG) {
|
|
12836
|
+
const truncated = args.text.slice(0, MAX_LEAD_MSG);
|
|
12837
|
+
deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
|
|
12838
|
+
} else {
|
|
12839
|
+
deliveryText = `[Team message from ${senderName}]: ${args.text}`;
|
|
12840
|
+
}
|
|
12774
12841
|
await deps.client.session.promptAsync({
|
|
12775
12842
|
path: { id: recipientSessionId },
|
|
12776
|
-
body: { parts: [{ type: "text", text:
|
|
12843
|
+
body: { parts: [{ type: "text", text: deliveryText }] }
|
|
12777
12844
|
});
|
|
12778
12845
|
markDelivered(deps.db, msgId);
|
|
12779
12846
|
return `Message sent to ${args.to}.`;
|
|
@@ -12812,7 +12879,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
12812
12879
|
delivered++;
|
|
12813
12880
|
} catch {}
|
|
12814
12881
|
}
|
|
12815
|
-
|
|
12882
|
+
if (delivered > 0) {
|
|
12883
|
+
markDelivered(deps.db, msgId);
|
|
12884
|
+
}
|
|
12816
12885
|
return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`;
|
|
12817
12886
|
}
|
|
12818
12887
|
|
|
@@ -12829,7 +12898,18 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12829
12898
|
if (member.status === "shutdown")
|
|
12830
12899
|
throw new Error(`Teammate "${args.member}" is already shut down`);
|
|
12831
12900
|
deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
|
|
12832
|
-
|
|
12901
|
+
try {
|
|
12902
|
+
await deps.client.session.abort({ path: { id: member.session_id } });
|
|
12903
|
+
} catch {
|
|
12904
|
+
try {
|
|
12905
|
+
await deps.client.tui.showToast({
|
|
12906
|
+
title: "Team",
|
|
12907
|
+
message: `Failed to abort ${args.member} session \u2014 will rely on event hook`,
|
|
12908
|
+
variant: "warning",
|
|
12909
|
+
duration: 4000
|
|
12910
|
+
});
|
|
12911
|
+
} catch {}
|
|
12912
|
+
}
|
|
12833
12913
|
try {
|
|
12834
12914
|
const statuses = await deps.client.session.status();
|
|
12835
12915
|
const sessionStatus = statuses.data?.[member.session_id];
|
|
@@ -12958,36 +13038,36 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
12958
13038
|
return `Claimed task: ${task.content}`;
|
|
12959
13039
|
}
|
|
12960
13040
|
|
|
12961
|
-
// src/tools/team-
|
|
12962
|
-
async function
|
|
12963
|
-
const
|
|
12964
|
-
if (!
|
|
12965
|
-
throw new Error("
|
|
12966
|
-
|
|
12967
|
-
|
|
12968
|
-
|
|
12969
|
-
|
|
12970
|
-
|
|
12971
|
-
|
|
12972
|
-
|
|
12973
|
-
|
|
12974
|
-
|
|
12975
|
-
|
|
12976
|
-
content: text
|
|
12977
|
-
});
|
|
12978
|
-
await deps.client.session.promptAsync({
|
|
12979
|
-
path: { id: member.session_id },
|
|
12980
|
-
body: { parts: [{ type: "text", text: `[Plan ${args.approved ? "approved" : "rejected"} by lead]: ${text}` }] }
|
|
12981
|
-
});
|
|
12982
|
-
markDelivered(deps.db, msgId);
|
|
12983
|
-
return `Plan ${args.approved ? "approved" : "rejected"} for ${args.member}.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`;
|
|
13041
|
+
// src/tools/team-results.ts
|
|
13042
|
+
async function executeTeamResults(deps, args, sessionId) {
|
|
13043
|
+
const team = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
13044
|
+
if (!team)
|
|
13045
|
+
throw new Error("not in a team");
|
|
13046
|
+
const rows = args.from ? deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC").all(team.teamId, args.from) : deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC").all(team.teamId);
|
|
13047
|
+
if (rows.length === 0)
|
|
13048
|
+
return "No unread messages.";
|
|
13049
|
+
const ids = rows.map((r) => r.id);
|
|
13050
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
13051
|
+
deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids);
|
|
13052
|
+
return rows.map((r) => `[Message from ${r.from_name}]:
|
|
13053
|
+
${r.content}
|
|
13054
|
+
`).join(`
|
|
13055
|
+
`);
|
|
12984
13056
|
}
|
|
12985
13057
|
|
|
12986
13058
|
// src/tools/team-status.ts
|
|
13059
|
+
var lastCallTime = new Map;
|
|
13060
|
+
var RATE_LIMIT_MS = 30000;
|
|
12987
13061
|
async function executeTeamStatus(deps, sessionId) {
|
|
12988
13062
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
12989
13063
|
if (!teamInfo)
|
|
12990
13064
|
throw new Error("This session is not in a team.");
|
|
13065
|
+
const now = Date.now();
|
|
13066
|
+
const last = lastCallTime.get(teamInfo.teamId);
|
|
13067
|
+
if (last !== undefined && now - last < RATE_LIMIT_MS) {
|
|
13068
|
+
return "Status unchanged. STOP \u2014 do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you.";
|
|
13069
|
+
}
|
|
13070
|
+
lastCallTime.set(teamInfo.teamId, now);
|
|
12991
13071
|
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
12992
13072
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
12993
13073
|
const lines = [];
|
|
@@ -13030,7 +13110,9 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13030
13110
|
});
|
|
13031
13111
|
} catch {}
|
|
13032
13112
|
}
|
|
13033
|
-
return
|
|
13113
|
+
return `[Relay this status to the user in your next message:]
|
|
13114
|
+
|
|
13115
|
+
` + lines.join(`
|
|
13034
13116
|
`);
|
|
13035
13117
|
}
|
|
13036
13118
|
|
|
@@ -13042,8 +13124,12 @@ async function executeTeamView(deps, args, sessionId) {
|
|
|
13042
13124
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
13043
13125
|
if (!member)
|
|
13044
13126
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
13045
|
-
|
|
13046
|
-
|
|
13127
|
+
try {
|
|
13128
|
+
if (typeof deps.client.tui.selectSession === "function") {
|
|
13129
|
+
await deps.client.tui.selectSession({ sessionID: member.session_id });
|
|
13130
|
+
}
|
|
13131
|
+
} catch {}
|
|
13132
|
+
return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`;
|
|
13047
13133
|
}
|
|
13048
13134
|
|
|
13049
13135
|
// src/rate-limit.ts
|
|
@@ -13109,25 +13195,86 @@ class TokenBucket {
|
|
|
13109
13195
|
}
|
|
13110
13196
|
}
|
|
13111
13197
|
|
|
13198
|
+
// src/watchdog.ts
|
|
13199
|
+
class Watchdog {
|
|
13200
|
+
db;
|
|
13201
|
+
client;
|
|
13202
|
+
registry;
|
|
13203
|
+
ttlMs;
|
|
13204
|
+
checkIntervalMs;
|
|
13205
|
+
timer;
|
|
13206
|
+
constructor(opts) {
|
|
13207
|
+
this.db = opts.db;
|
|
13208
|
+
this.client = opts.client;
|
|
13209
|
+
this.registry = opts.registry;
|
|
13210
|
+
this.ttlMs = opts.ttlMs;
|
|
13211
|
+
this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
|
|
13212
|
+
}
|
|
13213
|
+
async check() {
|
|
13214
|
+
if (this.ttlMs === 0)
|
|
13215
|
+
return;
|
|
13216
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
13217
|
+
const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
|
|
13218
|
+
FROM team_member tm
|
|
13219
|
+
JOIN team t ON tm.team_id = t.id
|
|
13220
|
+
WHERE t.status = 'active'
|
|
13221
|
+
AND tm.status = 'busy'
|
|
13222
|
+
AND tm.time_updated < ?`).all(cutoff);
|
|
13223
|
+
for (const member of stale) {
|
|
13224
|
+
this.db.run("UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
|
|
13225
|
+
try {
|
|
13226
|
+
await this.client.session.abort({ path: { id: member.session_id } });
|
|
13227
|
+
} catch {}
|
|
13228
|
+
try {
|
|
13229
|
+
await this.client.tui.showToast({
|
|
13230
|
+
title: "Team",
|
|
13231
|
+
message: `${member.name} timed out`,
|
|
13232
|
+
variant: "warning",
|
|
13233
|
+
duration: 5000
|
|
13234
|
+
});
|
|
13235
|
+
} catch {}
|
|
13236
|
+
}
|
|
13237
|
+
}
|
|
13238
|
+
start() {
|
|
13239
|
+
if (this.timer)
|
|
13240
|
+
return;
|
|
13241
|
+
if (this.ttlMs === 0)
|
|
13242
|
+
return;
|
|
13243
|
+
this.timer = setInterval(() => this.check(), this.checkIntervalMs);
|
|
13244
|
+
}
|
|
13245
|
+
stop() {
|
|
13246
|
+
if (this.timer) {
|
|
13247
|
+
clearInterval(this.timer);
|
|
13248
|
+
this.timer = undefined;
|
|
13249
|
+
}
|
|
13250
|
+
}
|
|
13251
|
+
isRunning() {
|
|
13252
|
+
return this.timer !== undefined;
|
|
13253
|
+
}
|
|
13254
|
+
}
|
|
13255
|
+
|
|
13112
13256
|
// src/index.ts
|
|
13113
13257
|
var DEFAULT_RATE_LIMIT_CAPACITY = 10;
|
|
13114
13258
|
var DEFAULT_RATE_LIMIT_REFILL = 2;
|
|
13115
13259
|
var DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000;
|
|
13260
|
+
var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
|
|
13261
|
+
var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
|
|
13116
13262
|
var plugin = async (input) => {
|
|
13117
13263
|
const dbPath = getDbPath();
|
|
13118
13264
|
mkdirSync(path2.dirname(dbPath), { recursive: true });
|
|
13119
13265
|
const db = createDb(dbPath);
|
|
13120
13266
|
const registry2 = new MemberRegistry;
|
|
13121
13267
|
const tracker = new DescendantTracker;
|
|
13122
|
-
const
|
|
13268
|
+
const client = input.client;
|
|
13269
|
+
const deps = { db, registry: registry2, tracker, client };
|
|
13270
|
+
const recovery = await recoverStaleMembers(db, client);
|
|
13123
13271
|
if (recovery.interrupted > 0) {
|
|
13124
13272
|
const members = db.query("SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'").all();
|
|
13125
13273
|
for (const m of members) {
|
|
13126
13274
|
registry2.register(m.team_id, m.name, m.session_id);
|
|
13127
13275
|
}
|
|
13128
13276
|
}
|
|
13129
|
-
|
|
13130
|
-
const deps = { db, registry: registry2, tracker, client };
|
|
13277
|
+
recoverUndeliveredMessages(db, client, registry2).catch(() => {});
|
|
13131
13278
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
13132
13279
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
13133
13280
|
const rateLimiter = new TokenBucket({
|
|
@@ -13135,6 +13282,16 @@ var plugin = async (input) => {
|
|
|
13135
13282
|
refillRate: DEFAULT_RATE_LIMIT_REFILL,
|
|
13136
13283
|
refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS
|
|
13137
13284
|
});
|
|
13285
|
+
const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
|
|
13286
|
+
const watchdogTtl = timeoutEnv === "0" ? 0 : parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS;
|
|
13287
|
+
const watchdog = new Watchdog({
|
|
13288
|
+
db,
|
|
13289
|
+
client,
|
|
13290
|
+
registry: registry2,
|
|
13291
|
+
ttlMs: watchdogTtl,
|
|
13292
|
+
checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS
|
|
13293
|
+
});
|
|
13294
|
+
watchdog.start();
|
|
13138
13295
|
return {
|
|
13139
13296
|
async event({ event }) {
|
|
13140
13297
|
if (event.type === "session.status") {
|
|
@@ -13148,6 +13305,22 @@ var plugin = async (input) => {
|
|
|
13148
13305
|
notifyTeamEvent(client, "completed", { memberName: transition.memberName });
|
|
13149
13306
|
} else if (transition.to === "error") {
|
|
13150
13307
|
notifyTeamEvent(client, "error", { memberName: transition.memberName });
|
|
13308
|
+
} else if (transition.to === "retry") {
|
|
13309
|
+
try {
|
|
13310
|
+
await client.tui.showToast({
|
|
13311
|
+
title: "Team",
|
|
13312
|
+
message: `${transition.memberName} is being rate-limited`,
|
|
13313
|
+
variant: "warning",
|
|
13314
|
+
duration: 3000
|
|
13315
|
+
});
|
|
13316
|
+
} catch {}
|
|
13317
|
+
} else if (transition.to === "busy_while_shutdown") {
|
|
13318
|
+
const entry = registry2.getBySession(sessionID);
|
|
13319
|
+
if (entry) {
|
|
13320
|
+
try {
|
|
13321
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
13322
|
+
} catch {}
|
|
13323
|
+
}
|
|
13151
13324
|
}
|
|
13152
13325
|
await notifyWorkingProgress(client, db, transition.teamId);
|
|
13153
13326
|
}
|
|
@@ -13265,16 +13438,14 @@ var plugin = async (input) => {
|
|
|
13265
13438
|
return result;
|
|
13266
13439
|
}
|
|
13267
13440
|
}),
|
|
13268
|
-
|
|
13269
|
-
description: "
|
|
13441
|
+
team_results: tool({
|
|
13442
|
+
description: "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
|
|
13270
13443
|
args: {
|
|
13271
|
-
|
|
13272
|
-
approved: tool.schema.boolean().describe("true to approve, false to reject"),
|
|
13273
|
-
feedback: tool.schema.string().optional().describe("Feedback message (optional)")
|
|
13444
|
+
from: tool.schema.string().optional().describe("Filter messages by sender name (optional, returns all if omitted)")
|
|
13274
13445
|
},
|
|
13275
13446
|
async execute(args, ctx) {
|
|
13276
|
-
const result = await
|
|
13277
|
-
ctx.metadata({ title: `
|
|
13447
|
+
const result = await executeTeamResults(deps, args, ctx.sessionID);
|
|
13448
|
+
ctx.metadata({ title: `Results${args.from ? ` from ${args.from}` : ""}` });
|
|
13278
13449
|
return result;
|
|
13279
13450
|
}
|
|
13280
13451
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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/hooks.ts
CHANGED
|
@@ -50,6 +50,13 @@ export function handleSessionStatusEvent(
|
|
|
50
50
|
)
|
|
51
51
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
|
|
52
52
|
}
|
|
53
|
+
// Session went busy while shutdown was requested — signal for re-abort
|
|
54
|
+
if (member.status === "shutdown_requested") {
|
|
55
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" }
|
|
56
|
+
}
|
|
57
|
+
} else if (status === "retry") {
|
|
58
|
+
// Session is being rate-limited — signal for toast but don't change state
|
|
59
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" }
|
|
53
60
|
}
|
|
54
61
|
return undefined
|
|
55
62
|
}
|
|
@@ -71,7 +78,7 @@ export function handleSessionCreatedEvent(
|
|
|
71
78
|
/**
|
|
72
79
|
* Check whether a tool call should be blocked for sub-agent isolation.
|
|
73
80
|
* Throws if the tool is a team tool and the session is a descendant of a team member.
|
|
74
|
-
* OQ-11:
|
|
81
|
+
* OQ-11: confirmed — throwing inside tool.execute.before fails the tool call gracefully (verified in live testing).
|
|
75
82
|
*/
|
|
76
83
|
export function checkToolIsolation(
|
|
77
84
|
registry: MemberRegistry,
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
3
3
|
import path from "path"
|
|
4
4
|
import { mkdirSync } from "node:fs"
|
|
5
5
|
import { createDb, getDbPath } from "./db"
|
|
6
|
-
import { recoverStaleMembers } from "./recovery"
|
|
6
|
+
import { recoverStaleMembers, recoverUndeliveredMessages } from "./recovery"
|
|
7
7
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
8
8
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
9
9
|
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
@@ -17,15 +17,18 @@ import { executeTeamTasksList } from "./tools/team-tasks-list"
|
|
|
17
17
|
import { executeTeamTasksAdd } from "./tools/team-tasks-add"
|
|
18
18
|
import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
|
|
19
19
|
import { executeTeamClaim } from "./tools/team-claim"
|
|
20
|
-
import {
|
|
20
|
+
import { executeTeamResults } from "./tools/team-results"
|
|
21
21
|
import { executeTeamStatus } from "./tools/team-status"
|
|
22
22
|
import { executeTeamView } from "./tools/team-view"
|
|
23
23
|
import type { ToolDeps, PluginClient } from "./types"
|
|
24
24
|
import { TokenBucket } from "./rate-limit"
|
|
25
|
+
import { Watchdog } from "./watchdog"
|
|
25
26
|
|
|
26
27
|
const DEFAULT_RATE_LIMIT_CAPACITY = 10
|
|
27
28
|
const DEFAULT_RATE_LIMIT_REFILL = 2
|
|
28
29
|
const DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000
|
|
30
|
+
const DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000 // 30 minutes
|
|
31
|
+
const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* opencode-ensemble plugin entry point.
|
|
@@ -42,8 +45,12 @@ const plugin: Plugin = async (input) => {
|
|
|
42
45
|
const registry = new MemberRegistry()
|
|
43
46
|
const tracker = new DescendantTracker()
|
|
44
47
|
|
|
45
|
-
//
|
|
46
|
-
const
|
|
48
|
+
// Build shared tool dependencies
|
|
49
|
+
const client = input.client as unknown as PluginClient
|
|
50
|
+
const deps: ToolDeps = { db, registry, tracker, client }
|
|
51
|
+
|
|
52
|
+
// Run recovery on init — mark stale busy members as error + abort orphaned sessions
|
|
53
|
+
const recovery = await recoverStaleMembers(db, client)
|
|
47
54
|
if (recovery.interrupted > 0) {
|
|
48
55
|
// Rebuild registry from DB for recovered members
|
|
49
56
|
const members = db.query(
|
|
@@ -54,9 +61,10 @@ const plugin: Plugin = async (input) => {
|
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
// Redeliver undelivered messages from previous sessions
|
|
65
|
+
recoverUndeliveredMessages(db, client, registry).catch(() => {
|
|
66
|
+
// Best effort — don't block plugin init
|
|
67
|
+
})
|
|
60
68
|
|
|
61
69
|
// Initialize rate limiter
|
|
62
70
|
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
@@ -68,6 +76,17 @@ const plugin: Plugin = async (input) => {
|
|
|
68
76
|
refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS,
|
|
69
77
|
})
|
|
70
78
|
|
|
79
|
+
// Initialize watchdog for teammate timeouts
|
|
80
|
+
// OPENCODE_ENSEMBLE_TIMEOUT=0 disables it entirely
|
|
81
|
+
const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT
|
|
82
|
+
const watchdogTtl = timeoutEnv === "0" ? 0 : (parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS)
|
|
83
|
+
const watchdog = new Watchdog({
|
|
84
|
+
db, client, registry,
|
|
85
|
+
ttlMs: watchdogTtl,
|
|
86
|
+
checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS,
|
|
87
|
+
})
|
|
88
|
+
watchdog.start()
|
|
89
|
+
|
|
71
90
|
return {
|
|
72
91
|
// Event hook — drives state machine transitions + descendant tracking + toasts
|
|
73
92
|
async event({ event }) {
|
|
@@ -84,6 +103,24 @@ const plugin: Plugin = async (input) => {
|
|
|
84
103
|
notifyTeamEvent(client, "completed", { memberName: transition.memberName })
|
|
85
104
|
} else if (transition.to === "error") {
|
|
86
105
|
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
106
|
+
} else if (transition.to === "retry") {
|
|
107
|
+
// Teammate is being rate-limited — notify user
|
|
108
|
+
try {
|
|
109
|
+
await client.tui.showToast({
|
|
110
|
+
title: "Team",
|
|
111
|
+
message: `${transition.memberName} is being rate-limited`,
|
|
112
|
+
variant: "warning",
|
|
113
|
+
duration: 3000,
|
|
114
|
+
})
|
|
115
|
+
} catch { /* TUI may not be available */ }
|
|
116
|
+
} else if (transition.to === "busy_while_shutdown") {
|
|
117
|
+
// Session went busy after shutdown was requested — re-issue abort
|
|
118
|
+
const entry = registry.getBySession(sessionID)
|
|
119
|
+
if (entry) {
|
|
120
|
+
try {
|
|
121
|
+
await client.session.abort({ path: { id: sessionID } })
|
|
122
|
+
} catch { /* best effort */ }
|
|
123
|
+
}
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
// Show working progress after every transition so the user sees who's still active
|
|
@@ -217,16 +254,14 @@ const plugin: Plugin = async (input) => {
|
|
|
217
254
|
},
|
|
218
255
|
}),
|
|
219
256
|
|
|
220
|
-
|
|
221
|
-
description: "
|
|
257
|
+
team_results: tool({
|
|
258
|
+
description: "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
|
|
222
259
|
args: {
|
|
223
|
-
|
|
224
|
-
approved: tool.schema.boolean().describe("true to approve, false to reject"),
|
|
225
|
-
feedback: tool.schema.string().optional().describe("Feedback message (optional)"),
|
|
260
|
+
from: tool.schema.string().optional().describe("Filter messages by sender name (optional, returns all if omitted)"),
|
|
226
261
|
},
|
|
227
262
|
async execute(args, ctx) {
|
|
228
|
-
const result = await
|
|
229
|
-
ctx.metadata({ title: `
|
|
263
|
+
const result = await executeTeamResults(deps, args, ctx.sessionID)
|
|
264
|
+
ctx.metadata({ title: `Results${args.from ? ` from ${args.from}` : ""}` })
|
|
230
265
|
return result
|
|
231
266
|
},
|
|
232
267
|
}),
|
package/src/recovery.ts
CHANGED
|
@@ -6,16 +6,34 @@ import { getUndeliveredMessages, markDelivered } from "./messaging"
|
|
|
6
6
|
/**
|
|
7
7
|
* Scan for team members stuck in 'busy' status (stale from a crash)
|
|
8
8
|
* and mark them as 'error' with execution_status 'idle'.
|
|
9
|
+
* Also aborts their orphaned sessions (best effort).
|
|
9
10
|
* Only processes members in active teams.
|
|
10
11
|
* Returns the count of interrupted members.
|
|
11
12
|
*/
|
|
12
|
-
export function recoverStaleMembers(db: Database): { interrupted: number } {
|
|
13
|
+
export async function recoverStaleMembers(db: Database, client?: PluginClient): Promise<{ interrupted: number }> {
|
|
14
|
+
// Find stale members before updating so we can abort their sessions
|
|
15
|
+
const stale = db.query(
|
|
16
|
+
`SELECT tm.session_id FROM team_member tm
|
|
17
|
+
JOIN team t ON tm.team_id = t.id
|
|
18
|
+
WHERE tm.status = 'busy' AND t.status = 'active'`
|
|
19
|
+
).all() as Array<{ session_id: string }>
|
|
20
|
+
|
|
13
21
|
const result = db.run(
|
|
14
22
|
`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
15
23
|
WHERE status = 'busy'
|
|
16
24
|
AND team_id IN (SELECT id FROM team WHERE status = 'active')`,
|
|
17
25
|
[Date.now()]
|
|
18
26
|
)
|
|
27
|
+
|
|
28
|
+
// Abort orphaned sessions (best effort)
|
|
29
|
+
if (client) {
|
|
30
|
+
for (const member of stale) {
|
|
31
|
+
try {
|
|
32
|
+
await client.session.abort({ path: { id: member.session_id } })
|
|
33
|
+
} catch { /* best effort */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
return { interrupted: result.changes }
|
|
20
38
|
}
|
|
21
39
|
|
package/src/schema.ts
CHANGED
|
@@ -67,6 +67,9 @@ export const MIGRATIONS: string[] = [
|
|
|
67
67
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
|
|
68
68
|
WHERE delivered = 0;
|
|
69
69
|
`,
|
|
70
|
+
// Migration 2: Add read column to team_message for team_results tracking
|
|
71
|
+
`ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
|
|
72
|
+
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
|
|
70
73
|
]
|
|
71
74
|
|
|
72
75
|
/**
|
|
@@ -53,7 +53,10 @@ export async function executeTeamBroadcast(
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Only mark delivered if at least one recipient received it
|
|
57
|
+
if (delivered > 0) {
|
|
58
|
+
markDelivered(deps.db, msgId)
|
|
59
|
+
}
|
|
57
60
|
|
|
58
61
|
return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`
|
|
59
62
|
}
|
package/src/tools/team-create.ts
CHANGED
|
@@ -28,5 +28,5 @@ export async function executeTeamCreate(
|
|
|
28
28
|
[id, args.name, sessionId, now, now]
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly.`
|
|
31
|
+
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`
|
|
32
32
|
}
|
|
@@ -25,10 +25,20 @@ export async function executeTeamMessage(
|
|
|
25
25
|
content: args.text,
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
-
// Deliver via promptAsync
|
|
28
|
+
// Deliver via promptAsync — truncate lead-bound messages over 500 chars
|
|
29
|
+
const isToLead = args.to === "lead"
|
|
30
|
+
const MAX_LEAD_MSG = 500
|
|
31
|
+
let deliveryText: string
|
|
32
|
+
if (isToLead && args.text.length > MAX_LEAD_MSG) {
|
|
33
|
+
const truncated = args.text.slice(0, MAX_LEAD_MSG)
|
|
34
|
+
deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
|
|
35
|
+
} else {
|
|
36
|
+
deliveryText = `[Team message from ${senderName}]: ${args.text}`
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
await deps.client.session.promptAsync({
|
|
30
40
|
path: { id: recipientSessionId },
|
|
31
|
-
body: { parts: [{ type: "text", text:
|
|
41
|
+
body: { parts: [{ type: "text", text: deliveryText }] },
|
|
32
42
|
})
|
|
33
43
|
|
|
34
44
|
markDelivered(deps.db, msgId)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/** Row shape for unread messages query. */
|
|
5
|
+
interface UnreadMessageRow {
|
|
6
|
+
id: string
|
|
7
|
+
from_name: string
|
|
8
|
+
content: string
|
|
9
|
+
time_created: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Retrieve unread messages from the team message store.
|
|
14
|
+
* Optionally filter by sender name. Marks returned messages as read.
|
|
15
|
+
*/
|
|
16
|
+
export async function executeTeamResults(
|
|
17
|
+
deps: ToolDeps,
|
|
18
|
+
args: { from?: string },
|
|
19
|
+
sessionId: string,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const team = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
22
|
+
if (!team) throw new Error("not in a team")
|
|
23
|
+
|
|
24
|
+
const rows = args.from
|
|
25
|
+
? (deps.db
|
|
26
|
+
.query(
|
|
27
|
+
"SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC",
|
|
28
|
+
)
|
|
29
|
+
.all(team.teamId, args.from) as UnreadMessageRow[])
|
|
30
|
+
: (deps.db
|
|
31
|
+
.query(
|
|
32
|
+
"SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC",
|
|
33
|
+
)
|
|
34
|
+
.all(team.teamId) as UnreadMessageRow[])
|
|
35
|
+
|
|
36
|
+
if (rows.length === 0) return "No unread messages."
|
|
37
|
+
|
|
38
|
+
// Mark all returned messages as read
|
|
39
|
+
const ids = rows.map((r) => r.id)
|
|
40
|
+
const placeholders = ids.map(() => "?").join(", ")
|
|
41
|
+
deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
|
|
42
|
+
|
|
43
|
+
// Format output
|
|
44
|
+
return rows.map((r) => `[Message from ${r.from_name}]:\n${r.content}\n`).join("\n")
|
|
45
|
+
}
|
|
@@ -27,8 +27,20 @@ export async function executeTeamShutdown(
|
|
|
27
27
|
[Date.now(), teamInfo.teamId, args.member]
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
// Call abort on the member's session
|
|
31
|
-
|
|
30
|
+
// Call abort on the member's session (best effort)
|
|
31
|
+
try {
|
|
32
|
+
await deps.client.session.abort({ path: { id: member.session_id } })
|
|
33
|
+
} catch {
|
|
34
|
+
// Abort failed — session may already be gone. Fire a warning toast.
|
|
35
|
+
try {
|
|
36
|
+
await deps.client.tui.showToast({
|
|
37
|
+
title: "Team",
|
|
38
|
+
message: `Failed to abort ${args.member} session — will rely on event hook`,
|
|
39
|
+
variant: "warning",
|
|
40
|
+
duration: 4000,
|
|
41
|
+
})
|
|
42
|
+
} catch { /* TUI may not be available */ }
|
|
43
|
+
}
|
|
32
44
|
|
|
33
45
|
// Fallback: poll session status after abort. If already idle, transition
|
|
34
46
|
// directly to shutdown. This handles the case where abort() on an
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -73,12 +73,20 @@ export async function executeTeamSpawn(
|
|
|
73
73
|
const contextStr = context.join("\n")
|
|
74
74
|
|
|
75
75
|
// Fire-and-forget: send prompt to teammate session
|
|
76
|
-
// OQ-1:
|
|
77
|
-
// OQ-10:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
// OQ-1: confirmed — promptAsync queues when session is busy (verified in live testing)
|
|
77
|
+
// OQ-10: confirmed — fresh session accepts promptAsync without session.init() (verified in live testing)
|
|
78
|
+
try {
|
|
79
|
+
await deps.client.session.promptAsync({
|
|
80
|
+
path: { id: childSessionId },
|
|
81
|
+
body: { parts: [{ type: "text", text: contextStr }] },
|
|
82
|
+
})
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Rollback: clean up DB, registry, and abort the orphaned session
|
|
85
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
|
|
86
|
+
deps.registry.unregister(childSessionId)
|
|
87
|
+
try { await deps.client.session.abort({ path: { id: childSessionId } }) } catch { /* best effort */ }
|
|
88
|
+
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
|
|
89
|
+
}
|
|
82
90
|
|
|
83
|
-
return `Teammate "${args.name}" spawned (
|
|
91
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`
|
|
84
92
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { findTeamBySession } from "../types"
|
|
3
3
|
|
|
4
|
+
/** Tracks the last time team_status was called per team ID (epoch ms). */
|
|
5
|
+
export const lastCallTime = new Map<string, number>()
|
|
6
|
+
|
|
7
|
+
const RATE_LIMIT_MS = 30_000
|
|
8
|
+
|
|
4
9
|
/**
|
|
5
10
|
* Execute the team_status tool. Shows team overview with member statuses and task summary.
|
|
6
11
|
*/
|
|
@@ -11,6 +16,13 @@ export async function executeTeamStatus(
|
|
|
11
16
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
12
17
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
18
|
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
const last = lastCallTime.get(teamInfo.teamId)
|
|
21
|
+
if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
|
|
22
|
+
return "Status unchanged. STOP — do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you."
|
|
23
|
+
}
|
|
24
|
+
lastCallTime.set(teamInfo.teamId, now)
|
|
25
|
+
|
|
14
26
|
const members = deps.db.query(
|
|
15
27
|
"SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
16
28
|
).all(teamInfo.teamId) as Array<{
|
|
@@ -68,5 +80,5 @@ export async function executeTeamStatus(
|
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
return lines.join("\n")
|
|
83
|
+
return "[Relay this status to the user in your next message:]\n\n" + lines.join("\n")
|
|
72
84
|
}
|
package/src/tools/team-view.ts
CHANGED
|
@@ -17,7 +17,14 @@ export async function executeTeamView(
|
|
|
17
17
|
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
|
18
18
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// TUI session navigation — try selectSession if available, otherwise guide the user
|
|
21
|
+
try {
|
|
22
|
+
if (typeof deps.client.tui.selectSession === "function") {
|
|
23
|
+
await deps.client.tui.selectSession({ sessionID: member.session_id })
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// selectSession may not be available in all SDK versions — fall through to manual guidance
|
|
27
|
+
}
|
|
21
28
|
|
|
22
|
-
return `
|
|
29
|
+
return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`
|
|
23
30
|
}
|
package/src/watchdog.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { PluginClient } from "./types"
|
|
3
|
+
import type { MemberRegistry } from "./state"
|
|
4
|
+
|
|
5
|
+
interface WatchdogOpts {
|
|
6
|
+
db: Database
|
|
7
|
+
client: PluginClient
|
|
8
|
+
registry: MemberRegistry
|
|
9
|
+
/** Maximum time a member can stay busy before being timed out. 0 disables. */
|
|
10
|
+
ttlMs: number
|
|
11
|
+
/** How often to run the check. Defaults to 60s. */
|
|
12
|
+
checkIntervalMs?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Periodic watchdog that times out teammates stuck in busy state.
|
|
17
|
+
* Transitions them to error/timed_out, aborts their session, and fires a toast.
|
|
18
|
+
*/
|
|
19
|
+
export class Watchdog {
|
|
20
|
+
private readonly db: Database
|
|
21
|
+
private readonly client: PluginClient
|
|
22
|
+
private readonly registry: MemberRegistry
|
|
23
|
+
private readonly ttlMs: number
|
|
24
|
+
private readonly checkIntervalMs: number
|
|
25
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
26
|
+
|
|
27
|
+
constructor(opts: WatchdogOpts) {
|
|
28
|
+
this.db = opts.db
|
|
29
|
+
this.client = opts.client
|
|
30
|
+
this.registry = opts.registry
|
|
31
|
+
this.ttlMs = opts.ttlMs
|
|
32
|
+
this.checkIntervalMs = opts.checkIntervalMs ?? 60_000
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run a single check for stale busy members. */
|
|
36
|
+
async check(): Promise<void> {
|
|
37
|
+
if (this.ttlMs === 0) return
|
|
38
|
+
|
|
39
|
+
const cutoff = Date.now() - this.ttlMs
|
|
40
|
+
const stale = this.db.query(
|
|
41
|
+
`SELECT tm.team_id, tm.name, tm.session_id
|
|
42
|
+
FROM team_member tm
|
|
43
|
+
JOIN team t ON tm.team_id = t.id
|
|
44
|
+
WHERE t.status = 'active'
|
|
45
|
+
AND tm.status = 'busy'
|
|
46
|
+
AND tm.time_updated < ?`
|
|
47
|
+
).all(cutoff) as Array<{ team_id: string; name: string; session_id: string }>
|
|
48
|
+
|
|
49
|
+
for (const member of stale) {
|
|
50
|
+
// Mark as timed out
|
|
51
|
+
this.db.run(
|
|
52
|
+
"UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
53
|
+
[Date.now(), member.team_id, member.name]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Abort session (best effort)
|
|
57
|
+
try {
|
|
58
|
+
await this.client.session.abort({ path: { id: member.session_id } })
|
|
59
|
+
} catch { /* best effort */ }
|
|
60
|
+
|
|
61
|
+
// Notify
|
|
62
|
+
try {
|
|
63
|
+
await this.client.tui.showToast({
|
|
64
|
+
title: "Team",
|
|
65
|
+
message: `${member.name} timed out`,
|
|
66
|
+
variant: "warning",
|
|
67
|
+
duration: 5000,
|
|
68
|
+
})
|
|
69
|
+
} catch { /* TUI may not be available */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Start the periodic check. */
|
|
74
|
+
start(): void {
|
|
75
|
+
if (this.timer) return
|
|
76
|
+
if (this.ttlMs === 0) return
|
|
77
|
+
this.timer = setInterval(() => this.check(), this.checkIntervalMs)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Stop the periodic check. */
|
|
81
|
+
stop(): void {
|
|
82
|
+
if (this.timer) {
|
|
83
|
+
clearInterval(this.timer)
|
|
84
|
+
this.timer = undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether the watchdog is currently running. */
|
|
89
|
+
isRunning(): boolean {
|
|
90
|
+
return this.timer !== undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { ToolDeps } from "../types"
|
|
2
|
-
import { findTeamBySession, resolveRecipientSession } from "../types"
|
|
3
|
-
import { sendMessage, markDelivered } from "../messaging"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Execute the team_approve_plan tool. Approves or rejects a teammate's plan
|
|
7
|
-
* and notifies them via message.
|
|
8
|
-
*/
|
|
9
|
-
export async function executeTeamApprovePlan(
|
|
10
|
-
deps: ToolDeps,
|
|
11
|
-
args: { member: string; approved: boolean; feedback?: string },
|
|
12
|
-
sessionId: string,
|
|
13
|
-
): Promise<string> {
|
|
14
|
-
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
15
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can approve plans.")
|
|
17
|
-
|
|
18
|
-
const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
|
|
19
|
-
.get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
|
|
20
|
-
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
21
|
-
|
|
22
|
-
const text = args.approved
|
|
23
|
-
? `Your plan has been APPROVED.${args.feedback ? ` Feedback: ${args.feedback}` : " Proceed with implementation."}`
|
|
24
|
-
: `Your plan has been REJECTED. Please revise and resubmit.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`
|
|
25
|
-
|
|
26
|
-
// Send approval/rejection message to teammate
|
|
27
|
-
const msgId = sendMessage(deps.db, {
|
|
28
|
-
teamId: teamInfo.teamId,
|
|
29
|
-
from: "lead",
|
|
30
|
-
to: args.member,
|
|
31
|
-
content: text,
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
await deps.client.session.promptAsync({
|
|
35
|
-
path: { id: member.session_id },
|
|
36
|
-
body: { parts: [{ type: "text", text: `[Plan ${args.approved ? "approved" : "rejected"} by lead]: ${text}` }] },
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
markDelivered(deps.db, msgId)
|
|
40
|
-
|
|
41
|
-
return `Plan ${args.approved ? "approved" : "rejected"} for ${args.member}.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`
|
|
42
|
-
}
|