@hueyexe/opencode-ensemble 0.14.0 → 0.14.2

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,5 @@
1
1
  import { log } from "../log"
2
+ import { runCommand } from "../process"
2
3
 
3
4
  /** Result of merging a single branch. */
4
5
  export interface MergeResult {
@@ -24,11 +25,9 @@ export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
24
25
  * Returns true if the branch was successfully copied.
25
26
  */
26
27
  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()}`)
28
+ const result = await runCommand(["git", "branch", targetBranch, sourceBranch], { cwd })
29
+ if (result.exitCode !== 0) {
30
+ log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${result.stderr.trim()}`)
32
31
  return false
33
32
  }
34
33
  return true
@@ -38,9 +37,8 @@ export async function preserveBranch(sourceBranch: string, targetBranch: string,
38
37
  * Delete a git branch. Returns true if successful.
39
38
  */
40
39
  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) {
40
+ const result = await runCommand(["git", "branch", "-D", branch], { cwd })
41
+ if (result.exitCode !== 0) {
44
42
  log(`merge-helper:delete:failed branch=${branch}`)
45
43
  return false
46
44
  }
@@ -52,16 +50,13 @@ export async function deleteBranch(branch: string, cwd: string): Promise<boolean
52
50
  * Used by mergeBranch (single) and mergeMultipleBranches (batch).
53
51
  */
54
52
  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
53
+ const result = await runCommand(["git", "merge", "--squash", branch], { cwd })
58
54
 
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}` }
55
+ if (result.exitCode !== 0) {
56
+ const stderr = result.stderr.trim()
57
+ log(`merge-helper:merge:conflict branch=${branch} err=${stderr}`)
58
+ await runCommand(["git", "merge", "--abort"], { cwd })
59
+ return { ok: false, error: stderr || `merge exited with code ${result.exitCode}` }
65
60
  }
66
61
 
67
62
  return { ok: true }
@@ -69,8 +64,7 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
69
64
 
70
65
  /** Unstage all changes so merge results appear as unstaged. */
71
66
  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
67
+ await runCommand(["git", "reset", "HEAD"], { cwd })
74
68
  }
75
69
 
76
70
  /**
@@ -91,11 +85,9 @@ export async function mergeBranch(branch: string, cwd: string): Promise<MergeRes
91
85
  */
92
86
  export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
93
87
  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)
88
+ const result = await runCommand(["git", ...args], { cwd })
89
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${result.exitCode}`)
90
+ return result.stdout.split("\n").filter(Boolean)
99
91
  }
100
92
  const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
101
93
  const localChanged = await run(["diff", "--name-only", "HEAD"])
@@ -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
  }
@@ -5,6 +5,7 @@ import { spawnFailures } from "./team-spawn"
5
5
  import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } 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
11
  type ListBranchesFn = (teamName: string, cwd: string) => Promise<string[]>
@@ -36,12 +37,9 @@ interface PurgeMemberResource {
36
37
 
37
38
  async function listPreservedBranches(teamName: string, cwd: string): Promise<string[]> {
38
39
  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)
40
+ const result = await runCommand(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd })
41
+ if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
42
+ return result.stdout.split("\n").map(branch => branch.trim()).filter(Boolean)
45
43
  } catch (err) {
46
44
  const message = err instanceof Error ? err.message : String(err)
47
45
  if (message.includes("not a git repository")) return []
@@ -51,15 +49,12 @@ async function listPreservedBranches(teamName: string, cwd: string): Promise<str
51
49
 
52
50
  async function branchExists(branch: string, cwd: string): Promise<boolean> {
53
51
  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}`)
52
+ const result = await runCommand(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd })
53
+ if (result.exitCode !== 0) {
54
+ if (result.stderr.includes("not a git repository")) return false
55
+ throw new Error(result.stderr.trim() || `git branch exited with code ${result.exitCode}`)
61
56
  }
62
- return out.split("\n").map(item => item.trim()).includes(branch)
57
+ return result.stdout.split("\n").map(item => item.trim()).includes(branch)
63
58
  } catch (err) {
64
59
  const message = err instanceof Error ? err.message : String(err)
65
60
  if (message.includes("not a git repository")) return false
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/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"