From 2b0d65674e3e962776671527c9e8af2a24714eac Mon Sep 17 00:00:00 2001 From: nullswan Date: Sun, 8 Feb 2026 14:06:34 +0100 Subject: [PATCH] add coordination workers overview --- packages/app/src/components/session/index.ts | 1 + .../session/session-workers-tab.tsx | 45 ++ .../src/components/settings-permissions.tsx | 25 + .../src/context/global-sync/child-store.ts | 1 + .../context/global-sync/event-reducer.test.ts | 1 + .../src/context/global-sync/event-reducer.ts | 17 +- packages/app/src/context/global-sync/types.ts | 4 + .../app/src/context/sync-optimistic.test.ts | 2 + packages/app/src/context/sync.tsx | 26 +- packages/app/src/i18n/ar.ts | 12 + packages/app/src/i18n/br.ts | 12 + packages/app/src/i18n/bs.ts | 12 + packages/app/src/i18n/da.ts | 12 + packages/app/src/i18n/de.ts | 12 + packages/app/src/i18n/en.ts | 12 + packages/app/src/i18n/es.ts | 12 + packages/app/src/i18n/fr.ts | 12 + packages/app/src/i18n/ja.ts | 12 + packages/app/src/i18n/ko.ts | 12 + packages/app/src/i18n/no.ts | 12 + packages/app/src/i18n/parity.test.ts | 7 +- packages/app/src/i18n/pl.ts | 12 + packages/app/src/i18n/ru.ts | 12 + packages/app/src/i18n/th.ts | 12 + packages/app/src/i18n/zh.ts | 12 + packages/app/src/i18n/zht.ts | 12 + packages/app/src/pages/session.tsx | 47 +- .../src/pages/session/session-side-panel.tsx | 36 +- packages/opencode/src/agent/agent.ts | 16 +- .../cli/cmd/tui/component/coord-workers.tsx | 41 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 16 +- .../cli/cmd/tui/routes/session/sidebar.tsx | 51 +- packages/opencode/src/coord/inbox.ts | 91 +++ packages/opencode/src/coord/index.ts | 7 + packages/opencode/src/coord/paths.ts | 51 ++ packages/opencode/src/coord/protocol.ts | 160 ++++ packages/opencode/src/coord/session.ts | 50 ++ packages/opencode/src/coord/summary.ts | 51 ++ packages/opencode/src/coord/task.ts | 179 ++++ packages/opencode/src/coord/team.ts | 169 ++++ packages/opencode/src/server/routes/coord.ts | 377 +++++++++ .../opencode/src/server/routes/session.ts | 33 + packages/opencode/src/server/server.ts | 2 + packages/opencode/src/session/index.ts | 5 + packages/opencode/src/tool/coord-member.ts | 49 ++ packages/opencode/src/tool/coord-message.ts | 108 +++ packages/opencode/src/tool/coord-session.ts | 48 ++ packages/opencode/src/tool/coord-task.ts | 114 +++ packages/opencode/src/tool/coord-team.ts | 74 ++ packages/opencode/src/tool/registry.ts | 10 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 658 +++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 764 ++++++++++++++++++ 52 files changed, 3490 insertions(+), 38 deletions(-) create mode 100644 packages/app/src/components/session/session-workers-tab.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/component/coord-workers.tsx create mode 100644 packages/opencode/src/coord/inbox.ts create mode 100644 packages/opencode/src/coord/index.ts create mode 100644 packages/opencode/src/coord/paths.ts create mode 100644 packages/opencode/src/coord/protocol.ts create mode 100644 packages/opencode/src/coord/session.ts create mode 100644 packages/opencode/src/coord/summary.ts create mode 100644 packages/opencode/src/coord/task.ts create mode 100644 packages/opencode/src/coord/team.ts create mode 100644 packages/opencode/src/server/routes/coord.ts create mode 100644 packages/opencode/src/tool/coord-member.ts create mode 100644 packages/opencode/src/tool/coord-message.ts create mode 100644 packages/opencode/src/tool/coord-session.ts create mode 100644 packages/opencode/src/tool/coord-task.ts create mode 100644 packages/opencode/src/tool/coord-team.ts 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"