@hueyexe/opencode-ensemble 0.7.1 → 0.9.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/src/config.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ /** Plugin configuration shape. All fields optional — defaults applied. */
5
+ export interface EnsembleConfig {
6
+ /** Auto-merge worktree branches on cleanup (default: true) */
7
+ mergeOnCleanup?: boolean
8
+ /** Stall detection threshold in ms (default: 180000 = 3 min, 0 to disable) */
9
+ stallThresholdMs?: number
10
+ /** Min steps before token-based stall check (default: 3) */
11
+ stallMinSteps?: number
12
+ /** Output token threshold for stall detection (default: 500) */
13
+ stallTokenThreshold?: number
14
+ /** Hard timeout for busy members in ms (default: 1800000 = 30 min, 0 to disable) */
15
+ timeoutMs?: number
16
+ /** Rate limit capacity (default: 10, 0 to disable) */
17
+ rateLimitCapacity?: number
18
+ }
19
+
20
+ /** Default configuration values. */
21
+ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
22
+ mergeOnCleanup: true,
23
+ stallThresholdMs: 180_000,
24
+ stallMinSteps: 3,
25
+ stallTokenThreshold: 500,
26
+ timeoutMs: 30 * 60 * 1000,
27
+ rateLimitCapacity: 10,
28
+ }
29
+
30
+ /** Read a JSON config file, returning an empty object on missing/invalid. */
31
+ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
32
+ try {
33
+ const text = readFileSync(filePath, "utf-8")
34
+ const raw = JSON.parse(text) as Record<string, unknown>
35
+ // Validate types — only accept numbers for numeric fields, booleans for boolean fields
36
+ const result: Partial<EnsembleConfig> = {}
37
+ if (typeof raw.mergeOnCleanup === "boolean") result.mergeOnCleanup = raw.mergeOnCleanup
38
+ if (typeof raw.stallThresholdMs === "number") result.stallThresholdMs = raw.stallThresholdMs
39
+ if (typeof raw.stallMinSteps === "number") result.stallMinSteps = raw.stallMinSteps
40
+ if (typeof raw.stallTokenThreshold === "number") result.stallTokenThreshold = raw.stallTokenThreshold
41
+ if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
42
+ if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
43
+ return result
44
+ } catch (err) {
45
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
46
+ console.warn(`[ensemble] Invalid config at ${filePath}, using defaults`)
47
+ return {}
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Load plugin configuration. Merges global → project → env vars.
53
+ * Missing files are silently skipped. Invalid JSON logs a warning.
54
+ */
55
+ export function loadConfig(projectDir: string): Required<EnsembleConfig> {
56
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? ""
57
+ const globalPath = path.join(homeDir, ".config", "opencode", "ensemble.json")
58
+ const projectPath = path.join(projectDir, ".opencode", "ensemble.json")
59
+
60
+ const global = readConfigFile(globalPath)
61
+ const project = readConfigFile(projectPath)
62
+ const merged = { ...DEFAULT_CONFIG, ...global, ...project }
63
+
64
+ // Env vars override everything
65
+ const timeout = process.env.OPENCODE_ENSEMBLE_TIMEOUT
66
+ if (timeout !== undefined) merged.timeoutMs = timeout === "0" ? 0 : (parseInt(timeout, 10) || merged.timeoutMs)
67
+
68
+ const rateLimit = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
69
+ if (rateLimit !== undefined) merged.rateLimitCapacity = rateLimit === "0" ? 0 : (parseInt(rateLimit, 10) || merged.rateLimitCapacity)
70
+
71
+ const stall = process.env.STALL_THRESHOLD_MS
72
+ if (stall !== undefined) merged.stallThresholdMs = stall === "0" ? 0 : (parseInt(stall, 10) || merged.stallThresholdMs)
73
+
74
+ return merged
75
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
13
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
14
14
  import { log, initLog } from "./log"
15
15
  import { findTeamBySession } from "./types"
16
+ import { loadConfig } from "./config"
17
+ import { ProgressTracker } from "./progress"
16
18
  import { executeTeamCreate } from "./tools/team-create"
17
19
  import { executeTeamSpawn } from "./tools/team-spawn"
18
20
  import { executeTeamMessage } from "./tools/team-message"
@@ -30,10 +32,8 @@ import type { ToolDeps, } from "./types"
30
32
  import { TokenBucket } from "./rate-limit"
31
33
  import { Watchdog } from "./watchdog"
32
34
 
33
- const DEFAULT_RATE_LIMIT_CAPACITY = 10
34
35
  const DEFAULT_RATE_LIMIT_REFILL = 2
35
36
  const DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000
36
- const DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000 // 30 minutes
37
37
  const DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000 // 60 seconds
38
38
 
39
39
  /**
@@ -47,10 +47,14 @@ const plugin: Plugin = async (input) => {
47
47
  mkdirSync(path.dirname(dbPath), { recursive: true })
48
48
  const db = createDb(dbPath)
49
49
 
50
+ // Load plugin configuration (global → project → env vars)
51
+ const config = loadConfig(input.directory)
52
+
50
53
  // Initialize in-memory state
51
54
  const registry = new MemberRegistry()
52
55
  const tracker = new DescendantTracker()
53
56
  const nudgedMembers = new Set<string>()
57
+ const progressTracker = new ProgressTracker()
54
58
 
55
59
  // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
56
60
  // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
@@ -89,24 +93,22 @@ const plugin: Plugin = async (input) => {
89
93
  log(`init:skip-recovery (worktree instance: ${input.directory})`)
90
94
  }
91
95
 
92
- // Initialize rate limiter
93
- // OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
94
- const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
95
- const rateLimitCapacity = rateLimitEnv === "0" ? 0 : (parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY)
96
+ // Initialize rate limiter — config value already accounts for env var override
96
97
  const rateLimiter = new TokenBucket({
97
- capacity: rateLimitCapacity,
98
+ capacity: config.rateLimitCapacity,
98
99
  refillRate: DEFAULT_RATE_LIMIT_REFILL,
99
100
  refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS,
100
101
  })
101
102
 
102
- // Initialize watchdog for teammate timeouts
103
- // OPENCODE_ENSEMBLE_TIMEOUT=0 disables it entirely
104
- const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT
105
- const watchdogTtl = timeoutEnv === "0" ? 0 : (parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS)
103
+ // Initialize watchdog — config value already accounts for env var override
106
104
  const watchdog = new Watchdog({
107
105
  db, client, registry,
108
- ttlMs: watchdogTtl,
106
+ ttlMs: config.timeoutMs,
109
107
  checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS,
108
+ progressTracker,
109
+ stallThresholdMs: config.stallThresholdMs,
110
+ stallMinSteps: config.stallMinSteps,
111
+ stallTokenThreshold: config.stallTokenThreshold,
110
112
  })
111
113
  watchdog.start()
112
114
 
@@ -185,6 +187,16 @@ const plugin: Plugin = async (input) => {
185
187
  handleSessionCreatedEvent(tracker, info.id, info.parentID)
186
188
  }
187
189
  }
190
+
191
+ // Track per-step output tokens for stall detection
192
+ if (event.type === "message.part.updated") {
193
+ const part = (event.properties as { part?: { type?: string; sessionID?: string; tokens?: { output?: number } } }).part
194
+ if (part?.type === "step-finish" && part.sessionID && part.tokens?.output !== undefined) {
195
+ if (registry.getBySession(part.sessionID)) {
196
+ progressTracker.recordStep(part.sessionID, part.tokens.output)
197
+ }
198
+ }
199
+ }
188
200
  },
189
201
 
190
202
  // Sub-agent isolation + rate limiting hook
@@ -283,6 +295,8 @@ const plugin: Plugin = async (input) => {
283
295
  },
284
296
  async execute(args, ctx) {
285
297
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
298
+ // Track message activity for stall detection
299
+ progressTracker.recordMessage(ctx.sessionID)
286
300
  ctx.metadata({ title: `Message → ${args.to}` })
287
301
  return result
288
302
  },
@@ -295,6 +309,8 @@ const plugin: Plugin = async (input) => {
295
309
  },
296
310
  async execute(args, ctx) {
297
311
  const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
312
+ // Track broadcast activity for stall detection
313
+ progressTracker.recordMessage(ctx.sessionID)
298
314
  ctx.metadata({ title: "Broadcast to team" })
299
315
  return result
300
316
  },
@@ -334,6 +350,8 @@ const plugin: Plugin = async (input) => {
334
350
  },
335
351
  async execute(args, ctx) {
336
352
  const result = await executeTeamTasksComplete(deps, args, ctx.sessionID)
353
+ // Track task completion for stall detection
354
+ progressTracker.recordTaskComplete(ctx.sessionID)
337
355
  ctx.metadata({ title: `Completed task` })
338
356
  return result
339
357
  },
@@ -372,6 +390,9 @@ const plugin: Plugin = async (input) => {
372
390
  },
373
391
  async execute(args, ctx) {
374
392
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
393
+ // Clean up progress tracking for the shut-down member
394
+ const member = deps.db.query("SELECT session_id FROM team_member WHERE name = ? AND status IN ('shutdown', 'shutdown_requested')").get(args.member) as { session_id: string } | null
395
+ if (member) progressTracker.remove(member.session_id)
375
396
  const hasWarning = result.includes("uncommitted")
376
397
  ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
377
398
  return result
@@ -385,7 +406,7 @@ const plugin: Plugin = async (input) => {
385
406
  acknowledge_uncommitted: tool.schema.boolean().default(false),
386
407
  },
387
408
  async execute(args, ctx) {
388
- const result = await executeTeamCleanup(deps, args, ctx.sessionID)
409
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config.mergeOnCleanup)
389
410
  const blocked = result.includes("uncommitted")
390
411
  ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
391
412
  return result
@@ -0,0 +1,85 @@
1
+ /** A single model step record. */
2
+ export interface StepRecord {
3
+ outputTokens: number
4
+ timestamp: number
5
+ }
6
+
7
+ /**
8
+ * In-memory tracker for teammate activity signals.
9
+ * Used by the watchdog for stall detection.
10
+ */
11
+ export class ProgressTracker {
12
+ private steps = new Map<string, StepRecord[]>()
13
+ private lastMessageAt = new Map<string, number>()
14
+ private lastTaskAt = new Map<string, number>()
15
+ private reported = new Set<string>()
16
+ private readonly maxSteps: number
17
+
18
+ constructor(maxSteps = 10) {
19
+ this.maxSteps = maxSteps
20
+ }
21
+
22
+ /** Record a model step completion with output token count. Keeps last `maxSteps` entries. */
23
+ recordStep(sessionId: string, outputTokens: number): void {
24
+ const records = this.steps.get(sessionId) ?? []
25
+ records.push({ outputTokens, timestamp: Date.now() })
26
+ if (records.length > this.maxSteps) records.shift()
27
+ this.steps.set(sessionId, records)
28
+ }
29
+
30
+ /** Record that this member sent a team_message. Clears stall report. */
31
+ recordMessage(sessionId: string): void {
32
+ this.lastMessageAt.set(sessionId, Date.now())
33
+ this.clearReport(sessionId)
34
+ }
35
+
36
+ /** Record that this member completed a task. Clears stall report. */
37
+ recordTaskComplete(sessionId: string): void {
38
+ this.lastTaskAt.set(sessionId, Date.now())
39
+ this.clearReport(sessionId)
40
+ }
41
+
42
+ /** Token-based stall: last `minSteps` steps all produced < `threshold` output tokens. */
43
+ isTokenStalled(sessionId: string, minSteps: number, threshold: number): boolean {
44
+ const records = this.steps.get(sessionId)
45
+ if (!records || records.length < minSteps) return false
46
+ const recent = records.slice(-minSteps)
47
+ return recent.every(r => r.outputTokens < threshold)
48
+ }
49
+
50
+ /** Time-based stall: no message or task completion within `thresholdMs` of now. */
51
+ isTimeStalled(sessionId: string, thresholdMs: number): boolean {
52
+ const records = this.steps.get(sessionId)
53
+ if (!records || records.length === 0) return false
54
+
55
+ const msgAt = this.lastMessageAt.get(sessionId) ?? 0
56
+ const taskAt = this.lastTaskAt.get(sessionId) ?? 0
57
+ const lastStep = records[records.length - 1]?.timestamp ?? 0
58
+ const baseline = Math.max(msgAt, taskAt, lastStep)
59
+
60
+ return Date.now() - baseline >= thresholdMs
61
+ }
62
+
63
+ /** Mark this session as reported-stalled (avoid spam). */
64
+ markReported(sessionId: string): void {
65
+ this.reported.add(sessionId)
66
+ }
67
+
68
+ /** Check if already reported. */
69
+ isReported(sessionId: string): boolean {
70
+ return this.reported.has(sessionId)
71
+ }
72
+
73
+ /** Clear stall report (called on new activity). */
74
+ clearReport(sessionId: string): void {
75
+ this.reported.delete(sessionId)
76
+ }
77
+
78
+ /** Remove all tracking for a session (on shutdown). */
79
+ remove(sessionId: string): void {
80
+ this.steps.delete(sessionId)
81
+ this.lastMessageAt.delete(sessionId)
82
+ this.lastTaskAt.delete(sessionId)
83
+ this.reported.delete(sessionId)
84
+ }
85
+ }
@@ -0,0 +1,40 @@
1
+ /** Structured result parsed from `<task-result>` XML. */
2
+ export interface TaskResult {
3
+ status: string
4
+ summary: string
5
+ details: string
6
+ branch?: string
7
+ }
8
+
9
+ /**
10
+ * Parse `<task-result>` XML from message content.
11
+ * Returns null if not found or missing required fields.
12
+ */
13
+ export function parseTaskResult(content: string): TaskResult | null {
14
+ const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/)
15
+ if (!match) return null
16
+
17
+ const inner = match[1]!
18
+ const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
19
+ const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
20
+ const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim()
21
+ const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim()
22
+
23
+ if (!status || !summary || !details) return null
24
+
25
+ return { status, summary, details, branch: branch || undefined }
26
+ }
27
+
28
+ /**
29
+ * Format a TaskResult as clean readable text.
30
+ */
31
+ export function formatTaskResult(from: string, result: TaskResult): string {
32
+ const lines = [
33
+ `[Result from ${from}]:`,
34
+ ` Status: ${result.status}`,
35
+ ` Summary: ${result.summary}`,
36
+ ` Details: ${result.details}`,
37
+ ]
38
+ if (result.branch) lines.push(` Branch: ${result.branch}`)
39
+ return lines.join("\n")
40
+ }
@@ -1,5 +1,11 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import { markDelivered } from "./messaging"
3
+ import { parseTaskResult, formatTaskResult } from "./result-parser"
4
+
5
+ /** Truncate a string to maxLen chars, appending "..." if truncated. */
6
+ function truncate(s: string, maxLen: number): string {
7
+ return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s
8
+ }
3
9
 
4
10
  /** Status display mapping from DB status to human-readable label. */
5
11
  const STATUS_DISPLAY: Record<string, string> = {
@@ -51,11 +57,38 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
51
57
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
52
58
  ]
53
59
 
60
+ // Inline active and recently completed tasks
61
+ const activeTasks = db.query(
62
+ "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5"
63
+ ).all(teamId) as Array<{ content: string; assignee: string | null }>
64
+
65
+ const recentCompleted = db.query(
66
+ "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3"
67
+ ).all(teamId) as Array<{ content: string; assignee: string | null }>
68
+
69
+ if (activeTasks.length > 0) {
70
+ lines.push("Active tasks:")
71
+ for (const t of activeTasks) {
72
+ lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
73
+ }
74
+ }
75
+ if (recentCompleted.length > 0) {
76
+ lines.push("Recently completed:")
77
+ for (const t of recentCompleted) {
78
+ lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
79
+ }
80
+ }
81
+
54
82
  if (pendingMessages.length > 0) {
55
83
  lines.push("", "--- Team Messages ---")
56
84
  const MAX_MSG = 500
57
85
  for (const msg of pendingMessages) {
58
- if (msg.content.length > MAX_MSG) {
86
+ const parsed = parseTaskResult(msg.content)
87
+ if (parsed) {
88
+ // Truncate details for system prompt — full content available via team_results
89
+ const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
90
+ lines.push(formatTaskResult(msg.from_name, truncatedResult))
91
+ } else if (msg.content.length > MAX_MSG) {
59
92
  lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
60
93
  } else {
61
94
  lines.push(`[From ${msg.from_name}]: ${msg.content}`)
@@ -141,8 +174,41 @@ export function buildTeamCompactionContext(
141
174
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
142
175
  ]
143
176
 
144
- if (role === "member") {
177
+ if (role === "member" && memberName) {
145
178
  lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
179
+
180
+ // Include original task prompt
181
+ const member = db.query("SELECT prompt FROM team_member WHERE team_id = ? AND name = ?")
182
+ .get(teamId, memberName) as { prompt: string | null } | null
183
+ if (member?.prompt) {
184
+ lines.push(`Your original task: ${truncate(member.prompt, 300)}`)
185
+ }
186
+
187
+ // Include recent messages involving this member
188
+ const recentMsgs = db.query(
189
+ "SELECT from_name, content FROM team_message WHERE team_id = ? AND (from_name = ? OR to_name = ?) ORDER BY time_created DESC LIMIT 3"
190
+ ).all(teamId, memberName, memberName) as Array<{ from_name: string; content: string }>
191
+ if (recentMsgs.length > 0) {
192
+ lines.push("Recent context:")
193
+ for (const msg of recentMsgs) {
194
+ lines.push(` [${msg.from_name}]: ${truncate(msg.content, 200)}`)
195
+ }
196
+ }
197
+ } else if (role === "member") {
198
+ lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
199
+ }
200
+
201
+ if (role === "lead") {
202
+ // Include recently completed tasks
203
+ const completedTasks = db.query(
204
+ "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 5"
205
+ ).all(teamId) as Array<{ content: string; assignee: string | null }>
206
+ if (completedTasks.length > 0) {
207
+ lines.push("Recently completed:")
208
+ for (const t of completedTasks) {
209
+ lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` (by ${t.assignee})` : ""}`)
210
+ }
211
+ }
146
212
  }
147
213
 
148
214
  return lines.join("\n")
@@ -1,8 +1,64 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
+ import { spawnFailures } from "./team-spawn"
4
5
  import { log } from "../log"
5
6
 
7
+ /** Result of merging all branches. */
8
+ export interface MergeAllResult {
9
+ merged: string[]
10
+ conflicted: string[]
11
+ }
12
+
13
+ /** Injectable function that merges all branches into the working directory. */
14
+ export type MergeAllFn = (branches: string[], cwd: string) => Promise<MergeAllResult>
15
+
16
+ /**
17
+ * Default merge implementation: stash user work, squash-merge each branch,
18
+ * unstage merged changes, restore user work. One stash/pop cycle for all branches.
19
+ */
20
+ export async function mergeAllBranches(branches: string[], cwd: string): Promise<MergeAllResult> {
21
+ const merged: string[] = []
22
+ const conflicted: string[] = []
23
+
24
+ // Stash existing work to preserve staged/unstaged state
25
+ const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
26
+ const stashOut = await new Response(stash.stdout).text()
27
+ const stashExit = await stash.exited
28
+ const didStash = stashExit === 0 && !stashOut.includes("No local changes")
29
+
30
+ for (const branch of branches) {
31
+ const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
32
+ const stderrPromise = new Response(merge.stderr).text()
33
+ const exit = await merge.exited
34
+ if (exit !== 0) {
35
+ const stderr = await stderrPromise
36
+ log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`)
37
+ const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
38
+ await abort.exited
39
+ conflicted.push(branch)
40
+ } else {
41
+ const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
42
+ await del.exited
43
+ merged.push(branch)
44
+ }
45
+ }
46
+
47
+ // Unstage merged changes so user reviews with `git diff`
48
+ if (merged.length > 0) {
49
+ const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
50
+ await reset.exited
51
+ }
52
+
53
+ // Restore user's original staged/unstaged work
54
+ if (didStash) {
55
+ const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
56
+ await pop.exited
57
+ }
58
+
59
+ return { merged, conflicted }
60
+ }
61
+
6
62
  /**
7
63
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
8
64
  * Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
@@ -14,6 +70,8 @@ export async function executeTeamCleanup(
14
70
  args: { force: boolean; acknowledge_uncommitted?: boolean },
15
71
  sessionId: string,
16
72
  isDirty: IsDirtyFn = checkWorktreeDirty,
73
+ mergeAll: MergeAllFn = mergeAllBranches,
74
+ mergeOnCleanup = true,
17
75
  ): Promise<string> {
18
76
  const teamInfo = requireLead(deps, sessionId)
19
77
 
@@ -57,8 +115,25 @@ export async function executeTeamCleanup(
57
115
  }
58
116
  }
59
117
 
60
- // Remove workspaces, worktrees, and collect branches for merging
61
- const branches: string[] = []
118
+ // Collect branches and merge BEFORE removing worktrees (worktree.remove may delete the branch ref)
119
+ const branches = members
120
+ .map(m => m.worktree_branch)
121
+ .filter((b): b is string => b !== null)
122
+
123
+ let mergeResult = ""
124
+ if (branches.length > 0 && mergeOnCleanup) {
125
+ const { merged, conflicted } = await mergeAll(branches, deps.directory)
126
+ const parts: string[] = []
127
+ if (merged.length > 0) {
128
+ parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`)
129
+ }
130
+ if (conflicted.length > 0) {
131
+ parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`)
132
+ }
133
+ mergeResult = parts.join("\n")
134
+ }
135
+
136
+ // Remove workspaces and worktrees (after merging)
62
137
  for (const member of members) {
63
138
  if (member.workspace_id) {
64
139
  try {
@@ -72,16 +147,18 @@ export async function executeTeamCleanup(
72
147
  deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
73
148
  } catch { /* best effort — worktree may already be gone */ }
74
149
  }
75
- if (member.worktree_branch) {
76
- branches.push(member.worktree_branch)
77
- }
78
150
  }
79
151
 
80
152
  // Archive team
81
153
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId])
82
154
 
83
- // Clean up registry
155
+ // Clean up in-memory state
84
156
  deps.registry.unregisterTeam(teamInfo.teamId)
157
+ spawnFailures.delete(teamInfo.teamId)
158
+
159
+ if (mergeResult) {
160
+ return `Team "${teamInfo.teamName}" cleaned up.\n${mergeResult}`
161
+ }
85
162
 
86
163
  if (branches.length > 0) {
87
164
  const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
+ import { parseTaskResult, formatTaskResult } from "../result-parser"
3
4
 
4
5
  /** Row shape for unread messages query. */
5
6
  interface UnreadMessageRow {
@@ -39,6 +40,10 @@ export async function executeTeamResults(
39
40
  const placeholders = ids.map(() => "?").join(", ")
40
41
  deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
41
42
 
42
- // Format output
43
- return rows.map((r) => `[Message from ${r.from_name}]:\n${r.content}\n`).join("\n")
43
+ // Format output — parse structured task results when present
44
+ return rows.map((r) => {
45
+ const parsed = parseTaskResult(r.content)
46
+ if (parsed) return formatTaskResult(r.from_name, parsed)
47
+ return `[Message from ${r.from_name}]:\n${r.content}`
48
+ }).join("\n\n")
44
49
  }
@@ -4,6 +4,9 @@ import { requireLead } from "./shared"
4
4
  import { sendMessage } from "../messaging"
5
5
  import { log } from "../log"
6
6
 
7
+ /** Tracks consecutive spawn failures per team for circuit breaker. */
8
+ export const spawnFailures = new Map<string, { count: number; lastError: string }>()
9
+
7
10
  /** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
8
11
  function getSpawnTimeout(): number {
9
12
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
@@ -41,6 +44,12 @@ export async function executeTeamSpawn(
41
44
 
42
45
  const teamInfo = requireLead(deps, sessionId)
43
46
 
47
+ // Circuit breaker — stop retrying after 3 consecutive failures
48
+ const failures = spawnFailures.get(teamInfo.teamId)
49
+ if (failures && failures.count >= 3) {
50
+ throw new Error(`Spawn circuit breaker tripped for team "${teamInfo.teamName}": 3 consecutive failures. Last error: ${failures.lastError}. Investigate before retrying — the circuit breaker resets on the next successful spawn.`)
51
+ }
52
+
44
53
  // Check duplicate name
45
54
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
46
55
  .get(teamInfo.teamId, args.name)
@@ -146,6 +155,10 @@ export async function executeTeamSpawn(
146
155
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
147
156
  } catch (err) {
148
157
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
158
+ // Track failure for circuit breaker
159
+ const errMsg = err instanceof Error ? err.message : String(err)
160
+ const prev = spawnFailures.get(teamInfo.teamId)
161
+ spawnFailures.set(teamInfo.teamId, { count: (prev?.count ?? 0) + 1, lastError: errMsg })
149
162
  // Rollback workspace and worktree if session creation failed
150
163
  if (workspaceId) {
151
164
  try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
@@ -184,6 +197,14 @@ export async function executeTeamSpawn(
184
197
  `Your agent type is "${args.agent}".`,
185
198
  ]
186
199
 
200
+ // Show other teammates so this agent knows who to message
201
+ const otherMembers = deps.db.query(
202
+ "SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')"
203
+ ).all(teamInfo.teamId, args.name) as Array<{ name: string }>
204
+ if (otherMembers.length > 0) {
205
+ context.push(`Other teammates: ${otherMembers.map(m => m.name).join(", ")}`)
206
+ }
207
+
187
208
  if (worktreeBranch && worktreeDir && !workspaceId) {
188
209
  // Workspace binding failed — fallback to prompt-based CWD instruction
189
210
  context.push(
@@ -263,7 +284,9 @@ export async function executeTeamSpawn(
263
284
  context.push(
264
285
  `${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
265
286
  "",
266
- "If you are blocked, send ONE message to the lead describing the specific blocker.",
287
+ "If you are blocked:",
288
+ "- Send ONE message to the lead via team_message describing the specific blocker.",
289
+ "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.",
267
290
  "",
268
291
  "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
269
292
  )
@@ -319,6 +342,8 @@ export async function executeTeamSpawn(
319
342
 
320
343
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
321
344
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
345
+ // Reset circuit breaker on success
346
+ spawnFailures.delete(teamInfo.teamId)
322
347
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
323
348
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
324
349
  }