@hueyexe/opencode-ensemble 0.1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.js +13275 -0
- package/package.json +44 -0
- package/src/db.ts +23 -0
- package/src/hooks.ts +96 -0
- package/src/index.ts +282 -0
- package/src/messaging.ts +78 -0
- package/src/notify.ts +54 -0
- package/src/rate-limit.ts +76 -0
- package/src/recovery.ts +71 -0
- package/src/schema.ts +83 -0
- package/src/state.ts +103 -0
- package/src/tools/team-approve-plan.ts +42 -0
- package/src/tools/team-broadcast.ts +59 -0
- package/src/tools/team-claim.ts +36 -0
- package/src/tools/team-cleanup.ts +45 -0
- package/src/tools/team-create.ts +32 -0
- package/src/tools/team-message.ts +37 -0
- package/src/tools/team-shutdown.ts +51 -0
- package/src/tools/team-spawn.ts +77 -0
- package/src/tools/team-status.ts +49 -0
- package/src/tools/team-tasks-add.ts +49 -0
- package/src/tools/team-tasks-complete.ts +48 -0
- package/src/tools/team-tasks-list.ts +23 -0
- package/src/tools/team-view.ts +23 -0
- package/src/types.ts +82 -0
- package/src/util.ts +35 -0
package/src/recovery.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { PluginClient } from "./types"
|
|
3
|
+
import type { MemberRegistry } from "./state"
|
|
4
|
+
import { getUndeliveredMessages, markDelivered } from "./messaging"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scan for team members stuck in 'busy' status (stale from a crash)
|
|
8
|
+
* and mark them as 'error' with execution_status 'idle'.
|
|
9
|
+
* Only processes members in active teams.
|
|
10
|
+
* Returns the count of interrupted members.
|
|
11
|
+
*/
|
|
12
|
+
export function recoverStaleMembers(db: Database): { interrupted: number } {
|
|
13
|
+
const result = db.run(
|
|
14
|
+
`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
15
|
+
WHERE status = 'busy'
|
|
16
|
+
AND team_id IN (SELECT id FROM team WHERE status = 'active')`,
|
|
17
|
+
[Date.now()]
|
|
18
|
+
)
|
|
19
|
+
return { interrupted: result.changes }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Redeliver undelivered messages (delivered=0) via promptAsync.
|
|
24
|
+
* Resolves recipient session IDs from the member registry or team lead.
|
|
25
|
+
* Continues on partial failure — logs but doesn't abort.
|
|
26
|
+
*/
|
|
27
|
+
export async function recoverUndeliveredMessages(
|
|
28
|
+
db: Database,
|
|
29
|
+
client: PluginClient,
|
|
30
|
+
registry: MemberRegistry,
|
|
31
|
+
): Promise<{ redelivered: number }> {
|
|
32
|
+
// Get all active teams
|
|
33
|
+
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'")
|
|
34
|
+
.all() as Array<{ id: string; lead_session_id: string }>
|
|
35
|
+
|
|
36
|
+
let redelivered = 0
|
|
37
|
+
|
|
38
|
+
for (const team of teams) {
|
|
39
|
+
const messages = getUndeliveredMessages(db, team.id)
|
|
40
|
+
|
|
41
|
+
for (const msg of messages) {
|
|
42
|
+
// Resolve recipient session ID
|
|
43
|
+
let recipientSessionId: string | undefined
|
|
44
|
+
|
|
45
|
+
if (msg.to_name === "lead") {
|
|
46
|
+
recipientSessionId = team.lead_session_id
|
|
47
|
+
} else if (msg.to_name) {
|
|
48
|
+
const entry = registry.getByName(team.id, msg.to_name)
|
|
49
|
+
recipientSessionId = entry?.sessionId
|
|
50
|
+
} else {
|
|
51
|
+
// Broadcast — skip for now, broadcasts are best-effort
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!recipientSessionId) continue
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await client.session.promptAsync({
|
|
59
|
+
path: { id: recipientSessionId },
|
|
60
|
+
body: { parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }] },
|
|
61
|
+
})
|
|
62
|
+
markDelivered(db, msg.id)
|
|
63
|
+
redelivered++
|
|
64
|
+
} catch {
|
|
65
|
+
// Continue on failure — message stays undelivered for next recovery
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { redelivered }
|
|
71
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
/** Migration SQL statements, applied in order. Version = index + 1. */
|
|
4
|
+
export const MIGRATIONS: string[] = [
|
|
5
|
+
// Migration 1: Initial schema — 4 tables
|
|
6
|
+
`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS team (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
name TEXT NOT NULL UNIQUE,
|
|
10
|
+
lead_session_id TEXT NOT NULL,
|
|
11
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')),
|
|
12
|
+
delegate INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
time_created INTEGER NOT NULL,
|
|
14
|
+
time_updated INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS team_lead_idx ON team(lead_session_id);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS team_status_idx ON team(status);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS team_member (
|
|
20
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
session_id TEXT NOT NULL,
|
|
23
|
+
agent TEXT NOT NULL,
|
|
24
|
+
status TEXT NOT NULL DEFAULT 'ready'
|
|
25
|
+
CHECK(status IN ('ready', 'busy', 'shutdown_requested', 'shutdown', 'error')),
|
|
26
|
+
execution_status TEXT NOT NULL DEFAULT 'idle'
|
|
27
|
+
CHECK(execution_status IN ('idle', 'starting', 'running',
|
|
28
|
+
'cancel_requested', 'cancelling', 'cancelled',
|
|
29
|
+
'completing', 'completed', 'failed', 'timed_out')),
|
|
30
|
+
model TEXT,
|
|
31
|
+
prompt TEXT,
|
|
32
|
+
time_created INTEGER NOT NULL,
|
|
33
|
+
time_updated INTEGER NOT NULL,
|
|
34
|
+
PRIMARY KEY (team_id, name)
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS team_member_session_idx ON team_member(session_id);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS team_member_status_idx ON team_member(team_id, status);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS team_task (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
42
|
+
content TEXT NOT NULL,
|
|
43
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
44
|
+
CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled', 'blocked')),
|
|
45
|
+
priority TEXT NOT NULL DEFAULT 'medium'
|
|
46
|
+
CHECK(priority IN ('high', 'medium', 'low')),
|
|
47
|
+
assignee TEXT,
|
|
48
|
+
depends_on TEXT,
|
|
49
|
+
time_created INTEGER NOT NULL,
|
|
50
|
+
time_updated INTEGER NOT NULL
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS team_task_team_idx ON team_task(team_id);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS team_task_assignee_idx ON team_task(assignee);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS team_task_status_idx ON team_task(team_id, status);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS team_message (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
team_id TEXT NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
|
59
|
+
from_name TEXT NOT NULL,
|
|
60
|
+
to_name TEXT,
|
|
61
|
+
content TEXT NOT NULL,
|
|
62
|
+
delivered INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
time_created INTEGER NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS team_message_team_idx ON team_message(team_id);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
|
|
68
|
+
WHERE delivered = 0;
|
|
69
|
+
`,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Apply pending migrations to the database.
|
|
74
|
+
* Uses PRAGMA user_version to track which migrations have been applied.
|
|
75
|
+
*/
|
|
76
|
+
export function applyMigrations(db: Database): void {
|
|
77
|
+
const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
|
|
78
|
+
|
|
79
|
+
for (let i = current; i < MIGRATIONS.length; i++) {
|
|
80
|
+
db.exec(MIGRATIONS[i]!)
|
|
81
|
+
db.exec(`PRAGMA user_version = ${i + 1}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Info about a registered team member in the in-memory registry. */
|
|
2
|
+
export interface MemberEntry {
|
|
3
|
+
teamId: string
|
|
4
|
+
memberName: string
|
|
5
|
+
sessionId: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory registry of team members, indexed by sessionID and by (teamId, name).
|
|
10
|
+
* This is the fast-path lookup used by hooks and tools — SQLite is the source of truth.
|
|
11
|
+
*/
|
|
12
|
+
export class MemberRegistry {
|
|
13
|
+
private bySession = new Map<string, MemberEntry>()
|
|
14
|
+
private byTeamName = new Map<string, MemberEntry>()
|
|
15
|
+
|
|
16
|
+
/** Register a member. */
|
|
17
|
+
register(teamId: string, name: string, sessionId: string): void {
|
|
18
|
+
const entry: MemberEntry = { teamId, memberName: name, sessionId }
|
|
19
|
+
this.bySession.set(sessionId, entry)
|
|
20
|
+
this.byTeamName.set(`${teamId}:${name}`, entry)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Look up a member by session ID. */
|
|
24
|
+
getBySession(sessionId: string): MemberEntry | undefined {
|
|
25
|
+
return this.bySession.get(sessionId)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Look up a member by team ID and name. */
|
|
29
|
+
getByName(teamId: string, name: string): MemberEntry | undefined {
|
|
30
|
+
return this.byTeamName.get(`${teamId}:${name}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** List all members for a team. */
|
|
34
|
+
listByTeam(teamId: string): MemberEntry[] {
|
|
35
|
+
const result: MemberEntry[] = []
|
|
36
|
+
for (const entry of this.bySession.values()) {
|
|
37
|
+
if (entry.teamId === teamId) result.push(entry)
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove a member by session ID. */
|
|
43
|
+
unregister(sessionId: string): void {
|
|
44
|
+
const entry = this.bySession.get(sessionId)
|
|
45
|
+
if (!entry) return
|
|
46
|
+
this.bySession.delete(sessionId)
|
|
47
|
+
this.byTeamName.delete(`${entry.teamId}:${entry.memberName}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove all members for a team. */
|
|
51
|
+
unregisterTeam(teamId: string): void {
|
|
52
|
+
for (const entry of this.listByTeam(teamId)) {
|
|
53
|
+
this.bySession.delete(entry.sessionId)
|
|
54
|
+
this.byTeamName.delete(`${teamId}:${entry.memberName}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if a session ID belongs to a registered team member. */
|
|
59
|
+
isTeamSession(sessionId: string): boolean {
|
|
60
|
+
return this.bySession.has(sessionId)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MAX_DEPTH = 10
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tracks parent-child session relationships for sub-agent isolation.
|
|
68
|
+
* Used to walk the parent chain and determine if a session is a descendant
|
|
69
|
+
* of a team member session.
|
|
70
|
+
*/
|
|
71
|
+
export class DescendantTracker {
|
|
72
|
+
private parents = new Map<string, string>()
|
|
73
|
+
|
|
74
|
+
/** Record that childId's parent is parentId. */
|
|
75
|
+
track(childId: string, parentId: string): void {
|
|
76
|
+
this.parents.set(childId, parentId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get the parent of a session. */
|
|
80
|
+
getParent(sessionId: string): string | undefined {
|
|
81
|
+
return this.parents.get(sessionId)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Walk the parent chain from sessionId. Returns true if any ancestor
|
|
86
|
+
* (up to maxDepth) is in the given set of session IDs.
|
|
87
|
+
*/
|
|
88
|
+
isDescendantOf(sessionId: string, ancestors: Set<string>, maxDepth = DEFAULT_MAX_DEPTH): boolean {
|
|
89
|
+
let current = sessionId
|
|
90
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
91
|
+
const parent = this.parents.get(current)
|
|
92
|
+
if (!parent) return false
|
|
93
|
+
if (ancestors.has(parent)) return true
|
|
94
|
+
current = parent
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Remove a session from tracking. */
|
|
100
|
+
remove(sessionId: string): void {
|
|
101
|
+
this.parents.delete(sessionId)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
import { broadcastMessage, markDelivered } from "../messaging"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute the team_broadcast tool. Sends a message to all team members + lead (excluding sender).
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamBroadcast(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { text: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
15
|
+
|
|
16
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName!
|
|
17
|
+
|
|
18
|
+
const msgId = broadcastMessage(deps.db, {
|
|
19
|
+
teamId: teamInfo.teamId,
|
|
20
|
+
from: senderName,
|
|
21
|
+
content: args.text,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Collect all recipient session IDs (excluding sender)
|
|
25
|
+
const recipients: Array<{ name: string; sessionId: string }> = []
|
|
26
|
+
|
|
27
|
+
// Add lead if sender is not lead
|
|
28
|
+
if (teamInfo.role !== "lead") {
|
|
29
|
+
const leadSession = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
30
|
+
.get(teamInfo.teamId) as { lead_session_id: string } | null
|
|
31
|
+
if (leadSession) recipients.push({ name: "lead", sessionId: leadSession.lead_session_id })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Add all members except sender
|
|
35
|
+
const members = deps.registry.listByTeam(teamInfo.teamId)
|
|
36
|
+
for (const member of members) {
|
|
37
|
+
if (member.sessionId !== sessionId) {
|
|
38
|
+
recipients.push({ name: member.memberName, sessionId: member.sessionId })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Deliver to all recipients — partial failures logged but don't fail the broadcast
|
|
43
|
+
let delivered = 0
|
|
44
|
+
for (const recipient of recipients) {
|
|
45
|
+
try {
|
|
46
|
+
await deps.client.session.promptAsync({
|
|
47
|
+
path: { id: recipient.sessionId },
|
|
48
|
+
body: { parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }] },
|
|
49
|
+
})
|
|
50
|
+
delivered++
|
|
51
|
+
} catch {
|
|
52
|
+
// Log but don't fail
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
markDelivered(deps.db, msgId)
|
|
57
|
+
|
|
58
|
+
return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`
|
|
59
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_claim tool. Atomically claims a pending task.
|
|
6
|
+
* Rejects if the task is already claimed, blocked, or not pending.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamClaim(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { task_id: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
|
+
|
|
16
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName!
|
|
17
|
+
|
|
18
|
+
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
19
|
+
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
20
|
+
if (!task) throw new Error(`Task "${args.task_id}" not found`)
|
|
21
|
+
if (task.status === "blocked") throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`)
|
|
22
|
+
if (task.status !== "pending") throw new Error(`Task "${args.task_id}" is not pending (status: ${task.status})`)
|
|
23
|
+
if (task.assignee) throw new Error(`Task "${args.task_id}" is already claimed by ${task.assignee}`)
|
|
24
|
+
|
|
25
|
+
// Atomic claim: UPDATE only if still pending and unassigned
|
|
26
|
+
const result = deps.db.run(
|
|
27
|
+
"UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL",
|
|
28
|
+
[claimerName, Date.now(), args.task_id]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if (result.changes === 0) {
|
|
32
|
+
throw new Error(`Task "${args.task_id}" is already claimed (race condition)`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return `Claimed task: ${task.content}`
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
6
|
+
* If force=true, aborts all active member sessions first.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamCleanup(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { force: boolean },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
|
+
if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
|
|
16
|
+
|
|
17
|
+
const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?")
|
|
18
|
+
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string }>
|
|
19
|
+
|
|
20
|
+
const active = members.filter(m => m.status !== "shutdown")
|
|
21
|
+
|
|
22
|
+
if (active.length > 0 && !args.force) {
|
|
23
|
+
const names = active.map(m => m.name).join(", ")
|
|
24
|
+
throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Force-abort active members
|
|
28
|
+
if (args.force) {
|
|
29
|
+
for (const member of active) {
|
|
30
|
+
try {
|
|
31
|
+
await deps.client.session.abort({ path: { id: member.session_id } })
|
|
32
|
+
} catch {
|
|
33
|
+
// Best effort
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Archive team
|
|
39
|
+
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId])
|
|
40
|
+
|
|
41
|
+
// Clean up registry
|
|
42
|
+
deps.registry.unregisterTeam(teamInfo.teamId)
|
|
43
|
+
|
|
44
|
+
return `Team "${teamInfo.teamName}" cleaned up.`
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { generateId, validateTeamName } from "../util"
|
|
3
|
+
import { findTeamBySession } from "../types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute the team_create tool. Creates a new team with the caller as lead.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamCreate(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { name: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const nameError = validateTeamName(args.name)
|
|
14
|
+
if (nameError) throw new Error(nameError)
|
|
15
|
+
|
|
16
|
+
// Check if team name already exists
|
|
17
|
+
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND status = 'active'").get(args.name)
|
|
18
|
+
if (existing) throw new Error(`Team "${args.name}" already exists`)
|
|
19
|
+
|
|
20
|
+
// Check if session already leads a team
|
|
21
|
+
const lead = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
22
|
+
if (lead) throw new Error(`This session already belongs to team "${lead.teamName}"`)
|
|
23
|
+
|
|
24
|
+
const id = generateId("team")
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
deps.db.run(
|
|
27
|
+
"INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)",
|
|
28
|
+
[id, args.name, sessionId, now, now]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates.`
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession, resolveRecipientSession } from "../types"
|
|
3
|
+
import { sendMessage, markDelivered } from "../messaging"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute the team_message tool. Sends a direct message to a teammate or lead.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamMessage(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { to: string; text: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
|
+
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
15
|
+
|
|
16
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName!
|
|
17
|
+
|
|
18
|
+
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
19
|
+
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
20
|
+
|
|
21
|
+
const msgId = sendMessage(deps.db, {
|
|
22
|
+
teamId: teamInfo.teamId,
|
|
23
|
+
from: senderName,
|
|
24
|
+
to: args.to,
|
|
25
|
+
content: args.text,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Deliver via promptAsync
|
|
29
|
+
await deps.client.session.promptAsync({
|
|
30
|
+
path: { id: recipientSessionId },
|
|
31
|
+
body: { parts: [{ type: "text", text: `[Team message from ${senderName}]: ${args.text}` }] },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
markDelivered(deps.db, msgId)
|
|
35
|
+
|
|
36
|
+
return `Message sent to ${args.to}.`
|
|
37
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute the team_shutdown tool. Requests a teammate to shut down.
|
|
6
|
+
* Sets status to shutdown_requested, calls abort, then polls session status.
|
|
7
|
+
* If the session is already idle after abort, transitions directly to shutdown.
|
|
8
|
+
* Otherwise the event hook handles the transition when the session goes idle.
|
|
9
|
+
*/
|
|
10
|
+
export async function executeTeamShutdown(
|
|
11
|
+
deps: ToolDeps,
|
|
12
|
+
args: { member: string },
|
|
13
|
+
sessionId: string,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
16
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
17
|
+
if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
|
|
18
|
+
|
|
19
|
+
const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
|
|
20
|
+
.get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
|
|
21
|
+
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
22
|
+
if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
|
|
23
|
+
|
|
24
|
+
// Set to shutdown_requested
|
|
25
|
+
deps.db.run(
|
|
26
|
+
"UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
27
|
+
[Date.now(), teamInfo.teamId, args.member]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Call abort on the member's session
|
|
31
|
+
await deps.client.session.abort({ path: { id: member.session_id } })
|
|
32
|
+
|
|
33
|
+
// Fallback: poll session status after abort. If already idle, transition
|
|
34
|
+
// directly to shutdown. This handles the case where abort() on an
|
|
35
|
+
// already-idle session doesn't fire a session.status event.
|
|
36
|
+
try {
|
|
37
|
+
const statuses = await deps.client.session.status()
|
|
38
|
+
const sessionStatus = statuses.data?.[member.session_id]
|
|
39
|
+
if (!sessionStatus || sessionStatus.type === "idle") {
|
|
40
|
+
deps.db.run(
|
|
41
|
+
"UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
42
|
+
[Date.now(), teamInfo.teamId, args.member]
|
|
43
|
+
)
|
|
44
|
+
return `Teammate "${args.member}" has been shut down.`
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Status poll failed — fall through to eventual consistency
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `Shutdown requested for ${args.member}. Will complete when current work finishes.`
|
|
51
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { generateId, validateMemberName } from "../util"
|
|
3
|
+
import { findTeamBySession } from "../types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
7
|
+
*/
|
|
8
|
+
export async function executeTeamSpawn(
|
|
9
|
+
deps: ToolDeps,
|
|
10
|
+
args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string },
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const nameError = validateMemberName(args.name)
|
|
14
|
+
if (nameError) throw new Error(nameError)
|
|
15
|
+
|
|
16
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
17
|
+
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
18
|
+
if (teamInfo.role !== "lead") throw new Error("Only the team lead can spawn teammates.")
|
|
19
|
+
|
|
20
|
+
// Check duplicate name
|
|
21
|
+
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
|
|
22
|
+
.get(teamInfo.teamId, args.name)
|
|
23
|
+
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
24
|
+
|
|
25
|
+
// Create child session via SDK
|
|
26
|
+
const createResult = await deps.client.session.create({
|
|
27
|
+
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` },
|
|
28
|
+
})
|
|
29
|
+
const childSessionId = createResult.data?.id
|
|
30
|
+
if (!childSessionId) throw new Error("Failed to create teammate session")
|
|
31
|
+
|
|
32
|
+
// Register in DB
|
|
33
|
+
const now = Date.now()
|
|
34
|
+
deps.db.run(
|
|
35
|
+
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
|
|
36
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`,
|
|
37
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Register in memory
|
|
41
|
+
deps.registry.register(teamInfo.teamId, args.name, childSessionId)
|
|
42
|
+
|
|
43
|
+
// Build teammate context message per AGENTS.md Teammate Context Message Design
|
|
44
|
+
const context = [
|
|
45
|
+
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
46
|
+
`Your agent type is "${args.agent}".`,
|
|
47
|
+
"",
|
|
48
|
+
"Tools available to you:",
|
|
49
|
+
"- team_message: send a message to the lead or another teammate",
|
|
50
|
+
"- team_broadcast: send a message to all team members",
|
|
51
|
+
"- team_tasks_list: view the shared team task board",
|
|
52
|
+
"- team_tasks_add: add tasks to the shared board",
|
|
53
|
+
"- team_tasks_complete: mark a task complete on the shared board",
|
|
54
|
+
"- team_claim: claim a pending task from the shared board",
|
|
55
|
+
"",
|
|
56
|
+
"When you finish your task:",
|
|
57
|
+
"1. Send ONE message to the lead with your findings using team_message.",
|
|
58
|
+
"2. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
59
|
+
"",
|
|
60
|
+
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
61
|
+
"",
|
|
62
|
+
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
63
|
+
"",
|
|
64
|
+
"Your task:",
|
|
65
|
+
args.prompt,
|
|
66
|
+
].join("\n")
|
|
67
|
+
|
|
68
|
+
// Fire-and-forget: send prompt to teammate session
|
|
69
|
+
// OQ-1: assuming promptAsync queues when session is busy
|
|
70
|
+
// OQ-10: assuming fresh session accepts promptAsync without session.init()
|
|
71
|
+
await deps.client.session.promptAsync({
|
|
72
|
+
path: { id: childSessionId },
|
|
73
|
+
body: { parts: [{ type: "text", text: context }] },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""}`
|
|
77
|
+
}
|