@hueyexe/opencode-ensemble 0.17.0 → 0.20.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.
@@ -0,0 +1,312 @@
1
+ import type { PluginClient } from "./types"
2
+ import { log } from "./log"
3
+ import { runCommand } from "./process"
4
+
5
+ /** V2 plugin context subset Ensemble needs (structural, mock-friendly). */
6
+ export interface V2Context {
7
+ /** Plugin location; `project.id` is required by the 2.0.19+ worktree API. */
8
+ location?: { directory?: string; project?: { id?: string } }
9
+ session: {
10
+ create(input: Record<string, unknown>): Promise<{ id: string }>
11
+ prompt(input: Record<string, unknown>): Promise<unknown>
12
+ switchAgent(input: Record<string, unknown>): Promise<unknown>
13
+ switchModel(input: Record<string, unknown>): Promise<unknown>
14
+ interrupt(input: Record<string, unknown>): Promise<unknown>
15
+ get(input: Record<string, unknown>): Promise<unknown>
16
+ context(input: Record<string, unknown>): Promise<unknown[]>
17
+ active(): Promise<Record<string, { type: string }>>
18
+ synthetic(input: Record<string, unknown>): Promise<unknown>
19
+ }
20
+ worktree: {
21
+ create(input: Record<string, unknown>): Promise<{ directory: string }>
22
+ remove(input: Record<string, unknown>): Promise<unknown>
23
+ list(input?: Record<string, unknown>): Promise<Array<{ directory: string }>>
24
+ refresh(input?: Record<string, unknown>): Promise<unknown>
25
+ }
26
+ permission: {
27
+ /** Present on 2.0.3–2.0.18 only; removed in 2.0.19 (see issue #40). */
28
+ rules?(input: Record<string, unknown>): Promise<unknown>
29
+ }
30
+ /** Agent lookup (2.0.19+): exposes each agent's resolved permission rules. */
31
+ agent?: {
32
+ get(input: Record<string, unknown>): Promise<unknown>
33
+ }
34
+ }
35
+
36
+ type PermissionRuleV2 = { action?: unknown; resource?: unknown; effect?: unknown }
37
+
38
+ /** OpenCode wildcard match: `*` matches any run of characters. */
39
+ function wildcardMatch(value: string, pattern: string): boolean {
40
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
41
+ return new RegExp(`^${escaped}$`).test(value)
42
+ }
43
+
44
+ /**
45
+ * True when an agent's own rules deny editing everywhere. OpenCode evaluates
46
+ * the last matching rule, so this mirrors `evaluate("edit", "*")` over the
47
+ * agent ruleset. Undefined when the rules carry no edit decision.
48
+ */
49
+ export function agentRulesAreReadOnly(rules: unknown): boolean | undefined {
50
+ if (!Array.isArray(rules)) return undefined
51
+ const match = [...(rules as PermissionRuleV2[])].reverse().find((rule) =>
52
+ typeof rule?.action === "string" && typeof rule?.resource === "string"
53
+ && wildcardMatch("edit", rule.action) && (rule.resource === "*" || rule.resource === "**"))
54
+ if (!match) return undefined
55
+ return match.effect === "deny"
56
+ }
57
+
58
+ /** Prefix marking a workspace id that is really a local directory (see below). */
59
+ const DIR_WORKSPACE_PREFIX = "v2dir:"
60
+
61
+ /** Injectable shell lookups (default to git; overridden in tests). */
62
+ export interface V2ClientDeps {
63
+ /** Current branch checked out in a directory, or null when unknown. */
64
+ gitBranch?: (directory: string) => Promise<string | null>
65
+ /** Create and check out a branch in a directory. Returns the effective
66
+ * branch name, or null when the branch could not be created or checked
67
+ * out — never record the requested name without this confirmation. */
68
+ gitCreateBranch?: (directory: string, branch: string) => Promise<string | null>
69
+ /** Worktree directory currently on a branch, or null when none. */
70
+ gitDir?: (branch: string) => Promise<string | null>
71
+ /** RPC emitter for companion events (team_view navigation). */
72
+ rpcEmitter?: { events: { emit(name: string, data: unknown): Promise<unknown> } }
73
+ }
74
+
75
+ /** Current branch in a directory via git. */
76
+ async function gitBranchDefault(directory: string): Promise<string | null> {
77
+ try {
78
+ const result = await runCommand(["git", "branch", "--show-current"], { cwd: directory })
79
+ const branch = result.stdout.trim()
80
+ return result.exitCode === 0 && branch ? branch : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ /** Create and check out a branch in a directory via git. Returns the
87
+ * effective branch name, or null when neither create nor switch worked —
88
+ * callers must record the return value, not the requested name, so a
89
+ * failed create can never be recorded as a live branch. */
90
+ async function gitCreateBranchDefault(directory: string, branch: string): Promise<string | null> {
91
+ try {
92
+ await runCommand(["git", "switch", "-c", branch], { cwd: directory })
93
+ return branch
94
+ } catch (err) {
95
+ // The branch may already exist (re-spawn after the abort-cleanup race
96
+ // documented in AGENTS.md) — switch to it instead of recording a name
97
+ // with no ref behind it. Log both attempts; swallowing failures silently
98
+ // made team_merge squash a stale branch and silently lose new work
99
+ // (PR #46 review).
100
+ log(`v2-client:gitCreateBranch:create-failed directory=${directory} branch=${branch} err=${err instanceof Error ? err.message : String(err)} — trying existing branch`)
101
+ try {
102
+ await runCommand(["git", "switch", branch], { cwd: directory })
103
+ return branch
104
+ } catch (err2) {
105
+ log(`v2-client:gitCreateBranch:switch-failed directory=${directory} branch=${branch} err=${err2 instanceof Error ? err2.message : String(err2)}`)
106
+ return null
107
+ }
108
+ }
109
+ }
110
+
111
+ /** Concatenate the text parts of a promptAsync payload into one string. */
112
+ function joinParts(parts: Array<{ type: "text"; text: string }>): string {
113
+ return parts
114
+ .filter((part) => part.type === "text")
115
+ .map((part) => part.text)
116
+ .join("\n")
117
+ }
118
+
119
+ /**
120
+ * Adapt a V2 plugin context to the shared PluginClient interface so all 14
121
+ * team tools run unchanged on V2 (DRY: one adapter, zero logic forks).
122
+ *
123
+ * Mapping notes (all spike-verified live against v2.0.3 unless marked OQ):
124
+ * - create: V1 `permission` rules become V2 `{action, resource, effect}`;
125
+ * `workspaceID`/`directory` nest under `location`.
126
+ * - promptAsync: queue delivery wakes idle sessions; `agent` is applied via
127
+ * switchAgent first (V2 binds the agent at create/admission, and the shared
128
+ * create signature has no agent slot — only spawn passes one).
129
+ * - abort: interrupt. status: derived from the active set — sessions absent
130
+ * from it read as idle, which matches the shutdown default (team-shutdown
131
+ * treats unknown as idle). OQ-V2-status: if V2 ever reports non-running
132
+ * states here, extend the mapping.
133
+ * - tui.*: no-op. Toasts and session-select move to the CLI (`./tui`) plugin
134
+ * in a later phase — the server plugin must not depend on a TUI.
135
+ * - worktree.create: V2 reports only the directory, so the branch is read
136
+ * back via git (falls back to the requested name). OQ-V2-branch.
137
+ * - worktree.list: V2 entries carry directory only; name is the basename
138
+ * (recovery filters on the `ensemble-` prefix — a non-matching dir is
139
+ * skipped, never wrongly deleted). reset has no production callers and
140
+ * maps to refresh. OQ-V2-wtname.
141
+ * - workspace.*: V2 ctx has no workspace domain (workspaces are
142
+ * provider-based). create bridges a branch to its worktree directory and
143
+ * returns a `v2dir:<dir>` id; session.create routes that id to
144
+ * location.directory, preserving worktree isolation with zero tool
145
+ * changes. list returns [] and remove resolves — the DB workspace_id
146
+ * column stays the source of truth and is still nulled by cleanup.
147
+ */
148
+ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient {
149
+ const gitBranch = deps.gitBranch ?? gitBranchDefault
150
+ const gitCreateBranch = deps.gitCreateBranch ?? gitCreateBranchDefault
151
+ // 2.0.19+ worktree endpoints require the project; older hosts ignore it.
152
+ const project = (): Record<string, unknown> => {
153
+ const projectID = ctx.location?.project?.id
154
+ return projectID ? { projectID } : {}
155
+ }
156
+ return {
157
+ agent: {
158
+ readOnly: async (agent: string) => {
159
+ if (!ctx.agent) return undefined
160
+ try {
161
+ const info = (await ctx.agent.get({ agentID: agent })) as { permissions?: unknown; data?: { permissions?: unknown } } | undefined
162
+ return agentRulesAreReadOnly(info?.permissions ?? info?.data?.permissions)
163
+ } catch {
164
+ return undefined
165
+ }
166
+ },
167
+ },
168
+ session: {
169
+ create: async (options) => {
170
+ const input: Record<string, unknown> = { title: options.title }
171
+ if (options.parentID) input["parentID"] = options.parentID
172
+ // V1 action names predate the V2 vocabulary: bash is now shell.
173
+ const V1_ACTION_MAP: Record<string, string> = { bash: "shell" }
174
+ const permissions = options.permission?.map((rule) => ({
175
+ action: V1_ACTION_MAP[rule.permission] ?? rule.permission,
176
+ resource: rule.pattern,
177
+ effect: rule.action,
178
+ }))
179
+ if (permissions) input["permissions"] = permissions
180
+ if (options.workspaceID ?? options.directory) {
181
+ if (options.workspaceID?.startsWith(DIR_WORKSPACE_PREFIX)) {
182
+ input["location"] = {
183
+ directory: options.workspaceID.slice(DIR_WORKSPACE_PREFIX.length),
184
+ }
185
+ } else {
186
+ input["location"] = {
187
+ ...(options.directory ? { directory: options.directory } : {}),
188
+ ...(options.workspaceID ? { workspaceID: options.workspaceID } : {}),
189
+ }
190
+ }
191
+ }
192
+ const created = await ctx.session.create(input)
193
+ if (permissions && typeof ctx.permission?.rules === "function") {
194
+ // 2.0.3–2.0.18 merged agent rules after session rules, so create()
195
+ // permissions lost to the agent's and had to be re-asserted. From
196
+ // 2.0.19 the session ruleset merges last (core permission.ts
197
+ // `merge(agent.permissions, session.permissions)` with last-match
198
+ // evaluation) and rules() no longer exists, so create() is enough.
199
+ await ctx.permission.rules({ sessionID: created.id, permissions })
200
+ }
201
+ return { data: { id: created.id } }
202
+ },
203
+ promptAsync: async (options) => {
204
+ // Synthetic (system) messages render as persistent transcript entries
205
+ // instead of fake user turns — the native form for agent notifications.
206
+ if (options.synthetic) {
207
+ return ctx.session.synthetic({ sessionID: options.sessionID, text: joinParts(options.parts), delivery: "queue" })
208
+ }
209
+ if (options.agent) {
210
+ await ctx.session.switchAgent({ sessionID: options.sessionID, agent: options.agent })
211
+ }
212
+ // V2 binds model at create/admission; the shared create signature has
213
+ // no model slot, so apply it here (spawn is the only model passer).
214
+ // Caught live: without this, teammates inherit the server default.
215
+ if (options.model) {
216
+ await ctx.session.switchModel({
217
+ sessionID: options.sessionID,
218
+ model: { providerID: options.model.providerID, id: options.model.modelID },
219
+ })
220
+ }
221
+ const text = joinParts(options.parts)
222
+ return ctx.session.prompt({ sessionID: options.sessionID, text, delivery: "queue" })
223
+ },
224
+ abort: async (options) => ctx.session.interrupt({ sessionID: options.sessionID }),
225
+ status: async () => {
226
+ const active = await ctx.session.active()
227
+ const data: Record<string, { type: string }> = {}
228
+ for (const sessionID of Object.keys(active)) data[sessionID] = { type: "busy" }
229
+ return { data }
230
+ },
231
+ messages: async (options) => {
232
+ const messages = await ctx.session.context({ sessionID: options.sessionID })
233
+ return { data: messages as Array<{ info: unknown; parts: unknown[] }> }
234
+ },
235
+ get: async (options) => {
236
+ const data = await ctx.session.get({ sessionID: options.sessionID })
237
+ return { data }
238
+ },
239
+ },
240
+ tui: {
241
+ showToast: async () => undefined,
242
+ selectSession: async (options) => {
243
+ // Native agent switching on V2: shared team_view calls this, the
244
+ // companion receives the event and navigates the TUI to the
245
+ // teammate session. No server-side TUI API exists on V2.
246
+ if (deps.rpcEmitter) {
247
+ await deps.rpcEmitter.events.emit("view", { sessionID: options.sessionID }).catch(() => {
248
+ // Companion optional — never fail team_view for it.
249
+ })
250
+ }
251
+ return undefined
252
+ },
253
+ },
254
+ worktree: {
255
+ create: async (options) => {
256
+ const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree"
257
+ const created = await ctx.worktree.create({ ...project(), name })
258
+ let branch = await gitBranch(created.directory)
259
+ if (!branch) {
260
+ // 2.0.32+ creates worktrees detached; team_merge needs a named
261
+ // branch. Record the effective branch only — never the requested
262
+ // name — so a failed create cannot point team_merge at a stale
263
+ // same-named branch whose commits would silently shadow new work
264
+ // (PR #46 review).
265
+ branch = (await gitCreateBranch(created.directory, name)) ?? ""
266
+ }
267
+ return { data: { name, branch, directory: created.directory } }
268
+ },
269
+ remove: async (options) => {
270
+ const directory = (options.worktreeRemoveInput as { directory: string }).directory
271
+ return ctx.worktree.remove({ ...project(), directory, force: false })
272
+ },
273
+ list: async () => {
274
+ const entries = await ctx.worktree.list(project())
275
+ return {
276
+ data: entries.map((entry) => ({
277
+ name: entry.directory.split("/").pop() ?? entry.directory,
278
+ branch: "",
279
+ directory: entry.directory,
280
+ })),
281
+ }
282
+ },
283
+ reset: async () => ctx.worktree.refresh(project()),
284
+ },
285
+ workspace: {
286
+ create: async (options) => {
287
+ const branch = (options as { branch?: string }).branch ?? ""
288
+ const directory = await deps.gitDir?.(branch)
289
+ if (!directory) throw new Error(`V2 workspace bridge: no worktree found on branch "${branch}"`)
290
+ return {
291
+ data: {
292
+ id: `${DIR_WORKSPACE_PREFIX}${directory}`,
293
+ type: "local",
294
+ branch,
295
+ directory,
296
+ projectID: "",
297
+ },
298
+ }
299
+ },
300
+ remove: async () => undefined,
301
+ list: async () => ({
302
+ data: [] as Array<{
303
+ id: string
304
+ type: string
305
+ branch: string | null
306
+ directory: string | null
307
+ projectID: string
308
+ }>,
309
+ }),
310
+ },
311
+ } as PluginClient
312
+ }
@@ -0,0 +1,73 @@
1
+ import type { Database } from "./db"
2
+ import type { MemberRegistry, DescendantTracker } from "./state"
3
+ import {
4
+ handleSessionCreatedEvent,
5
+ handleSessionStatusEvent,
6
+ type StatusTransition,
7
+ } from "./hooks"
8
+
9
+ /** Minimal structural shape of a V2 server event. */
10
+ export interface V2EventLike {
11
+ type: string
12
+ data?: {
13
+ sessionID?: string
14
+ parentID?: string
15
+ status?: string | { type?: string }
16
+ attempt?: number
17
+ at?: number
18
+ error?: { message?: string }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Dispatch a V2 server event to the shared member state machine in hooks.ts.
24
+ * Returns the status transition when one occurred (for notifications).
25
+ */
26
+ export function dispatchV2Event(
27
+ db: Database,
28
+ registry: MemberRegistry,
29
+ tracker: DescendantTracker,
30
+ event: V2EventLike,
31
+ ): StatusTransition | undefined {
32
+ const data = event.data ?? {}
33
+ switch (event.type) {
34
+ case "session.status": {
35
+ const raw = data.status
36
+ const status = typeof raw === "string" ? raw : raw?.type
37
+ if (status === "idle" || status === "busy") {
38
+ return handleSessionStatusEvent(db, registry, data.sessionID ?? "", status)
39
+ }
40
+ if (status === "retry") {
41
+ return handleSessionStatusEvent(db, registry, data.sessionID ?? "", "retry")
42
+ }
43
+ return undefined
44
+ }
45
+ case "session.idle":
46
+ if (!data.sessionID) return undefined
47
+ return handleSessionStatusEvent(db, registry, data.sessionID, "idle")
48
+ case "session.execution.started":
49
+ if (!data.sessionID) return undefined
50
+ return handleSessionStatusEvent(db, registry, data.sessionID, "busy")
51
+ case "session.execution.succeeded":
52
+ case "session.execution.failed":
53
+ // Ended means idle even on failure — the idle-without-report nudge then
54
+ // prompts the teammate to report, which surfaces the failure to the lead.
55
+ if (!data.sessionID) return undefined
56
+ return handleSessionStatusEvent(db, registry, data.sessionID, "idle")
57
+ case "session.created":
58
+ handleSessionCreatedEvent(tracker, data.sessionID ?? "", data.parentID)
59
+ return undefined
60
+ case "session.retry.scheduled": {
61
+ if (!data.sessionID) return undefined
62
+ const attempt = typeof data.attempt === "number" ? data.attempt : 0
63
+ const next = typeof data.at === "number" ? data.at : Date.now()
64
+ return handleSessionStatusEvent(db, registry, data.sessionID, "retry", {
65
+ attempt,
66
+ message: data.error?.message ?? "rate limited",
67
+ next,
68
+ })
69
+ }
70
+ default:
71
+ return undefined
72
+ }
73
+ }
package/src/v2-rpc.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { EnsembleRpc } from "./rpc"
2
+
3
+ export { EnsembleRpc }
4
+
5
+ /** Minimal structural shape of an RPC registration's event emitter. */
6
+ export interface RpcEmitter {
7
+ events: { emit(name: string, data: unknown): Promise<unknown> }
8
+ }
9
+
10
+ /** Publish a member status transition for companions. */
11
+ export async function emitMemberEvent(
12
+ registration: RpcEmitter,
13
+ data: { memberName: string; teamId: string; from: string; to: string },
14
+ ): Promise<void> {
15
+ await registration.events.emit("member", data)
16
+ }
17
+
18
+ /** Publish a toast payload for companions. */
19
+ export async function emitNoticeEvent(
20
+ registration: RpcEmitter,
21
+ data: { title?: string; message: string; variant?: string },
22
+ ): Promise<void> {
23
+ await registration.events.emit("notice", data)
24
+ }
@@ -0,0 +1,56 @@
1
+ /** V2 session adapter (issue #36). Wraps the `ctx.session` subset Ensemble uses. */
2
+
3
+ /** A V2 permission rule: ordered, last match wins. Child sessions inherit. */
4
+ export interface V2PermissionRule {
5
+ action: string
6
+ resource: string
7
+ effect: "allow" | "deny" | "ask"
8
+ }
9
+
10
+ /** Minimal structural subset of the V2 `ctx.session` domain. */
11
+ export interface V2SessionPort {
12
+ create(input: {
13
+ title?: string
14
+ agent?: string
15
+ parentID?: string
16
+ permissions?: V2PermissionRule[]
17
+ }): Promise<{ id: string }>
18
+ prompt(input: { sessionID: string; text: string; delivery?: "steer" | "queue" }): Promise<unknown>
19
+ interrupt(input: { sessionID: string }): Promise<unknown>
20
+ }
21
+
22
+ /** Input for spawning a teammate session on V2. */
23
+ export interface CreateTeammateInput {
24
+ title: string
25
+ agent: string
26
+ parentID?: string
27
+ permissions?: V2PermissionRule[]
28
+ }
29
+
30
+ /** Create a teammate session. Returns the child session ID. */
31
+ export async function createTeammateSession(
32
+ port: V2SessionPort,
33
+ input: CreateTeammateInput,
34
+ ): Promise<string> {
35
+ const created = await port.create({
36
+ title: input.title,
37
+ agent: input.agent,
38
+ ...(input.parentID ? { parentID: input.parentID } : {}),
39
+ ...(input.permissions ? { permissions: input.permissions } : {}),
40
+ })
41
+ return created.id
42
+ }
43
+
44
+ /** Deliver a system text to a session. Uses queue delivery (spike-verified). */
45
+ export async function deliverPrompt(
46
+ port: V2SessionPort,
47
+ sessionID: string,
48
+ text: string,
49
+ ): Promise<unknown> {
50
+ return port.prompt({ sessionID, text, delivery: "queue" })
51
+ }
52
+
53
+ /** Interrupt a teammate session (V2 replacement for abort). Best-effort. */
54
+ export async function interruptTeammate(port: V2SessionPort, sessionID: string): Promise<unknown> {
55
+ return port.interrupt({ sessionID })
56
+ }