@hueyexe/opencode-ensemble 0.17.0 → 0.18.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,227 @@
1
+ import type { PluginClient } from "./types"
2
+ import { runCommand } from "./process"
3
+
4
+ /** V2 plugin context subset Ensemble needs (structural, mock-friendly). */
5
+ export interface V2Context {
6
+ session: {
7
+ create(input: Record<string, unknown>): Promise<{ id: string }>
8
+ prompt(input: Record<string, unknown>): Promise<unknown>
9
+ switchAgent(input: Record<string, unknown>): Promise<unknown>
10
+ switchModel(input: Record<string, unknown>): Promise<unknown>
11
+ interrupt(input: Record<string, unknown>): Promise<unknown>
12
+ get(input: Record<string, unknown>): Promise<unknown>
13
+ context(input: Record<string, unknown>): Promise<unknown[]>
14
+ active(): Promise<Record<string, { type: string }>>
15
+ synthetic(input: Record<string, unknown>): Promise<unknown>
16
+ }
17
+ worktree: {
18
+ create(input: Record<string, unknown>): Promise<{ directory: string }>
19
+ remove(input: Record<string, unknown>): Promise<unknown>
20
+ list(): Promise<Array<{ directory: string }>>
21
+ refresh(): Promise<unknown>
22
+ }
23
+ permission: {
24
+ rules(input: Record<string, unknown>): Promise<unknown>
25
+ }
26
+ }
27
+
28
+ /** Prefix marking a workspace id that is really a local directory (see below). */
29
+ const DIR_WORKSPACE_PREFIX = "v2dir:"
30
+
31
+ /** Injectable shell lookups (default to git; overridden in tests). */
32
+ export interface V2ClientDeps {
33
+ /** Current branch checked out in a directory, or null when unknown. */
34
+ gitBranch?: (directory: string) => Promise<string | null>
35
+ /** Worktree directory currently on a branch, or null when none. */
36
+ gitDir?: (branch: string) => Promise<string | null>
37
+ /** RPC emitter for companion events (team_view navigation). */
38
+ rpcEmitter?: { events: { emit(name: string, data: unknown): Promise<unknown> } }
39
+ }
40
+
41
+ /** Current branch in a directory via git. */
42
+ async function gitBranchDefault(directory: string): Promise<string | null> {
43
+ try {
44
+ const result = await runCommand(["git", "branch", "--show-current"], { cwd: directory })
45
+ const branch = result.stdout.trim()
46
+ return result.exitCode === 0 && branch ? branch : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ /** Concatenate the text parts of a promptAsync payload into one string. */
53
+ function joinParts(parts: Array<{ type: "text"; text: string }>): string {
54
+ return parts
55
+ .filter((part) => part.type === "text")
56
+ .map((part) => part.text)
57
+ .join("\n")
58
+ }
59
+
60
+ /**
61
+ * Adapt a V2 plugin context to the shared PluginClient interface so all 14
62
+ * team tools run unchanged on V2 (DRY: one adapter, zero logic forks).
63
+ *
64
+ * Mapping notes (all spike-verified live against v2.0.3 unless marked OQ):
65
+ * - create: V1 `permission` rules become V2 `{action, resource, effect}`;
66
+ * `workspaceID`/`directory` nest under `location`.
67
+ * - promptAsync: queue delivery wakes idle sessions; `agent` is applied via
68
+ * switchAgent first (V2 binds the agent at create/admission, and the shared
69
+ * create signature has no agent slot — only spawn passes one).
70
+ * - abort: interrupt. status: derived from the active set — sessions absent
71
+ * from it read as idle, which matches the shutdown default (team-shutdown
72
+ * treats unknown as idle). OQ-V2-status: if V2 ever reports non-running
73
+ * states here, extend the mapping.
74
+ * - tui.*: no-op. Toasts and session-select move to the CLI (`./tui`) plugin
75
+ * in a later phase — the server plugin must not depend on a TUI.
76
+ * - worktree.create: V2 reports only the directory, so the branch is read
77
+ * back via git (falls back to the requested name). OQ-V2-branch.
78
+ * - worktree.list: V2 entries carry directory only; name is the basename
79
+ * (recovery filters on the `ensemble-` prefix — a non-matching dir is
80
+ * skipped, never wrongly deleted). reset has no production callers and
81
+ * maps to refresh. OQ-V2-wtname.
82
+ * - workspace.*: V2 ctx has no workspace domain (workspaces are
83
+ * provider-based). create bridges a branch to its worktree directory and
84
+ * returns a `v2dir:<dir>` id; session.create routes that id to
85
+ * location.directory, preserving worktree isolation with zero tool
86
+ * changes. list returns [] and remove resolves — the DB workspace_id
87
+ * column stays the source of truth and is still nulled by cleanup.
88
+ */
89
+ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient {
90
+ const gitBranch = deps.gitBranch ?? gitBranchDefault
91
+ return {
92
+ session: {
93
+ create: async (options) => {
94
+ const input: Record<string, unknown> = { title: options.title }
95
+ if (options.parentID) input["parentID"] = options.parentID
96
+ // V1 action names predate the V2 vocabulary: bash is now shell.
97
+ const V1_ACTION_MAP: Record<string, string> = { bash: "shell" }
98
+ const permissions = options.permission?.map((rule) => ({
99
+ action: V1_ACTION_MAP[rule.permission] ?? rule.permission,
100
+ resource: rule.pattern,
101
+ effect: rule.action,
102
+ }))
103
+ if (permissions) input["permissions"] = permissions
104
+ if (options.workspaceID ?? options.directory) {
105
+ if (options.workspaceID?.startsWith(DIR_WORKSPACE_PREFIX)) {
106
+ input["location"] = {
107
+ directory: options.workspaceID.slice(DIR_WORKSPACE_PREFIX.length),
108
+ }
109
+ } else {
110
+ input["location"] = {
111
+ ...(options.directory ? { directory: options.directory } : {}),
112
+ ...(options.workspaceID ? { workspaceID: options.workspaceID } : {}),
113
+ }
114
+ }
115
+ }
116
+ const created = await ctx.session.create(input)
117
+ if (permissions) {
118
+ // Session.create permissions lose to agent rules (last match wins
119
+ // and agent rules apply later), which strips team tools from
120
+ // read-only agents. Re-assert here: rules() evaluates after the
121
+ // agent's rules, restoring the tools. OQ-V2-rules.
122
+ await ctx.permission.rules({ sessionID: created.id, permissions })
123
+ }
124
+ return { data: { id: created.id } }
125
+ },
126
+ promptAsync: async (options) => {
127
+ // Synthetic (system) messages render as persistent transcript entries
128
+ // instead of fake user turns — the native form for agent notifications.
129
+ if (options.synthetic) {
130
+ return ctx.session.synthetic({ sessionID: options.sessionID, text: joinParts(options.parts), delivery: "queue" })
131
+ }
132
+ if (options.agent) {
133
+ await ctx.session.switchAgent({ sessionID: options.sessionID, agent: options.agent })
134
+ }
135
+ // V2 binds model at create/admission; the shared create signature has
136
+ // no model slot, so apply it here (spawn is the only model passer).
137
+ // Caught live: without this, teammates inherit the server default.
138
+ if (options.model) {
139
+ await ctx.session.switchModel({
140
+ sessionID: options.sessionID,
141
+ model: { providerID: options.model.providerID, id: options.model.modelID },
142
+ })
143
+ }
144
+ const text = joinParts(options.parts)
145
+ return ctx.session.prompt({ sessionID: options.sessionID, text, delivery: "queue" })
146
+ },
147
+ abort: async (options) => ctx.session.interrupt({ sessionID: options.sessionID }),
148
+ status: async () => {
149
+ const active = await ctx.session.active()
150
+ const data: Record<string, { type: string }> = {}
151
+ for (const sessionID of Object.keys(active)) data[sessionID] = { type: "busy" }
152
+ return { data }
153
+ },
154
+ messages: async (options) => {
155
+ const messages = await ctx.session.context({ sessionID: options.sessionID })
156
+ return { data: messages as Array<{ info: unknown; parts: unknown[] }> }
157
+ },
158
+ get: async (options) => {
159
+ const data = await ctx.session.get({ sessionID: options.sessionID })
160
+ return { data }
161
+ },
162
+ },
163
+ tui: {
164
+ showToast: async () => undefined,
165
+ selectSession: async (options) => {
166
+ // Native agent switching on V2: shared team_view calls this, the
167
+ // companion receives the event and navigates the TUI to the
168
+ // teammate session. No server-side TUI API exists on V2.
169
+ if (deps.rpcEmitter) {
170
+ await deps.rpcEmitter.events.emit("view", { sessionID: options.sessionID }).catch(() => {
171
+ // Companion optional — never fail team_view for it.
172
+ })
173
+ }
174
+ return undefined
175
+ },
176
+ },
177
+ worktree: {
178
+ create: async (options) => {
179
+ const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree"
180
+ const created = await ctx.worktree.create({ name })
181
+ const branch = (await gitBranch(created.directory)) ?? name
182
+ return { data: { name, branch, directory: created.directory } }
183
+ },
184
+ remove: async (options) => {
185
+ const directory = (options.worktreeRemoveInput as { directory: string }).directory
186
+ return ctx.worktree.remove({ directory, force: false })
187
+ },
188
+ list: async () => {
189
+ const entries = await ctx.worktree.list()
190
+ return {
191
+ data: entries.map((entry) => ({
192
+ name: entry.directory.split("/").pop() ?? entry.directory,
193
+ branch: "",
194
+ directory: entry.directory,
195
+ })),
196
+ }
197
+ },
198
+ reset: async () => ctx.worktree.refresh(),
199
+ },
200
+ workspace: {
201
+ create: async (options) => {
202
+ const branch = (options as { branch?: string }).branch ?? ""
203
+ const directory = await deps.gitDir?.(branch)
204
+ if (!directory) throw new Error(`V2 workspace bridge: no worktree found on branch "${branch}"`)
205
+ return {
206
+ data: {
207
+ id: `${DIR_WORKSPACE_PREFIX}${directory}`,
208
+ type: "local",
209
+ branch,
210
+ directory,
211
+ projectID: "",
212
+ },
213
+ }
214
+ },
215
+ remove: async () => undefined,
216
+ list: async () => ({
217
+ data: [] as Array<{
218
+ id: string
219
+ type: string
220
+ branch: string | null
221
+ directory: string | null
222
+ projectID: string
223
+ }>,
224
+ }),
225
+ },
226
+ } as PluginClient
227
+ }
@@ -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
+ }