@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.
@@ -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
+ }