@hueyexe/opencode-ensemble 0.16.1 → 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.
@@ -2,7 +2,9 @@ import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
3
  import { requireLead } from "./shared"
4
4
  import { claimTask } from "./team-claim"
5
- import { sendMessage } from "../messaging"
5
+ import { notifyLead } from "../notify"
6
+ import { releaseMemberTasks } from "../tasks"
7
+ import { parseModelId } from "../member-model"
6
8
  import { log } from "../log"
7
9
  import type { EnsembleConfig } from "../config"
8
10
  import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
@@ -10,13 +12,6 @@ import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
10
12
  /** Tracks consecutive spawn failures per team for circuit breaker. */
11
13
  export const spawnFailures = new Map<string, { count: number; lastError: string }>()
12
14
 
13
- /** Parse "provider/model" string into { providerID, modelID } for the SDK. */
14
- function parseModelId(model: string): { providerID: string; modelID: string } | undefined {
15
- const slash = model.indexOf("/")
16
- if (slash <= 0 || slash === model.length - 1) return undefined
17
- return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
18
- }
19
-
20
15
  /**
21
16
  * Resolve which model to use for a spawned agent.
22
17
  * Priority: explicit arg > modelsByAgent > rotation/random > defaultModel > undefined.
@@ -152,7 +147,21 @@ export async function executeTeamSpawn(
152
147
  // Permission rules on session.create are the hard gate (server-enforced).
153
148
  // For read-only agents, deny write tools and explicitly allow team tools.
154
149
  // For all agents with worktrees, allowlist the worktree path for edit/bash.
155
- const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
150
+ // The 9 member-accessible tools per the documented tool table: the 6 worker
151
+ // tools plus the 3 inspection tools (team_results/team_status/team_view —
152
+ // any member). Caught live 2026-09-15: teammates could not read each
153
+ // other's messages because team_results was missing from this list.
154
+ const TEAM_TOOLS = [
155
+ "team_message",
156
+ "team_broadcast",
157
+ "team_tasks_list",
158
+ "team_tasks_add",
159
+ "team_tasks_complete",
160
+ "team_claim",
161
+ "team_results",
162
+ "team_status",
163
+ "team_view",
164
+ ] as const
156
165
  const permission: PermissionRule[] = []
157
166
 
158
167
  if (worktreeDir) {
@@ -175,6 +184,12 @@ export async function executeTeamSpawn(
175
184
  ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
176
185
  )
177
186
 
187
+ // Code Mode is the invocation path for plugin tools on V2 — without this,
188
+ // read-only agents (deny-all policies like explore's) cannot reach any
189
+ // team tool. Safe: nested calls inside execute still enforce their own
190
+ // permissions, so explore stays read-only.
191
+ permission.push({ permission: "execute", pattern: "*", action: "allow" })
192
+
178
193
  // Create child session — bind to workspace if available (server-enforced CWD isolation).
179
194
  // Falls back to no workspace binding if workspace.create failed.
180
195
  let childSessionId: string | undefined
@@ -231,6 +246,12 @@ export async function executeTeamSpawn(
231
246
  [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
232
247
  )
233
248
 
249
+ // Row is inserted as 'busy' directly -- there is no ready->busy status-event
250
+ // transition for a fresh spawn, so the watchdog's usual hook point never fires.
251
+ // Record the baseline here instead, or a member stalled on its first action
252
+ // is invisible to checkStalled() until its first step-finish event lands.
253
+ deps.progressTracker.recordBusyStart(childSessionId)
254
+
234
255
  // Register in memory
235
256
  deps.registry.register(teamInfo.teamId, args.name, childSessionId)
236
257
 
@@ -396,7 +417,7 @@ export async function executeTeamSpawn(
396
417
  // Release any task auto-claimed for this teammate so it returns to the pool.
397
418
  if (claimedTaskContent) {
398
419
  deps.db.run(
399
- "UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ?",
420
+ "UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ? AND status = 'in_progress'",
400
421
  [Date.now(), args.claim_task, args.name]
401
422
  )
402
423
  }
@@ -414,12 +435,12 @@ export async function executeTeamSpawn(
414
435
  variant: "error",
415
436
  duration: 8000,
416
437
  }).catch(() => { /* TUI may not be available */ })
417
- sendMessage(deps.db, {
418
- teamId: teamInfo.teamId,
419
- from: "system",
420
- to: "lead",
421
- content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
422
- })
438
+ notifyLead(
439
+ deps.client,
440
+ deps.db,
441
+ teamInfo.teamId,
442
+ `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
443
+ )
423
444
  } catch { /* rollback failed — watchdog will clean up stale member */ }
424
445
  })
425
446
 
@@ -4,8 +4,28 @@ import { requireTeamMember } from "./shared"
4
4
  /** Tracks the last time team_status was called per team ID (epoch ms). */
5
5
  export const lastCallTime = new Map<string, number>()
6
6
 
7
+ /** Tracks the state marker (see computeStateMarker) observed at the last real read per team. */
8
+ export const lastKnownState = new Map<string, number>()
9
+
7
10
  const RATE_LIMIT_MS = 30_000
8
11
 
12
+ /**
13
+ * Cheap per-team "has anything changed" fingerprint — the max time_updated/time_created
14
+ * across the three tables team_status reports on. One MAX() query, no hashing: the
15
+ * throttle only needs to detect "something changed," not what, so this is deliberately
16
+ * the simplest sufficient version (see Fix 3 in the spec).
17
+ */
18
+ function computeStateMarker(deps: ToolDeps, teamId: string): number {
19
+ const row = deps.db.query(
20
+ `SELECT MAX(x) as marker FROM (
21
+ SELECT MAX(time_updated) as x FROM team_member WHERE team_id = ?
22
+ UNION SELECT MAX(time_updated) FROM team_task WHERE team_id = ?
23
+ UNION SELECT MAX(time_created) FROM team_message WHERE team_id = ?
24
+ )`
25
+ ).get(teamId, teamId, teamId) as { marker: number | null } | null
26
+ return row?.marker ?? 0
27
+ }
28
+
9
29
  /** Format a duration in ms to a human-readable string. */
10
30
  export function formatDuration(ms: number): string {
11
31
  if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
@@ -24,15 +44,20 @@ export async function executeTeamStatus(
24
44
 
25
45
  const now = Date.now()
26
46
  const last = lastCallTime.get(teamInfo.teamId)
27
- if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
47
+ const currentMarker = computeStateMarker(deps, teamInfo.teamId)
48
+ // The 30s throttle's purpose is preventing hammering, not lying about state — only
49
+ // short-circuit when nothing has actually changed since the last real read.
50
+ if (last !== undefined && (now - last) < RATE_LIMIT_MS && lastKnownState.get(teamInfo.teamId) === currentMarker) {
28
51
  return "No changes since last check."
29
52
  }
30
53
  lastCallTime.set(teamInfo.teamId, now)
54
+ lastKnownState.set(teamInfo.teamId, currentMarker)
31
55
 
32
56
  const members = deps.db.query(
33
- "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
57
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated, last_nudged_at, retry_until, retry_attempt FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
34
58
  ).all(teamInfo.teamId) as Array<{
35
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number
59
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string; time_updated: number; last_nudged_at: number | null
60
+ retry_until: number | null; retry_attempt: number | null
36
61
  }>
37
62
 
38
63
  const tasks = deps.db.query(
@@ -52,13 +77,22 @@ export async function executeTeamStatus(
52
77
  const duration = formatDuration(now - m.time_updated)
53
78
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
54
79
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
80
+ // Additive display-staleness annotation (Fix 1) — surfaces a soft stall-nudge
81
+ // without inventing a new status value. Annotates the existing status, doesn't
82
+ // replace it.
83
+ const nudged = m.last_nudged_at ? `, nudged ${formatDuration(now - m.last_nudged_at)} ago` : ""
84
+ // Additive provider-retry annotation (Fix 4) — derived at read time from the
85
+ // retry_until TTL (Fix 3), same annotation pattern as `nudged` above. Generic
86
+ // "retrying" label, no rate-limit-specific wording (Fix 2).
87
+ const isRetrying = m.retry_until !== null && m.retry_until > now
88
+ const retrying = isRetrying ? `, retrying (attempt ${m.retry_attempt}, ~${formatDuration(m.retry_until! - now)})` : ""
55
89
 
56
90
  // Last message time
57
91
  const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?")
58
92
  .get(teamInfo.teamId, m.name) as { last_msg: number | null } | null
59
93
  const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet"
60
94
 
61
- lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`)
95
+ lines.push(` ${m.name} [${statusIcon} ${duration}, ${msgInfo}${plan}${nudged}${retrying}] agent: ${m.agent}${branch}`)
62
96
 
63
97
  // Current task
64
98
  const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1")
@@ -2,12 +2,14 @@ import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
- * Execute the team_view tool. Navigates the TUI to a teammate's session
6
- * so the user can see what they're doing.
5
+ * Execute the team_view tool. Resolves a teammate's session; navigates the
6
+ * TUI to it ONLY when navigate: true is passed — an agent must never hijack
7
+ * the user's client unprompted. Default behavior reports where the session
8
+ * is so the user can open it themselves (ctrl+p).
7
9
  */
8
10
  export async function executeTeamView(
9
11
  deps: ToolDeps,
10
- args: { member: string },
12
+ args: { member: string; navigate?: boolean },
11
13
  sessionId: string,
12
14
  ): Promise<string> {
13
15
  const teamInfo = requireTeamMember(deps, sessionId)
@@ -16,9 +18,13 @@ export async function executeTeamView(
16
18
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
17
19
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
18
20
 
21
+ if (args.navigate !== true) {
22
+ return `${args.member}'s session: ${member.session_id} (status: ${member.status}, agent: ${member.agent}). Open it from the session picker (ctrl+p) — or ask to view it and I'll switch for you.`
23
+ }
24
+
19
25
  try {
20
26
  await deps.client.tui.selectSession({ sessionID: member.session_id })
21
- return `Switched to ${args.member}'s session.`
27
+ return `Switched to ${args.member}'s session. Use the session picker (ctrl+p) to return.`
22
28
  } catch {
23
29
  return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
24
30
  }
@@ -0,0 +1,50 @@
1
+ /** Minimal member shape for sidebar rendering. */
2
+ export interface SidebarMember {
3
+ name: string
4
+ status: string
5
+ }
6
+
7
+ /** Rendered sidebar model: counts plus one line per member. */
8
+ export interface SidebarSummary {
9
+ team: string
10
+ working: number
11
+ idle: number
12
+ total: number
13
+ lines: string[]
14
+ }
15
+
16
+ /** Glyph per member status (paired with text elsewhere — never color-only). */
17
+ function glyph(status: string): string {
18
+ switch (status) {
19
+ case "busy":
20
+ return "●"
21
+ case "ready":
22
+ return "○"
23
+ case "shutdown_requested":
24
+ return "◐"
25
+ case "shutdown":
26
+ return "■"
27
+ case "error":
28
+ return "✕"
29
+ default:
30
+ return "?"
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Build the sidebar model for a team. Busy members count as working, ready
36
+ * as idle, terminal states (shutdown/error) as done.
37
+ */
38
+ export function summarizeMembers(team: string, members: SidebarMember[]): SidebarSummary {
39
+ if (members.length === 0) {
40
+ return { team, working: 0, idle: 0, total: 0, lines: ["No teammates"] }
41
+ }
42
+ let working = 0
43
+ let idle = 0
44
+ const lines = members.map((member) => {
45
+ if (member.status === "busy") working += 1
46
+ else if (member.status === "ready") idle += 1
47
+ return `${glyph(member.status)} ${member.name} (${member.status})`
48
+ })
49
+ return { team, working, idle, total: members.length, lines }
50
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,101 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import "@opentui/solid"
3
+ import { createSignal, onCleanup, onMount, Show } from "solid-js"
4
+ import { Plugin } from "@opencode/plugin/tui"
5
+ import { EnsembleRpc } from "./rpc"
6
+
7
+ /** Team context shape from the server bridge. */
8
+ interface TeamContextData {
9
+ team: string | null
10
+ members: Array<{ name: string; status: string }>
11
+ tasks: { pending: number; done: number }
12
+ }
13
+
14
+ /** Sidebar widget: team members with status plus task counts. */
15
+ function TeamSidebar(props: { fetchContext: () => Promise<TeamContextData | null>; onEvent: (cb: () => void) => () => void }) {
16
+ const [state, setState] = createSignal<TeamContextData | null>(null)
17
+ const load = async (): Promise<void> => {
18
+ try {
19
+ setState(await props.fetchContext())
20
+ } catch {
21
+ // Server unreachable — keep the last known state.
22
+ }
23
+ }
24
+ onMount(() => {
25
+ void load()
26
+ const off = props.onEvent(() => void load())
27
+ onCleanup(off)
28
+ // Heartbeat: events alone go stale (cleanup emits nothing, events can be
29
+ // missed across reconnects). Re-fetch on a slow tick — local RPC read.
30
+ const tick = setInterval(() => void load(), 10_000)
31
+ onCleanup(() => clearInterval(tick))
32
+ })
33
+ return (
34
+ <Show when={state()?.team} fallback={<text>Ensemble: no active team</text>}>
35
+ <box flexDirection="column">
36
+ <text>Ensemble: {state()?.team}</text>
37
+ {(state()?.members ?? []).map((member) => (
38
+ <text>
39
+ {member.status === "busy" ? "●" : member.status === "ready" ? "○" : "■"} {member.name} ({member.status})
40
+ </text>
41
+ ))}
42
+ <text>
43
+ tasks: {(state()?.tasks.pending ?? 0)} open / {(state()?.tasks.done ?? 0)} done
44
+ </text>
45
+ </box>
46
+ </Show>
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Terminal companion for opencode-ensemble (issue #36).
52
+ *
53
+ * Two persistent surfaces only — transient toasts were removed as noise
54
+ * (live state lives in the sidebar; events live in the transcript as
55
+ * synthetic system lines):
56
+ * 1. Sidebar widget: live member status + task counts.
57
+ * 2. Native agent switching: team_view with navigate: true (user opt-in
58
+ * only — agents never hijack the client unprompted).
59
+ *
60
+ * Enhancement only: desktop, web, and companion-less terminals lose nothing
61
+ * functional — tools, approvals, model-context messages, and the dashboard
62
+ * all live server-side.
63
+ */
64
+ export default Plugin.define({
65
+ id: "ensemble.tui",
66
+ setup(context) {
67
+ const rpc = context.client.rpc(EnsembleRpc)
68
+
69
+ // Native agent switching: team_view(navigate: true) emits "view";
70
+ // the companion navigates the TUI to the teammate session (ctrl+p to return).
71
+ const offView = rpc.events.on("view", (event) => {
72
+ const data = event.data as unknown as { sessionID: string }
73
+ if (!data.sessionID) return
74
+ if (context.ui.tabs.enabled()) {
75
+ void context.ui.tabs.focus(data.sessionID)
76
+ } else {
77
+ void context.ui.router.navigate({ type: "session", sessionID: data.sessionID })
78
+ }
79
+ })
80
+
81
+ const onEvent = (cb: () => void): (() => void) => rpc.events.on("member", cb)
82
+
83
+ const offSidebar = context.ui.slot({
84
+ append: "sidebar.content",
85
+ render: (panel: { sessionID?: string }) => (
86
+ <TeamSidebar
87
+ fetchContext={async () => {
88
+ if (!panel.sessionID) return null
89
+ return (await rpc.teamContext({ sessionID: panel.sessionID })) as TeamContextData
90
+ }}
91
+ onEvent={onEvent}
92
+ />
93
+ ),
94
+ })
95
+
96
+ return () => {
97
+ offView()
98
+ offSidebar()
99
+ }
100
+ },
101
+ })
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
3
3
  import type { EnsembleConfig } from "./config"
4
+ import type { ProgressTracker } from "./progress"
4
5
 
5
6
  /**
6
7
  * Shared dependencies injected into every tool's execute function.
@@ -17,6 +18,8 @@ export interface ToolDeps {
17
18
  directory: string
18
19
  /** Plugin configuration. */
19
20
  config: Required<EnsembleConfig>
21
+ /** Stall/activity tracker — shared with the Watchdog. */
22
+ progressTracker: ProgressTracker
20
23
  }
21
24
 
22
25
  /** A single permission rule for session-level enforcement. */
@@ -47,6 +50,8 @@ export interface PluginClient {
47
50
  agent?: string
48
51
  tools?: Record<string, boolean>
49
52
  system?: string
53
+ /** V2: deliver as a synthetic (system) message instead of a user prompt. */
54
+ synthetic?: boolean
50
55
  }): Promise<unknown>
51
56
  abort(options: { sessionID: string }): Promise<unknown>
52
57
  status(): Promise<{ data?: Record<string, { type: string }> }>
@@ -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
+ }