@hueyexe/opencode-ensemble 0.14.1 → 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.
- package/README.md +13 -6
- package/dist/index.js +2310 -3100
- package/package.json +8 -4
- package/src/dashboard.ts +92 -57
- package/src/db.ts +86 -2
- package/src/hooks.ts +72 -5
- package/src/index.ts +21 -9
- package/src/messaging.ts +1 -1
- package/src/notify.ts +1 -1
- package/src/process.ts +55 -0
- package/src/recovery.ts +30 -9
- package/src/schema.ts +1 -1
- package/src/system-prompt.ts +1 -1
- package/src/tools/merge-helper.ts +16 -24
- package/src/tools/shared.ts +7 -10
- package/src/tools/team-cleanup.ts +9 -14
- package/src/types.ts +51 -5
- package/src/watchdog.ts +1 -1
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
42
|
-
|
|
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
|
|
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 (
|
|
60
|
-
const stderr =
|
|
61
|
-
log(`merge-helper:merge:conflict branch=${branch} err=${stderr
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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"])
|
package/src/tools/shared.ts
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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 "
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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