@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.
- package/README.md +42 -28
- package/dist/index.js +42925 -19704
- package/package.json +18 -5
- package/src/index.ts +26 -3
- package/src/log.ts +9 -0
- package/src/notify.ts +3 -1
- package/src/rpc.ts +99 -0
- package/src/tools/team-message.ts +1 -0
- package/src/tools/team-spawn.ts +21 -1
- package/src/tools/team-view.ts +10 -4
- package/src/tui-view.ts +50 -0
- package/src/tui.tsx +101 -0
- package/src/types.ts +2 -0
- package/src/v2-client.ts +227 -0
- package/src/v2-events.ts +73 -0
- package/src/v2-rpc.ts +24 -0
- package/src/v2-session.ts +56 -0
- package/src/v2-setup.ts +349 -0
- package/src/v2-tools.ts +349 -0
package/src/v2-client.ts
ADDED
|
@@ -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
|
+
}
|
package/src/v2-events.ts
ADDED
|
@@ -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
|
+
}
|