@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.
- package/README.md +57 -35
- package/dist/index.js +28760 -19583
- 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/tool-arg-hint.ts +71 -0
- package/src/tools/team-message.ts +1 -0
- package/src/tools/team-spawn.ts +48 -4
- package/src/tools/team-tasks-add.ts +17 -0
- 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 +6 -0
- package/src/v2-client.ts +312 -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 +362 -0
- package/src/v2-tools.ts +349 -0
package/src/v2-client.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|