@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,349 @@
1
+ import type { Database } from "./db"
2
+ import { createDb, getDbPath } from "./db"
3
+ import type { MemberRegistry, DescendantTracker } from "./state"
4
+ import { MemberRegistry as MemberRegistryImpl, DescendantTracker as DescendantTrackerImpl, PendingPurgeApprovals } from "./state"
5
+ import { dispatchV2Event, type V2EventLike } from "./v2-events"
6
+ import { deliverPrompt, type V2SessionPort } from "./v2-session"
7
+ import type { V2Context } from "./v2-client"
8
+ import { createV2Client } from "./v2-client"
9
+ import { registerV2Tools } from "./v2-tools"
10
+ import type { ToolDeps } from "./types"
11
+ import { ProgressTracker } from "./progress"
12
+ import { checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
13
+ import { hasReportedCompletion } from "./messaging"
14
+ import { rehydrateRegistry } from "./recovery"
15
+ import { findTeamBySession } from "./types"
16
+ import { loadConfig } from "./config"
17
+ import { TokenBucket } from "./rate-limit"
18
+ import { ActivityBuffer, recordFromToolBefore, recordFromToolAfter } from "./activity"
19
+ import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
20
+ import { startDashboard, type DashboardServer } from "./dashboard"
21
+ import { EnsembleRpc, emitMemberEvent, emitNoticeEvent } from "./v2-rpc"
22
+ import { isWorktreeInstance } from "./util"
23
+ import { vlog } from "./log"
24
+
25
+ /** V2 setup context subset (structural, mock-friendly). */
26
+ export interface V2SetupContext {
27
+ location: { directory: string }
28
+ options: Record<string, unknown>
29
+ session: V2Context["session"] & {
30
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
31
+ }
32
+ worktree: V2Context["worktree"]
33
+ permission: V2Context["permission"]
34
+ event: {
35
+ subscribe(opts?: { signal?: AbortSignal }): AsyncIterable<V2EventLike>
36
+ }
37
+ tool: {
38
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
39
+ transform(cb: (editor: never) => void): Promise<{ dispose(): Promise<void> }>
40
+ }
41
+ rpc: {
42
+ register(
43
+ definition: unknown,
44
+ handlers: Record<string, (input: unknown, context: unknown) => Promise<unknown>>,
45
+ ): Promise<{ events: { emit(name: string, data: unknown): Promise<unknown> } }>
46
+ }
47
+ shell: {
48
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
49
+ }
50
+ }
51
+
52
+ /** Extract answer text from a V2 tool result for approval matching. */
53
+ export function extractQuestionOutput(result?: {
54
+ content?: string | Array<{ type?: string; text?: string }>
55
+ }): string {
56
+ const content = result?.content
57
+ if (typeof content === "string") return content
58
+ if (Array.isArray(content)) {
59
+ return content
60
+ .filter((part) => part.type === "text")
61
+ .map((part) => part.text ?? "")
62
+ .join("\n")
63
+ }
64
+ return ""
65
+ }
66
+
67
+ /** Options for setupEnsemble. */
68
+ export interface SetupOptions {
69
+ /** SQLite path. Defaults to the global ensemble.db. */
70
+ dbPath?: string
71
+ /** Dashboard port. Defaults to config; 0 disables. */
72
+ dashboardPort?: number
73
+ }
74
+
75
+ /** Live handle for a V2 ensemble instance. */
76
+ export interface EnsembleHandle {
77
+ db: Database
78
+ registry: MemberRegistry
79
+ tracker: DescendantTracker
80
+ /** Dispatch one event (used by the live subscription loop; tests call directly). */
81
+ dispatch(event: V2EventLike): Promise<void>
82
+ /** Stop the event loop and release resources. */
83
+ dispose(): Promise<void>
84
+ }
85
+
86
+ /**
87
+ * Initialize Ensemble on a V2 plugin context: database, registries, event
88
+ * subscription, tool hooks, session hooks, and shell env. Tool registration
89
+ * lands in the next slice.
90
+ */
91
+ export async function setupEnsemble(
92
+ ctx: V2SetupContext,
93
+ options: SetupOptions = {},
94
+ ): Promise<EnsembleHandle> {
95
+ const db = createDb(options.dbPath ?? getDbPath())
96
+ const config = loadConfig(ctx.location.directory)
97
+ const registry: MemberRegistry = new MemberRegistryImpl()
98
+ const tracker: DescendantTracker = new DescendantTrackerImpl()
99
+ const purgeApprovals = new PendingPurgeApprovals()
100
+ const activityBuffer = new ActivityBuffer()
101
+ const progressTracker = new ProgressTracker()
102
+ const nudgedMembers = new Set<string>()
103
+
104
+ // Rehydrate the in-memory registry from SQLite so members from before a
105
+ // restart are visible again. Without this, their events are ignored and
106
+ // teams freeze silently after every server restart (seen live 2026-09-14).
107
+ const rehydrated = rehydrateRegistry(db, registry)
108
+ if (rehydrated > 0) vlog(`init:registry:rehydrated members=${rehydrated}`)
109
+
110
+ /** Send the idle-without-report nudge once per member. */
111
+ const nudgeMember = (teamId: string, memberName: string, sessionId: string): void => {
112
+ const nudgeKey = `${teamId}:${memberName}`
113
+ if (nudgedMembers.has(nudgeKey)) return
114
+ if (!shouldNudgeIdleMember(db, teamId, memberName)) return
115
+ if (hasReportedCompletion(db, teamId, memberName)) return
116
+ nudgedMembers.add(nudgeKey)
117
+ // Persist so hot-reloads don't re-nudge: every setup re-runs the sweep,
118
+ // and without this each file save re-nudged every silent member (seen
119
+ // live 2026-09-14 — 40+ nudges on one scout).
120
+ db.run("UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?", [
121
+ Date.now(),
122
+ teamId,
123
+ memberName,
124
+ ])
125
+ vlog(`nudge:idle-without-report name=${memberName}`)
126
+ void deliverPrompt(
127
+ ctx.session as unknown as V2SessionPort,
128
+ sessionId,
129
+ "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now.",
130
+ ).catch((err) => {
131
+ vlog(`nudge:idle-without-report:failed name=${memberName} err=${err instanceof Error ? err.message : String(err)}`)
132
+ })
133
+ }
134
+
135
+ // Startup sweep: members already idle (e.g. finished while the plugin was
136
+ // down) never emit a fresh transition, so nudge them now. Skip members
137
+ // nudged within the hour — the guard survives reloads via the DB column.
138
+ const nudgeCutoff = Date.now() - 60 * 60 * 1000
139
+ const silent = db.query(
140
+ `SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm
141
+ JOIN team t ON tm.team_id = t.id
142
+ WHERE t.status = 'active' AND tm.status = 'ready'
143
+ AND (tm.last_nudged_at IS NULL OR tm.last_nudged_at < ?)`,
144
+ ).all(nudgeCutoff) as Array<{ team_id: string; name: string; session_id: string }>
145
+ for (const member of silent) nudgeMember(member.team_id, member.name, member.session_id)
146
+ const rateLimiter = new TokenBucket({
147
+ capacity: config.rateLimitCapacity,
148
+ refillRate: 2,
149
+ refillIntervalMs: 1000,
150
+ })
151
+ const controller = new AbortController()
152
+
153
+ const dispatch = async (event: V2EventLike): Promise<void> => {
154
+ const transition = dispatchV2Event(db, registry, tracker, event)
155
+ // Idle-without-report nudge (parity with V1): a member that went idle
156
+ // without ever messaging the lead gets ONE reminder to report via
157
+ // team_message. Without this, weak models finish silently and the lead
158
+ // waits forever — the exact failure seen live on 2026-09-14.
159
+ if (transition && transition.to === "ready" && transition.from === "busy") {
160
+ const entry = registry.getByName(transition.teamId, transition.memberName)
161
+ if (entry) nudgeMember(transition.teamId, transition.memberName, entry.sessionId)
162
+ }
163
+ if (transition && rpc) {
164
+ await emitMemberEvent(rpc, transition).catch(() => {
165
+ // Companions are optional — never break the state machine for them.
166
+ })
167
+ if (transition.to === "ready" && transition.from === "busy") {
168
+ await emitNoticeEvent(rpc, {
169
+ title: "Team",
170
+ message: `${transition.memberName} finished`,
171
+ variant: "success",
172
+ }).catch(() => undefined)
173
+ } else if (transition.to === "error") {
174
+ await emitNoticeEvent(rpc, {
175
+ title: "Team",
176
+ message: `${transition.memberName} errored`,
177
+ variant: "error",
178
+ }).catch(() => undefined)
179
+ }
180
+ }
181
+ }
182
+
183
+ // RPC bridge for terminal companions (toasts, navigation). Best-effort:
184
+ // a failed registration must not break tools, hooks, or events.
185
+ let rpc: { events: { emit(name: string, data: unknown): Promise<unknown> } } | null = null
186
+ try {
187
+ rpc = await ctx.rpc.register(EnsembleRpc, {
188
+ summary: async (input) => {
189
+ const team = (input as { team?: string }).team ?? ""
190
+ const row = db.query("SELECT status FROM team WHERE name = ?").get(team) as {
191
+ status: string
192
+ } | null
193
+ if (!row) return { text: `No team "${team}".` }
194
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE name = ?)").all(team) as Array<{
195
+ name: string
196
+ status: string
197
+ }>
198
+ const summary = members.map((m) => `${m.name}: ${m.status}`).join(", ")
199
+ return { text: `Team "${team}" (${row.status}): ${summary || "no members"}` }
200
+ },
201
+ teamContext: async (input) => {
202
+ const sessionID = (input as { sessionID?: string }).sessionID ?? ""
203
+ const teamInfo = findTeamBySession(db, registry, sessionID)
204
+ if (!teamInfo) return { team: null, members: [], tasks: { pending: 0, done: 0 } }
205
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(
206
+ teamInfo.teamId,
207
+ ) as Array<{ name: string; status: string }>
208
+ const pending = (
209
+ db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ? AND status != 'completed'").get(
210
+ teamInfo.teamId,
211
+ ) as { c: number }
212
+ ).c
213
+ const done = (
214
+ db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ? AND status = 'completed'").get(
215
+ teamInfo.teamId,
216
+ ) as { c: number }
217
+ ).c
218
+ return { team: teamInfo.teamName, members, tasks: { pending, done } }
219
+ },
220
+ })
221
+ } catch (err) {
222
+ vlog(`init:rpc:failed err=${err instanceof Error ? err.message : String(err)}`)
223
+ }
224
+
225
+ // Live event loop — fire-and-forget; dispose() aborts it.
226
+ void (async () => {
227
+ try {
228
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
229
+ await dispatch(event)
230
+ }
231
+ } catch {
232
+ // Aborted on dispose or stream error — nothing to report.
233
+ }
234
+ })()
235
+
236
+ await ctx.tool.hook("execute.before", (raw) => {
237
+ const input = raw as unknown as { tool: string; sessionID: string }
238
+ checkToolIsolation(registry, tracker, input.tool, input.sessionID, db)
239
+ if (input.tool.startsWith("team_")) {
240
+ if (!rateLimiter.tryConsume()) {
241
+ return rateLimiter.waitForToken().then(() => undefined)
242
+ }
243
+ }
244
+ recordFromToolBefore({ sessionID: input.sessionID, tool: input.tool }, registry, activityBuffer)
245
+ })
246
+
247
+ await ctx.tool.hook("execute.after", (raw) => {
248
+ const event = raw as unknown as {
249
+ tool: string
250
+ sessionID: string
251
+ input: unknown
252
+ status: string
253
+ result?: { content?: string | Array<{ type?: string; text?: string }> }
254
+ }
255
+ if (event.tool === "question") {
256
+ // Feed the answer text to purge approvals (team_cleanup confirm flow).
257
+ // The question tool renders in every client, so this is the universal
258
+ // approval channel on V2 — no forms or TUI needed.
259
+ purgeApprovals.recordQuestionAnswer(
260
+ event.sessionID,
261
+ extractQuestionOutput(event.result),
262
+ event.input,
263
+ )
264
+ }
265
+ recordFromToolAfter(
266
+ { sessionID: event.sessionID, tool: event.tool },
267
+ {},
268
+ registry,
269
+ activityBuffer,
270
+ )
271
+ })
272
+
273
+ await ctx.session.hook("context", (raw) => {
274
+ const event = raw as unknown as {
275
+ sessionID: string
276
+ system: Array<{ type: string; text: string }>
277
+ }
278
+ if (!event.sessionID) return
279
+ const teamInfo = findTeamBySession(db, registry, event.sessionID)
280
+ if (!teamInfo) return
281
+ const prompt =
282
+ teamInfo.role === "lead"
283
+ ? buildLeadSystemPrompt(db, teamInfo.teamId, config)
284
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
285
+ vlog(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
286
+ event.system.push({ type: "text", text: prompt })
287
+ if (teamInfo.role === "member") {
288
+ // V2 invokes plugin tools through Code Mode, not direct calls —
289
+ // without this hint teammates attempt direct calls that the runtime
290
+ // rejects with "No tool named team_message is currently available"
291
+ // (seen live 2026-09-14: explore scout denied twice, gave up).
292
+ event.system.push({
293
+ type: "text",
294
+ text: 'Ensemble tools on this platform are called through the execute tool, e.g. tools.team_message({ to: "lead", text: "..." }). Do not call them directly.',
295
+ })
296
+ }
297
+ })
298
+
299
+ await ctx.session.hook("compaction", (raw) => {
300
+ const event = raw as unknown as {
301
+ sessionID: string
302
+ system: Array<{ type: string; text: string }>
303
+ }
304
+ if (!event.sessionID) return
305
+ const teamInfo = findTeamBySession(db, registry, event.sessionID)
306
+ if (!teamInfo) return
307
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName)
308
+ event.system.push({ type: "text", text: context })
309
+ })
310
+
311
+ // OQ-V2-shell: the V2 shell hook carries no sessionID, so per-session
312
+ // ENSEMBLE_* env cannot be scoped. Registered as a placeholder until the
313
+ // API gains session scope — team tools and prompts carry identity instead.
314
+ await ctx.shell.hook("create.before", () => undefined)
315
+
316
+ const client = createV2Client(ctx, rpc ? { rpcEmitter: rpc } : {})
317
+ const deps: ToolDeps = {
318
+ db,
319
+ registry,
320
+ tracker,
321
+ purgeApprovals,
322
+ client,
323
+ directory: ctx.location.directory,
324
+ config,
325
+ progressTracker,
326
+ }
327
+ await registerV2Tools(ctx.tool, deps)
328
+
329
+ // Dashboard mirrors V1: main instance only, skipped when port is 0.
330
+ let dashboard: DashboardServer | null = null
331
+ const dashboardPort = options.dashboardPort ?? config.dashboardPort
332
+ if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
333
+ dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
334
+ vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
335
+ return null
336
+ })
337
+ }
338
+
339
+ return {
340
+ db,
341
+ registry,
342
+ tracker,
343
+ dispatch,
344
+ dispose: async () => {
345
+ controller.abort()
346
+ dashboard?.stop()
347
+ },
348
+ }
349
+ }
@@ -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
+ }