@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,349 @@
1
+ import type { ToolDeps } from "./types"
2
+ import { executeTeamCreate } from "./tools/team-create"
3
+ import { executeTeamSpawn } from "./tools/team-spawn"
4
+ import { executeTeamMessage } from "./tools/team-message"
5
+ import { executeTeamBroadcast } from "./tools/team-broadcast"
6
+ import { executeTeamTasksList } from "./tools/team-tasks-list"
7
+ import { executeTeamTasksAdd } from "./tools/team-tasks-add"
8
+ import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
9
+ import { executeTeamClaim } from "./tools/team-claim"
10
+ import { executeTeamResults } from "./tools/team-results"
11
+ import { executeTeamShutdown } from "./tools/team-shutdown"
12
+ import { executeTeamCleanup } from "./tools/team-cleanup"
13
+ import { executeTeamMerge } from "./tools/team-merge"
14
+ import { executeTeamStatus } from "./tools/team-status"
15
+ import { executeTeamView } from "./tools/team-view"
16
+
17
+ /** V2 tool definition (JSON Schema input, structured content output). */
18
+ export interface V2ToolDef {
19
+ name: string
20
+ description: string
21
+ input: Record<string, unknown>
22
+ execute(input: unknown, context: { sessionID: string }): Promise<{ content: string }>
23
+ }
24
+
25
+ /** Minimal structural subset of the V2 tool domain. */
26
+ export interface V2ToolDomain {
27
+ transform(cb: (editor: { add(def: V2ToolDef): void }) => void): Promise<{ dispose(): Promise<void> }>
28
+ }
29
+
30
+ /** JSON Schema string property. */
31
+ function str(description: string): Record<string, unknown> {
32
+ return { type: "string", description }
33
+ }
34
+
35
+ /** JSON Schema boolean property with default. */
36
+ function bool(description: string, fallback: boolean): Record<string, unknown> {
37
+ return { type: "boolean", description, default: fallback }
38
+ }
39
+
40
+ /** Task priority enum shared by the schema and the runtime validator. */
41
+ const TASK_PRIORITIES = ["high", "medium", "low"] as const
42
+ type TaskPriority = (typeof TASK_PRIORITIES)[number]
43
+
44
+ /** JSON Schema enum property for task priority. */
45
+ function priority(): Record<string, unknown> {
46
+ return { type: "string", description: "Task priority", enum: [...TASK_PRIORITIES], default: "medium" }
47
+ }
48
+
49
+ /** Throw on unknown priority — fail fast instead of writing garbage to SQLite. */
50
+ export function normalizePriority(value: string | undefined): TaskPriority {
51
+ if (value === undefined) return "medium"
52
+ if ((TASK_PRIORITIES as readonly string[]).includes(value)) return value as TaskPriority
53
+ throw new Error(`Invalid priority "${value}" — expected one of: high, medium, low.`)
54
+ }
55
+
56
+ /**
57
+ * Register all 14 team tools on a V2 context. Each execute delegates to the
58
+ * same implementation V1 uses (DRY) — only arg transport changes from Zod to
59
+ * JSON Schema. V1 applied schema defaults before execute; V2 may not, so
60
+ * priority is normalized here (team_tasks_add writes it straight to SQLite).
61
+ *
62
+ * Purge approval (team_cleanup) has no approver on V2 yet — the question-tool
63
+ * flow it used is TUI-side. Preview works; confirmed purge needs the forms
64
+ * slice. team_view resolves without navigating (no server-side TUI select).
65
+ */
66
+ export async function registerV2Tools(domain: V2ToolDomain, deps: ToolDeps): Promise<void> {
67
+ const run = <A>(
68
+ fn: (deps: ToolDeps, args: A, sessionID: string) => Promise<string>,
69
+ track?: (sessionID: string, args: A) => void,
70
+ ) => async (input: unknown, context: { sessionID: string }) => {
71
+ const args = input as A
72
+ track?.(context.sessionID, args)
73
+ return { content: await fn(deps, args, context.sessionID) }
74
+ }
75
+ const runSession = (fn: (deps: ToolDeps, sessionID: string) => Promise<string>) => async (
76
+ _input: unknown,
77
+ context: { sessionID: string },
78
+ ) => ({ content: await fn(deps, context.sessionID) })
79
+
80
+ await domain.transform((editor) => {
81
+ editor.add({
82
+ name: "team_create",
83
+ description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
84
+ input: {
85
+ type: "object",
86
+ properties: {
87
+ name: str("Team name (lowercase alphanumeric with hyphens, 1-64 chars)"),
88
+ project_name: str("Project display name for first use of this working directory."),
89
+ },
90
+ required: ["name"],
91
+ additionalProperties: false,
92
+ },
93
+ execute: run(executeTeamCreate),
94
+ })
95
+
96
+ editor.add({
97
+ name: "team_spawn",
98
+ description:
99
+ "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
100
+ "Each teammate gets their own git worktree for file isolation. " +
101
+ "Teammates work asynchronously and will message you when done. Do not poll for their status.",
102
+ input: {
103
+ type: "object",
104
+ properties: {
105
+ name: str("Teammate name (lowercase alphanumeric with hyphens)"),
106
+ agent: str("Agent type (e.g. 'build', 'plan', 'explore')"),
107
+ prompt: str("Task instructions for the teammate"),
108
+ model: str("Model in provider/model format (optional, uses default)"),
109
+ claim_task: str("Task ID to auto-claim for this teammate (optional)"),
110
+ worktree: bool("Create a git worktree for file isolation (default: true, set false for read-only agents)", true),
111
+ plan_approval: bool("Require teammate to send a plan for approval before writing files (default: false)", false),
112
+ },
113
+ required: ["name", "prompt"],
114
+ additionalProperties: false,
115
+ },
116
+ execute: run(executeTeamSpawn),
117
+ })
118
+
119
+ editor.add({
120
+ name: "team_message",
121
+ description:
122
+ "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
123
+ "Lead only: pass 'model' (provider/model) to update a teammate's model in-place.",
124
+ input: {
125
+ type: "object",
126
+ properties: {
127
+ to: str("Recipient name ('lead' or teammate name)"),
128
+ text: str("Message content (max 10KB). Optional only when 'model' is provided."),
129
+ approve: bool("Approve a teammate's plan (only when recipient has plan_approval='pending')", false),
130
+ reject: str("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
131
+ force: bool("Lead only. Re-activate a teammate who already reported task completion.", false),
132
+ model: str("Lead only: update the recipient teammate's model in-place, in 'provider/model' format."),
133
+ },
134
+ required: ["to"],
135
+ additionalProperties: false,
136
+ },
137
+ execute: run(executeTeamMessage, (sessionID, args) => {
138
+ const to = args.to ?? ""
139
+ deps.progressTracker.recordMessage(sessionID)
140
+ if (to !== "lead") deps.progressTracker.recordPeerMessage(sessionID)
141
+ }),
142
+ })
143
+
144
+ editor.add({
145
+ name: "team_broadcast",
146
+ description: "Send a message to all teammates and the lead (excluding yourself).",
147
+ input: {
148
+ type: "object",
149
+ properties: { text: str("Message content (max 10KB)") },
150
+ required: ["text"],
151
+ additionalProperties: false,
152
+ },
153
+ execute: run(executeTeamBroadcast, (sessionID) => {
154
+ deps.progressTracker.recordMessage(sessionID)
155
+ deps.progressTracker.recordPeerMessage(sessionID)
156
+ },
157
+ ),
158
+ })
159
+
160
+ editor.add({
161
+ name: "team_tasks_list",
162
+ description:
163
+ "View the shared team task board. Use this to check task status, not to wait for teammates. Teammates will message you when done.",
164
+ input: { type: "object", properties: {}, additionalProperties: false },
165
+ execute: runSession(executeTeamTasksList),
166
+ })
167
+
168
+ editor.add({
169
+ name: "team_tasks_add",
170
+ description: "Add tasks to the shared team task board so teammates can see what work is available and claim it.",
171
+ input: {
172
+ type: "object",
173
+ properties: {
174
+ tasks: {
175
+ type: "array",
176
+ description: "Tasks to add",
177
+ items: {
178
+ type: "object",
179
+ properties: {
180
+ content: str("Task description"),
181
+ priority: priority(),
182
+ depends_on: {
183
+ type: "array",
184
+ items: { type: "string" },
185
+ description: "Task IDs this depends on",
186
+ },
187
+ },
188
+ required: ["content"],
189
+ additionalProperties: false,
190
+ },
191
+ },
192
+ },
193
+ required: ["tasks"],
194
+ additionalProperties: false,
195
+ },
196
+ execute: async (input: unknown, context: { sessionID: string }) => {
197
+ const args = input as {
198
+ tasks: Array<{ content: string; priority?: string; depends_on?: string[] }>
199
+ }
200
+ const content = await executeTeamTasksAdd(
201
+ deps,
202
+ {
203
+ tasks: args.tasks.map((task) => ({
204
+ content: task.content,
205
+ priority: normalizePriority(task.priority),
206
+ ...(task.depends_on ? { depends_on: task.depends_on } : {}),
207
+ })),
208
+ },
209
+ context.sessionID,
210
+ )
211
+ return { content }
212
+ },
213
+ })
214
+
215
+ editor.add({
216
+ name: "team_tasks_complete",
217
+ description: "Mark a task as completed on the shared board. This unblocks any tasks that depend on it.",
218
+ input: {
219
+ type: "object",
220
+ properties: { task_id: str("ID of the task to mark complete") },
221
+ required: ["task_id"],
222
+ additionalProperties: false,
223
+ },
224
+ execute: run(executeTeamTasksComplete, (sessionID) =>
225
+ deps.progressTracker.recordTaskComplete(sessionID),
226
+ ),
227
+ })
228
+
229
+ editor.add({
230
+ name: "team_claim",
231
+ description: "Claim a pending task from the shared task list. Only unclaimed, unblocked tasks can be claimed.",
232
+ input: {
233
+ type: "object",
234
+ properties: { task_id: str("ID of the task to claim") },
235
+ required: ["task_id"],
236
+ additionalProperties: false,
237
+ },
238
+ execute: run(executeTeamClaim),
239
+ })
240
+
241
+ editor.add({
242
+ name: "team_results",
243
+ description:
244
+ "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
245
+ input: {
246
+ type: "object",
247
+ properties: { from: str("Filter messages by sender name (optional, returns all if omitted)") },
248
+ additionalProperties: false,
249
+ },
250
+ execute: run(executeTeamResults),
251
+ })
252
+
253
+ editor.add({
254
+ name: "team_shutdown",
255
+ description:
256
+ "Request a teammate to shut down. The teammate finishes current work then stops. Pass force: true to abort immediately without waiting.",
257
+ input: {
258
+ type: "object",
259
+ properties: {
260
+ member: str("Teammate name to shut down"),
261
+ force: bool("Force immediate abort without waiting for current work to finish", false),
262
+ },
263
+ required: ["member"],
264
+ additionalProperties: false,
265
+ },
266
+ execute: run(executeTeamShutdown),
267
+ })
268
+
269
+ editor.add({
270
+ name: "team_cleanup",
271
+ description:
272
+ "Clean up the current team, or purge archived teams after human approval. Omit purge for normal cleanup.",
273
+ input: {
274
+ type: "object",
275
+ properties: {
276
+ force: bool("Force cleanup even if members are active (will abort them)", false),
277
+ acknowledge_uncommitted: bool("Acknowledge uncommitted changes", false),
278
+ purge: {
279
+ type: "array",
280
+ items: { type: "string" },
281
+ description: "Archived team names to permanently delete, or ['*'] for all archived teams.",
282
+ },
283
+ confirm_purge: bool("Set true only after the user explicitly approves the purge preview.", false),
284
+ confirm_token: str("Confirmation token from the purge preview."),
285
+ },
286
+ additionalProperties: false,
287
+ },
288
+ execute: async (input: unknown, context: { sessionID: string }) => {
289
+ const args = input as {
290
+ force?: boolean
291
+ acknowledge_uncommitted?: boolean
292
+ purge?: string[]
293
+ confirm_purge?: boolean
294
+ confirm_token?: string
295
+ }
296
+ const content = await executeTeamCleanup(
297
+ deps,
298
+ {
299
+ force: args.force ?? false,
300
+ acknowledge_uncommitted: args.acknowledge_uncommitted,
301
+ purge: args.purge,
302
+ confirm_purge: args.confirm_purge,
303
+ confirm_token: args.confirm_token,
304
+ },
305
+ context.sessionID,
306
+ )
307
+ return { content }
308
+ },
309
+ })
310
+
311
+ editor.add({
312
+ name: "team_merge",
313
+ description:
314
+ "Merge a shutdown teammate's branch into the working directory as unstaged changes. Use this after team_shutdown to review and integrate a teammate's work. The teammate must be shut down first.",
315
+ input: {
316
+ type: "object",
317
+ properties: { member: str("Teammate name whose branch to merge") },
318
+ required: ["member"],
319
+ additionalProperties: false,
320
+ },
321
+ execute: run(executeTeamMerge),
322
+ })
323
+
324
+ editor.add({
325
+ name: "team_status",
326
+ description:
327
+ "View team members with their current status, agent type, and session IDs. Use this to check who is working, idle, or shut down. Includes a task summary.",
328
+ input: { type: "object", properties: {}, additionalProperties: false },
329
+ execute: runSession(executeTeamStatus),
330
+ })
331
+
332
+ editor.add({
333
+ name: "team_view",
334
+ description:
335
+ "Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
336
+ "navigate: true switches the user's client — ONLY when the user explicitly asked to view the teammate's session.",
337
+ input: {
338
+ type: "object",
339
+ properties: {
340
+ member: str("Teammate name to view"),
341
+ navigate: bool("Switch the user's client to this session. Only when the user explicitly asked.", false),
342
+ },
343
+ required: ["member"],
344
+ additionalProperties: false,
345
+ },
346
+ execute: run(executeTeamView),
347
+ })
348
+ })
349
+ }