diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 20124b6fdef..44f45642238 100644
--- a/packages/app/src/components/session/index.ts
+++ b/packages/app/src/components/session/index.ts
@@ -1,5 +1,6 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
+export { SessionWorkersTab } from "./session-workers-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
diff --git a/packages/app/src/components/session/session-workers-tab.tsx b/packages/app/src/components/session/session-workers-tab.tsx
new file mode 100644
index 00000000000..ef0957e7857
--- /dev/null
+++ b/packages/app/src/components/session/session-workers-tab.tsx
@@ -0,0 +1,45 @@
+import { For, Show, createMemo, type JSX } from "solid-js"
+import type { CoordTeamSummary } from "@opencode-ai/sdk/v2/client"
+import { useLanguage } from "@/context/language"
+
+export function SessionWorkersTab(props: { summary: CoordTeamSummary | null | undefined }): JSX.Element {
+ const language = useLanguage()
+ const members = createMemo(() => props.summary?.team.members ?? [])
+ const inbox = createMemo(() => props.summary?.inbox ?? [])
+ const unreadFor = (name: string) => inbox().find((item) => item.name === name)?.unread ?? 0
+
+ return (
+
+ {language.t("session.workers.empty")}
}
+ >
+
+
{props.summary?.team.name}
+
+ {props.summary?.team.description}
+
+
+
+
+ {(member) => (
+
+
+
{member.name}
+
{member.agentType}
+
+
0}>
+ {(count) => (
+
+ {count()}
+
+ )}
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/settings-permissions.tsx b/packages/app/src/components/settings-permissions.tsx
index 7dd43a70753..d7d2a529172 100644
--- a/packages/app/src/components/settings-permissions.tsx
+++ b/packages/app/src/components/settings-permissions.tsx
@@ -103,6 +103,31 @@ const ITEMS = [
title: "settings.permissions.tool.doom_loop.title",
description: "settings.permissions.tool.doom_loop.description",
},
+ {
+ id: "coord_team",
+ title: "settings.permissions.tool.coord_team.title",
+ description: "settings.permissions.tool.coord_team.description",
+ },
+ {
+ id: "coord_member",
+ title: "settings.permissions.tool.coord_member.title",
+ description: "settings.permissions.tool.coord_member.description",
+ },
+ {
+ id: "coord_message",
+ title: "settings.permissions.tool.coord_message.title",
+ description: "settings.permissions.tool.coord_message.description",
+ },
+ {
+ id: "coord_task",
+ title: "settings.permissions.tool.coord_task.title",
+ description: "settings.permissions.tool.coord_task.description",
+ },
+ {
+ id: "coord_session",
+ title: "settings.permissions.tool.coord_session.title",
+ description: "settings.permissions.tool.coord_session.description",
+ },
] as const
const VALID_ACTIONS = new Set(["allow", "ask", "deny"])
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 2feb7fe0884..a1ff6be8fc7 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -173,6 +173,7 @@ export function createChildStoreManager(input: {
session_status: {},
session_diff: {},
todo: {},
+ coord: {},
permission: {},
question: {},
mcp: {},
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
index f79b9fc958f..130b9c833f9 100644
--- a/packages/app/src/context/global-sync/event-reducer.test.ts
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -58,6 +58,7 @@ const baseState = (input: Partial = {}) =>
limit: 10,
message: {},
part: {},
+ coord: {},
...input,
}) as State
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index c658d82c8b7..c271b8923b1 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -10,6 +10,7 @@ import type {
Session,
SessionStatus,
Todo,
+ CoordTeamSummary,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
@@ -44,10 +45,12 @@ function cleanupSessionCaches(store: Store, setStore: SetStoreFunction {
@@ -62,6 +65,7 @@ function cleanupSessionCaches(store: Store, setStore: SetStoreFunction {
const draft = {
message: { [sessionID]: [userMessage("msg_2", sessionID)] },
part: {} as Record,
+ coord: {},
}
applyOptimisticAdd(draft, {
@@ -45,6 +46,7 @@ describe("sync optimistic reducers", () => {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record,
+ coord: {},
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 66c53dc8021..ce7ca10d322 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -5,7 +5,7 @@ import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, CoordTeamSummary } from "@opencode-ai/sdk/v2/client"
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
@@ -14,8 +14,10 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record
part: Record
+ coord: Record
}
+
type OptimisticAddInput = {
sessionID: string
message: Message
@@ -67,6 +69,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map>()
const inflightDiff = new Map>()
const inflightTodo = new Map>()
+ const inflightCoord = new Map>()
const [meta, setMeta] = createStore({
limit: {} as Record,
complete: {} as Record,
@@ -291,6 +294,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
inflightTodo.set(key, promise)
return promise
},
+ async coord(sessionID: string) {
+ const directory = sdk.directory
+ const client = sdk.client
+ const [store, setStore] = globalSync.child(directory)
+ if (store.coord[sessionID] !== undefined) return
+
+ const key = keyFor(directory, sessionID)
+ const pending = inflightCoord.get(key)
+ if (pending) return pending
+
+ const promise = retry(() => client.session.coord({ sessionID }))
+ .then((coord) => {
+ setStore("coord", sessionID, reconcile(coord.data ?? null))
+ })
+ .finally(() => {
+ inflightCoord.delete(key)
+ })
+
+ inflightCoord.set(key, promise)
+ return promise
+ },
history: {
more(sessionID: string) {
const store = current()[0]
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 77a3edb062a..a988f9a502d 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -416,6 +416,8 @@ export const dict = {
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
+ "session.tab.workers": "العمال",
+ "session.workers.empty": "لا يوجد عمّال بعد",
"session.panel.reviewAndFiles": "المراجعة والملفات",
"session.review.filesChanged": "تم تغيير {{count}} ملفات",
"session.review.change.one": "تغيير",
@@ -680,6 +682,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت",
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
+ "settings.permissions.tool.coord_team.title": "فريق التنسيق",
+ "settings.permissions.tool.coord_team.description": "إنشاء فرق التنسيق وعرضها وحذفها",
+ "settings.permissions.tool.coord_member.title": "عضو التنسيق",
+ "settings.permissions.tool.coord_member.description": "إضافة أعضاء فريق التنسيق أو إزالتهم",
+ "settings.permissions.tool.coord_message.title": "رسالة التنسيق",
+ "settings.permissions.tool.coord_message.description": "إرسال وإدارة رسائل صندوق وارد الفريق",
+ "settings.permissions.tool.coord_task.title": "مهمة التنسيق",
+ "settings.permissions.tool.coord_task.description": "إنشاء مهام التنسيق وإدارتها",
+ "settings.permissions.tool.coord_session.title": "جلسة التنسيق",
+ "settings.permissions.tool.coord_session.description": "ربط الجلسات بفرق التنسيق",
"session.delete.failed.title": "فشل حذف الجلسة",
"session.delete.title": "حذف الجلسة",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index a743a3d8969..c310b68cefd 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -417,6 +417,8 @@ export const dict = {
"session.tab.session": "Sessão",
"session.tab.review": "Revisão",
"session.tab.context": "Contexto",
+ "session.tab.workers": "Trabalhadores",
+ "session.workers.empty": "Nenhum trabalhador ainda",
"session.panel.reviewAndFiles": "Revisão e arquivos",
"session.review.filesChanged": "{{count}} Arquivos Alterados",
"session.review.change.one": "Alteração",
@@ -686,6 +688,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto",
"settings.permissions.tool.doom_loop.title": "Loop Infinito",
"settings.permissions.tool.doom_loop.description": "Detectar chamadas de ferramentas repetidas com entrada idêntica",
+ "settings.permissions.tool.coord_team.title": "Equipe de coordenação",
+ "settings.permissions.tool.coord_team.description": "Criar, listar e excluir equipes de coordenação",
+ "settings.permissions.tool.coord_member.title": "Membro de coordenação",
+ "settings.permissions.tool.coord_member.description": "Adicionar ou remover membros da equipe de coordenação",
+ "settings.permissions.tool.coord_message.title": "Mensagem de coordenação",
+ "settings.permissions.tool.coord_message.description": "Enviar e gerenciar mensagens da caixa de entrada da equipe",
+ "settings.permissions.tool.coord_task.title": "Tarefa de coordenação",
+ "settings.permissions.tool.coord_task.description": "Criar e gerenciar tarefas de coordenação",
+ "settings.permissions.tool.coord_session.title": "Sessão de coordenação",
+ "settings.permissions.tool.coord_session.description": "Vincular sessões a equipes de coordenação",
"session.delete.failed.title": "Falha ao excluir sessão",
"session.delete.title": "Excluir sessão",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index ce37989c259..41454ead836 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -444,6 +444,8 @@ export const dict = {
"session.tab.session": "Sesija",
"session.tab.review": "Pregled",
"session.tab.context": "Kontekst",
+ "session.tab.workers": "Radnici",
+ "session.workers.empty": "Još nema radnika",
"session.panel.reviewAndFiles": "Pregled i datoteke",
"session.review.filesChanged": "Izmijenjeno {{count}} datoteka",
"session.review.change.one": "Izmjena",
@@ -713,6 +715,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta",
"settings.permissions.tool.doom_loop.title": "Beskonačna petlja",
"settings.permissions.tool.doom_loop.description": "Otkriva ponovljene pozive alata sa identičnim unosom",
+ "settings.permissions.tool.coord_team.title": "Koordinacijski tim",
+ "settings.permissions.tool.coord_team.description": "Kreiraj, prikaži i izbriši koordinacijske timove",
+ "settings.permissions.tool.coord_member.title": "Koordinacijski član",
+ "settings.permissions.tool.coord_member.description": "Dodaj ili ukloni članove koordinacijskog tima",
+ "settings.permissions.tool.coord_message.title": "Koordinacijska poruka",
+ "settings.permissions.tool.coord_message.description": "Pošalji i upravljaj porukama timskog sandučića",
+ "settings.permissions.tool.coord_task.title": "Koordinacijski zadatak",
+ "settings.permissions.tool.coord_task.description": "Kreiraj i upravljaj koordinacijskim zadacima",
+ "settings.permissions.tool.coord_session.title": "Koordinacijska sesija",
+ "settings.permissions.tool.coord_session.description": "Poveži sesije s koordinacijskim timovima",
"session.delete.failed.title": "Neuspjelo brisanje sesije",
"session.delete.title": "Izbriši sesiju",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 88704607b33..7c9ecadb03c 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -418,6 +418,8 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Gennemgang",
"session.tab.context": "Kontekst",
+ "session.tab.workers": "Arbejdere",
+ "session.workers.empty": "Ingen arbejdere endnu",
"session.panel.reviewAndFiles": "Gennemgang og filer",
"session.review.filesChanged": "{{count}} Filer ændret",
"session.review.change.one": "Ændring",
@@ -685,6 +687,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Opdag gentagne værktøjskald med identisk input",
+ "settings.permissions.tool.coord_team.title": "Koordineringsteam",
+ "settings.permissions.tool.coord_team.description": "Opret, vis og slet koordinationsteams",
+ "settings.permissions.tool.coord_member.title": "Koordineringsmedlem",
+ "settings.permissions.tool.coord_member.description": "Tilføj eller fjern koordinationsteammedlemmer",
+ "settings.permissions.tool.coord_message.title": "Koordineringsbesked",
+ "settings.permissions.tool.coord_message.description": "Send og administrer teamets indbakke-beskeder",
+ "settings.permissions.tool.coord_task.title": "Koordineringsopgave",
+ "settings.permissions.tool.coord_task.description": "Opret og administrer koordineringsopgaver",
+ "settings.permissions.tool.coord_session.title": "Koordineringssession",
+ "settings.permissions.tool.coord_session.description": "Knyt sessioner til koordinationsteams",
"session.delete.failed.title": "Kunne ikke slette session",
"session.delete.title": "Slet session",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index a4d12d44543..9570b080a19 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -460,6 +460,8 @@ export const dict = {
"session.tab.session": "Sitzung",
"session.tab.review": "Überprüfung",
"session.tab.context": "Kontext",
+ "session.tab.workers": "Mitarbeiter",
+ "session.workers.empty": "Noch keine Mitarbeiter",
"session.panel.reviewAndFiles": "Überprüfung und Dateien",
"session.review.filesChanged": "{{count}} Dateien geändert",
"session.review.change.one": "Änderung",
@@ -730,6 +732,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Wiederholte Tool-Aufrufe mit identischer Eingabe erkennen",
+ "settings.permissions.tool.coord_team.title": "Koordinationsteam",
+ "settings.permissions.tool.coord_team.description": "Koordinationsteams erstellen, auflisten und löschen",
+ "settings.permissions.tool.coord_member.title": "Koordinationsmitglied",
+ "settings.permissions.tool.coord_member.description": "Mitglieder von Koordinationsteams hinzufügen oder entfernen",
+ "settings.permissions.tool.coord_message.title": "Koordinationsnachricht",
+ "settings.permissions.tool.coord_message.description": "Team-Posteingangsnachrichten senden und verwalten",
+ "settings.permissions.tool.coord_task.title": "Koordinationsaufgabe",
+ "settings.permissions.tool.coord_task.description": "Koordinationsaufgaben erstellen und verwalten",
+ "settings.permissions.tool.coord_session.title": "Koordinationssitzung",
+ "settings.permissions.tool.coord_session.description": "Sitzungen mit Koordinationsteams verknüpfen",
"session.delete.failed.title": "Sitzung konnte nicht gelöscht werden",
"session.delete.title": "Sitzung löschen",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 4d7d571afb7..3e2c0dedb63 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -481,6 +481,8 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Review",
"session.tab.context": "Context",
+ "session.tab.workers": "Workers",
+ "session.workers.empty": "No workers yet",
"session.panel.reviewAndFiles": "Review and files",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
@@ -756,6 +758,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input",
+ "settings.permissions.tool.coord_team.title": "Coord Team",
+ "settings.permissions.tool.coord_team.description": "Create, list, and delete coordination teams",
+ "settings.permissions.tool.coord_member.title": "Coord Member",
+ "settings.permissions.tool.coord_member.description": "Add or remove coordination team members",
+ "settings.permissions.tool.coord_message.title": "Coord Message",
+ "settings.permissions.tool.coord_message.description": "Send and manage team inbox messages",
+ "settings.permissions.tool.coord_task.title": "Coord Task",
+ "settings.permissions.tool.coord_task.description": "Create and manage coordination tasks",
+ "settings.permissions.tool.coord_session.title": "Coord Session",
+ "settings.permissions.tool.coord_session.description": "Link sessions with coordination teams",
"session.delete.failed.title": "Failed to delete session",
"session.delete.title": "Delete session",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 5d48ba49493..8b0a3a05b71 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -420,6 +420,8 @@ export const dict = {
"session.tab.session": "Sesión",
"session.tab.review": "Revisión",
"session.tab.context": "Contexto",
+ "session.tab.workers": "Trabajadores",
+ "session.workers.empty": "Aún no hay trabajadores",
"session.panel.reviewAndFiles": "Revisión y archivos",
"session.review.filesChanged": "{{count}} Archivos Cambiados",
"session.review.change.one": "Cambio",
@@ -691,6 +693,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto",
"settings.permissions.tool.doom_loop.title": "Bucle Infinito",
"settings.permissions.tool.doom_loop.description": "Detectar llamadas a herramientas repetidas con entrada idéntica",
+ "settings.permissions.tool.coord_team.title": "Equipo de coordinación",
+ "settings.permissions.tool.coord_team.description": "Crear, listar y eliminar equipos de coordinación",
+ "settings.permissions.tool.coord_member.title": "Miembro de coordinación",
+ "settings.permissions.tool.coord_member.description": "Añadir o eliminar miembros del equipo de coordinación",
+ "settings.permissions.tool.coord_message.title": "Mensaje de coordinación",
+ "settings.permissions.tool.coord_message.description": "Enviar y gestionar mensajes de la bandeja del equipo",
+ "settings.permissions.tool.coord_task.title": "Tarea de coordinación",
+ "settings.permissions.tool.coord_task.description": "Crear y gestionar tareas de coordinación",
+ "settings.permissions.tool.coord_session.title": "Sesión de coordinación",
+ "settings.permissions.tool.coord_session.description": "Vincular sesiones con equipos de coordinación",
"session.delete.failed.title": "Fallo al eliminar sesión",
"session.delete.title": "Eliminar sesión",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index a76e57ff153..91c4c336cc4 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -425,6 +425,8 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Revue",
"session.tab.context": "Contexte",
+ "session.tab.workers": "Travailleurs",
+ "session.workers.empty": "Aucun travailleur pour l'instant",
"session.panel.reviewAndFiles": "Revue et fichiers",
"session.review.filesChanged": "{{count}} fichiers modifiés",
"session.review.change.one": "Modification",
@@ -697,6 +699,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet",
"settings.permissions.tool.doom_loop.title": "Boucle infernale",
"settings.permissions.tool.doom_loop.description": "Détecter les appels d'outils répétés avec une entrée identique",
+ "settings.permissions.tool.coord_team.title": "Équipe de coordination",
+ "settings.permissions.tool.coord_team.description": "Créer, lister et supprimer des équipes de coordination",
+ "settings.permissions.tool.coord_member.title": "Membre de coordination",
+ "settings.permissions.tool.coord_member.description": "Ajouter ou supprimer des membres de l'équipe de coordination",
+ "settings.permissions.tool.coord_message.title": "Message de coordination",
+ "settings.permissions.tool.coord_message.description": "Envoyer et gérer les messages de la boîte de réception de l'équipe",
+ "settings.permissions.tool.coord_task.title": "Tâche de coordination",
+ "settings.permissions.tool.coord_task.description": "Créer et gérer des tâches de coordination",
+ "settings.permissions.tool.coord_session.title": "Session de coordination",
+ "settings.permissions.tool.coord_session.description": "Lier des sessions à des équipes de coordination",
"session.delete.failed.title": "Échec de la suppression de la session",
"session.delete.title": "Supprimer la session",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index e41dea9dc73..546f44c9ce6 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -412,6 +412,8 @@ export const dict = {
"session.tab.session": "セッション",
"session.tab.review": "レビュー",
"session.tab.context": "コンテキスト",
+ "session.tab.workers": "ワーカー",
+ "session.workers.empty": "まだワーカーはいません",
"session.panel.reviewAndFiles": "レビューとファイル",
"session.review.filesChanged": "{{count}} ファイル変更",
"session.review.change.one": "変更",
@@ -679,6 +681,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "同一入力による繰り返しのツール呼び出しを検出",
+ "settings.permissions.tool.coord_team.title": "調整チーム",
+ "settings.permissions.tool.coord_team.description": "調整チームの作成、一覧表示、削除",
+ "settings.permissions.tool.coord_member.title": "調整メンバー",
+ "settings.permissions.tool.coord_member.description": "調整チームメンバーの追加または削除",
+ "settings.permissions.tool.coord_message.title": "調整メッセージ",
+ "settings.permissions.tool.coord_message.description": "チーム受信箱メッセージの送信と管理",
+ "settings.permissions.tool.coord_task.title": "調整タスク",
+ "settings.permissions.tool.coord_task.description": "調整タスクの作成と管理",
+ "settings.permissions.tool.coord_session.title": "調整セッション",
+ "settings.permissions.tool.coord_session.description": "セッションを調整チームに紐づける",
"session.delete.failed.title": "セッションの削除に失敗しました",
"session.delete.title": "セッションの削除",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index a4f42a583e0..d3d87e43a04 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -419,6 +419,8 @@ export const dict = {
"session.tab.session": "세션",
"session.tab.review": "검토",
"session.tab.context": "컨텍스트",
+ "session.tab.workers": "작업자",
+ "session.workers.empty": "아직 작업자가 없습니다",
"session.panel.reviewAndFiles": "검토 및 파일",
"session.review.filesChanged": "{{count}}개 파일 변경됨",
"session.review.change.one": "변경",
@@ -684,6 +686,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스",
"settings.permissions.tool.doom_loop.title": "무한 반복",
"settings.permissions.tool.doom_loop.description": "동일한 입력으로 반복되는 도구 호출 감지",
+ "settings.permissions.tool.coord_team.title": "조정 팀",
+ "settings.permissions.tool.coord_team.description": "조정 팀을 생성, 목록 조회, 삭제",
+ "settings.permissions.tool.coord_member.title": "조정 멤버",
+ "settings.permissions.tool.coord_member.description": "조정 팀 멤버 추가 또는 제거",
+ "settings.permissions.tool.coord_message.title": "조정 메시지",
+ "settings.permissions.tool.coord_message.description": "팀 받은편지함 메시지 전송 및 관리",
+ "settings.permissions.tool.coord_task.title": "조정 작업",
+ "settings.permissions.tool.coord_task.description": "조정 작업 생성 및 관리",
+ "settings.permissions.tool.coord_session.title": "조정 세션",
+ "settings.permissions.tool.coord_session.description": "세션을 조정 팀에 연결",
"session.delete.failed.title": "세션 삭제 실패",
"session.delete.title": "세션 삭제",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 3de7837f800..08126daedde 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -420,6 +420,8 @@ export const dict = {
"session.tab.session": "Sesjon",
"session.tab.review": "Gjennomgang",
"session.tab.context": "Kontekst",
+ "session.tab.workers": "Arbeidere",
+ "session.workers.empty": "Ingen arbeidere ennå",
"session.panel.reviewAndFiles": "Gjennomgang og filer",
"session.review.filesChanged": "{{count}} filer endret",
"session.review.change.one": "Endring",
@@ -688,6 +690,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Oppdager gjentatte verktøykall med identisk input",
+ "settings.permissions.tool.coord_team.title": "Koordineringsteam",
+ "settings.permissions.tool.coord_team.description": "Opprett, list og slett koordineringsteam",
+ "settings.permissions.tool.coord_member.title": "Koordineringsmedlem",
+ "settings.permissions.tool.coord_member.description": "Legg til eller fjern koordineringsmedlemmer",
+ "settings.permissions.tool.coord_message.title": "Koordineringsmelding",
+ "settings.permissions.tool.coord_message.description": "Send og administrer meldinger i teaminnboksen",
+ "settings.permissions.tool.coord_task.title": "Koordineringsoppgave",
+ "settings.permissions.tool.coord_task.description": "Opprett og administrer koordineringsoppgaver",
+ "settings.permissions.tool.coord_session.title": "Koordineringsøkt",
+ "settings.permissions.tool.coord_session.description": "Knytt økter til koordineringsteam",
"session.delete.failed.title": "Kunne ikke slette sesjon",
"session.delete.title": "Slett sesjon",
diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts
index a75dbd3a300..63c74eec783 100644
--- a/packages/app/src/i18n/parity.test.ts
+++ b/packages/app/src/i18n/parity.test.ts
@@ -17,7 +17,12 @@ import { dict as zh } from "./zh"
import { dict as zht } from "./zht"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
-const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
+const keys = [
+ "command.session.previous.unseen",
+ "command.session.next.unseen",
+ "session.tab.workers",
+ "session.workers.empty",
+] as const
describe("i18n parity", () => {
test("non-English locales translate targeted unseen session keys", () => {
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 44bc4677be9..1e18f0e9960 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -419,6 +419,8 @@ export const dict = {
"session.tab.session": "Sesja",
"session.tab.review": "Przegląd",
"session.tab.context": "Kontekst",
+ "session.tab.workers": "Pracownicy",
+ "session.workers.empty": "Brak pracowników",
"session.panel.reviewAndFiles": "Przegląd i pliki",
"session.review.filesChanged": "Zmieniono {{count}} plików",
"session.review.change.one": "Zmiana",
@@ -687,6 +689,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu",
"settings.permissions.tool.doom_loop.title": "Zapętlenie",
"settings.permissions.tool.doom_loop.description": "Wykrywanie powtarzających się wywołań narzędzi (doom loop)",
+ "settings.permissions.tool.coord_team.title": "Zespół koordynacji",
+ "settings.permissions.tool.coord_team.description": "Twórz, listuj i usuwaj zespoły koordynacji",
+ "settings.permissions.tool.coord_member.title": "Członek koordynacji",
+ "settings.permissions.tool.coord_member.description": "Dodawaj lub usuwaj członków zespołu koordynacji",
+ "settings.permissions.tool.coord_message.title": "Wiadomość koordynacji",
+ "settings.permissions.tool.coord_message.description": "Wysyłaj i zarządzaj wiadomościami w skrzynce zespołu",
+ "settings.permissions.tool.coord_task.title": "Zadanie koordynacji",
+ "settings.permissions.tool.coord_task.description": "Twórz i zarządzaj zadaniami koordynacji",
+ "settings.permissions.tool.coord_session.title": "Sesja koordynacji",
+ "settings.permissions.tool.coord_session.description": "Łącz sesje z zespołami koordynacji",
"session.delete.failed.title": "Nie udało się usunąć sesji",
"session.delete.title": "Usuń sesję",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 28785c0e9fb..704d1fd2f5f 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -421,6 +421,8 @@ export const dict = {
"session.tab.session": "Сессия",
"session.tab.review": "Обзор",
"session.tab.context": "Контекст",
+ "session.tab.workers": "Сотрудники",
+ "session.workers.empty": "Пока нет сотрудников",
"session.panel.reviewAndFiles": "Обзор и файлы",
"session.review.filesChanged": "{{count}} файлов изменено",
"session.review.change.one": "Изменение",
@@ -691,6 +693,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Обнаружение повторных вызовов инструментов с одинаковым вводом",
+ "settings.permissions.tool.coord_team.title": "Команда координации",
+ "settings.permissions.tool.coord_team.description": "Создавайте, просматривайте и удаляйте команды координации",
+ "settings.permissions.tool.coord_member.title": "Участник координации",
+ "settings.permissions.tool.coord_member.description": "Добавляйте или удаляйте участников команды координации",
+ "settings.permissions.tool.coord_message.title": "Сообщение координации",
+ "settings.permissions.tool.coord_message.description": "Отправляйте и управляйте сообщениями в командном ящике",
+ "settings.permissions.tool.coord_task.title": "Задача координации",
+ "settings.permissions.tool.coord_task.description": "Создавайте и управляйте задачами координации",
+ "settings.permissions.tool.coord_session.title": "Сессия координации",
+ "settings.permissions.tool.coord_session.description": "Связывайте сессии с командами координации",
"session.delete.failed.title": "Не удалось удалить сессию",
"session.delete.title": "Удалить сессию",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 9858f39d772..8053710a472 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -421,6 +421,8 @@ export const dict = {
"session.tab.session": "เซสชัน",
"session.tab.review": "ตรวจสอบ",
"session.tab.context": "บริบท",
+ "session.tab.workers": "ผู้ปฏิบัติงาน",
+ "session.workers.empty": "ยังไม่มีผู้ปฏิบัติงาน",
"session.panel.reviewAndFiles": "ตรวจสอบและไฟล์",
"session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
"session.review.change.one": "การเปลี่ยนแปลง",
@@ -678,6 +680,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "ตรวจจับการเรียกเครื่องมือซ้ำด้วยข้อมูลนำเข้าเหมือนกัน",
+ "settings.permissions.tool.coord_team.title": "ทีมประสานงาน",
+ "settings.permissions.tool.coord_team.description": "สร้าง แสดงรายการ และลบทีมประสานงาน",
+ "settings.permissions.tool.coord_member.title": "สมาชิกทีมประสานงาน",
+ "settings.permissions.tool.coord_member.description": "เพิ่มหรือลบสมาชิกทีมประสานงาน",
+ "settings.permissions.tool.coord_message.title": "ข้อความประสานงาน",
+ "settings.permissions.tool.coord_message.description": "ส่งและจัดการข้อความในกล่องข้อความของทีม",
+ "settings.permissions.tool.coord_task.title": "งานประสานงาน",
+ "settings.permissions.tool.coord_task.description": "สร้างและจัดการงานประสานงาน",
+ "settings.permissions.tool.coord_session.title": "เซสชันประสานงาน",
+ "settings.permissions.tool.coord_session.description": "เชื่อมเซสชันกับทีมประสานงาน",
"session.delete.failed.title": "ไม่สามารถลบเซสชัน",
"session.delete.title": "ลบเซสชัน",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index a8fda6f3a60..a0db70714fa 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -456,6 +456,8 @@ export const dict = {
"session.tab.session": "会话",
"session.tab.review": "审查",
"session.tab.context": "上下文",
+ "session.tab.workers": "工作者",
+ "session.workers.empty": "暂无工作者",
"session.panel.reviewAndFiles": "审查和文件",
"session.review.filesChanged": "{{count}} 个文件变更",
"session.review.change.one": "更改",
@@ -718,6 +720,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "访问项目目录之外的文件",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "检测具有相同输入的重复工具调用",
+ "settings.permissions.tool.coord_team.title": "协调团队",
+ "settings.permissions.tool.coord_team.description": "创建、列出并删除协调团队",
+ "settings.permissions.tool.coord_member.title": "协调成员",
+ "settings.permissions.tool.coord_member.description": "添加或移除协调团队成员",
+ "settings.permissions.tool.coord_message.title": "协调消息",
+ "settings.permissions.tool.coord_message.description": "发送并管理团队收件箱消息",
+ "settings.permissions.tool.coord_task.title": "协调任务",
+ "settings.permissions.tool.coord_task.description": "创建并管理协调任务",
+ "settings.permissions.tool.coord_session.title": "协调会话",
+ "settings.permissions.tool.coord_session.description": "将会话关联到协调团队",
"session.delete.failed.title": "删除会话失败",
"session.delete.title": "删除会话",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 319f5c51d15..78700391628 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -453,6 +453,8 @@ export const dict = {
"session.tab.session": "工作階段",
"session.tab.review": "審查",
"session.tab.context": "上下文",
+ "session.tab.workers": "工作者",
+ "session.workers.empty": "尚無工作者",
"session.panel.reviewAndFiles": "審查與檔案",
"session.review.filesChanged": "{{count}} 個檔案變更",
"session.review.change.one": "變更",
@@ -716,6 +718,16 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "偵測具有相同輸入的重複工具呼叫",
+ "settings.permissions.tool.coord_team.title": "協調團隊",
+ "settings.permissions.tool.coord_team.description": "建立、列出並刪除協調團隊",
+ "settings.permissions.tool.coord_member.title": "協調成員",
+ "settings.permissions.tool.coord_member.description": "新增或移除協調團隊成員",
+ "settings.permissions.tool.coord_message.title": "協調訊息",
+ "settings.permissions.tool.coord_message.description": "傳送並管理團隊收件匣訊息",
+ "settings.permissions.tool.coord_task.title": "協調任務",
+ "settings.permissions.tool.coord_task.description": "建立並管理協調任務",
+ "settings.permissions.tool.coord_session.title": "協調工作階段",
+ "settings.permissions.tool.coord_session.description": "將工作階段連結至協調團隊",
"session.delete.failed.title": "刪除工作階段失敗",
"session.delete.title": "刪除工作階段",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 31f9e3fb7c3..6b353031fcc 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -37,7 +37,7 @@ import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
-import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
+import { SessionHeader, SessionContextTab, SessionWorkersTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
@@ -268,6 +268,11 @@ export default function Page() {
const next = normalizeTab(value)
tabs().open(next)
+ if (next === "workers") {
+ openReviewPanel()
+ return
+ }
+
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
@@ -884,12 +889,21 @@ export default function Page() {
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
+ const workersOpen = createMemo(() => tabs().active() === "workers" || tabs().all().includes("workers"))
const openedTabs = createMemo(() =>
tabs()
.all()
- .filter((tab) => tab !== "context" && tab !== "review"),
+ .filter((tab) => tab !== "context" && tab !== "review" && tab !== "workers"),
)
+ const workersSummary = createMemo(() => {
+ const id = params.id
+ if (!id) return
+ return sync.data.coord[id]
+ })
+
+ const hasWorkers = createMemo(() => !!workersSummary())
+
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
@@ -1149,12 +1163,14 @@ export default function Page() {
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
+ if (active === "workers" && hasWorkers()) return "workers"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
+ if (workersOpen() && hasWorkers()) return "workers"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
@@ -1165,10 +1181,11 @@ export default function Page() {
return active
})
+
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
- if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return
+ if (openedTabs().length === 0 && !contextOpen() && !hasWorkers() && !(reviewTab() && hasReview())) return
const next = activeTab()
if (next === "empty") return
@@ -1190,6 +1207,10 @@ export default function Page() {
}
if (fileTreeTab() !== "changes") return
+ if (hasWorkers()) {
+ tabs().setActive("workers")
+ return
+ }
tabs().setActive("review")
},
{ defer: true },
@@ -1202,7 +1223,7 @@ export default function Page() {
if (fileTreeTab() !== "all") return
const active = tabs().active()
- if (active && active !== "review") return
+ if (active && active !== "review" && active !== "workers") return
const first = openedTabs()[0]
if (first) {
@@ -1210,7 +1231,12 @@ export default function Page() {
return
}
- if (contextOpen()) tabs().setActive("context")
+ if (contextOpen()) {
+ tabs().setActive("context")
+ return
+ }
+
+ if (hasWorkers()) tabs().setActive("workers")
})
createEffect(() => {
@@ -1227,6 +1253,15 @@ export default function Page() {
void sync.session.diff(id)
})
+ createEffect(() => {
+ const id = params.id
+ if (!id) return
+ if (sync.data.coord[id] !== undefined) return
+ if (sync.status === "loading") return
+
+ void sync.session.coord(id)
+ })
+
let treeDir: string | undefined
createEffect(() => {
const dir = sdk.directory
@@ -1702,6 +1737,7 @@ export default function Page() {
reviewCount={reviewCount()}
reviewTab={reviewTab()}
contextOpen={contextOpen}
+ workersOpen={workersOpen}
openedTabs={openedTabs}
activeTab={activeTab}
activeFileTab={activeFileTab}
@@ -1713,6 +1749,7 @@ export default function Page() {
visibleUserMessages={visibleUserMessages as () => unknown[]}
view={view}
info={info as () => unknown}
+ workersSummary={workersSummary}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index d9460cc1a76..61ce15bf947 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -6,7 +6,8 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
-import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
+import type { CoordTeamSummary } from "@opencode-ai/sdk/v2/client"
+import { SessionContextTab, SessionWorkersTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
@@ -36,6 +37,7 @@ export function SessionSidePanel(props: {
reviewCount: number
reviewTab: boolean
contextOpen: () => boolean
+ workersOpen: () => boolean
openedTabs: () => string[]
activeTab: () => string
activeFileTab: () => string | undefined
@@ -47,6 +49,7 @@ export function SessionSidePanel(props: {
visibleUserMessages: () => unknown[]
view: () => ReturnType["view"]>
info: () => unknown
+ workersSummary: () => CoordTeamSummary | null | undefined
handoffFiles: () => Record | undefined
codeComponent: NonNullable
addCommentToContext: (input: {
@@ -136,6 +139,28 @@ export function SessionSidePanel(props: {
+
+
+ props.tabs().close("workers")}
+ aria-label={props.language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => props.tabs().close("workers")}
+ >
+
+
{props.language.t("session.tab.workers")}
+
+
+
{(tab) => }
@@ -196,6 +221,15 @@ export function SessionSidePanel(props: {
+
+
+
+
+
+
+
+
+
{(tab) => (
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index e338559be7e..48ecd3a8858 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -60,11 +60,17 @@ export namespace Agent {
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
},
- question: "deny",
- plan_enter: "deny",
- plan_exit: "deny",
- // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
- read: {
+ question: "deny",
+ plan_enter: "deny",
+ plan_exit: "deny",
+ coord_team: "ask",
+ coord_member: "ask",
+ coord_message: "ask",
+ coord_task: "ask",
+ coord_session: "ask",
+ // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
+ read: {
+
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
diff --git a/packages/opencode/src/cli/cmd/tui/component/coord-workers.tsx b/packages/opencode/src/cli/cmd/tui/component/coord-workers.tsx
new file mode 100644
index 00000000000..7e68a51e80a
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/coord-workers.tsx
@@ -0,0 +1,41 @@
+import { createMemo, For, Show } from "solid-js"
+import type { CoordTeamSummary } from "@opencode-ai/sdk/v2"
+import { useTheme } from "@tui/context/theme"
+
+export function CoordWorkers(props: { summary: CoordTeamSummary | null }) {
+ const { theme } = useTheme()
+ const members = createMemo(() => props.summary?.team.members ?? [])
+ const inbox = createMemo(() => props.summary?.inbox ?? [])
+ const unreadFor = (name: string) => inbox().find((item) => item.name === name)?.unread ?? 0
+
+ return (
+
+ No workers yet.}
+ >
+
+
+ {props.summary?.team.name}
+
+
+ {(desc) => {desc()}}
+
+
+
+ {(member) => (
+
+
+ {member.name}
+ {member.agentType}
+
+ 0}>
+ {(count) => ● {count()}}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index eb8ed2d9bba..c083f131a1e 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -17,6 +17,7 @@ import type {
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
+ CoordTeamSummary,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@@ -57,6 +58,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
todo: {
[sessionID: string]: Todo[]
}
+ coord: {
+ [sessionID: string]: CoordTeamSummary | null
+ }
message: {
[sessionID: string]: Message[]
}
@@ -92,6 +96,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session_status: {},
session_diff: {},
todo: {},
+ coord: {},
message: {},
part: {},
lsp: [],
@@ -185,10 +190,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
- case "todo.updated":
+ case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
+ case "coord.summary.updated":
+ setStore("coord", event.properties.sessionID, reconcile(event.properties.summary))
+ break
+
+
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
@@ -441,11 +451,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
- const [session, messages, todo, diff] = await Promise.all([
+ const [session, messages, todo, diff, coord] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
+ sdk.client.session.coord({ sessionID }),
])
setStore(
produce((draft) => {
@@ -453,6 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
+ draft.coord[sessionID] = coord.data ?? null
draft.message[sessionID] = messages.data!.map((x) => x.info)
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 4ffe91558ed..fb646f55d1f 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -11,6 +11,7 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
+import { CoordWorkers } from "../../component/coord-workers"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -18,6 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const session = createMemo(() => sync.session.get(props.sessionID)!)
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
+ const coord = createMemo(() => sync.data.coord[props.sessionID] ?? null)
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const [expanded, setExpanded] = createStore({
@@ -202,25 +204,36 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
- 0 && todo().some((t) => t.status !== "completed")}>
-
- todo().length > 2 && setExpanded("todo", !expanded.todo)}
- >
- 2}>
- {expanded.todo ? "▼" : "▶"}
-
-
- Todo
-
-
-
- {(todo) => }
-
-
-
+
+
+
+
+ Workers
+
+
+
+
+
+ 0 && todo().some((t) => t.status !== "completed")}>
+
+ todo().length > 2 && setExpanded("todo", !expanded.todo)}
+ >
+ 2}>
+ {expanded.todo ? "▼" : "▶"}
+
+
+ Todo
+
+
+
+ {(todo) => }
+
+
+
+
0}>
[] as Message[])
+ if (!Array.isArray(inbox)) return []
+ return inbox
+}
+
+async function writeInbox(file: string, messages: Message[]) {
+ await Bun.write(file, JSON.stringify(messages, null, 2))
+}
+
+export async function sendMessage(input: { teamID: string; recipient: string; message: MessageInput }) {
+ const file = Paths.inboxFile(input.teamID, input.recipient)
+ await Paths.ensureTeam(input.teamID)
+ using _ = await Lock.write(file)
+ const messages = await readInbox(file)
+ messages.push(createMessage(input.message))
+ await writeInbox(file, messages)
+ await Storage.write(["coord", "inbox", input.teamID, input.recipient], messages)
+}
+
+export async function inbox(input: { teamID: string; member: string }) {
+ const file = Paths.inboxFile(input.teamID, input.member)
+ using _ = await Lock.read(file)
+ const messages = await readInbox(file)
+ return messages.map((message, index) => ({ ...message, index }))
+}
+
+export async function unread(input: { teamID: string; member: string }) {
+ const messages = await inbox(input)
+ return messages.filter((message) => !message.read)
+}
+
+export async function markRead(input: { teamID: string; member: string; index?: number }) {
+ const file = Paths.inboxFile(input.teamID, input.member)
+ using _ = await Lock.write(file)
+ const messages = await readInbox(file)
+ if (input.index === undefined) {
+ messages.forEach((message) => {
+ message.read = true
+ })
+ }
+ if (input.index !== undefined && messages[input.index]) {
+ messages[input.index].read = true
+ }
+ await writeInbox(file, messages)
+ await Storage.write(["coord", "inbox", input.teamID, input.member], messages)
+}
+
+export async function broadcast(input: { teamID: string; from: string; recipients: string[]; content: string; summary: string }) {
+ const payload: MessageInput = {
+ type: "broadcast",
+ from: input.from,
+ content: input.content,
+ summary: input.summary,
+ }
+ await Promise.all(
+ input.recipients
+ .filter((recipient) => recipient !== input.from)
+ .map((recipient) =>
+ sendMessage({
+ teamID: input.teamID,
+ recipient,
+ message: payload,
+ }),
+ ),
+ )
+}
+
+export async function loadCachedInbox(input: { teamID: string; member: string }) {
+ return Storage.read(["coord", "inbox", input.teamID, input.member]).catch(() => [])
+}
+
+export function parseInbox(input: unknown) {
+ if (!Array.isArray(input)) return []
+ return input.map((item) => safeValidate(item)).filter((item): item is Message => !!item)
+}
+
+export async function upsertFromStorage(input: { teamID: string; member: string; messages: Message[] }) {
+ const file = Paths.inboxFile(input.teamID, input.member)
+ await Paths.ensureTeam(input.teamID)
+ using _ = await Lock.write(file)
+ await writeInbox(file, input.messages)
+ await Storage.write(["coord", "inbox", input.teamID, input.member], input.messages)
+}
diff --git a/packages/opencode/src/coord/index.ts b/packages/opencode/src/coord/index.ts
new file mode 100644
index 00000000000..9b860cd568c
--- /dev/null
+++ b/packages/opencode/src/coord/index.ts
@@ -0,0 +1,7 @@
+export * as CoordTeam from "./team"
+export * as CoordInbox from "./inbox"
+export * as CoordTask from "./task"
+export * as CoordProtocol from "./protocol"
+export * as CoordPaths from "./paths"
+export * as CoordSession from "./session"
+export * as CoordSummary from "./summary"
diff --git a/packages/opencode/src/coord/paths.ts b/packages/opencode/src/coord/paths.ts
new file mode 100644
index 00000000000..3d23041a009
--- /dev/null
+++ b/packages/opencode/src/coord/paths.ts
@@ -0,0 +1,51 @@
+import path from "path"
+import fs from "fs/promises"
+import { Instance } from "@/project/instance"
+
+const ROOT = ".opencode/.teams"
+
+export function root() {
+ return path.join(Instance.worktree, ROOT)
+}
+
+export function indexPath() {
+ return path.join(root(), "index.json")
+}
+
+export function teamDir(id: string) {
+ return path.join(root(), id)
+}
+
+export function teamFile(id: string) {
+ return path.join(teamDir(id), "team.json")
+}
+
+export function inboxDir(id: string) {
+ return path.join(teamDir(id), "inboxes")
+}
+
+export function inboxFile(id: string, name: string) {
+ return path.join(inboxDir(id), `${name}.json`)
+}
+
+export function taskDir(id: string) {
+ return path.join(teamDir(id), "tasks")
+}
+
+export function taskFile(id: string, taskID: string) {
+ return path.join(taskDir(id), `task-${taskID}.json`)
+}
+
+export function counterFile(id: string) {
+ return path.join(taskDir(id), ".counter")
+}
+
+export async function ensureRoot() {
+ await fs.mkdir(root(), { recursive: true })
+}
+
+export async function ensureTeam(id: string) {
+ await fs.mkdir(teamDir(id), { recursive: true })
+ await fs.mkdir(inboxDir(id), { recursive: true })
+ await fs.mkdir(taskDir(id), { recursive: true })
+}
diff --git a/packages/opencode/src/coord/protocol.ts b/packages/opencode/src/coord/protocol.ts
new file mode 100644
index 00000000000..30a6f03d61e
--- /dev/null
+++ b/packages/opencode/src/coord/protocol.ts
@@ -0,0 +1,160 @@
+import z from "zod"
+
+const BaseMessage = z.object({
+ from: z.string(),
+ timestamp: z.string().datetime(),
+ read: z.boolean().default(false),
+})
+
+export const MessageSchema = BaseMessage.extend({
+ type: z.literal("message"),
+ recipient: z.string(),
+ content: z.string(),
+ summary: z.string(),
+})
+
+export const BroadcastSchema = BaseMessage.extend({
+ type: z.literal("broadcast"),
+ content: z.string(),
+ summary: z.string(),
+})
+
+export const ShutdownRequestSchema = BaseMessage.extend({
+ type: z.literal("shutdown_request"),
+ recipient: z.string(),
+ requestId: z.string(),
+ content: z.string().optional(),
+})
+
+export const ShutdownApprovedSchema = BaseMessage.extend({
+ type: z.literal("shutdown_approved"),
+ requestId: z.string(),
+})
+
+export const ShutdownRejectedSchema = BaseMessage.extend({
+ type: z.literal("shutdown_rejected"),
+ requestId: z.string(),
+ content: z.string().optional(),
+})
+
+export const PlanApprovalRequestSchema = BaseMessage.extend({
+ type: z.literal("plan_approval_request"),
+ requestId: z.string(),
+ planContent: z.string(),
+})
+
+export const PlanApprovalResponseSchema = BaseMessage.extend({
+ type: z.literal("plan_approval_response"),
+ requestId: z.string(),
+ approved: z.boolean(),
+ content: z.string().optional(),
+})
+
+export const IdleNotificationSchema = BaseMessage.extend({
+ type: z.literal("idle_notification"),
+ lastTaskId: z.string().optional(),
+ peerSummary: z.string().optional(),
+})
+
+export const TaskAssignmentSchema = BaseMessage.extend({
+ type: z.literal("task_assignment"),
+ taskId: z.string(),
+ recipient: z.string(),
+})
+
+export const TeammateTerminatedSchema = BaseMessage.extend({
+ type: z.literal("teammate_terminated"),
+ agentName: z.string(),
+ reason: z.string().optional(),
+})
+
+export const PermissionRequestSchema = BaseMessage.extend({
+ type: z.literal("permission_request"),
+ requestId: z.string(),
+ action: z.string(),
+ details: z.string().optional(),
+})
+
+export const PermissionResponseSchema = BaseMessage.extend({
+ type: z.literal("permission_response"),
+ requestId: z.string(),
+ granted: z.boolean(),
+ reason: z.string().optional(),
+})
+
+export const SandboxPermissionRequestSchema = BaseMessage.extend({
+ type: z.literal("sandbox_permission_request"),
+ requestId: z.string(),
+ command: z.string(),
+ details: z.string().optional(),
+})
+
+export const SandboxPermissionResponseSchema = BaseMessage.extend({
+ type: z.literal("sandbox_permission_response"),
+ requestId: z.string(),
+ granted: z.boolean(),
+ reason: z.string().optional(),
+})
+
+export const Message = z.discriminatedUnion("type", [
+ MessageSchema,
+ BroadcastSchema,
+ ShutdownRequestSchema,
+ ShutdownApprovedSchema,
+ ShutdownRejectedSchema,
+ PlanApprovalRequestSchema,
+ PlanApprovalResponseSchema,
+ IdleNotificationSchema,
+ TaskAssignmentSchema,
+ TeammateTerminatedSchema,
+ PermissionRequestSchema,
+ PermissionResponseSchema,
+ SandboxPermissionRequestSchema,
+ SandboxPermissionResponseSchema,
+]).meta({
+ ref: "CoordMessage",
+})
+
+export type Message = z.infer
+export type MessageInput = DistributiveOmit
+
+type DistributiveOmit = T extends unknown ? Omit : never
+
+export const MessageInputSchema = z.discriminatedUnion("type", [
+ MessageSchema.omit({ from: true, timestamp: true, read: true }),
+ BroadcastSchema.omit({ from: true, timestamp: true, read: true }),
+ ShutdownRequestSchema.omit({ from: true, timestamp: true, read: true }),
+ ShutdownApprovedSchema.omit({ from: true, timestamp: true, read: true }),
+ ShutdownRejectedSchema.omit({ from: true, timestamp: true, read: true }),
+ PlanApprovalRequestSchema.omit({ from: true, timestamp: true, read: true }),
+ PlanApprovalResponseSchema.omit({ from: true, timestamp: true, read: true }),
+ IdleNotificationSchema.omit({ from: true, timestamp: true, read: true }),
+ TaskAssignmentSchema.omit({ from: true, timestamp: true, read: true }),
+ TeammateTerminatedSchema.omit({ from: true, timestamp: true, read: true }),
+ PermissionRequestSchema.omit({ from: true, timestamp: true, read: true }),
+ PermissionResponseSchema.omit({ from: true, timestamp: true, read: true }),
+ SandboxPermissionRequestSchema.omit({ from: true, timestamp: true, read: true }),
+ SandboxPermissionResponseSchema.omit({ from: true, timestamp: true, read: true }),
+])
+
+export function validate(input: unknown) {
+ return Message.parse(input)
+}
+
+export function safeValidate(input: unknown) {
+ const result = Message.safeParse(input)
+ if (result.success) return result.data
+ return
+}
+
+export function createMessage(input: T): T & { timestamp: string; read: boolean } {
+ return {
+ ...input,
+ timestamp: new Date().toISOString(),
+ read: false,
+ }
+}
+
+export function generateRequestId() {
+ return `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
diff --git a/packages/opencode/src/coord/session.ts b/packages/opencode/src/coord/session.ts
new file mode 100644
index 00000000000..9ee121ae79c
--- /dev/null
+++ b/packages/opencode/src/coord/session.ts
@@ -0,0 +1,50 @@
+import { Storage } from "@/storage/storage"
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
+import z from "zod"
+import * as CoordTeam from "./team"
+
+export const SessionTeam = z.object({
+ sessionID: z.string(),
+ teamID: z.string(),
+}).meta({
+ ref: "CoordSessionTeam",
+})
+
+export type SessionTeam = z.infer
+
+export const Event = {
+ Updated: BusEvent.define(
+ "coord.session.updated",
+ z.object({
+ sessionID: z.string(),
+ teamID: z.string(),
+ }),
+ ),
+}
+
+export async function setTeam(sessionID: string, teamID: string) {
+ await Storage.write(["coord", "session", sessionID], { sessionID, teamID })
+ Bus.publish(Event.Updated, { sessionID, teamID })
+ return { sessionID, teamID }
+}
+
+export async function getTeam(sessionID: string) {
+ return Storage.read(["coord", "session", sessionID]).catch(() => undefined)
+}
+
+export async function teamInfo(sessionID: string) {
+ const link = await getTeam(sessionID)
+ if (!link) return
+ const team = await CoordTeam.getTeam(link.teamID)
+ if (!team) return
+ return { link, team }
+}
+
+export async function clearTeam(teamID: string) {
+ const items = await Storage.list(["coord", "session"])
+ const links = await Promise.all(items.map((item) => Storage.read(item).catch(() => undefined)))
+ const targets = links.filter((link): link is SessionTeam => !!link && link.teamID === teamID)
+ await Promise.all(targets.map((link) => Storage.remove(["coord", "session", link.sessionID])))
+ return targets.map((link) => link.sessionID)
+}
diff --git a/packages/opencode/src/coord/summary.ts b/packages/opencode/src/coord/summary.ts
new file mode 100644
index 00000000000..5ba1305c808
--- /dev/null
+++ b/packages/opencode/src/coord/summary.ts
@@ -0,0 +1,51 @@
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
+import z from "zod"
+import * as CoordTeam from "./team"
+import * as CoordInbox from "./inbox"
+
+export const MemberInbox = z.object({
+ name: z.string(),
+ unread: z.number(),
+ total: z.number(),
+}).meta({
+ ref: "CoordMemberInbox",
+})
+
+export const TeamSummary = z.object({
+ team: CoordTeam.TeamConfig,
+ inbox: MemberInbox.array(),
+}).meta({
+ ref: "CoordTeamSummary",
+})
+
+export type TeamSummary = z.infer
+
+export const Event = {
+ Updated: BusEvent.define(
+ "coord.summary.updated",
+ z.object({
+ sessionID: z.string(),
+ summary: TeamSummary,
+ }),
+ ),
+}
+
+export async function summarize(sessionID: string, teamID: string) {
+ const team = await CoordTeam.getTeam(teamID)
+ if (!team) return
+ const inbox = await Promise.all(
+ team.members.map(async (member) => {
+ const messages = await CoordInbox.inbox({ teamID, member: member.name }).catch(() => [])
+ const unread = messages.filter((message) => !message.read).length
+ return {
+ name: member.name,
+ unread,
+ total: messages.length,
+ }
+ }),
+ )
+ const summary = TeamSummary.parse({ team, inbox })
+ Bus.publish(Event.Updated, { sessionID, summary })
+ return summary
+}
diff --git a/packages/opencode/src/coord/task.ts b/packages/opencode/src/coord/task.ts
new file mode 100644
index 00000000000..508afd34972
--- /dev/null
+++ b/packages/opencode/src/coord/task.ts
@@ -0,0 +1,179 @@
+import z from "zod"
+import { Lock } from "@/util/lock"
+import { Storage } from "@/storage/storage"
+import * as Paths from "./paths"
+
+export const Task = z.object({
+ id: z.string(),
+ subject: z.string(),
+ description: z.string(),
+ status: z.enum(["pending", "in_progress", "completed", "deleted"]),
+ owner: z.string().nullable(),
+ blockedBy: z.array(z.string()),
+ blocks: z.array(z.string()),
+ activeForm: z.string().nullable(),
+ metadata: z.record(z.string(), z.any()),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+}).meta({
+ ref: "CoordTask",
+})
+
+export type Task = z.infer
+
+export const TaskSummary = Task.pick({
+ id: true,
+ subject: true,
+ status: true,
+ owner: true,
+ blockedBy: true,
+}).meta({
+ ref: "CoordTaskSummary",
+})
+
+export type TaskSummary = z.infer
+
+async function readTask(file: string) {
+ const raw = await Bun.file(file).json().catch(() => undefined)
+ if (!raw) return
+ const parsed = Task.safeParse(raw)
+ if (parsed.success) return parsed.data
+}
+
+async function writeTask(file: string, task: Task) {
+ await Bun.write(file, JSON.stringify(task, null, 2))
+ await Storage.write(["coord", "task", task.id], task)
+}
+
+function unresolved(teamID: string, blockedBy: string[]) {
+ return Promise.all(
+ blockedBy.map(async (id) => {
+ const task = await readTask(Paths.taskFile(teamID, id))
+ if (!task) return
+ if (task.status !== "completed") return id
+ }),
+ ).then((items) => items.filter((item): item is string => !!item))
+}
+
+async function nextId(teamID: string) {
+ const file = Paths.counterFile(teamID)
+ using _ = await Lock.write(file)
+ const raw = await Bun.file(file)
+ .text()
+ .then((x) => parseInt(x.trim(), 10))
+ .catch(() => 0)
+ const next = Number.isFinite(raw) ? raw + 1 : 1
+ await Bun.write(file, String(next))
+ return String(next)
+}
+
+export async function createTask(input: {
+ teamID: string
+ subject: string
+ description?: string
+ activeForm?: string
+ blockedBy?: string[]
+ blocks?: string[]
+ metadata?: Record
+}) {
+ await Paths.ensureTeam(input.teamID)
+ const id = await nextId(input.teamID)
+ const now = new Date().toISOString()
+ const task: Task = {
+ id,
+ subject: input.subject,
+ description: input.description ?? "",
+ status: "pending",
+ owner: null,
+ blockedBy: input.blockedBy ?? [],
+ blocks: input.blocks ?? [],
+ activeForm: input.activeForm ?? null,
+ metadata: input.metadata ?? {},
+ createdAt: now,
+ updatedAt: now,
+ }
+ const file = Paths.taskFile(input.teamID, id)
+ await writeTask(file, task)
+ return task
+}
+
+export async function listTasks(teamID: string) {
+ await Paths.ensureTeam(teamID)
+ const dir = Paths.taskDir(teamID)
+ const glob = new Bun.Glob("task-*.json")
+ const tasks: TaskSummary[] = []
+ for await (const file of glob.scan({ cwd: dir, absolute: true })) {
+ const task = await readTask(file)
+ if (!task || task.status === "deleted") continue
+ tasks.push({
+ id: task.id,
+ subject: task.subject,
+ status: task.status,
+ owner: task.owner,
+ blockedBy: await unresolved(teamID, task.blockedBy),
+ })
+ }
+ return tasks.sort((a, b) => Number(a.id) - Number(b.id))
+}
+
+export async function getTask(teamID: string, taskID: string) {
+ return readTask(Paths.taskFile(teamID, taskID))
+}
+
+export async function updateTask(
+ teamID: string,
+ taskID: string,
+ updates: Partial> & {
+ addBlockedBy?: string[]
+ addBlocks?: string[]
+ },
+) {
+ const file = Paths.taskFile(teamID, taskID)
+ using _ = await Lock.write(file)
+ const task = await readTask(file)
+ if (!task) return
+
+ if (updates.subject !== undefined) task.subject = updates.subject
+ if (updates.description !== undefined) task.description = updates.description
+ if (updates.status !== undefined) task.status = updates.status
+ if (updates.owner !== undefined) task.owner = updates.owner
+ if (updates.activeForm !== undefined) task.activeForm = updates.activeForm
+ if (updates.metadata !== undefined) task.metadata = { ...task.metadata, ...updates.metadata }
+ if (updates.addBlockedBy) {
+ task.blockedBy = [...new Set([...task.blockedBy, ...updates.addBlockedBy])]
+ }
+ if (updates.addBlocks) {
+ task.blocks = [...new Set([...task.blocks, ...updates.addBlocks])]
+ }
+
+ task.updatedAt = new Date().toISOString()
+ await writeTask(file, task)
+ return task
+}
+
+export async function claimTask(teamID: string, taskID: string, owner: string) {
+ const file = Paths.taskFile(teamID, taskID)
+ using _ = await Lock.write(file)
+ const task = await readTask(file)
+ if (!task) return { error: `Task ${taskID} not found` }
+ if (task.status !== "pending") return { error: `Task ${taskID} is not pending (status: ${task.status})` }
+
+ const blockers = await unresolved(teamID, task.blockedBy)
+ if (blockers.length > 0) {
+ return { error: `Task ${taskID} is blocked by: ${blockers.join(", ")}` }
+ }
+
+ task.status = "in_progress"
+ task.owner = owner
+ task.updatedAt = new Date().toISOString()
+ await writeTask(file, task)
+ return task
+}
+
+export async function completeTask(teamID: string, taskID: string) {
+ return updateTask(teamID, taskID, { status: "completed" })
+}
+
+export async function deleteTask(teamID: string, taskID: string) {
+ return updateTask(teamID, taskID, { status: "deleted" })
+}
diff --git a/packages/opencode/src/coord/team.ts b/packages/opencode/src/coord/team.ts
new file mode 100644
index 00000000000..36a4f22f87f
--- /dev/null
+++ b/packages/opencode/src/coord/team.ts
@@ -0,0 +1,169 @@
+import z from "zod"
+import path from "path"
+import fs from "fs/promises"
+import { createHash } from "crypto"
+import { Lock } from "@/util/lock"
+import { Storage } from "@/storage/storage"
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
+import * as Paths from "./paths"
+
+const TeamMember = z.object({
+ name: z.string(),
+ agentId: z.string(),
+ agentType: z.string(),
+})
+
+export const TeamConfig = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string(),
+ createdAt: z.string(),
+ members: TeamMember.array(),
+ metadata: z.record(z.string(), z.any()),
+}).meta({
+ ref: "CoordTeam",
+})
+
+export type TeamConfig = z.infer
+export type TeamMember = z.infer
+
+const TeamSummary = TeamConfig.pick({ id: true, name: true, description: true, createdAt: true })
+
+export type TeamSummary = z.infer
+
+export const Event = {
+ Updated: BusEvent.define(
+ "coord.team.updated",
+ z.object({
+ team: TeamConfig,
+ }),
+ ),
+}
+
+function hashId(name: string, team: string) {
+ return createHash("sha256").update(`${name}:${team}`).digest("hex").slice(0, 16)
+}
+
+async function readTeam(id: string) {
+ const filepath = Paths.teamFile(id)
+ const file = Bun.file(filepath)
+ if (!(await file.exists())) return
+ return TeamConfig.parse(await file.json())
+}
+
+async function writeTeam(team: TeamConfig) {
+ await fs.mkdir(path.dirname(Paths.teamFile(team.id)), { recursive: true })
+ await Bun.write(Paths.teamFile(team.id), JSON.stringify(team, null, 2))
+ await Storage.write(["coord", "team", team.id], team)
+ Bus.publish(Event.Updated, { team })
+ return team
+}
+
+async function updateIndex(id: string, info: TeamSummary) {
+ await Paths.ensureRoot()
+ const key = Paths.indexPath()
+ using _ = await Lock.write(key)
+ const file = Bun.file(key)
+ const data: Record = (await file.json().catch(() => ({})))
+ data[id] = info
+ await Bun.write(key, JSON.stringify(data, null, 2))
+ await Storage.write(["coord", "team_index"], data)
+}
+
+async function removeIndex(id: string) {
+ await Paths.ensureRoot()
+ const key = Paths.indexPath()
+ using _ = await Lock.write(key)
+ const file = Bun.file(key)
+ const data: Record = (await file.json().catch(() => ({})))
+ delete data[id]
+ await Bun.write(key, JSON.stringify(data, null, 2))
+ await Storage.write(["coord", "team_index"], data)
+}
+
+export async function createTeam(input: { id: string; name: string; description?: string }) {
+ await Paths.ensureTeam(input.id)
+ const existing = await readTeam(input.id)
+ if (existing) throw new Error(`Team "${input.id}" already exists`)
+ const now = new Date().toISOString()
+ const team: TeamConfig = {
+ id: input.id,
+ name: input.name,
+ description: input.description ?? "",
+ createdAt: now,
+ members: [],
+ metadata: {},
+ }
+ await writeTeam(team)
+ await updateIndex(input.id, TeamSummary.parse(team))
+ return team
+}
+
+export async function deleteTeam(id: string) {
+ const team = await readTeam(id)
+ if (!team) return
+ if (team.members.length > 0) {
+ throw new Error(`Cannot delete team "${id}" with active members`)
+ }
+ await fs.rm(Paths.teamDir(id), { recursive: true, force: true })
+ await Storage.remove(["coord", "team", id])
+ await removeIndex(id)
+}
+
+export async function listTeams() {
+ await Paths.ensureRoot()
+ const key = Paths.indexPath()
+ using _ = await Lock.read(key)
+ const file = Bun.file(key)
+ const data = (await file.json().catch(() => ({}))) as Record
+ return Object.values(data).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
+}
+
+export async function getTeam(id: string) {
+ return readTeam(id)
+}
+
+export async function addMember(input: { teamID: string; name: string; agentType: string }) {
+ const key = Paths.teamFile(input.teamID)
+ using _ = await Lock.write(key)
+ const team = await readTeam(input.teamID)
+ if (!team) throw new Error(`Team "${input.teamID}" not found`)
+ if (team.members.some((m) => m.name === input.name)) {
+ throw new Error(`Member "${input.name}" already exists in team "${input.teamID}"`)
+ }
+ const member: TeamMember = {
+ name: input.name,
+ agentId: hashId(input.name, input.teamID),
+ agentType: input.agentType,
+ }
+ team.members.push(member)
+ await writeTeam(team)
+ await ensureInbox({ teamID: input.teamID, name: input.name })
+ return member
+}
+
+export async function removeMember(input: { teamID: string; name: string }) {
+ const key = Paths.teamFile(input.teamID)
+ using _ = await Lock.write(key)
+ const team = await readTeam(input.teamID)
+ if (!team) throw new Error(`Team "${input.teamID}" not found`)
+ const index = team.members.findIndex((m) => m.name === input.name)
+ if (index === -1) throw new Error(`Member "${input.name}" not found in team "${input.teamID}"`)
+ team.members.splice(index, 1)
+ await writeTeam(team)
+ await fs.rm(Paths.inboxFile(input.teamID, input.name), { force: true })
+}
+
+export async function ensureInbox(input: { teamID: string; name: string }) {
+ await Paths.ensureTeam(input.teamID)
+ const file = Paths.inboxFile(input.teamID, input.name)
+ const exists = await Bun.file(file).exists()
+ if (!exists) await Bun.write(file, "[]")
+}
+
+export async function memberNames(teamID: string) {
+ const team = await readTeam(teamID)
+ if (!team) return []
+ return team.members.map((m) => m.name)
+}
diff --git a/packages/opencode/src/server/routes/coord.ts b/packages/opencode/src/server/routes/coord.ts
new file mode 100644
index 00000000000..dead917a84e
--- /dev/null
+++ b/packages/opencode/src/server/routes/coord.ts
@@ -0,0 +1,377 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { lazy } from "@/util/lazy"
+import { CoordTeam, CoordTask, CoordInbox, CoordProtocol, CoordSession, CoordSummary } from "@/coord"
+import { errors } from "../error"
+
+export const CoordRoutes = lazy(() =>
+ new Hono()
+ .get(
+ "/team",
+ describeRoute({
+ summary: "List coordination teams",
+ description: "List all coordination teams in the current project.",
+ operationId: "coord.team.list",
+ responses: {
+ 200: {
+ description: "List of teams",
+ content: { "application/json": { schema: resolver(CoordTeam.TeamConfig.array()) } },
+ },
+ },
+ }),
+ async (c) => {
+ const teams = await CoordTeam.listTeams()
+ return c.json(teams)
+ },
+ )
+ .post(
+ "/team",
+ describeRoute({
+ summary: "Create coordination team",
+ description: "Create a new coordination team.",
+ operationId: "coord.team.create",
+ responses: {
+ 200: {
+ description: "Created team",
+ content: { "application/json": { schema: resolver(CoordTeam.TeamConfig) } },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ team_id: z.string(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ }),
+ ),
+ async (c) => {
+ const body = c.req.valid("json")
+ const team = await CoordTeam.createTeam({
+ id: body.team_id,
+ name: body.name ?? body.team_id,
+ description: body.description,
+ })
+ return c.json(team)
+ },
+ )
+ .get(
+ "/team/:teamID",
+ describeRoute({
+ summary: "Get coordination team",
+ description: "Get a coordination team by ID.",
+ operationId: "coord.team.get",
+ responses: {
+ 200: {
+ description: "Team",
+ content: { "application/json": { schema: resolver(CoordTeam.TeamConfig) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string() })),
+ async (c) => {
+ const team = await CoordTeam.getTeam(c.req.valid("param").teamID)
+ if (!team) throw new Error("Team not found")
+ return c.json(team)
+ },
+ )
+ .delete(
+ "/team/:teamID",
+ describeRoute({
+ summary: "Delete coordination team",
+ description: "Delete a coordination team.",
+ operationId: "coord.team.delete",
+ responses: {
+ 200: {
+ description: "Deleted",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ deleted: z.boolean(), sessions: z.array(z.string()) })),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string() })),
+ async (c) => {
+ const teamID = c.req.valid("param").teamID
+ const sessions = await CoordSession.clearTeam(teamID)
+ await CoordTeam.deleteTeam(teamID)
+ return c.json({ deleted: true, sessions })
+ },
+ )
+ .delete(
+ "/team/:teamID/member/:name",
+ describeRoute({
+ summary: "Remove team member",
+ description: "Remove a member from a coordination team.",
+ operationId: "coord.member.remove",
+ responses: {
+ 200: {
+ description: "Removed",
+ content: { "application/json": { schema: resolver(z.boolean()) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string(), name: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ await CoordTeam.removeMember({ teamID: params.teamID, name: params.name })
+ return c.json(true)
+ },
+ )
+ .post(
+ "/team/:teamID/message",
+ describeRoute({
+ summary: "Send team message",
+ description: "Send a message to a team member.",
+ operationId: "coord.message.send",
+ responses: {
+ 200: {
+ description: "Sent",
+ content: { "application/json": { schema: resolver(z.boolean()) } },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ recipient: z.string(),
+ message: CoordProtocol.MessageInputSchema,
+ from: z.string().optional(),
+ }),
+ ),
+ validator("param", z.object({ teamID: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const body = c.req.valid("json")
+ const message = { ...body.message, from: body.from ?? "coord" }
+ await CoordInbox.sendMessage({ teamID: params.teamID, recipient: body.recipient, message })
+ return c.json(true)
+ },
+ )
+ .get(
+ "/team/:teamID/inbox/:member",
+ describeRoute({
+ summary: "Read inbox",
+ description: "Read a team member inbox.",
+ operationId: "coord.inbox",
+ responses: {
+ 200: {
+ description: "Inbox",
+ content: { "application/json": { schema: resolver(CoordProtocol.Message.array()) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string(), member: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const messages = await CoordInbox.inbox({ teamID: params.teamID, member: params.member })
+ return c.json(messages)
+ },
+ )
+ .post(
+ "/team/:teamID/inbox/:member/read",
+ describeRoute({
+ summary: "Mark inbox read",
+ description: "Mark messages as read.",
+ operationId: "coord.inbox.read",
+ responses: {
+ 200: {
+ description: "Updated",
+ content: { "application/json": { schema: resolver(z.boolean()) } },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ index: z.number().int().optional(),
+ }),
+ ),
+ validator("param", z.object({ teamID: z.string(), member: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const body = c.req.valid("json")
+ await CoordInbox.markRead({ teamID: params.teamID, member: params.member, index: body.index })
+ return c.json(true)
+ },
+ )
+ .get(
+ "/team/:teamID/task",
+ describeRoute({
+ summary: "List tasks",
+ description: "List tasks for a coordination team.",
+ operationId: "coord.task.list",
+ responses: {
+ 200: {
+ description: "Task list",
+ content: { "application/json": { schema: resolver(CoordTask.TaskSummary.array()) } },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string() })),
+ async (c) => {
+ const tasks = await CoordTask.listTasks(c.req.valid("param").teamID)
+ return c.json(tasks)
+ },
+ )
+ .post(
+ "/team/:teamID/task",
+ describeRoute({
+ summary: "Create task",
+ description: "Create a task for a coordination team.",
+ operationId: "coord.task.create",
+ responses: {
+ 200: {
+ description: "Task",
+ content: { "application/json": { schema: resolver(CoordTask.Task) } },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ subject: z.string(),
+ description: z.string().optional(),
+ active_form: z.string().optional(),
+ blocked_by: z.array(z.string()).optional(),
+ }),
+ ),
+ validator("param", z.object({ teamID: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const body = c.req.valid("json")
+ const task = await CoordTask.createTask({
+ teamID: params.teamID,
+ subject: body.subject,
+ description: body.description,
+ activeForm: body.active_form,
+ blockedBy: body.blocked_by,
+ })
+ return c.json(task)
+ },
+ )
+ .patch(
+ "/team/:teamID/task/:taskID",
+ describeRoute({
+ summary: "Update task",
+ description: "Update a coordination task.",
+ operationId: "coord.task.update",
+ responses: {
+ 200: {
+ description: "Task",
+ content: { "application/json": { schema: resolver(CoordTask.Task) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ subject: z.string().optional(),
+ description: z.string().optional(),
+ status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
+ owner: z.string().optional(),
+ }),
+ ),
+ validator("param", z.object({ teamID: z.string(), taskID: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const body = c.req.valid("json")
+ const task = await CoordTask.updateTask(params.teamID, params.taskID, {
+ subject: body.subject,
+ description: body.description,
+ status: body.status,
+ owner: body.owner,
+ })
+ if (!task) throw new Error("Task not found")
+ return c.json(task)
+ },
+ )
+ .post(
+ "/team/:teamID/task/:taskID/claim",
+ describeRoute({
+ summary: "Claim task",
+ description: "Claim a coordination task.",
+ operationId: "coord.task.claim",
+ responses: {
+ 200: {
+ description: "Task",
+ content: { "application/json": { schema: resolver(CoordTask.Task) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ owner: z.string(),
+ }),
+ ),
+ validator("param", z.object({ teamID: z.string(), taskID: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const body = c.req.valid("json")
+ const result = await CoordTask.claimTask(params.teamID, params.taskID, body.owner)
+ if ("error" in result) throw new Error(result.error)
+ return c.json(result)
+ },
+ )
+ .post(
+ "/team/:teamID/task/:taskID/complete",
+ describeRoute({
+ summary: "Complete task",
+ description: "Complete a coordination task.",
+ operationId: "coord.task.complete",
+ responses: {
+ 200: {
+ description: "Task",
+ content: { "application/json": { schema: resolver(CoordTask.Task) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ teamID: z.string(), taskID: z.string() })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const task = await CoordTask.completeTask(params.teamID, params.taskID)
+ if (!task) throw new Error("Task not found")
+ return c.json(task)
+ },
+ )
+
+ .get(
+ "/session/:sessionID",
+ describeRoute({
+ summary: "Get session team",
+ description: "Get coordination team summary for a session.",
+ operationId: "coord.session",
+ responses: {
+ 200: {
+ description: "Session summary",
+ content: { "application/json": { schema: resolver(CoordSummary.TeamSummary.nullable()) } },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator("param", z.object({ sessionID: z.string() })),
+ async (c) => {
+ const sessionID = c.req.valid("param").sessionID
+ const link = await CoordSession.getTeam(sessionID)
+ if (!link) return c.json(null)
+ const summary = await CoordSummary.summarize(sessionID, link.teamID)
+ return c.json(summary ?? null)
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 82e6f3121bf..e5a9c52c5b8 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -14,6 +14,7 @@ import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
+import { CoordSession, CoordSummary } from "@/coord"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -182,6 +183,38 @@ export const SessionRoutes = lazy(() =>
return c.json(todos)
},
)
+ .get(
+ "/:sessionID/coord",
+ describeRoute({
+ summary: "Get session coordination",
+ description: "Retrieve the coordination team for a session, including inbox summary.",
+ operationId: "session.coord",
+ responses: {
+ 200: {
+ description: "Coordination summary",
+ content: {
+ "application/json": {
+ schema: resolver(CoordSummary.TeamSummary.nullable()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ sessionID: z.string().meta({ description: "Session ID" }),
+ }),
+ ),
+ async (c) => {
+ const sessionID = c.req.valid("param").sessionID
+ const link = await CoordSession.getTeam(sessionID)
+ if (!link) return c.json(null)
+ const summary = await CoordSummary.summarize(sessionID, link.teamID)
+ return c.json(summary ?? null)
+ },
+ )
.post(
"/",
describeRoute({
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 015553802a4..e43c6e82f22 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -39,6 +39,7 @@ import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
+import { CoordRoutes } from "./routes/coord"
import { MDNS } from "./mdns"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
@@ -221,6 +222,7 @@ export namespace Server {
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
+ .route("/coord", CoordRoutes())
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 556fad01f59..86c53571ad3 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -22,6 +22,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
+import { CoordSession, CoordSummary } from "@/coord"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -243,6 +244,8 @@ export namespace Session {
Bus.publish(Event.Updated, {
info: result,
})
+ const coord = await CoordSession.getTeam(result.id).catch(() => undefined)
+ if (coord) await CoordSummary.summarize(result.id, coord.teamID)
return result
}
@@ -305,6 +308,8 @@ export namespace Session {
Bus.publish(Event.Updated, {
info: result,
})
+ const coord = await CoordSession.getTeam(result.id).catch(() => undefined)
+ if (coord) await CoordSummary.summarize(result.id, coord.teamID)
return result
}
diff --git a/packages/opencode/src/tool/coord-member.ts b/packages/opencode/src/tool/coord-member.ts
new file mode 100644
index 00000000000..a58ad3ba331
--- /dev/null
+++ b/packages/opencode/src/tool/coord-member.ts
@@ -0,0 +1,49 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { CoordSummary, CoordTeam } from "@/coord"
+
+const parameters = z.object({
+ action: z.enum(["add", "remove"]).describe("Member action"),
+ team_id: z.string().describe("Team ID"),
+ name: z.string().describe("Member name"),
+ agent_type: z.string().describe("Member agent type").optional(),
+})
+
+export const CoordMemberTool = Tool.define("coord_member", {
+ description: "Manage coordination team members (add/remove).",
+ parameters,
+ async execute(params, ctx): Promise<{ title: string; output: string; metadata: { member: CoordTeam.TeamMember | undefined; members: CoordTeam.TeamMember[] } }> {
+ await ctx.ask({
+ permission: "coord_member",
+ patterns: [params.team_id],
+ always: ["*"],
+ metadata: {
+ action: params.action,
+ team_id: params.team_id,
+ name: params.name,
+ },
+ })
+
+ if (params.action === "add") {
+ const member = await CoordTeam.addMember({
+ teamID: params.team_id,
+ name: params.name,
+ agentType: params.agent_type ?? "general",
+ })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Member ${member.name} added`,
+ output: JSON.stringify(member, null, 2),
+ metadata: { member, members: [] },
+ }
+ }
+
+ await CoordTeam.removeMember({ teamID: params.team_id, name: params.name })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Member ${params.name} removed`,
+ output: "true",
+ metadata: { member: undefined, members: [] },
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/coord-message.ts b/packages/opencode/src/tool/coord-message.ts
new file mode 100644
index 00000000000..fa7489a543c
--- /dev/null
+++ b/packages/opencode/src/tool/coord-message.ts
@@ -0,0 +1,108 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { CoordInbox, CoordProtocol, CoordSummary, CoordTeam } from "@/coord"
+
+const parameters = z.object({
+ action: z.enum(["send", "broadcast", "inbox", "unread", "mark_read"]).describe("Message action"),
+ team_id: z.string().describe("Team ID"),
+ recipient: z.string().describe("Recipient member name").optional(),
+ message: CoordProtocol.MessageInputSchema.optional(),
+ from: z.string().describe("Sender name").optional(),
+ content: z.string().describe("Message content").optional(),
+ summary: z.string().describe("Message summary").optional(),
+ index: z.number().int().optional(),
+})
+
+export const CoordMessageTool = Tool.define("coord_message", {
+ description: "Send and read coordination messages.",
+ parameters,
+ async execute(params, ctx): Promise<{ title: string; output: string; metadata: { messages: CoordProtocol.Message[]; sent: boolean } }> {
+ await ctx.ask({
+ permission: "coord_message",
+ patterns: [params.action],
+ always: ["*"],
+ metadata: {
+ action: params.action,
+ team_id: params.team_id,
+ recipient: params.recipient,
+ },
+ })
+
+ if (params.action === "inbox") {
+ if (!params.recipient) throw new Error("recipient is required")
+ const messages = await CoordInbox.inbox({ teamID: params.team_id, member: params.recipient })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Inbox ${params.recipient}`,
+ output: JSON.stringify(messages, null, 2),
+ metadata: { messages, sent: false },
+ }
+ }
+
+ if (params.action === "unread") {
+ if (!params.recipient) throw new Error("recipient is required")
+ const messages = await CoordInbox.unread({ teamID: params.team_id, member: params.recipient })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Unread ${params.recipient}`,
+ output: JSON.stringify(messages, null, 2),
+ metadata: { messages, sent: false },
+ }
+ }
+
+ if (params.action === "mark_read") {
+ if (!params.recipient) throw new Error("recipient is required")
+ await CoordInbox.markRead({ teamID: params.team_id, member: params.recipient, index: params.index })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Marked read ${params.recipient}`,
+ output: "true",
+ metadata: { messages: [], sent: false },
+ }
+ }
+
+ if (params.action === "broadcast") {
+ const from = params.from ?? "coord"
+ const content = params.content ?? ""
+ const summary = params.summary ?? content.slice(0, 50)
+ const members = await CoordTeam.memberNames(params.team_id)
+ await CoordInbox.broadcast({
+ teamID: params.team_id,
+ from,
+ recipients: members,
+ content,
+ summary,
+ })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Broadcast to ${members.length}`,
+ output: "true",
+ metadata: { messages: [], sent: true },
+ }
+ }
+
+ if (params.action === "send") {
+ if (!params.recipient) throw new Error("recipient is required")
+ if (!params.message && !params.content) throw new Error("message or content is required")
+ const from = params.from ?? "coord"
+ const payload = params.message
+ ? ({ ...params.message, from } as CoordProtocol.MessageInput)
+ : {
+ type: "message" as const,
+ from,
+ recipient: params.recipient,
+ content: params.content ?? "",
+ summary: params.summary ?? (params.content ?? "").slice(0, 50),
+ }
+ await CoordInbox.sendMessage({ teamID: params.team_id, recipient: params.recipient, message: payload })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Message sent to ${params.recipient}`,
+ output: "true",
+ metadata: { messages: [], sent: true },
+ }
+ }
+
+ throw new Error("Unsupported action")
+ },
+})
diff --git a/packages/opencode/src/tool/coord-session.ts b/packages/opencode/src/tool/coord-session.ts
new file mode 100644
index 00000000000..7f5d689b72c
--- /dev/null
+++ b/packages/opencode/src/tool/coord-session.ts
@@ -0,0 +1,48 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { CoordSession, CoordSummary, CoordTeam } from "@/coord"
+
+const parameters = z.object({
+ action: z.enum(["set", "get"]).describe("Session coordination action"),
+ session_id: z.string().describe("Session ID"),
+ team_id: z.string().describe("Team ID").optional(),
+})
+
+export const CoordSessionTool = Tool.define("coord_session", {
+ description: "Attach or read a coordination team for a session.",
+ parameters,
+ async execute(params, ctx) {
+ await ctx.ask({
+ permission: "coord_session",
+ patterns: [params.action],
+ always: ["*"],
+ metadata: {
+ action: params.action,
+ session_id: params.session_id,
+ team_id: params.team_id,
+ },
+ })
+
+ if (params.action === "get") {
+ const info = await CoordSession.teamInfo(params.session_id)
+ return {
+ title: info ? `Session ${params.session_id} team` : "No team",
+ output: JSON.stringify(info ?? null, null, 2),
+ metadata: { team: info?.team, link: info?.link },
+ }
+ }
+
+ if (!params.team_id) throw new Error("team_id is required")
+ const team = await CoordTeam.getTeam(params.team_id)
+ if (!team) throw new Error(`Team ${params.team_id} not found`)
+
+ const link = await CoordSession.setTeam(params.session_id, params.team_id)
+ await CoordSummary.summarize(params.session_id, params.team_id)
+
+ return {
+ title: `Session ${params.session_id} linked`,
+ output: JSON.stringify({ link, team }, null, 2),
+ metadata: { link, team },
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/coord-task.ts b/packages/opencode/src/tool/coord-task.ts
new file mode 100644
index 00000000000..e8665a3fa6d
--- /dev/null
+++ b/packages/opencode/src/tool/coord-task.ts
@@ -0,0 +1,114 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { CoordSummary, CoordTask } from "@/coord"
+
+const parameters = z.object({
+ action: z.enum(["create", "list", "get", "claim", "complete", "update"]).describe("Task action"),
+ team_id: z.string().describe("Team ID"),
+ task_id: z.string().describe("Task ID").optional(),
+ subject: z.string().describe("Task subject").optional(),
+ description: z.string().describe("Task description").optional(),
+ active_form: z.string().describe("Active form").optional(),
+ blocked_by: z.string().describe("Comma-separated blocking task IDs").optional(),
+ status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
+ owner: z.string().describe("Task owner").optional(),
+})
+
+export const CoordTaskTool = Tool.define("coord_task", {
+ description: "Manage coordination task board.",
+ parameters,
+ async execute(params, ctx): Promise<{ title: string; output: string; metadata: { tasks: CoordTask.TaskSummary[]; task: CoordTask.Task | undefined } }> {
+ await ctx.ask({
+ permission: "coord_task",
+ patterns: [params.action],
+ always: ["*"],
+ metadata: {
+ action: params.action,
+ team_id: params.team_id,
+ task_id: params.task_id,
+ },
+ })
+
+ if (params.action === "list") {
+ const tasks = await CoordTask.listTasks(params.team_id)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `${tasks.length} tasks`,
+ output: JSON.stringify(tasks, null, 2),
+ metadata: { tasks, task: undefined },
+ }
+ }
+
+ if (params.action === "create") {
+ if (!params.subject) throw new Error("subject is required")
+ const blocked = params.blocked_by ? params.blocked_by.split(",").map((item) => item.trim()) : undefined
+ const task = await CoordTask.createTask({
+ teamID: params.team_id,
+ subject: params.subject,
+ description: params.description,
+ activeForm: params.active_form,
+ blockedBy: blocked,
+ })
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Task ${task.id} created`,
+ output: JSON.stringify(task, null, 2),
+ metadata: { task, tasks: [] },
+ }
+ }
+
+ if (!params.task_id) throw new Error("task_id is required")
+
+ if (params.action === "get") {
+ const task = await CoordTask.getTask(params.team_id, params.task_id)
+ if (!task) throw new Error(`Task ${params.task_id} not found`)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Task ${task.id}`,
+ output: JSON.stringify(task, null, 2),
+ metadata: { task, tasks: [] },
+ }
+ }
+
+ if (params.action === "claim") {
+ if (!params.owner) throw new Error("owner is required")
+ const result = await CoordTask.claimTask(params.team_id, params.task_id, params.owner)
+ if ("error" in result) throw new Error(result.error)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Task ${params.task_id} claimed`,
+ output: JSON.stringify(result, null, 2),
+ metadata: { task: result, tasks: [] },
+ }
+ }
+
+ if (params.action === "complete") {
+ const task = await CoordTask.completeTask(params.team_id, params.task_id)
+ if (!task) throw new Error(`Task ${params.task_id} not found`)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Task ${params.task_id} completed`,
+ output: JSON.stringify(task, null, 2),
+ metadata: { task, tasks: [] },
+ }
+ }
+
+ if (params.action === "update") {
+ const task = await CoordTask.updateTask(params.team_id, params.task_id, {
+ status: params.status,
+ owner: params.owner,
+ subject: params.subject,
+ description: params.description,
+ })
+ if (!task) throw new Error(`Task ${params.task_id} not found`)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Task ${params.task_id} updated`,
+ output: JSON.stringify(task, null, 2),
+ metadata: { task, tasks: [] },
+ }
+ }
+
+ throw new Error("Unsupported action")
+ },
+})
diff --git a/packages/opencode/src/tool/coord-team.ts b/packages/opencode/src/tool/coord-team.ts
new file mode 100644
index 00000000000..45d96ad4b26
--- /dev/null
+++ b/packages/opencode/src/tool/coord-team.ts
@@ -0,0 +1,74 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { CoordSession, CoordSummary, CoordTeam } from "@/coord"
+import { Slug } from "@opencode-ai/util/slug"
+
+const parameters = z.object({
+ action: z.enum(["create", "list", "get", "delete"]).describe("Team action"),
+ team_id: z.string().describe("Team ID").optional(),
+ name: z.string().describe("Team name").optional(),
+ description: z.string().describe("Team description").optional(),
+})
+
+export const CoordTeamTool = Tool.define("coord_team", {
+ description: "Manage coordination teams (create/list/get/delete).",
+ parameters,
+ async execute(params, ctx): Promise<{ title: string; output: string; metadata: { teams: CoordTeam.TeamSummary[]; team: CoordTeam.TeamConfig | undefined; sessions: string[] | undefined } }> {
+ await ctx.ask({
+ permission: "coord_team",
+ patterns: [params.action],
+ always: ["*"],
+ metadata: {
+ action: params.action,
+ team_id: params.team_id,
+ },
+ })
+
+ if (params.action === "list") {
+ const teams = await CoordTeam.listTeams()
+ return {
+ title: `${teams.length} teams`,
+ output: JSON.stringify(teams, null, 2),
+ metadata: { teams, team: undefined, sessions: undefined },
+ }
+ }
+
+ if (params.action === "create") {
+ const id = params.team_id ?? Slug.create()
+ const name = params.name ?? id
+ const team = await CoordTeam.createTeam({ id, name, description: params.description })
+ await CoordSession.setTeam(ctx.sessionID, team.id)
+ await CoordSummary.summarize(ctx.sessionID, team.id)
+ return {
+ title: `Team ${team.name} created`,
+ output: JSON.stringify(team, null, 2),
+ metadata: { team, teams: [], sessions: undefined },
+ }
+ }
+
+ if (!params.team_id) throw new Error("team_id is required")
+
+ if (params.action === "get") {
+ const team = await CoordTeam.getTeam(params.team_id)
+ if (!team) throw new Error(`Team ${params.team_id} not found`)
+ await CoordSummary.summarize(ctx.sessionID, params.team_id)
+ return {
+ title: `Team ${team.name}`,
+ output: JSON.stringify(team, null, 2),
+ metadata: { team, teams: [], sessions: undefined },
+ }
+ }
+
+ if (params.action === "delete") {
+ const sessions = await CoordSession.clearTeam(params.team_id)
+ await CoordTeam.deleteTeam(params.team_id)
+ return {
+ title: `Team ${params.team_id} deleted`,
+ output: JSON.stringify({ deleted: true, sessions }, null, 2),
+ metadata: { sessions, team: undefined, teams: [] },
+ }
+ }
+
+ throw new Error("Unsupported action")
+ },
+})
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 5ed5a879b48..0a7b41850e0 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -27,6 +27,11 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
+import { CoordTeamTool } from "./coord-team"
+import { CoordMemberTool } from "./coord-member"
+import { CoordMessageTool } from "./coord-message"
+import { CoordTaskTool } from "./coord-task"
+import { CoordSessionTool } from "./coord-session"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -112,6 +117,11 @@ export namespace ToolRegistry {
CodeSearchTool,
SkillTool,
ApplyPatchTool,
+ CoordTeamTool,
+ CoordMemberTool,
+ CoordMessageTool,
+ CoordTaskTool,
+ CoordSessionTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b757b753507..91fbbc57e4a 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -19,6 +19,33 @@ import type {
ConfigProvidersResponses,
ConfigUpdateErrors,
ConfigUpdateResponses,
+ CoordInboxErrors,
+ CoordInboxReadErrors,
+ CoordInboxReadResponses,
+ CoordInboxResponses,
+ CoordMemberRemoveErrors,
+ CoordMemberRemoveResponses,
+ CoordMessageSendErrors,
+ CoordMessageSendResponses,
+ CoordSessionErrors,
+ CoordSessionResponses,
+ CoordTaskClaimErrors,
+ CoordTaskClaimResponses,
+ CoordTaskCompleteErrors,
+ CoordTaskCompleteResponses,
+ CoordTaskCreateErrors,
+ CoordTaskCreateResponses,
+ CoordTaskListErrors,
+ CoordTaskListResponses,
+ CoordTaskUpdateErrors,
+ CoordTaskUpdateResponses,
+ CoordTeamCreateErrors,
+ CoordTeamCreateResponses,
+ CoordTeamDeleteErrors,
+ CoordTeamDeleteResponses,
+ CoordTeamGetErrors,
+ CoordTeamGetResponses,
+ CoordTeamListResponses,
EventSubscribeResponses,
EventTuiCommandExecute,
EventTuiPromptAppend,
@@ -102,6 +129,8 @@ import type {
SessionChildrenResponses,
SessionCommandErrors,
SessionCommandResponses,
+ SessionCoordErrors,
+ SessionCoordResponses,
SessionCreateErrors,
SessionCreateResponses,
SessionDeleteErrors,
@@ -1181,6 +1210,36 @@ export class Session extends HeyApiClient {
})
}
+ /**
+ * Get session coordination
+ *
+ * Retrieve the coordination team for a session, including inbox summary.
+ */
+ public coord(
+ parameters: {
+ sessionID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "sessionID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/session/{sessionID}/coord",
+ ...options,
+ ...params,
+ })
+ }
+
/**
* Initialize session
*
@@ -1942,6 +2001,600 @@ export class Permission extends HeyApiClient {
}
}
+export class Team extends HeyApiClient {
+ /**
+ * List coordination teams
+ *
+ * List all coordination teams in the current project.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get({
+ url: "/coord/team",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Create coordination team
+ *
+ * Create a new coordination team.
+ */
+ public create(
+ parameters?: {
+ directory?: string
+ team_id?: string
+ name?: string
+ description?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "body", key: "team_id" },
+ { in: "body", key: "name" },
+ { in: "body", key: "description" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Delete coordination team
+ *
+ * Delete a coordination team.
+ */
+ public delete(
+ parameters: {
+ teamID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).delete({
+ url: "/coord/team/{teamID}",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Get coordination team
+ *
+ * Get a coordination team by ID.
+ */
+ public get(
+ parameters: {
+ teamID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/coord/team/{teamID}",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Member extends HeyApiClient {
+ /**
+ * Remove team member
+ *
+ * Remove a member from a coordination team.
+ */
+ public remove(
+ parameters: {
+ teamID: string
+ name: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "name" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).delete({
+ url: "/coord/team/{teamID}/member/{name}",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Message extends HeyApiClient {
+ /**
+ * Send team message
+ *
+ * Send a message to a team member.
+ */
+ public send(
+ parameters: {
+ teamID: string
+ directory?: string
+ recipient?: string
+ message?:
+ | {
+ type: "message"
+ recipient: string
+ content: string
+ summary: string
+ }
+ | {
+ type: "broadcast"
+ content: string
+ summary: string
+ }
+ | {
+ type: "shutdown_request"
+ recipient: string
+ requestId: string
+ content?: string
+ }
+ | {
+ type: "shutdown_approved"
+ requestId: string
+ }
+ | {
+ type: "shutdown_rejected"
+ requestId: string
+ content?: string
+ }
+ | {
+ type: "plan_approval_request"
+ requestId: string
+ planContent: string
+ }
+ | {
+ type: "plan_approval_response"
+ requestId: string
+ approved: boolean
+ content?: string
+ }
+ | {
+ type: "idle_notification"
+ lastTaskId?: string
+ peerSummary?: string
+ }
+ | {
+ type: "task_assignment"
+ taskId: string
+ recipient: string
+ }
+ | {
+ type: "teammate_terminated"
+ agentName: string
+ reason?: string
+ }
+ | {
+ type: "permission_request"
+ requestId: string
+ action: string
+ details?: string
+ }
+ | {
+ type: "permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+ | {
+ type: "sandbox_permission_request"
+ requestId: string
+ command: string
+ details?: string
+ }
+ | {
+ type: "sandbox_permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+ from?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "recipient" },
+ { in: "body", key: "message" },
+ { in: "body", key: "from" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team/{teamID}/message",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
+export class Inbox extends HeyApiClient {
+ /**
+ * Mark inbox read
+ *
+ * Mark messages as read.
+ */
+ public read(
+ parameters: {
+ teamID: string
+ member: string
+ directory?: string
+ index?: number
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "member" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "index" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team/{teamID}/inbox/{member}/read",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
+export class Task extends HeyApiClient {
+ /**
+ * List tasks
+ *
+ * List tasks for a coordination team.
+ */
+ public list(
+ parameters: {
+ teamID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/coord/team/{teamID}/task",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Create task
+ *
+ * Create a task for a coordination team.
+ */
+ public create(
+ parameters: {
+ teamID: string
+ directory?: string
+ subject?: string
+ description?: string
+ active_form?: string
+ blocked_by?: Array
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "subject" },
+ { in: "body", key: "description" },
+ { in: "body", key: "active_form" },
+ { in: "body", key: "blocked_by" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team/{teamID}/task",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Update task
+ *
+ * Update a coordination task.
+ */
+ public update(
+ parameters: {
+ teamID: string
+ taskID: string
+ directory?: string
+ subject?: string
+ description?: string
+ status?: "pending" | "in_progress" | "completed" | "deleted"
+ owner?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "taskID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "subject" },
+ { in: "body", key: "description" },
+ { in: "body", key: "status" },
+ { in: "body", key: "owner" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).patch({
+ url: "/coord/team/{teamID}/task/{taskID}",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Claim task
+ *
+ * Claim a coordination task.
+ */
+ public claim(
+ parameters: {
+ teamID: string
+ taskID: string
+ directory?: string
+ owner?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "taskID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "owner" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team/{teamID}/task/{taskID}/claim",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Complete task
+ *
+ * Complete a coordination task.
+ */
+ public complete(
+ parameters: {
+ teamID: string
+ taskID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "taskID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/coord/team/{teamID}/task/{taskID}/complete",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Coord extends HeyApiClient {
+ /**
+ * Read inbox
+ *
+ * Read a team member inbox.
+ */
+ public inbox(
+ parameters: {
+ teamID: string
+ member: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "teamID" },
+ { in: "path", key: "member" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/coord/team/{teamID}/inbox/{member}",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Get session team
+ *
+ * Get coordination team summary for a session.
+ */
+ public session(
+ parameters: {
+ sessionID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "sessionID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/coord/session/{sessionID}",
+ ...options,
+ ...params,
+ })
+ }
+
+ private _team?: Team
+ get team(): Team {
+ return (this._team ??= new Team({ client: this.client }))
+ }
+
+ private _member?: Member
+ get member(): Member {
+ return (this._member ??= new Member({ client: this.client }))
+ }
+
+ private _message?: Message
+ get message(): Message {
+ return (this._message ??= new Message({ client: this.client }))
+ }
+
+ private _inbox?: Inbox
+ get inbox2(): Inbox {
+ return (this._inbox ??= new Inbox({ client: this.client }))
+ }
+
+ private _task?: Task
+ get task(): Task {
+ return (this._task ??= new Task({ client: this.client }))
+ }
+}
+
export class Question extends HeyApiClient {
/**
* List pending questions
@@ -3241,6 +3894,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._permission ??= new Permission({ client: this.client }))
}
+ private _coord?: Coord
+ get coord(): Coord {
+ return (this._coord ??= new Coord({ client: this.client }))
+ }
+
private _question?: Question
get question(): Question {
return (this._question ??= new Question({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index d72c37a28b5..fb88c884367 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -666,6 +666,55 @@ export type EventTodoUpdated = {
}
}
+export type CoordTeam = {
+ id: string
+ name: string
+ description: string
+ createdAt: string
+ members: Array<{
+ name: string
+ agentId: string
+ agentType: string
+ }>
+ metadata: {
+ [key: string]: unknown
+ }
+}
+
+export type EventCoordTeamUpdated = {
+ type: "coord.team.updated"
+ properties: {
+ team: CoordTeam
+ }
+}
+
+export type EventCoordSessionUpdated = {
+ type: "coord.session.updated"
+ properties: {
+ sessionID: string
+ teamID: string
+ }
+}
+
+export type CoordMemberInbox = {
+ name: string
+ unread: number
+ total: number
+}
+
+export type CoordTeamSummary = {
+ team: CoordTeam
+ inbox: Array
+}
+
+export type EventCoordSummaryUpdated = {
+ type: "coord.summary.updated"
+ properties: {
+ sessionID: string
+ summary: CoordTeamSummary
+ }
+}
+
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -909,6 +958,9 @@ export type Event =
| EventSessionCompacted
| EventFileWatcherUpdated
| EventTodoUpdated
+ | EventCoordTeamUpdated
+ | EventCoordSessionUpdated
+ | EventCoordSummaryUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -2038,6 +2090,150 @@ export type SubtaskPartInput = {
command?: string
}
+export type CoordMessage =
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "message"
+ recipient: string
+ content: string
+ summary: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "broadcast"
+ content: string
+ summary: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "shutdown_request"
+ recipient: string
+ requestId: string
+ content?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "shutdown_approved"
+ requestId: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "shutdown_rejected"
+ requestId: string
+ content?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "plan_approval_request"
+ requestId: string
+ planContent: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "plan_approval_response"
+ requestId: string
+ approved: boolean
+ content?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "idle_notification"
+ lastTaskId?: string
+ peerSummary?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "task_assignment"
+ taskId: string
+ recipient: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "teammate_terminated"
+ agentName: string
+ reason?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "permission_request"
+ requestId: string
+ action: string
+ details?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "sandbox_permission_request"
+ requestId: string
+ command: string
+ details?: string
+ }
+ | {
+ from: string
+ timestamp: string
+ read?: boolean
+ type: "sandbox_permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+
+export type CoordTaskSummary = {
+ id: string
+ subject: string
+ status: "pending" | "in_progress" | "completed" | "deleted"
+ owner: string | null
+ blockedBy: Array
+}
+
+export type CoordTask = {
+ id: string
+ subject: string
+ description: string
+ status: "pending" | "in_progress" | "completed" | "deleted"
+ owner: string | null
+ blockedBy: Array
+ blocks: Array
+ activeForm: string | null
+ metadata: {
+ [key: string]: unknown
+ }
+ createdAt: string
+ updatedAt: string
+}
+
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
@@ -3099,6 +3295,42 @@ export type SessionTodoResponses = {
export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses]
+export type SessionCoordData = {
+ body?: never
+ path: {
+ /**
+ * Session ID
+ */
+ sessionID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/session/{sessionID}/coord"
+}
+
+export type SessionCoordErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type SessionCoordError = SessionCoordErrors[keyof SessionCoordErrors]
+
+export type SessionCoordResponses = {
+ /**
+ * Coordination summary
+ */
+ 200: CoordTeamSummary | null
+}
+
+export type SessionCoordResponse = SessionCoordResponses[keyof SessionCoordResponses]
+
export type SessionInitData = {
body?: {
modelID: string
@@ -3803,6 +4035,538 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
+export type CoordTeamListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team"
+}
+
+export type CoordTeamListResponses = {
+ /**
+ * List of teams
+ */
+ 200: Array
+}
+
+export type CoordTeamListResponse = CoordTeamListResponses[keyof CoordTeamListResponses]
+
+export type CoordTeamCreateData = {
+ body?: {
+ team_id: string
+ name?: string
+ description?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team"
+}
+
+export type CoordTeamCreateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CoordTeamCreateError = CoordTeamCreateErrors[keyof CoordTeamCreateErrors]
+
+export type CoordTeamCreateResponses = {
+ /**
+ * Created team
+ */
+ 200: CoordTeam
+}
+
+export type CoordTeamCreateResponse = CoordTeamCreateResponses[keyof CoordTeamCreateResponses]
+
+export type CoordTeamDeleteData = {
+ body?: never
+ path: {
+ teamID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}"
+}
+
+export type CoordTeamDeleteErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordTeamDeleteError = CoordTeamDeleteErrors[keyof CoordTeamDeleteErrors]
+
+export type CoordTeamDeleteResponses = {
+ /**
+ * Deleted
+ */
+ 200: {
+ deleted: boolean
+ sessions: Array
+ }
+}
+
+export type CoordTeamDeleteResponse = CoordTeamDeleteResponses[keyof CoordTeamDeleteResponses]
+
+export type CoordTeamGetData = {
+ body?: never
+ path: {
+ teamID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}"
+}
+
+export type CoordTeamGetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordTeamGetError = CoordTeamGetErrors[keyof CoordTeamGetErrors]
+
+export type CoordTeamGetResponses = {
+ /**
+ * Team
+ */
+ 200: CoordTeam
+}
+
+export type CoordTeamGetResponse = CoordTeamGetResponses[keyof CoordTeamGetResponses]
+
+export type CoordMemberRemoveData = {
+ body?: never
+ path: {
+ teamID: string
+ name: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/member/{name}"
+}
+
+export type CoordMemberRemoveErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordMemberRemoveError = CoordMemberRemoveErrors[keyof CoordMemberRemoveErrors]
+
+export type CoordMemberRemoveResponses = {
+ /**
+ * Removed
+ */
+ 200: boolean
+}
+
+export type CoordMemberRemoveResponse = CoordMemberRemoveResponses[keyof CoordMemberRemoveResponses]
+
+export type CoordMessageSendData = {
+ body?: {
+ recipient: string
+ message:
+ | {
+ type: "message"
+ recipient: string
+ content: string
+ summary: string
+ }
+ | {
+ type: "broadcast"
+ content: string
+ summary: string
+ }
+ | {
+ type: "shutdown_request"
+ recipient: string
+ requestId: string
+ content?: string
+ }
+ | {
+ type: "shutdown_approved"
+ requestId: string
+ }
+ | {
+ type: "shutdown_rejected"
+ requestId: string
+ content?: string
+ }
+ | {
+ type: "plan_approval_request"
+ requestId: string
+ planContent: string
+ }
+ | {
+ type: "plan_approval_response"
+ requestId: string
+ approved: boolean
+ content?: string
+ }
+ | {
+ type: "idle_notification"
+ lastTaskId?: string
+ peerSummary?: string
+ }
+ | {
+ type: "task_assignment"
+ taskId: string
+ recipient: string
+ }
+ | {
+ type: "teammate_terminated"
+ agentName: string
+ reason?: string
+ }
+ | {
+ type: "permission_request"
+ requestId: string
+ action: string
+ details?: string
+ }
+ | {
+ type: "permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+ | {
+ type: "sandbox_permission_request"
+ requestId: string
+ command: string
+ details?: string
+ }
+ | {
+ type: "sandbox_permission_response"
+ requestId: string
+ granted: boolean
+ reason?: string
+ }
+ from?: string
+ }
+ path: {
+ teamID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/message"
+}
+
+export type CoordMessageSendErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CoordMessageSendError = CoordMessageSendErrors[keyof CoordMessageSendErrors]
+
+export type CoordMessageSendResponses = {
+ /**
+ * Sent
+ */
+ 200: boolean
+}
+
+export type CoordMessageSendResponse = CoordMessageSendResponses[keyof CoordMessageSendResponses]
+
+export type CoordInboxData = {
+ body?: never
+ path: {
+ teamID: string
+ member: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/inbox/{member}"
+}
+
+export type CoordInboxErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordInboxError = CoordInboxErrors[keyof CoordInboxErrors]
+
+export type CoordInboxResponses = {
+ /**
+ * Inbox
+ */
+ 200: Array
+}
+
+export type CoordInboxResponse = CoordInboxResponses[keyof CoordInboxResponses]
+
+export type CoordInboxReadData = {
+ body?: {
+ index?: number
+ }
+ path: {
+ teamID: string
+ member: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/inbox/{member}/read"
+}
+
+export type CoordInboxReadErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CoordInboxReadError = CoordInboxReadErrors[keyof CoordInboxReadErrors]
+
+export type CoordInboxReadResponses = {
+ /**
+ * Updated
+ */
+ 200: boolean
+}
+
+export type CoordInboxReadResponse = CoordInboxReadResponses[keyof CoordInboxReadResponses]
+
+export type CoordTaskListData = {
+ body?: never
+ path: {
+ teamID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/task"
+}
+
+export type CoordTaskListErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CoordTaskListError = CoordTaskListErrors[keyof CoordTaskListErrors]
+
+export type CoordTaskListResponses = {
+ /**
+ * Task list
+ */
+ 200: Array
+}
+
+export type CoordTaskListResponse = CoordTaskListResponses[keyof CoordTaskListResponses]
+
+export type CoordTaskCreateData = {
+ body?: {
+ subject: string
+ description?: string
+ active_form?: string
+ blocked_by?: Array
+ }
+ path: {
+ teamID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/task"
+}
+
+export type CoordTaskCreateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type CoordTaskCreateError = CoordTaskCreateErrors[keyof CoordTaskCreateErrors]
+
+export type CoordTaskCreateResponses = {
+ /**
+ * Task
+ */
+ 200: CoordTask
+}
+
+export type CoordTaskCreateResponse = CoordTaskCreateResponses[keyof CoordTaskCreateResponses]
+
+export type CoordTaskUpdateData = {
+ body?: {
+ subject?: string
+ description?: string
+ status?: "pending" | "in_progress" | "completed" | "deleted"
+ owner?: string
+ }
+ path: {
+ teamID: string
+ taskID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/task/{taskID}"
+}
+
+export type CoordTaskUpdateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordTaskUpdateError = CoordTaskUpdateErrors[keyof CoordTaskUpdateErrors]
+
+export type CoordTaskUpdateResponses = {
+ /**
+ * Task
+ */
+ 200: CoordTask
+}
+
+export type CoordTaskUpdateResponse = CoordTaskUpdateResponses[keyof CoordTaskUpdateResponses]
+
+export type CoordTaskClaimData = {
+ body?: {
+ owner: string
+ }
+ path: {
+ teamID: string
+ taskID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/task/{taskID}/claim"
+}
+
+export type CoordTaskClaimErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordTaskClaimError = CoordTaskClaimErrors[keyof CoordTaskClaimErrors]
+
+export type CoordTaskClaimResponses = {
+ /**
+ * Task
+ */
+ 200: CoordTask
+}
+
+export type CoordTaskClaimResponse = CoordTaskClaimResponses[keyof CoordTaskClaimResponses]
+
+export type CoordTaskCompleteData = {
+ body?: never
+ path: {
+ teamID: string
+ taskID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/team/{teamID}/task/{taskID}/complete"
+}
+
+export type CoordTaskCompleteErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordTaskCompleteError = CoordTaskCompleteErrors[keyof CoordTaskCompleteErrors]
+
+export type CoordTaskCompleteResponses = {
+ /**
+ * Task
+ */
+ 200: CoordTask
+}
+
+export type CoordTaskCompleteResponse = CoordTaskCompleteResponses[keyof CoordTaskCompleteResponses]
+
+export type CoordSessionData = {
+ body?: never
+ path: {
+ sessionID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/coord/session/{sessionID}"
+}
+
+export type CoordSessionErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type CoordSessionError = CoordSessionErrors[keyof CoordSessionErrors]
+
+export type CoordSessionResponses = {
+ /**
+ * Session summary
+ */
+ 200: CoordTeamSummary | null
+}
+
+export type CoordSessionResponse = CoordSessionResponses[keyof CoordSessionResponses]
+
export type PermissionReplyData = {
body?: {
reply: "once" | "always" | "reject"