@hueyexe/opencode-ensemble 0.14.1 → 0.15.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.
@@ -1,4 +1,6 @@
1
+ import type { Database } from "../db"
1
2
  import { log } from "../log"
3
+ import { runCommand } from "../process"
2
4
 
3
5
  /** Result of merging a single branch. */
4
6
  export interface MergeResult {
@@ -18,17 +20,58 @@ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd:
18
20
  /** Injectable function for deleting a branch. */
19
21
  export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
20
22
 
23
+ /** Human-readable resource identity for a team. */
24
+ export interface TeamResourceParts {
25
+ projectName: string
26
+ teamName: string
27
+ teamId: string
28
+ }
29
+
30
+ function shortTeamId(teamId: string): string {
31
+ return (teamId.split("_").at(-1) || teamId).slice(0, 6)
32
+ }
33
+
34
+ function resourcePart(value: string): string {
35
+ const part = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")
36
+ return part || "unnamed"
37
+ }
38
+
39
+ /** Load the human-readable pieces used to build team resource names. */
40
+ export function getTeamResourceParts(db: Database, teamId: string): TeamResourceParts {
41
+ const row = db.query(
42
+ `SELECT t.id as team_id, t.name as team_name, p.name as project_name
43
+ FROM team t
44
+ JOIN project p ON t.project_id = p.id
45
+ WHERE t.id = ?`
46
+ ).get(teamId) as { team_id: string; team_name: string; project_name: string } | null
47
+ if (!row) throw new Error(`Team not found: ${teamId}`)
48
+ return { projectName: row.project_name, teamName: row.team_name, teamId: row.team_id }
49
+ }
50
+
51
+ /** Build the readable namespace used for team-owned resources. */
52
+ export function teamResourceSlug(projectName: string, teamName: string, teamId: string): string {
53
+ return `${resourcePart(projectName)}-${resourcePart(teamName)}#${shortTeamId(teamId)}`
54
+ }
55
+
56
+ /** Build the team-only resource segment used under project-scoped namespaces. */
57
+ export function teamResourceSegment(teamName: string, teamId: string): string {
58
+ return `${resourcePart(teamName)}#${shortTeamId(teamId)}`
59
+ }
60
+
61
+ /** Build an OpenCode worktree name for a team member. */
62
+ export function teamWorktreeName(projectName: string, teamName: string, teamId: string, memberName: string): string {
63
+ return `ensemble-${teamResourceSlug(projectName, teamName, teamId)}-${memberName}`
64
+ }
65
+
21
66
  /**
22
67
  * Copy a git branch to a new ref. Used to preserve worktree branches
23
68
  * before session.abort() which may delete the worktree and its branch.
24
69
  * Returns true if the branch was successfully copied.
25
70
  */
26
71
  export async function preserveBranch(sourceBranch: string, targetBranch: string, cwd: string): Promise<boolean> {
27
- const proc = Bun.spawn(["git", "branch", targetBranch, sourceBranch], { cwd, stdout: "pipe", stderr: "pipe" })
28
- const exit = await proc.exited
29
- if (exit !== 0) {
30
- const stderr = await new Response(proc.stderr).text()
31
- log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${stderr.trim()}`)
72
+ const result = await runCommand(["git", "branch", targetBranch, sourceBranch], { cwd })
73
+ if (result.exitCode !== 0) {
74
+ log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${result.stderr.trim()}`)
32
75
  return false
33
76
  }
34
77
  return true
@@ -38,9 +81,8 @@ export async function preserveBranch(sourceBranch: string, targetBranch: string,
38
81
  * Delete a git branch. Returns true if successful.
39
82
  */
40
83
  export async function deleteBranch(branch: string, cwd: string): Promise<boolean> {
41
- const proc = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
42
- const exit = await proc.exited
43
- if (exit !== 0) {
84
+ const result = await runCommand(["git", "branch", "-D", branch], { cwd })
85
+ if (result.exitCode !== 0) {
44
86
  log(`merge-helper:delete:failed branch=${branch}`)
45
87
  return false
46
88
  }
@@ -52,16 +94,13 @@ export async function deleteBranch(branch: string, cwd: string): Promise<boolean
52
94
  * Used by mergeBranch (single) and mergeMultipleBranches (batch).
53
95
  */
54
96
  export async function mergeBranchRaw(branch: string, cwd: string): Promise<MergeResult> {
55
- const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
56
- const stderrPromise = new Response(merge.stderr).text()
57
- const mergeExit = await merge.exited
58
-
59
- if (mergeExit !== 0) {
60
- const stderr = await stderrPromise
61
- log(`merge-helper:merge:conflict branch=${branch} err=${stderr.trim()}`)
62
- const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
63
- await abort.exited
64
- return { ok: false, error: stderr.trim() || `merge exited with code ${mergeExit}` }
97
+ const result = await runCommand(["git", "merge", "--squash", branch], { cwd })
98
+
99
+ if (result.exitCode !== 0) {
100
+ const stderr = result.stderr.trim()
101
+ log(`merge-helper:merge:conflict branch=${branch} err=${stderr}`)
102
+ await runCommand(["git", "merge", "--abort"], { cwd })
103
+ return { ok: false, error: stderr || `merge exited with code ${result.exitCode}` }
65
104
  }
66
105
 
67
106
  return { ok: true }
@@ -69,8 +108,7 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
69
108
 
70
109
  /** Unstage all changes so merge results appear as unstaged. */
71
110
  export async function gitReset(cwd: string): Promise<void> {
72
- const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
73
- await reset.exited
111
+ await runCommand(["git", "reset", "HEAD"], { cwd })
74
112
  }
75
113
 
76
114
  /**
@@ -91,11 +129,9 @@ export async function mergeBranch(branch: string, cwd: string): Promise<MergeRes
91
129
  */
92
130
  export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
93
131
  const run = async (args: string[]) => {
94
- const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
95
- const out = await new Response(proc.stdout).text()
96
- const exit = await proc.exited
97
- if (exit !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`)
98
- return out.split("\n").filter(Boolean)
132
+ const result = await runCommand(["git", ...args], { cwd })
133
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${result.exitCode}`)
134
+ return result.stdout.split("\n").filter(Boolean)
99
135
  }
100
136
  const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
101
137
  const localChanged = await run(["diff", "--name-only", "HEAD"])
@@ -107,6 +143,6 @@ export async function getOverlappingFiles(branch: string, cwd: string): Promise<
107
143
  /**
108
144
  * Build the preserved branch name for a team member.
109
145
  */
110
- export function preservedBranchName(teamName: string, memberName: string): string {
111
- return `ensemble/preserved/${teamName}/${memberName}`
146
+ export function preservedBranchName(projectName: string, teamName: string, teamId: string, memberName: string): string {
147
+ return `ensemble/preserved/${resourcePart(projectName)}/${teamResourceSegment(teamName, teamId)}/${resourcePart(memberName)}`
112
148
  }
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { findTeamBySession } from "../types"
3
+ import { runCommand } from "../process"
3
4
 
4
5
  /** Function type for dirty worktree check — injectable for testing. */
5
6
  export type IsDirtyFn = (dir: string) => Promise<boolean>
@@ -10,11 +11,9 @@ export type CommitCountFn = (branch: string, cwd: string) => Promise<number>
10
11
  /** Count commits a branch has ahead of HEAD. Approximate — may include base divergence. Returns -1 if check fails. */
11
12
  export async function countBranchCommits(branch: string, cwd: string): Promise<number> {
12
13
  try {
13
- const proc = Bun.spawn(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd, stdout: "pipe", stderr: "pipe" })
14
- const out = await new Response(proc.stdout).text()
15
- const exit = await proc.exited
16
- if (exit !== 0) return -1
17
- const n = Number.parseInt(out.trim(), 10)
14
+ const result = await runCommand(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd })
15
+ if (result.exitCode !== 0) return -1
16
+ const n = Number.parseInt(result.stdout.trim(), 10)
18
17
  return Number.isNaN(n) ? -1 : n
19
18
  } catch { return -1 }
20
19
  }
@@ -22,11 +21,9 @@ export async function countBranchCommits(branch: string, cwd: string): Promise<n
22
21
  /** Check if a worktree directory has uncommitted changes via git status. */
23
22
  export async function checkWorktreeDirty(dir: string): Promise<boolean> {
24
23
  try {
25
- const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" })
26
- const output = await new Response(proc.stdout).text()
27
- const exitCode = await proc.exited
28
- if (exitCode !== 0) return false // git failed — assume clean
29
- return output.trim().length > 0
24
+ const result = await runCommand(["git", "-C", dir, "status", "--porcelain"])
25
+ if (result.exitCode !== 0) return false // git failed — assume clean
26
+ return result.stdout.trim().length > 0
30
27
  } catch {
31
28
  return false // can't check — assume clean
32
29
  }
@@ -2,17 +2,20 @@ import type { ToolDeps } from "../types"
2
2
  import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
4
  import { spawnFailures } from "./team-spawn"
5
- import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
5
+ import { getTeamResourceParts, mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles, teamResourceSegment } from "./merge-helper"
6
6
  import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
7
7
  import { log } from "../log"
8
+ import { runCommand } from "../process"
8
9
 
9
10
  type PurgeApprovalFn = (preview: string) => Promise<void>
10
- type ListBranchesFn = (teamName: string, cwd: string) => Promise<string[]>
11
+ type ListBranchesFn = (namespace: string, cwd: string) => Promise<string[]>
11
12
  type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
12
13
 
13
14
  interface PurgeTarget {
14
15
  id: string
15
16
  name: string
17
+ project_id: string
18
+ project_name: string
16
19
  time_updated: number
17
20
  }
18
21
 
@@ -28,6 +31,7 @@ interface PurgeStats extends PurgeTarget {
28
31
  interface PurgeMemberResource {
29
32
  team_id: string
30
33
  team_name: string
34
+ project_name: string
31
35
  member_name: string
32
36
  worktree_dir: string | null
33
37
  workspace_id: string | null
@@ -36,12 +40,9 @@ interface PurgeMemberResource {
36
40
 
37
41
  async function listPreservedBranches(teamName: string, cwd: string): Promise<string[]> {
38
42
  try {
39
- const proc = Bun.spawn(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
40
- const out = await new Response(proc.stdout).text()
41
- const stderr = await new Response(proc.stderr).text()
42
- const exit = await proc.exited
43
- if (exit !== 0) throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
44
- return out.split("\n").map(branch => branch.trim()).filter(Boolean)
43
+ const result = await runCommand(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd })
44
+ if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
45
+ return result.stdout.split("\n").map(branch => branch.trim()).filter(Boolean)
45
46
  } catch (err) {
46
47
  const message = err instanceof Error ? err.message : String(err)
47
48
  if (message.includes("not a git repository")) return []
@@ -51,15 +52,12 @@ async function listPreservedBranches(teamName: string, cwd: string): Promise<str
51
52
 
52
53
  async function branchExists(branch: string, cwd: string): Promise<boolean> {
53
54
  try {
54
- const proc = Bun.spawn(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
55
- const out = await new Response(proc.stdout).text()
56
- const stderr = await new Response(proc.stderr).text()
57
- const exit = await proc.exited
58
- if (exit !== 0) {
59
- if (stderr.includes("not a git repository")) return false
60
- throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
55
+ const result = await runCommand(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd })
56
+ if (result.exitCode !== 0) {
57
+ if (result.stderr.includes("not a git repository")) return false
58
+ throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
61
59
  }
62
- return out.split("\n").map(item => item.trim()).includes(branch)
60
+ return result.stdout.split("\n").map(item => item.trim()).includes(branch)
63
61
  } catch (err) {
64
62
  const message = err instanceof Error ? err.message : String(err)
65
63
  if (message.includes("not a git repository")) return false
@@ -78,24 +76,25 @@ function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
78
76
  }
79
77
 
80
78
  if (purge.includes("*")) {
81
- return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
82
- .all() as PurgeTarget[]
79
+ return deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.status = 'archived' AND t.project_id = ? ORDER BY t.time_updated DESC, t.name ASC")
80
+ .all(deps.directory) as PurgeTarget[]
83
81
  }
84
82
 
85
83
  const uniqueNames = [...new Set(purge)]
86
84
  const rows = uniqueNames.map(name => ({
87
85
  name,
88
- team: deps.db.query("SELECT id, name, status, time_updated FROM team WHERE name = ?").get(name) as { id: string; name: string; status: string; time_updated: number } | null,
86
+ teams: deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.status, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.name = ? AND t.project_id = ? ORDER BY t.time_updated DESC")
87
+ .all(name, deps.directory) as Array<{ id: string; name: string; project_id: string; project_name: string; status: string; time_updated: number }>,
89
88
  }))
90
89
 
91
- const missing = rows.filter(row => row.team === null).map(row => row.name)
90
+ const missing = rows.filter(row => row.teams.length === 0).map(row => row.name)
92
91
  if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
93
92
 
94
- const active = rows.filter(row => row.team?.status === "active").map(row => row.name)
93
+ const active = rows.filter(row => row.teams.some(team => team.status === "active")).map(row => row.name)
95
94
  if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
96
95
 
97
96
  return rows
98
- .map(row => row.team!)
97
+ .map(row => row.teams[0]!)
99
98
  .sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
100
99
  }
101
100
 
@@ -121,6 +120,7 @@ function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
121
120
  function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
122
121
  return targets.flatMap(target => deps.db.query(
123
122
  `SELECT t.name as team_name,
123
+ p.name as project_name,
124
124
  tm.team_id,
125
125
  tm.name as member_name,
126
126
  tm.worktree_dir,
@@ -128,23 +128,32 @@ function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeM
128
128
  tm.worktree_branch
129
129
  FROM team_member tm
130
130
  JOIN team t ON tm.team_id = t.id
131
+ JOIN project p ON t.project_id = p.id
131
132
  WHERE tm.team_id = ?`
132
133
  ).all(target.id) as PurgeMemberResource[])
133
134
  }
134
135
 
135
136
  function preservedBranchPrefix(resource: PurgeMemberResource): string {
137
+ return `ensemble/preserved/${resource.project_name}/${teamResourceSegment(resource.team_name, resource.team_id)}/`
138
+ }
139
+
140
+ function legacyPreservedBranchPrefix(resource: PurgeMemberResource): string {
136
141
  return `ensemble/preserved/${resource.team_name}/`
137
142
  }
138
143
 
139
144
  function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
140
145
  return [
146
+ `ensemble-${resource.team_id}-${resource.member_name}`,
141
147
  `ensemble-${resource.team_name}-${resource.member_name}`,
142
148
  `opencode/ensemble-${resource.team_name}-${resource.member_name}`,
143
149
  ]
144
150
  }
145
151
 
146
152
  function isPreservedBranch(resource: PurgeMemberResource): boolean {
147
- return resource.worktree_branch !== null && resource.worktree_branch.startsWith(preservedBranchPrefix(resource))
153
+ return resource.worktree_branch !== null && (
154
+ resource.worktree_branch.startsWith(preservedBranchPrefix(resource)) ||
155
+ resource.worktree_branch.startsWith(legacyPreservedBranchPrefix(resource))
156
+ )
148
157
  }
149
158
 
150
159
  function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
@@ -305,16 +314,16 @@ async function collectPreservedBranches(
305
314
  listBranches: ListBranchesFn,
306
315
  ): Promise<Map<string, string[]>> {
307
316
  const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
308
- const prefix = `ensemble/preserved/${target.name}/`
317
+ const prefixes = [`ensemble/preserved/${target.project_name}/${teamResourceSegment(target.name, target.id)}/`, `ensemble/preserved/${target.name}/`]
309
318
  let listed: string[]
310
319
  try {
311
- listed = await listBranches(target.name, cwd)
320
+ listed = [...await listBranches(target.project_name, cwd), ...await listBranches(target.name, cwd)]
312
321
  } catch (err) {
313
322
  const message = err instanceof Error ? err.message : String(err)
314
323
  if (message.includes("not a git repository")) listed = []
315
- else throw new Error(`Failed to list preserved branches for ${target.name}: ${message}`)
324
+ else throw new Error(`Failed to list preserved branches for ${target.id}: ${message}`)
316
325
  }
317
- const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
326
+ const branches = listed.map(normalizeBranchName).filter(branch => prefixes.some(prefix => branch.startsWith(prefix)))
318
327
  return [target.id, [...new Set(branches)]]
319
328
  }))
320
329
  return new Map(entries)
@@ -478,7 +487,8 @@ export async function executeTeamCleanup(
478
487
  for (const member of active) {
479
488
  // Preserve branch before abort — session.abort() may destroy the worktree + branch
480
489
  if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
481
- const safeBranch = preservedBranchName(teamInfo.teamName, member.name)
490
+ const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
491
+ const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, member.name)
482
492
  const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory)
483
493
  if (ok) {
484
494
  deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { generateId, validateTeamName } from "../util"
2
+ import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
3
3
  import { findTeamBySession } from "../types"
4
4
 
5
5
  /**
@@ -7,14 +7,19 @@ import { findTeamBySession } from "../types"
7
7
  */
8
8
  export async function executeTeamCreate(
9
9
  deps: ToolDeps,
10
- args: { name: string },
10
+ args: { name: string; project_name?: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
13
  const nameError = validateTeamName(args.name)
14
14
  if (nameError) throw new Error(nameError)
15
+ if (args.project_name) {
16
+ const projectNameError = validateProjectName(args.project_name)
17
+ if (projectNameError) throw new Error(projectNameError)
18
+ }
15
19
 
16
20
  // Check if team name already exists
17
- const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND status = 'active'").get(args.name)
21
+ const projectId = deps.directory
22
+ const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId)
18
23
  if (existing) throw new Error(`Team "${args.name}" already exists`)
19
24
 
20
25
  // Check if session already leads a team
@@ -23,9 +28,16 @@ export async function executeTeamCreate(
23
28
 
24
29
  const id = generateId("team")
25
30
  const now = Date.now()
31
+ const projectName = args.project_name ?? generateProjectName()
26
32
  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]
33
+ `INSERT INTO project (id, name, path, status, time_created, time_updated)
34
+ VALUES (?, ?, ?, 'active', ?, ?)
35
+ ON CONFLICT(id) DO UPDATE SET time_updated = excluded.time_updated`,
36
+ [projectId, projectName, projectId, now, now]
37
+ )
38
+ deps.db.run(
39
+ "INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, ?, 'active', 0, ?, ?)",
40
+ [id, args.name, projectId, sessionId, now, now]
29
41
  )
30
42
 
31
43
  return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
@@ -1,7 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
3
3
  import type { IsDirtyFn, CommitCountFn } from "./shared"
4
- import { preserveBranch, preservedBranchName } from "./merge-helper"
4
+ import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
5
5
  import type { PreserveBranchFn } from "./merge-helper"
6
6
  import { log } from "../log"
7
7
 
@@ -30,7 +30,7 @@ export async function executeTeamShutdown(
30
30
 
31
31
  // Second call on an already-requested member → force abort
32
32
  if (member.status === "shutdown_requested") {
33
- await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
33
+ await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
34
34
  const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
35
35
  return `Force shut down "${args.member}".${status}`
36
36
  }
@@ -46,7 +46,7 @@ export async function executeTeamShutdown(
46
46
  }
47
47
 
48
48
  if (isIdle || force) {
49
- await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
49
+ await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
50
50
  const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
51
51
  return `Teammate "${args.member}" has been shut down.${status}`
52
52
  }
@@ -55,7 +55,8 @@ export async function executeTeamShutdown(
55
55
  // Branch must be preserved NOW — if the session crashes during shutdown_requested,
56
56
  // the worktree and branch could be lost before force-abort ever runs.
57
57
  if (member.worktree_branch) {
58
- const safeBranch = preservedBranchName(teamInfo.teamName, args.member)
58
+ const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
59
+ const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, args.member)
59
60
  const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
60
61
  if (ok) {
61
62
  deps.db.run(
@@ -88,13 +89,12 @@ export async function executeTeamShutdown(
88
89
 
89
90
  /**
90
91
  * Preserve the worktree branch, then abort the session and mark shutdown.
91
- * The branch is copied to ensemble/preserved/{team}/{name} BEFORE abort,
92
+ * The branch is copied to ensemble/preserved/{team_id}/{name} BEFORE abort,
92
93
  * so session.abort() cannot destroy the agent's committed work.
93
94
  */
94
95
  async function preserveAndAbort(
95
96
  deps: ToolDeps,
96
97
  teamId: string,
97
- teamName: string,
98
98
  memberName: string,
99
99
  sessionId: string,
100
100
  worktreeBranch: string | null,
@@ -102,7 +102,8 @@ async function preserveAndAbort(
102
102
  ): Promise<void> {
103
103
  // Preserve the branch BEFORE aborting — session.abort() may delete the worktree + branch
104
104
  if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
105
- const safeBranch = preservedBranchName(teamName, memberName)
105
+ const resource = getTeamResourceParts(deps.db, teamId)
106
+ const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, memberName)
106
107
  const ok = await preserve(worktreeBranch, safeBranch, deps.directory)
107
108
  if (ok) {
108
109
  deps.db.run(
@@ -4,6 +4,7 @@ import { requireLead } from "./shared"
4
4
  import { sendMessage } from "../messaging"
5
5
  import { log } from "../log"
6
6
  import type { EnsembleConfig } from "../config"
7
+ import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
7
8
 
8
9
  /** Tracks consecutive spawn failures per team for circuit breaker. */
9
10
  export const spawnFailures = new Map<string, { count: number; lastError: string }>()
@@ -96,7 +97,8 @@ export async function executeTeamSpawn(
96
97
  let worktreeBranch: string | null = null
97
98
 
98
99
  if (useWorktree) {
99
- const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
100
+ const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
101
+ const worktreeName = teamWorktreeName(resource.projectName, resource.teamName, resource.teamId, args.name)
100
102
  try {
101
103
  log(`spawn:worktree:start name=${args.name}`)
102
104
  const result = await withTimeout(
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
3
3
  import type { EnsembleConfig } from "./config"
4
4
 
@@ -76,20 +76,54 @@ export interface PluginClient {
76
76
  /**
77
77
  * Look up which team a session belongs to (as lead or member).
78
78
  * Returns team info or undefined.
79
+ *
80
+ * Lookup order for member sessions:
81
+ * 1. Registry fast-path (in-memory cache)
82
+ * 2. SQLite fallback (canonical source)
83
+ *
84
+ * The SQLite fallback is critical when multiple Plugin instances run in the
85
+ * same process (e.g. opencode Desktop's local sidecar plus a connected
86
+ * `opencode serve` sharing the same DB). Each instance has its own
87
+ * MemberRegistry; without a SQLite fallback, a tool call dispatched to
88
+ * a Plugin instance whose registry never saw the spawn fails with
89
+ * "This session is not in a team."
90
+ *
91
+ * Only members in active teams with non-terminal status are resolved.
79
92
  */
80
93
  export function findTeamBySession(
81
94
  db: Database,
82
95
  registry: MemberRegistry,
83
96
  sessionId: string,
84
97
  ): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } | undefined {
85
- // Check if session is a team member
98
+ // Fast path: registry cache
86
99
  const entry = registry.getBySession(sessionId)
87
100
  if (entry) {
88
101
  const team = db.query("SELECT name FROM team WHERE id = ? AND status = 'active'").get(entry.teamId) as { name: string } | null
89
102
  if (team) return { teamId: entry.teamId, teamName: team.name, role: "member", memberName: entry.memberName }
90
103
  }
91
104
 
92
- // Check if session is a team lead
105
+ // SQLite fallback: another Plugin instance may have registered this teammate
106
+ if (!entry) {
107
+ const memberRow = db.query(
108
+ `SELECT tm.team_id, tm.name as member_name, t.name as team_name
109
+ FROM team_member tm
110
+ JOIN team t ON tm.team_id = t.id
111
+ WHERE tm.session_id = ?
112
+ AND t.status = 'active'
113
+ AND tm.status NOT IN ('shutdown', 'error')`
114
+ ).get(sessionId) as { team_id: string; member_name: string; team_name: string } | null
115
+ if (memberRow) {
116
+ // Populate the cache so subsequent lookups skip the SQL query.
117
+ // Safe without TTL: session IDs are server-generated UUIDs and are
118
+ // never reused across team_spawn invocations, so a session ID maps
119
+ // to at most one (teamId, memberName) for the life of the process.
120
+ // team_cleanup explicitly invalidates via registry.unregisterTeam.
121
+ registry.register(memberRow.team_id, memberRow.member_name, sessionId)
122
+ return { teamId: memberRow.team_id, teamName: memberRow.team_name, role: "member", memberName: memberRow.member_name }
123
+ }
124
+ }
125
+
126
+ // Check if session is a team lead (already SQLite-backed, naturally cross-instance)
93
127
  const leadTeam = db.query("SELECT id, name FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string; name: string } | null
94
128
  if (leadTeam) return { teamId: leadTeam.id, teamName: leadTeam.name, role: "lead" }
95
129
 
@@ -99,7 +133,8 @@ export function findTeamBySession(
99
133
  /**
100
134
  * Get the session ID for a recipient by name.
101
135
  * "lead" resolves to the team's lead_session_id.
102
- * Otherwise looks up the member registry.
136
+ * Otherwise looks up the member via the registry, then falls back to SQLite
137
+ * (covers the multi-Plugin-instance scenario — see findTeamBySession).
103
138
  */
104
139
  export function resolveRecipientSession(
105
140
  db: Database,
@@ -112,5 +147,16 @@ export function resolveRecipientSession(
112
147
  return team?.lead_session_id
113
148
  }
114
149
  const entry = registry.getByName(teamId, recipientName)
115
- return entry?.sessionId
150
+ if (entry) return entry.sessionId
151
+
152
+ // SQLite fallback: another Plugin instance may have registered this teammate
153
+ const memberRow = db.query(
154
+ `SELECT session_id FROM team_member
155
+ WHERE team_id = ? AND name = ? AND status NOT IN ('shutdown', 'error')`
156
+ ).get(teamId, recipientName) as { session_id: string } | null
157
+ if (!memberRow) return undefined
158
+
159
+ // Populate the cache
160
+ registry.register(teamId, recipientName, memberRow.session_id)
161
+ return memberRow.session_id
116
162
  }
package/src/util.ts CHANGED
@@ -23,6 +23,23 @@ export function validateTeamName(name: string): string | undefined {
23
23
  return undefined
24
24
  }
25
25
 
26
+ const PROJECT_ADJECTIVES = ["amber", "brave", "calm", "copper", "crisp", "dark", "ember", "frost", "quiet", "silver", "steady", "swift"]
27
+ const PROJECT_NOUNS = ["atlas", "beacon", "comet", "harbor", "lab", "orbit", "river", "signal", "sparrow", "summit", "vector", "voyage"]
28
+
29
+ /** Generate a short human-readable project name for implicit project creation. */
30
+ export function generateProjectName(): string {
31
+ const adjective = PROJECT_ADJECTIVES[Math.floor(Math.random() * PROJECT_ADJECTIVES.length)] ?? "quiet"
32
+ const noun = PROJECT_NOUNS[Math.floor(Math.random() * PROJECT_NOUNS.length)] ?? "project"
33
+ return `${adjective}-${noun}`
34
+ }
35
+
36
+ /** Validate a project display name. Returns an error if invalid. */
37
+ export function validateProjectName(name: string): string | undefined {
38
+ if (name.length < 1 || name.length > 64) return "Project name must be 1-64 characters"
39
+ if (!NAME_PATTERN.test(name)) return "Project name must be lowercase alphanumeric with hyphens only"
40
+ return undefined
41
+ }
42
+
26
43
  /**
27
44
  * Detect if the plugin is loading inside a teammate's worktree instance.
28
45
  * Worktree instances should skip recovery to avoid deadlocking the server.
package/src/watchdog.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Database } from "bun:sqlite"
1
+ import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import type { ProgressTracker } from "./progress"
@@ -185,18 +185,19 @@ export class Watchdog {
185
185
 
186
186
  const cutoff = Date.now() - this.ttlMs
187
187
  const stale = this.db.query(
188
- `SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name
188
+ `SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name, p.name as project_name
189
189
  FROM team_member tm
190
190
  JOIN team t ON tm.team_id = t.id
191
+ JOIN project p ON t.project_id = p.id
191
192
  WHERE t.status = 'active'
192
193
  AND tm.status = 'busy'
193
194
  AND tm.time_updated < ?`
194
- ).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string }>
195
+ ).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string; project_name: string }>
195
196
 
196
197
  for (const member of stale) {
197
198
  // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
198
199
  if (this.cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
199
- const safeBranch = preservedBranchName(member.team_name, member.name)
200
+ const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
200
201
  const ok = await preserveBranch(member.worktree_branch, safeBranch, this.cwd)
201
202
  if (ok) {
202
203
  this.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",