@hueyexe/opencode-ensemble 0.14.2 → 0.15.1

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,3 +1,4 @@
1
+ import type { Database } from "../db"
1
2
  import { log } from "../log"
2
3
  import { runCommand } from "../process"
3
4
 
@@ -19,6 +20,49 @@ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd:
19
20
  /** Injectable function for deleting a branch. */
20
21
  export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
21
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
+
22
66
  /**
23
67
  * Copy a git branch to a new ref. Used to preserve worktree branches
24
68
  * before session.abort() which may delete the worktree and its branch.
@@ -99,6 +143,6 @@ export async function getOverlappingFiles(branch: string, cwd: string): Promise<
99
143
  /**
100
144
  * Build the preserved branch name for a team member.
101
145
  */
102
- export function preservedBranchName(teamName: string, memberName: string): string {
103
- 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)}`
104
148
  }
@@ -2,18 +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
8
  import { runCommand } from "../process"
9
9
 
10
10
  type PurgeApprovalFn = (preview: string) => Promise<void>
11
- type ListBranchesFn = (teamName: string, cwd: string) => Promise<string[]>
11
+ type ListBranchesFn = (namespace: string, cwd: string) => Promise<string[]>
12
12
  type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
13
13
 
14
14
  interface PurgeTarget {
15
15
  id: string
16
16
  name: string
17
+ project_id: string
18
+ project_name: string
17
19
  time_updated: number
18
20
  }
19
21
 
@@ -29,6 +31,7 @@ interface PurgeStats extends PurgeTarget {
29
31
  interface PurgeMemberResource {
30
32
  team_id: string
31
33
  team_name: string
34
+ project_name: string
32
35
  member_name: string
33
36
  worktree_dir: string | null
34
37
  workspace_id: string | null
@@ -73,24 +76,25 @@ function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
73
76
  }
74
77
 
75
78
  if (purge.includes("*")) {
76
- return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
77
- .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[]
78
81
  }
79
82
 
80
83
  const uniqueNames = [...new Set(purge)]
81
84
  const rows = uniqueNames.map(name => ({
82
85
  name,
83
- 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 }>,
84
88
  }))
85
89
 
86
- 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)
87
91
  if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
88
92
 
89
- 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)
90
94
  if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
91
95
 
92
96
  return rows
93
- .map(row => row.team!)
97
+ .map(row => row.teams[0]!)
94
98
  .sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
95
99
  }
96
100
 
@@ -116,6 +120,7 @@ function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
116
120
  function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
117
121
  return targets.flatMap(target => deps.db.query(
118
122
  `SELECT t.name as team_name,
123
+ p.name as project_name,
119
124
  tm.team_id,
120
125
  tm.name as member_name,
121
126
  tm.worktree_dir,
@@ -123,23 +128,32 @@ function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeM
123
128
  tm.worktree_branch
124
129
  FROM team_member tm
125
130
  JOIN team t ON tm.team_id = t.id
131
+ JOIN project p ON t.project_id = p.id
126
132
  WHERE tm.team_id = ?`
127
133
  ).all(target.id) as PurgeMemberResource[])
128
134
  }
129
135
 
130
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 {
131
141
  return `ensemble/preserved/${resource.team_name}/`
132
142
  }
133
143
 
134
144
  function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
135
145
  return [
146
+ `ensemble-${resource.team_id}-${resource.member_name}`,
136
147
  `ensemble-${resource.team_name}-${resource.member_name}`,
137
148
  `opencode/ensemble-${resource.team_name}-${resource.member_name}`,
138
149
  ]
139
150
  }
140
151
 
141
152
  function isPreservedBranch(resource: PurgeMemberResource): boolean {
142
- 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
+ )
143
157
  }
144
158
 
145
159
  function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
@@ -300,16 +314,16 @@ async function collectPreservedBranches(
300
314
  listBranches: ListBranchesFn,
301
315
  ): Promise<Map<string, string[]>> {
302
316
  const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
303
- const prefix = `ensemble/preserved/${target.name}/`
317
+ const prefixes = [`ensemble/preserved/${target.project_name}/${teamResourceSegment(target.name, target.id)}/`, `ensemble/preserved/${target.name}/`]
304
318
  let listed: string[]
305
319
  try {
306
- listed = await listBranches(target.name, cwd)
320
+ listed = [...await listBranches(target.project_name, cwd), ...await listBranches(target.name, cwd)]
307
321
  } catch (err) {
308
322
  const message = err instanceof Error ? err.message : String(err)
309
323
  if (message.includes("not a git repository")) listed = []
310
- 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}`)
311
325
  }
312
- const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
326
+ const branches = listed.map(normalizeBranchName).filter(branch => prefixes.some(prefix => branch.startsWith(prefix)))
313
327
  return [target.id, [...new Set(branches)]]
314
328
  }))
315
329
  return new Map(entries)
@@ -473,7 +487,8 @@ export async function executeTeamCleanup(
473
487
  for (const member of active) {
474
488
  // Preserve branch before abort — session.abort() may destroy the worktree + branch
475
489
  if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
476
- 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)
477
492
  const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory)
478
493
  if (ok) {
479
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/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
@@ -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 = ?",