Skip to content

Commit 9fa2790

Browse files
author
shabarkin
committed
feat: port agent teams from PRs anomalyco#12730, anomalyco#12731, anomalyco#12732 to v1.3.0
Port the agent teams feature from the blog post three PRs onto the latest OpenCode v1.3.0 codebase. Adapts to current architecture: Effect patterns, branded IDs, and service refactoring. Core (PR anomalyco#12730): Team state, messaging, recovery, events Tools (PR anomalyco#12731): 9 MCP tools, HTTP routes, registry integration TUI (PR anomalyco#12732): Team dialog, sync store extension Gated behind OPENCODE_EXPERIMENTAL_AGENT_TEAMS flag.
1 parent 9a006d8 commit 9fa2790

17 files changed

Lines changed: 4109 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { useDialog } from "@tui/ui/dialog"
2+
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
3+
import { createMemo, onMount, Show } from "solid-js"
4+
import { useTheme } from "../context/theme"
5+
import { useSync } from "../context/sync"
6+
import { useRouteData } from "../context/route"
7+
import { useRoute } from "../context/route"
8+
import { useToast } from "../ui/toast"
9+
import { useSDK } from "../context/sdk"
10+
11+
function statusIcon(status: string): string {
12+
switch (status) {
13+
case "busy":
14+
return "*"
15+
case "ready":
16+
return "o"
17+
case "shutdown_requested":
18+
return "!"
19+
case "shutdown":
20+
return "x"
21+
case "completed":
22+
return "+"
23+
case "in_progress":
24+
return ">"
25+
case "blocked":
26+
return "#"
27+
case "cancelled":
28+
return "-"
29+
case "pending":
30+
return " "
31+
default:
32+
return "?"
33+
}
34+
}
35+
36+
function statusColor(status: string, theme: any): string {
37+
switch (status) {
38+
case "busy":
39+
return theme.primary
40+
case "ready":
41+
return theme.textMuted
42+
case "shutdown_requested":
43+
return theme.warning
44+
case "shutdown":
45+
return theme.error
46+
case "completed":
47+
return theme.success
48+
case "in_progress":
49+
return theme.primary
50+
case "blocked":
51+
return theme.error
52+
case "pending":
53+
return theme.textMuted
54+
default:
55+
return theme.textMuted
56+
}
57+
}
58+
59+
export function DialogTeam() {
60+
const dialog = useDialog()
61+
const { theme } = useTheme()
62+
const sync = useSync()
63+
const route = useRouteData("session")
64+
const nav = useRoute()
65+
const toast = useToast()
66+
const sdk = useSDK()
67+
68+
const teamInfo = createMemo(() => sync.data.team[route.sessionID])
69+
70+
// Refresh team data on open
71+
onMount(() => {
72+
dialog.setSize("large")
73+
fetch(`${sdk.url}/team/by-session/${route.sessionID}`)
74+
.then((r: Response) => r.json())
75+
.then((data: any) => {
76+
if (!data) return
77+
sync.set("team", route.sessionID, {
78+
teamName: data.team.name,
79+
role: data.role,
80+
memberName: data.memberName,
81+
members: data.team.members ?? [],
82+
tasks: data.tasks ?? [],
83+
})
84+
})
85+
.catch(() => {})
86+
})
87+
88+
const options = createMemo((): DialogSelectOption<string>[] => {
89+
const info = teamInfo()
90+
if (!info) return []
91+
92+
const memberOptions: DialogSelectOption<string>[] = info.members.map((m) => ({
93+
title: `${m.name} (@${m.agent})`,
94+
value: `member:${m.sessionID}`,
95+
category: "Teammates",
96+
footer: `Status: ${m.status}`,
97+
gutter: <text fg={statusColor(m.status, theme)}>{statusIcon(m.status)}</text>,
98+
}))
99+
100+
const taskOptions: DialogSelectOption<string>[] = (info.tasks ?? []).map((t) => ({
101+
title: t.content,
102+
value: `task:${t.id}`,
103+
category: "Shared Tasks",
104+
footer: [
105+
t.status,
106+
t.assignee ? `@${t.assignee}` : null,
107+
t.depends_on?.length ? `depends: ${t.depends_on.join(", ")}` : null,
108+
]
109+
.filter(Boolean)
110+
.join(" | "),
111+
gutter: <text fg={statusColor(t.status, theme)}>{statusIcon(t.status)}</text>,
112+
disabled: t.status === "completed" || t.status === "cancelled",
113+
}))
114+
115+
return [...memberOptions, ...taskOptions]
116+
})
117+
118+
const handleSelect = (option: DialogSelectOption<string>) => {
119+
const [type, id] = option.value.split(":", 2)
120+
if (type === "member" && id) {
121+
dialog.clear()
122+
nav.navigate({ type: "session", sessionID: id })
123+
}
124+
}
125+
126+
return (
127+
<Show
128+
when={teamInfo()}
129+
fallback={
130+
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
131+
<box flexDirection="row" justifyContent="space-between">
132+
<text fg={theme.text} attributes={1}>
133+
Agent Team
134+
</text>
135+
<text fg={theme.textMuted}>esc</text>
136+
</box>
137+
<text fg={theme.textMuted}>No active team for this session.</text>
138+
<text fg={theme.textMuted}>The lead agent can create a team using the team_create tool.</text>
139+
</box>
140+
}
141+
>
142+
<DialogSelect
143+
title={`Team: ${teamInfo()!.teamName} (${teamInfo()!.role})`}
144+
options={options()}
145+
onSelect={handleSelect}
146+
keybind={[
147+
{
148+
keybind: { name: "m", ctrl: false, meta: false, shift: false, leader: false },
149+
title: "message",
150+
onTrigger: (option) => {
151+
const [type] = option.value.split(":", 2)
152+
if (type === "member") {
153+
toast.show({ message: "Use team_message tool from the prompt to message teammates", variant: "info" })
154+
}
155+
},
156+
},
157+
{
158+
keybind: { name: "l", ctrl: false, meta: false, shift: false, leader: false },
159+
title: "go to lead",
160+
onTrigger: () => {
161+
const info = teamInfo()
162+
if (!info) return
163+
// Find lead session: iterate members looking for the session that has role=lead
164+
// Or look up from team data
165+
for (const [sid, entry] of Object.entries(sync.data.team)) {
166+
const e = entry as any
167+
if (e?.teamName === info.teamName && e?.role === "lead") {
168+
dialog.clear()
169+
nav.navigate({ type: "session", sessionID: sid })
170+
return
171+
}
172+
}
173+
},
174+
},
175+
]}
176+
/>
177+
</Show>
178+
)
179+
}

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,41 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
7575
vcs: VcsInfo | undefined
7676
path: Path
7777
workspaceList: Workspace[]
78+
team: {
79+
[sessionID: string]: {
80+
teamName: string
81+
role: "lead" | "member"
82+
memberName?: string
83+
delegate?: boolean
84+
members: Array<{
85+
name: string
86+
sessionID: string
87+
agent: string
88+
status: "ready" | "busy" | "shutdown_requested" | "shutdown" | "error"
89+
execution_status:
90+
| "idle"
91+
| "starting"
92+
| "running"
93+
| "cancel_requested"
94+
| "cancelling"
95+
| "cancelled"
96+
| "completing"
97+
| "completed"
98+
| "failed"
99+
| "timed_out"
100+
model?: string
101+
planApproval?: "none" | "pending" | "approved" | "rejected"
102+
}>
103+
tasks: Array<{
104+
id: string
105+
content: string
106+
status: string
107+
priority: string
108+
assignee?: string
109+
depends_on?: string[]
110+
}>
111+
}
112+
}
78113
}>({
79114
provider_next: {
80115
all: [],
@@ -103,6 +138,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
103138
vcs: undefined,
104139
path: { state: "", config: "", worktree: "", directory: "" },
105140
workspaceList: [],
141+
team: {},
106142
})
107143

108144
const sdk = useSDK()
@@ -349,6 +385,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
349385
setStore("vcs", { branch: event.properties.branch })
350386
break
351387
}
388+
389+
}
390+
391+
// Team events — handled outside the typed switch since team event types
392+
// aren't in the SDK event union yet (they come from BusEvent.define in team/events.ts)
393+
const type = (event as { type: string }).type
394+
if (type.startsWith("team.")) {
395+
// Refresh team data from the server for all active sessions
396+
for (const sessionID of Object.keys(store.session_status)) {
397+
fetch(`/team/by-session/${sessionID}`)
398+
.then((res) => res.ok ? res.json() : null)
399+
.then((data) => {
400+
if (data) {
401+
setStore("team", sessionID, reconcile(data as any))
402+
}
403+
})
404+
.catch(() => {})
405+
}
352406
}
353407
})
354408

packages/opencode/src/flag/flag.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export namespace Flag {
6666
Config.withDefault(false),
6767
)
6868
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
69+
export declare const OPENCODE_EXPERIMENTAL_AGENT_TEAMS: boolean
6970
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
7071
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
7172
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
@@ -126,3 +127,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
126127
enumerable: true,
127128
configurable: false,
128129
})
130+
131+
// Dynamic getter for OPENCODE_EXPERIMENTAL_AGENT_TEAMS
132+
// This must be evaluated at access time, not module load time,
133+
// because integration tests and external tooling may set this env var at runtime
134+
Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_AGENT_TEAMS", {
135+
get() {
136+
return Flag.OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_AGENT_TEAMS")
137+
},
138+
enumerable: true,
139+
configurable: false,
140+
})

packages/opencode/src/id/id.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export namespace Identifier {
1212
pty: "pty",
1313
tool: "tool",
1414
workspace: "wrk",
15+
team: "tea",
1516
} as const
1617

1718
export function schema(prefix: keyof typeof prefixes) {

packages/opencode/src/project/bootstrap.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Command } from "../command"
1111
import { Instance } from "./instance"
1212
import { Log } from "@/util/log"
1313
import { ShareNext } from "@/share/share-next"
14+
import { Flag } from "@/flag/flag"
1415

1516
export async function InstanceBootstrap() {
1617
Log.Default.info("bootstrapping", { directory: Instance.directory })
@@ -28,4 +29,25 @@ export async function InstanceBootstrap() {
2829
Project.setInitialized(Instance.project.id)
2930
}
3031
})
32+
33+
// Team features — order matters:
34+
// 1. onCleanedRestorePermissions() registers synchronously so it's ready
35+
// before recover(), which could trigger cleanup if all members are shutdown.
36+
// 2. recover() marks stale busy executions as cancelled, transitions members to ready, and notifies leads.
37+
// 3. autoCleanup() subscribes AFTER recover finishes (.finally()) to avoid
38+
// spurious MemberStatusChanged events during recovery triggering premature cleanup.
39+
// Fire-and-forget: don't block bootstrap completion.
40+
if (Flag.OPENCODE_EXPERIMENTAL_AGENT_TEAMS) {
41+
// Dynamic import — only load team module when the feature flag is enabled
42+
import("../team").then(({ Team }) => {
43+
Team.onCleanedRestorePermissions()
44+
Team.recover()
45+
.catch((err) => {
46+
Log.Default.warn("team recovery failed", { error: err instanceof Error ? err.message : err })
47+
})
48+
.finally(() => {
49+
Team.autoCleanup()
50+
})
51+
})
52+
}
3153
}

0 commit comments

Comments
 (0)