From bd8dbcd1e1a04d072d823bf4cd877deaa5d9f6de Mon Sep 17 00:00:00 2001 From: xiaodong Date: Thu, 26 Feb 2026 19:45:28 +0800 Subject: [PATCH 1/6] feat(opencode): add agent teams for parallel multi-session collaboration Add a team coordination system that lets a lead session spawn and manage multiple teammate sessions working in parallel. Built on SQLite/Drizzle to stay consistent with OpenCode's existing data layer. - 3 new DB tables: team, team_task, team_message - Unified `team` tool with actions: create, spawn, wait, message, broadcast, tasks, status, shutdown, cleanup - Non-blocking spawn with wait-to-collect semantics - Shared task list with dependency tracking - Prompt-level injection of pending team messages - TUI: header badge and keyboard shortcuts to cycle team members Refs #12661, #5887, #12711 Made-with: Cursor --- .../20260226093432_agent-teams/migration.sql | 47 + .../20260226093432_agent-teams/snapshot.json | 1418 +++++++++++++++++ .../src/cli/cmd/tui/routes/session/header.tsx | 33 + .../src/cli/cmd/tui/routes/session/index.tsx | 46 + packages/opencode/src/config/config.ts | 3 + packages/opencode/src/id/id.ts | 3 + packages/opencode/src/session/index.ts | 14 + packages/opencode/src/session/prompt.ts | 30 + packages/opencode/src/session/session.sql.ts | 8 +- packages/opencode/src/storage/schema.ts | 3 + packages/opencode/src/team/index.ts | 241 +++ packages/opencode/src/team/message.ts | 99 ++ packages/opencode/src/team/task.ts | 143 ++ .../opencode/src/team/team-message.sql.ts | 26 + packages/opencode/src/team/team-task.sql.ts | 24 + packages/opencode/src/team/team.sql.ts | 17 + packages/opencode/src/tool/registry.ts | 2 + packages/opencode/src/tool/team.ts | 317 ++++ packages/opencode/src/tool/team.txt | 25 + packages/opencode/test/team/team.test.ts | 289 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 90 ++ 22 files changed, 2881 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/migration/20260226093432_agent-teams/migration.sql create mode 100644 packages/opencode/migration/20260226093432_agent-teams/snapshot.json create mode 100644 packages/opencode/src/team/index.ts create mode 100644 packages/opencode/src/team/message.ts create mode 100644 packages/opencode/src/team/task.ts create mode 100644 packages/opencode/src/team/team-message.sql.ts create mode 100644 packages/opencode/src/team/team-task.sql.ts create mode 100644 packages/opencode/src/team/team.sql.ts create mode 100644 packages/opencode/src/tool/team.ts create mode 100644 packages/opencode/src/tool/team.txt create mode 100644 packages/opencode/test/team/team.test.ts diff --git a/packages/opencode/migration/20260226093432_agent-teams/migration.sql b/packages/opencode/migration/20260226093432_agent-teams/migration.sql new file mode 100644 index 000000000000..d9257724d073 --- /dev/null +++ b/packages/opencode/migration/20260226093432_agent-teams/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `team_message` ( + `id` text PRIMARY KEY, + `team_id` text NOT NULL, + `from_session_id` text NOT NULL, + `to_session_id` text, + `content` text NOT NULL, + `read` integer DEFAULT false NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_team_message_team_id_team_id_fk` FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_team_message_from_session_id_session_id_fk` FOREIGN KEY (`from_session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_team_message_to_session_id_session_id_fk` FOREIGN KEY (`to_session_id`) REFERENCES `session`(`id`) +); +--> statement-breakpoint +CREATE TABLE `team_task` ( + `id` text PRIMARY KEY, + `team_id` text NOT NULL, + `title` text NOT NULL, + `description` text, + `status` text NOT NULL, + `assigned_to` text, + `depends_on` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_team_task_team_id_team_id_fk` FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_team_task_assigned_to_session_id_fk` FOREIGN KEY (`assigned_to`) REFERENCES `session`(`id`) +); +--> statement-breakpoint +CREATE TABLE `team` ( + `id` text PRIMARY KEY, + `name` text NOT NULL, + `lead_session_id` text NOT NULL, + `status` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_team_lead_session_id_session_id_fk` FOREIGN KEY (`lead_session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +ALTER TABLE `session` ADD `team_id` text;--> statement-breakpoint +ALTER TABLE `session` ADD `team_role` text;--> statement-breakpoint +CREATE INDEX `session_team_idx` ON `session` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_message_team_idx` ON `team_message` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_message_to_idx` ON `team_message` (`to_session_id`);--> statement-breakpoint +CREATE INDEX `team_message_from_idx` ON `team_message` (`from_session_id`);--> statement-breakpoint +CREATE INDEX `team_task_team_idx` ON `team_task` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_task_assigned_idx` ON `team_task` (`assigned_to`);--> statement-breakpoint +CREATE INDEX `team_lead_idx` ON `team` (`lead_session_id`); \ No newline at end of file diff --git a/packages/opencode/migration/20260226093432_agent-teams/snapshot.json b/packages/opencode/migration/20260226093432_agent-teams/snapshot.json new file mode 100644 index 000000000000..ba742b3f6326 --- /dev/null +++ b/packages/opencode/migration/20260226093432_agent-teams/snapshot.json @@ -0,0 +1,1418 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "76b175f3-f496-45ef-944e-92e9ee1a8c6d", + "prevIds": [ + "d2736e43-700f-4e9e-8151-9f2f0d967bc8" + ], + "ddl": [ + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "team_message", + "entityType": "tables" + }, + { + "name": "team_task", + "entityType": "tables" + }, + { + "name": "team", + "entityType": "tables" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "team_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "team_role", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "team_id", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "from_session_id", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "to_session_id", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "read", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "team_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "team_id", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "description", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "assigned_to", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "depends_on", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "team_task" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "team" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "team" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lead_session_id", + "entityType": "columns", + "table": "team" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "team" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "team" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "team" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "team_id" + ], + "tableTo": "team", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_team_message_team_id_team_id_fk", + "entityType": "fks", + "table": "team_message" + }, + { + "columns": [ + "from_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_team_message_from_session_id_session_id_fk", + "entityType": "fks", + "table": "team_message" + }, + { + "columns": [ + "to_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_team_message_to_session_id_session_id_fk", + "entityType": "fks", + "table": "team_message" + }, + { + "columns": [ + "team_id" + ], + "tableTo": "team", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_team_task_team_id_team_id_fk", + "entityType": "fks", + "table": "team_task" + }, + { + "columns": [ + "assigned_to" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "nameExplicit": false, + "name": "fk_team_task_assigned_to_session_id_fk", + "entityType": "fks", + "table": "team_task" + }, + { + "columns": [ + "lead_session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_team_lead_session_id_session_id_fk", + "entityType": "fks", + "table": "team" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "team_message_pk", + "table": "team_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "team_task_pk", + "table": "team_task", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "team_pk", + "table": "team", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "team_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_team_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + }, + { + "columns": [ + { + "value": "team_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_message_team_idx", + "entityType": "indexes", + "table": "team_message" + }, + { + "columns": [ + { + "value": "to_session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_message_to_idx", + "entityType": "indexes", + "table": "team_message" + }, + { + "columns": [ + { + "value": "from_session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_message_from_idx", + "entityType": "indexes", + "table": "team_message" + }, + { + "columns": [ + { + "value": "team_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_task_team_idx", + "entityType": "indexes", + "table": "team_task" + }, + { + "columns": [ + { + "value": "assigned_to", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_task_assigned_idx", + "entityType": "indexes", + "table": "team_task" + }, + { + "columns": [ + { + "value": "lead_session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "team_lead_idx", + "entityType": "indexes", + "table": "team" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a85723..24a92d80ff01 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -122,6 +122,39 @@ export function Header() { + + + + + Team session{" "} + ({session()?.teamRole}) + + + + + setHover("prev")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("team.prev")} + backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} + > + + Prev {keybind.print("team_cycle_reverse")} + + + setHover("next")} + onMouseOut={() => setHover(null)} + onMouseUp={() => command.trigger("team.next")} + backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} + > + + Next {keybind.print("team_cycle")} + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f20267e0820e..392504652f8a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -325,6 +325,28 @@ export function Session() { } } + const teamMembers = createMemo(() => { + const tid = session()?.teamID + if (!tid) return [] + return sync.data.session + .filter((x) => x.teamID === tid) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + + function moveTeam(direction: number) { + const members = teamMembers() + if (members.length <= 1) return + let next = members.findIndex((x) => x.id === session()?.id) + direction + if (next >= members.length) next = 0 + if (next < 0) next = members.length - 1 + if (members[next]) { + navigate({ + type: "session", + sessionID: members[next].id, + }) + } + } + const command = useCommandDialog() command.register(() => [ { @@ -922,6 +944,30 @@ export function Session() { dialog.clear() }, }, + { + title: "Next team member", + value: "team.next", + keybind: "team_cycle", + category: "Team", + hidden: true, + enabled: !!session()?.teamID, + onSelect: (dialog) => { + moveTeam(1) + dialog.clear() + }, + }, + { + title: "Previous team member", + value: "team.prev", + keybind: "team_cycle_reverse", + category: "Team", + hidden: true, + enabled: !!session()?.teamID, + onSelect: (dialog) => { + moveTeam(-1) + dialog.clear() + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28aea4d67772..3e056663e190 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -899,6 +899,9 @@ export namespace Config { session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"), session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"), + team_cycle: z.string().optional().default("shift+down").describe("Next team member session"), + team_cycle_reverse: z.string().optional().default("shift+up").describe("Previous team member session"), + team_tasks: z.string().optional().default("ctrl+t").describe("Toggle team task list"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a458..866f6038eb3a 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -11,6 +11,9 @@ export namespace Identifier { part: "prt", pty: "pty", tool: "tool", + team: "tea", + team_task: "ttk", + team_message: "tmg", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 22de477f8d18..749ecb249c2f 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -71,6 +71,8 @@ export namespace Session { share, revert, permission: row.permission ?? undefined, + teamID: row.team_id ?? undefined, + teamRole: row.team_role ?? undefined, time: { created: row.time_created, updated: row.time_updated, @@ -96,6 +98,8 @@ export namespace Session { summary_diffs: info.summary?.diffs, revert: info.revert ?? null, permission: info.permission, + team_id: info.teamID, + team_role: info.teamRole, time_created: info.time.created, time_updated: info.time.updated, time_compacting: info.time.compacting, @@ -142,6 +146,8 @@ export namespace Session { archived: z.number().optional(), }), permission: PermissionNext.Ruleset.optional(), + teamID: z.string().optional(), + teamRole: z.enum(["lead", "member"]).optional(), revert: z .object({ messageID: z.string(), @@ -215,6 +221,8 @@ export namespace Session { parentID: Identifier.schema("session").optional(), title: z.string().optional(), permission: Info.shape.permission, + teamID: z.string().optional(), + teamRole: z.enum(["lead", "member"]).optional(), }) .optional(), async (input) => { @@ -223,6 +231,8 @@ export namespace Session { directory: Instance.directory, title: input?.title, permission: input?.permission, + teamID: input?.teamID, + teamRole: input?.teamRole, }) }, ) @@ -290,6 +300,8 @@ export namespace Session { parentID?: string directory: string permission?: PermissionNext.Ruleset + teamID?: string + teamRole?: "lead" | "member" }) { const result: Info = { id: Identifier.descending("session", input.id), @@ -300,6 +312,8 @@ export namespace Session { parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), permission: input.permission, + teamID: input.teamID, + teamRole: input.teamRole, time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..288ff42cbba4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -553,6 +553,36 @@ export namespace SessionPrompt { continue } + // inject pending team messages + if (session.teamID) { + const { TeamMessage } = await import("@/team/message") + const pendingTeamMsgs = TeamMessage.pending(sessionID, session.teamID) + if (pendingTeamMsgs.length > 0) { + const teamText = pendingTeamMsgs + .map((m) => `[Team message from ${m.from_session_id}]: ${m.content}`) + .join("\n") + const teamUserMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: lastUser.agent, + model: lastUser.model, + } + await Session.updateMessage(teamUserMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: teamUserMsg.id, + sessionID, + type: "text", + text: teamText, + synthetic: true, + } satisfies MessageV2.TextPart) + TeamMessage.markRead(pendingTeamMsgs.map((m) => m.id)) + continue + } + } + // normal processing const agent = await Agent.get(lastUser.agent) const maxSteps = agent.steps ?? Infinity diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 9c5c72c4c578..760d8b88e9e7 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -27,11 +27,17 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(), revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(), + team_id: text(), + team_role: text().$type<"lead" | "member">(), ...Timestamps, time_compacting: integer(), time_archived: integer(), }, - (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)], + (table) => [ + index("session_project_idx").on(table.project_id), + index("session_parent_idx").on(table.parent_id), + index("session_team_idx").on(table.team_id), + ], ) export const MessageTable = sqliteTable( diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 7961b0e3804b..852addb886e7 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -2,3 +2,6 @@ export { ControlAccountTable } from "../control/control.sql" export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" export { ProjectTable } from "../project/project.sql" +export { TeamTable } from "../team/team.sql" +export { TeamTaskTable } from "../team/team-task.sql" +export { TeamMessageTable } from "../team/team-message.sql" diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts new file mode 100644 index 000000000000..ec0bbcb75e55 --- /dev/null +++ b/packages/opencode/src/team/index.ts @@ -0,0 +1,241 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and } from "@/storage/db" +import { Identifier } from "@/id/id" +import { SessionTable } from "@/session/session.sql" +import { TeamTable } from "./team.sql" +import { TeamTaskTable } from "./team-task.sql" +import { TeamMessageTable } from "./team-message.sql" +import { Log } from "@/util/log" + +export namespace Team { + const log = Log.create({ service: "team" }) + + export const Info = z.object({ + id: z.string(), + name: z.string(), + leadSessionID: z.string(), + status: z.enum(["active", "archived"]), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + export type Info = z.infer<typeof Info> + + export const Event = { + Created: BusEvent.define("team.created", z.object({ info: Info })), + Archived: BusEvent.define("team.archived", z.object({ info: Info })), + MemberJoined: BusEvent.define( + "team.member.joined", + z.object({ + teamID: z.string(), + sessionID: z.string(), + role: z.enum(["lead", "member"]), + }), + ), + MemberLeft: BusEvent.define( + "team.member.left", + z.object({ + teamID: z.string(), + sessionID: z.string(), + }), + ), + TeammateIdle: BusEvent.define( + "team.teammate.idle", + z.object({ + teamID: z.string(), + sessionID: z.string(), + }), + ), + Message: BusEvent.define( + "team.message", + z.object({ + teamID: z.string(), + messageID: z.string(), + fromSessionID: z.string(), + toSessionID: z.string().nullable(), + content: z.string(), + }), + ), + TaskCompleted: BusEvent.define( + "team.task.completed", + z.object({ + teamID: z.string(), + taskID: z.string(), + sessionID: z.string(), + }), + ), + } + + function fromRow(row: typeof TeamTable.$inferSelect): Info { + return { + id: row.id, + name: row.name, + leadSessionID: row.lead_session_id, + status: row.status, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export async function create(input: { name: string; sessionID: string }) { + const existing = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), + ) + if (existing?.team_id) throw new Error("Session is already in a team") + + const id = Identifier.ascending("team") + const now = Date.now() + const row = Database.use((db) => { + db.insert(TeamTable) + .values({ + id, + name: input.name, + lead_session_id: input.sessionID, + status: "active", + time_created: now, + time_updated: now, + }) + .run() + db.update(SessionTable).set({ team_id: id, team_role: "lead" }).where(eq(SessionTable.id, input.sessionID)).run() + return db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()! + }) + const info = fromRow(row) + log.info("created", { id, name: input.name }) + await Bus.publish(Event.Created, { info }) + await Bus.publish(Event.MemberJoined, { + teamID: id, + sessionID: input.sessionID, + role: "lead", + }) + return info + } + + export async function get(id: string) { + const row = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()) + if (!row) throw new Error(`Team not found: ${id}`) + return fromRow(row) + } + + export async function list() { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + return rows.map(fromRow) + } + + export async function members(teamID: string) { + return Database.use((db) => + db + .select({ + id: SessionTable.id, + title: SessionTable.title, + team_role: SessionTable.team_role, + time_updated: SessionTable.time_updated, + }) + .from(SessionTable) + .where(eq(SessionTable.team_id, teamID)) + .all(), + ) + } + + export async function status(teamID: string) { + const info = await get(teamID) + const teamMembers = await members(teamID) + const tasks = Database.use((db) => db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all()) + return { + ...info, + members: teamMembers, + tasks: { + total: tasks.length, + pending: tasks.filter((t) => t.status === "pending").length, + in_progress: tasks.filter((t) => t.status === "in_progress").length, + completed: tasks.filter((t) => t.status === "completed").length, + }, + } + } + + export async function archive(teamID: string) { + const row = Database.use((db) => + db + .update(TeamTable) + .set({ status: "archived", time_updated: Date.now() }) + .where(eq(TeamTable.id, teamID)) + .returning() + .get(), + ) + if (!row) throw new Error(`Team not found: ${teamID}`) + const info = fromRow(row) + log.info("archived", { id: teamID }) + await Bus.publish(Event.Archived, { info }) + return info + } + + export async function cleanup(teamID: string, leadSessionID: string) { + const info = await get(teamID) + if (info.leadSessionID !== leadSessionID) throw new Error("Only the lead session can clean up the team") + + const active = await members(teamID) + const running = active.filter((m) => m.team_role === "member" && SessionPromptState.isRunning(m.id)) + if (running.length > 0) + throw new Error(`Cannot cleanup: ${running.length} teammate(s) still running. Shut them down first.`) + + const { Session: SessionModule } = await import("@/session") + const memberSessions = active.filter((m) => m.team_role === "member") + for (const m of memberSessions) { + await SessionModule.remove(m.id).catch((e) => log.error("remove member failed", { id: m.id, error: e })) + } + + Database.use((db) => { + db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.team_id, teamID)).run() + }) + return archive(teamID) + } + + export async function join(teamID: string, sessionID: string) { + Database.use((db) => { + db.update(SessionTable).set({ team_id: teamID, team_role: "member" }).where(eq(SessionTable.id, sessionID)).run() + }) + await Bus.publish(Event.MemberJoined, { + teamID, + sessionID, + role: "member", + }) + } + + export async function leave(sessionID: string) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row?.team_id) return + const teamID = row.team_id + Database.use((db) => { + db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.id, sessionID)).run() + }) + await Bus.publish(Event.MemberLeft, { teamID, sessionID }) + } + + export function getBySession(sessionID: string) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row?.team_id) return undefined + const team = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, row.team_id!)).get()) + if (!team) return undefined + return fromRow(team) + } +} + +export namespace SessionPromptState { + const running = new Set<string>() + + export function mark(sessionID: string) { + running.add(sessionID) + } + + export function unmark(sessionID: string) { + running.delete(sessionID) + } + + export function isRunning(sessionID: string) { + return running.has(sessionID) + } +} diff --git a/packages/opencode/src/team/message.ts b/packages/opencode/src/team/message.ts new file mode 100644 index 000000000000..d4391e8e2bec --- /dev/null +++ b/packages/opencode/src/team/message.ts @@ -0,0 +1,99 @@ +import { Database, eq, and, isNull, or, inArray } from "@/storage/db" +import { Bus } from "@/bus" +import { Identifier } from "@/id/id" +import { TeamMessageTable } from "./team-message.sql" +import { Team } from "./index" +import { Log } from "@/util/log" + +export namespace TeamMessage { + const log = Log.create({ service: "team-message" }) + + export type Info = typeof TeamMessageTable.$inferSelect + + export async function send(input: { teamID: string; fromSessionID: string; toSessionID: string; content: string }) { + const id = Identifier.ascending("team_message") + const now = Date.now() + Database.use((db) => { + db.insert(TeamMessageTable) + .values({ + id, + team_id: input.teamID, + from_session_id: input.fromSessionID, + to_session_id: input.toSessionID, + content: input.content, + read: false, + time_created: now, + time_updated: now, + }) + .run() + }) + log.info("sent", { id, from: input.fromSessionID, to: input.toSessionID }) + await Bus.publish(Team.Event.Message, { + teamID: input.teamID, + messageID: id, + fromSessionID: input.fromSessionID, + toSessionID: input.toSessionID, + content: input.content, + }) + return id + } + + export async function broadcast(input: { teamID: string; fromSessionID: string; content: string }) { + const id = Identifier.ascending("team_message") + const now = Date.now() + Database.use((db) => { + db.insert(TeamMessageTable) + .values({ + id, + team_id: input.teamID, + from_session_id: input.fromSessionID, + to_session_id: null, + content: input.content, + read: false, + time_created: now, + time_updated: now, + }) + .run() + }) + log.info("broadcast", { id, from: input.fromSessionID }) + await Bus.publish(Team.Event.Message, { + teamID: input.teamID, + messageID: id, + fromSessionID: input.fromSessionID, + toSessionID: null, + content: input.content, + }) + return id + } + + export function list(teamID: string) { + return Database.use((db) => db.select().from(TeamMessageTable).where(eq(TeamMessageTable.team_id, teamID)).all()) + } + + export function pending(sessionID: string, teamID: string) { + return Database.use((db) => + db + .select() + .from(TeamMessageTable) + .where( + and( + eq(TeamMessageTable.team_id, teamID), + eq(TeamMessageTable.read, false), + or(eq(TeamMessageTable.to_session_id, sessionID), isNull(TeamMessageTable.to_session_id)), + ), + ) + .all() + .filter((m) => m.from_session_id !== sessionID), + ) + } + + export function markRead(ids: string[]) { + if (ids.length === 0) return + Database.use((db) => { + db.update(TeamMessageTable) + .set({ read: true, time_updated: Date.now() }) + .where(inArray(TeamMessageTable.id, ids)) + .run() + }) + } +} diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts new file mode 100644 index 000000000000..508138e67551 --- /dev/null +++ b/packages/opencode/src/team/task.ts @@ -0,0 +1,143 @@ +import { Database, eq, and } from "@/storage/db" +import { Bus } from "@/bus" +import { Identifier } from "@/id/id" +import { TeamTaskTable } from "./team-task.sql" +import { Team } from "./index" +import { Log } from "@/util/log" + +export namespace TeamTask { + const log = Log.create({ service: "team-task" }) + + export type Info = typeof TeamTaskTable.$inferSelect + + export async function create(input: { + teamID: string + title: string + description?: string + depends_on?: string[] + }) { + const id = Identifier.ascending("team_task") + const now = Date.now() + Database.use((db) => { + db.insert(TeamTaskTable) + .values({ + id, + team_id: input.teamID, + title: input.title, + description: input.description ?? null, + status: "pending", + assigned_to: null, + depends_on: input.depends_on ?? null, + time_created: now, + time_updated: now, + }) + .run() + }) + log.info("created", { id, title: input.title }) + return id + } + + export async function claim(taskID: string, sessionID: string) { + return Database.use((db) => { + const task = db + .select() + .from(TeamTaskTable) + .where(eq(TeamTaskTable.id, taskID)) + .get() + if (!task) throw new Error(`Task not found: ${taskID}`) + if (task.status !== "pending") + throw new Error(`Task ${taskID} is not pending (status: ${task.status})`) + + if (task.depends_on && task.depends_on.length > 0) { + const deps = db + .select() + .from(TeamTaskTable) + .where(eq(TeamTaskTable.team_id, task.team_id)) + .all() + const incomplete = task.depends_on.filter((depID) => { + const dep = deps.find((d) => d.id === depID) + return !dep || dep.status !== "completed" + }) + if (incomplete.length > 0) + throw new Error( + `Task ${taskID} is blocked by unresolved dependencies: ${incomplete.join(", ")}`, + ) + } + + const result = db + .update(TeamTaskTable) + .set({ + status: "in_progress", + assigned_to: sessionID, + time_updated: Date.now(), + }) + .where(and(eq(TeamTaskTable.id, taskID), eq(TeamTaskTable.status, "pending"))) + .returning() + .get() + + if (!result) throw new Error(`Task ${taskID} was already claimed`) + log.info("claimed", { id: taskID, by: sessionID }) + return result + }) + } + + export async function complete(taskID: string, sessionID: string) { + const result = Database.use((db) => + db + .update(TeamTaskTable) + .set({ status: "completed", time_updated: Date.now() }) + .where( + and( + eq(TeamTaskTable.id, taskID), + eq(TeamTaskTable.assigned_to, sessionID), + ), + ) + .returning() + .get(), + ) + if (!result) throw new Error(`Task ${taskID} not found or not assigned to this session`) + log.info("completed", { id: taskID, by: sessionID }) + await Bus.publish(Team.Event.TaskCompleted, { + teamID: result.team_id, + taskID, + sessionID, + }) + return result + } + + export function list(teamID: string) { + return Database.use((db) => + db + .select() + .from(TeamTaskTable) + .where(eq(TeamTaskTable.team_id, teamID)) + .all(), + ) + } + + export async function reassign(sessionID: string) { + const updated = Database.use((db) => + db + .update(TeamTaskTable) + .set({ + status: "pending", + assigned_to: null, + time_updated: Date.now(), + }) + .where( + and( + eq(TeamTaskTable.assigned_to, sessionID), + eq(TeamTaskTable.status, "in_progress"), + ), + ) + .returning() + .all(), + ) + if (updated.length > 0) + log.info("reassigned", { + count: updated.length, + from: sessionID, + }) + return updated + } +} diff --git a/packages/opencode/src/team/team-message.sql.ts b/packages/opencode/src/team/team-message.sql.ts new file mode 100644 index 000000000000..b5b5543c345d --- /dev/null +++ b/packages/opencode/src/team/team-message.sql.ts @@ -0,0 +1,26 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { TeamTable } from "./team.sql" +import { SessionTable } from "../session/session.sql" +import { Timestamps } from "@/storage/schema.sql" + +export const TeamMessageTable = sqliteTable( + "team_message", + { + id: text().primaryKey(), + team_id: text() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + from_session_id: text() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + to_session_id: text().references(() => SessionTable.id), + content: text().notNull(), + read: integer({ mode: "boolean" }).notNull().default(false), + ...Timestamps, + }, + (table) => [ + index("team_message_team_idx").on(table.team_id), + index("team_message_to_idx").on(table.to_session_id), + index("team_message_from_idx").on(table.from_session_id), + ], +) diff --git a/packages/opencode/src/team/team-task.sql.ts b/packages/opencode/src/team/team-task.sql.ts new file mode 100644 index 000000000000..d6c91df814dd --- /dev/null +++ b/packages/opencode/src/team/team-task.sql.ts @@ -0,0 +1,24 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { TeamTable } from "./team.sql" +import { SessionTable } from "../session/session.sql" +import { Timestamps } from "@/storage/schema.sql" + +export const TeamTaskTable = sqliteTable( + "team_task", + { + id: text().primaryKey(), + team_id: text() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + title: text().notNull(), + description: text(), + status: text().notNull().$type<"pending" | "in_progress" | "completed">(), + assigned_to: text().references(() => SessionTable.id), + depends_on: text({ mode: "json" }).$type<string[]>(), + ...Timestamps, + }, + (table) => [ + index("team_task_team_idx").on(table.team_id), + index("team_task_assigned_idx").on(table.assigned_to), + ], +) diff --git a/packages/opencode/src/team/team.sql.ts b/packages/opencode/src/team/team.sql.ts new file mode 100644 index 000000000000..ff3e39aa5f76 --- /dev/null +++ b/packages/opencode/src/team/team.sql.ts @@ -0,0 +1,17 @@ +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core" +import { SessionTable } from "../session/session.sql" +import { Timestamps } from "@/storage/schema.sql" + +export const TeamTable = sqliteTable( + "team", + { + id: text().primaryKey(), + name: text().notNull(), + lead_session_id: text() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + status: text().notNull().$type<"active" | "archived">(), + ...Timestamps, + }, + (table) => [index("team_lead_idx").on(table.lead_session_id)], +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b2..8b8cf4881dc6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -28,6 +28,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { ApplyPatchTool } from "./apply_patch" +import { TeamTool } from "./team" import { Glob } from "../util/glob" import { pathToFileURL } from "url" @@ -117,6 +118,7 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, + TeamTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/opencode/src/tool/team.ts b/packages/opencode/src/tool/team.ts new file mode 100644 index 000000000000..026a373cd91c --- /dev/null +++ b/packages/opencode/src/tool/team.ts @@ -0,0 +1,317 @@ +import { Tool } from "./tool" +import DESCRIPTION from "./team.txt" +import z from "zod" +import { Session } from "../session" +import { Team, SessionPromptState } from "../team" +import { TeamMessage } from "../team/message" +import { TeamTask } from "../team/task" +import { Agent } from "../agent/agent" +import { SessionPrompt } from "../session/prompt" +import { Identifier } from "../id/id" +import { Bus } from "../bus" +import type { MessageV2 } from "../session/message-v2" + +const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes +const pending = new Map<string, Promise<string>>() + +function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { + const timeout = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("Teammate timed out")), ms) + }) + return Promise.race([promise, timeout]) +} + +const parameters = z.object({ + action: z + .enum(["create", "spawn", "message", "broadcast", "tasks", "status", "wait", "shutdown", "cleanup"]) + .describe("The team action to perform"), + name: z.string().optional().describe("Team name (for create) or display name (for spawn)"), + agent: z.string().optional().describe("Agent type for the teammate, e.g. 'build', 'plan' (for spawn)"), + prompt: z.string().optional().describe("Initial prompt/task for the teammate (for spawn)"), + to: z.string().optional().describe("Target session ID (for message)"), + content: z.string().optional().describe("Message content (for message/broadcast)"), + sub_action: z.enum(["create", "claim", "complete", "list"]).optional().describe("Task sub-action (for tasks)"), + title: z.string().optional().describe("Task title (for tasks.create)"), + description: z.string().optional().describe("Task description (for tasks.create)"), + depends_on: z.array(z.string()).optional().describe("Task IDs this depends on (for tasks.create)"), + task_id: z.string().optional().describe("Task ID (for tasks.claim/complete)"), + session_id: z.string().optional().describe("Teammate session ID (for shutdown)"), +}) + +export const TeamTool = Tool.define("team", async () => { + return { + description: DESCRIPTION, + parameters, + async execute(params: z.infer<typeof parameters>, ctx) { + const session = await Session.get(ctx.sessionID) + + function out(title: string, output: string, extra?: Record<string, any>) { + return { title, metadata: extra ?? {}, output } + } + + switch (params.action) { + case "create": { + if (!params.name) throw new Error("name is required for create action") + if (session.teamID) throw new Error("This session is already in a team") + const team = await Team.create({ + name: params.name, + sessionID: ctx.sessionID, + }) + return out( + `Created team: ${params.name}`, + `Team "${params.name}" created (ID: ${team.id}). You are the lead.\n\nWorkflow:\n1. Use spawn (multiple times) to add teammates - they start working immediately in parallel\n2. Use wait to block until all teammates finish and collect their results\n3. Use message/broadcast to communicate with teammates\n4. Use cleanup when done`, + { teamID: team.id }, + ) + } + + case "spawn": { + if (!params.agent) throw new Error("agent is required for spawn action") + if (!params.prompt) throw new Error("prompt is required for spawn action") + const team = await requireTeam(session) + if (session.teamRole !== "lead") throw new Error("Only the lead can spawn teammates") + + const agent = await Agent.get(params.agent) + if (!agent) throw new Error(`Unknown agent type: ${params.agent}`) + + const title = params.name ?? `Teammate - ${params.agent}` + const teammate = await Session.create({ + title, + teamID: team.id, + teamRole: "member", + permission: [ + { permission: "team", pattern: "create", action: "deny" }, + { permission: "team", pattern: "cleanup", action: "deny" }, + { permission: "team", pattern: "shutdown", action: "deny" }, + { permission: "team", pattern: "wait", action: "deny" }, + ], + }) + await Team.join(team.id, teammate.id) + + const model = { + modelID: agent.model?.modelID ?? "default", + providerID: agent.model?.providerID ?? "default", + } + + const promise = withTimeout( + runTeammate({ + sessionID: teammate.id, + teamID: team.id, + model, + agentName: agent.name, + prompt: params.prompt, + }), + TIMEOUT_MS, + ) + pending.set(teammate.id, promise) + + return out( + `Spawned teammate: ${title}`, + `Teammate "${title}" spawned (session: ${teammate.id}, agent: ${params.agent}). Working on: ${params.prompt}\n\nThe teammate is running in parallel. Spawn more teammates or use 'wait' to collect all results.`, + { sessionID: teammate.id }, + ) + } + + case "wait": { + const team = await requireTeam(session) + const members = await Team.members(team.id) + const teammates = members.filter((m) => m.team_role === "member") + + if (teammates.length === 0) return out("No teammates", "No teammates to wait for.") + + const results: string[] = [] + for (const m of teammates) { + const p = pending.get(m.id) + if (p) { + const text = await p + pending.delete(m.id) + results.push(`### ${m.title} (${m.id})\n${text}`) + } else if (SessionPromptState.isRunning(m.id)) { + results.push(`### ${m.title} (${m.id})\n(still running, no pending promise)`) + } else { + results.push(`### ${m.title} (${m.id})\n(already idle)`) + } + } + + return out(`Collected ${results.length} teammate results`, results.join("\n\n")) + } + + case "message": { + if (!params.to) throw new Error("to is required for message action") + if (!params.content) throw new Error("content is required for message action") + const team = await requireTeam(session) + await TeamMessage.send({ + teamID: team.id, + fromSessionID: ctx.sessionID, + toSessionID: params.to, + content: params.content, + }) + if (!SessionPromptState.isRunning(params.to)) { + const reply = await wakeForMessage(params.to, team.id) + return out("Message sent & reply received", `Message sent to ${params.to}.\n\nReply:\n${reply}`) + } + return out("Message sent", `Message sent to ${params.to}. (teammate is busy, will process when ready)`) + } + + case "broadcast": { + if (!params.content) throw new Error("content is required for broadcast action") + const team = await requireTeam(session) + await TeamMessage.broadcast({ + teamID: team.id, + fromSessionID: ctx.sessionID, + content: params.content, + }) + const members = await Team.members(team.id) + const idle = members.filter((m) => m.id !== ctx.sessionID && !SessionPromptState.isRunning(m.id)) + const replies: string[] = [] + await Promise.all( + idle.map(async (m) => { + const reply = await wakeForMessage(m.id, team.id) + if (reply) replies.push(`[${m.title}]: ${reply}`) + }), + ) + const body = + replies.length > 0 + ? `Broadcast sent. Replies from idle teammates:\n${replies.join("\n\n")}` + : "Broadcast sent to all teammates. Active teammates will process when ready." + return out("Broadcast sent", body) + } + + case "tasks": { + if (!params.sub_action) throw new Error("sub_action is required for tasks action") + const team = await requireTeam(session) + switch (params.sub_action) { + case "create": { + if (!params.title) throw new Error("title is required for task creation") + const id = await TeamTask.create({ + teamID: team.id, + title: params.title, + description: params.description, + depends_on: params.depends_on, + }) + return out(`Created task: ${params.title}`, `Task created (ID: ${id}): ${params.title}`, { taskID: id }) + } + case "claim": { + if (!params.task_id) throw new Error("task_id is required") + const task = await TeamTask.claim(params.task_id, ctx.sessionID) + return out(`Claimed task: ${task.title}`, `Task claimed: ${task.title} (${task.id})`, { taskID: task.id }) + } + case "complete": { + if (!params.task_id) throw new Error("task_id is required") + const task = await TeamTask.complete(params.task_id, ctx.sessionID) + return out(`Completed task: ${task.title}`, `Task completed: ${task.title} (${task.id})`, { + taskID: task.id, + }) + } + case "list": { + const tasks = TeamTask.list(team.id) + const lines = tasks.map( + (t) => `- [${t.status}] ${t.title} (${t.id})${t.assigned_to ? ` → ${t.assigned_to}` : ""}`, + ) + return out("Task list", tasks.length > 0 ? lines.join("\n") : "No tasks in the team.", { + count: tasks.length, + }) + } + } + throw new Error(`Unknown tasks sub_action`) + } + + case "status": { + const team = await requireTeam(session) + const info = await Team.status(team.id) + const memberLines = info.members.map( + (m) => + `- ${m.title} (${m.id}) [${m.team_role}] ${SessionPromptState.isRunning(m.id) ? "🟢 active" : "⚪ idle"}`, + ) + return out( + "Team status", + [ + `Team: ${info.name} (${info.id})`, + `Status: ${info.status}`, + `Members (${info.members.length}):`, + ...memberLines, + `Tasks: ${info.tasks.total} total, ${info.tasks.pending} pending, ${info.tasks.in_progress} in progress, ${info.tasks.completed} completed`, + ].join("\n"), + ) + } + + case "shutdown": { + if (!params.session_id) throw new Error("session_id is required for shutdown action") + const team = await requireTeam(session) + if (session.teamRole !== "lead") throw new Error("Only the lead can shut down teammates") + SessionPrompt.cancel(params.session_id) + pending.delete(params.session_id) + return out( + `Shutdown requested: ${params.session_id}`, + `Shutdown signal sent to ${params.session_id}. They will finish their current work and stop.`, + ) + } + + case "cleanup": { + const team = await requireTeam(session) + await Team.cleanup(team.id, ctx.sessionID) + for (const [id] of pending) pending.delete(id) + return out("Team cleaned up", `Team "${team.name}" has been archived and cleaned up.`) + } + } + }, + } +}) + +async function requireTeam(session: Session.Info) { + if (!session.teamID) throw new Error("This session is not in a team. Use 'create' first.") + return Team.get(session.teamID) +} + +async function runTeammate(input: { + sessionID: string + teamID: string + model: { modelID: string; providerID: string } + agentName: string + prompt: string +}): Promise<string> { + SessionPromptState.mark(input.sessionID) + const resp = await SessionPrompt.prompt({ + messageID: Identifier.ascending("message"), + sessionID: input.sessionID, + model: input.model, + agent: input.agentName, + tools: {}, + parts: [{ type: "text", text: input.prompt }], + }).finally(() => { + SessionPromptState.unmark(input.sessionID) + TeamTask.reassign(input.sessionID) + }) + const text = resp.parts.findLast((x) => x.type === "text")?.text ?? "" + return text || "(teammate produced no text output)" +} + +async function wakeForMessage(sessionID: string, teamID: string): Promise<string> { + const { MessageV2 } = await import("../session/message-v2") + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "user") + if (!last) return "" + + const isUserMessage = (msg: MessageV2.Info): msg is MessageV2.User => msg.role === "user" + const model = isUserMessage(last.info) ? last.info.model : { modelID: "default", providerID: "default" } + const agentName = last.info.agent ?? "build" + + const pendingMsgs = TeamMessage.pending(sessionID, teamID) + if (pendingMsgs.length === 0) return "" + + const text = pendingMsgs.map((m) => `[Team message from ${m.from_session_id}]: ${m.content}`).join("\n") + TeamMessage.markRead(pendingMsgs.map((m) => m.id)) + + SessionPromptState.mark(sessionID) + const resp = await SessionPrompt.prompt({ + messageID: Identifier.ascending("message"), + sessionID, + model, + agent: agentName, + tools: {}, + parts: [{ type: "text", text }], + }).finally(() => { + SessionPromptState.unmark(sessionID) + TeamTask.reassign(sessionID) + }) + return resp.parts.findLast((x) => x.type === "text")?.text ?? "" +} diff --git a/packages/opencode/src/tool/team.txt b/packages/opencode/src/tool/team.txt new file mode 100644 index 000000000000..4d8285aca6bc --- /dev/null +++ b/packages/opencode/src/tool/team.txt @@ -0,0 +1,25 @@ +Manage agent teams for parallel collaborative work. Teams let multiple AI sessions work together on complex tasks. + +IMPORTANT: Agent teams use significantly more tokens than a single session. Each teammate runs its own context window. Use teams for tasks that genuinely benefit from parallel exploration (3-5 teammates recommended). + +Actions: +- create: Create a new team (current session becomes lead) +- spawn: Add a teammate - starts working immediately in parallel (non-blocking). Spawn multiple teammates before calling wait. +- wait: Block until ALL teammates finish and collect their results. Call this after spawning all teammates. +- message: Send a point-to-point message to a specific teammate. If teammate is idle, wakes them up and waits for reply. +- broadcast: Send a message to all teammates. Wakes up idle teammates and collects their replies. +- tasks: Manage the shared task list (sub-actions: create, claim, complete, list) +- status: View team status, members (active/idle), and task summary +- shutdown: Request a teammate to shut down gracefully +- cleanup: Archive the team, delete teammate sessions, and clean up (lead only, all teammates must be idle) + +Typical workflow: +1. create a team +2. spawn teammate A with task X +3. spawn teammate B with task Y (both now running in parallel) +4. spawn teammate C with task Z +5. wait → collects results from A, B, C when they all finish +6. Teammates can communicate with each other via message/broadcast during execution +7. cleanup when done + +Teammates have their own tools and can read/write files, run commands, and communicate with each other via the team tool. diff --git a/packages/opencode/test/team/team.test.ts b/packages/opencode/test/team/team.test.ts new file mode 100644 index 000000000000..811939b41483 --- /dev/null +++ b/packages/opencode/test/team/team.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { Team, SessionPromptState } from "../../src/team" +import { TeamMessage } from "../../src/team/message" +import { TeamTask } from "../../src/team/task" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("team lifecycle", () => { + test("create and cleanup a team", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: session.id }) + + expect(team.name).toBe("test-team") + expect(team.status).toBe("active") + expect(team.leadSessionID).toBe(session.id) + + const updated = await Session.get(session.id) + expect(updated.teamID).toBe(team.id) + expect(updated.teamRole).toBe("lead") + + await Team.cleanup(team.id, session.id) + const archived = await Team.get(team.id) + expect(archived.status).toBe("archived") + + await Session.remove(session.id) + }, + }) + }) + + test("session cannot be in two teams", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + await Team.create({ name: "team-a", sessionID: session.id }) + + await expect(Team.create({ name: "team-b", sessionID: session.id })).rejects.toThrow( + "already in a team", + ) + + await Session.remove(session.id) + }, + }) + }) + + test("members returns all team members", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member = await Session.create({ teamID: team.id, teamRole: "member" }) + await Team.join(team.id, member.id) + + const members = await Team.members(team.id) + expect(members.length).toBe(2) + expect(members.find((m) => m.id === lead.id)?.team_role).toBe("lead") + expect(members.find((m) => m.id === member.id)?.team_role).toBe("member") + + await Session.remove(member.id) + await Session.remove(lead.id) + }, + }) + }) + + test("status returns team summary", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + await TeamTask.create({ teamID: team.id, title: "Task 1" }) + await TeamTask.create({ teamID: team.id, title: "Task 2" }) + + const info = await Team.status(team.id) + expect(info.name).toBe("test-team") + expect(info.tasks.total).toBe(2) + expect(info.tasks.pending).toBe(2) + expect(info.members.length).toBe(1) + + await Session.remove(lead.id) + }, + }) + }) +}) + +describe("team task list", () => { + test("create, claim, and complete tasks", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const taskID = await TeamTask.create({ teamID: team.id, title: "Review auth" }) + const tasks = TeamTask.list(team.id) + expect(tasks.length).toBe(1) + expect(tasks[0].status).toBe("pending") + + const claimed = await TeamTask.claim(taskID, lead.id) + expect(claimed.status).toBe("in_progress") + expect(claimed.assigned_to).toBe(lead.id) + + const completed = await TeamTask.complete(taskID, lead.id) + expect(completed.status).toBe("completed") + + await Session.remove(lead.id) + }, + }) + }) + + test("concurrent claim is atomic", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member1 = await Session.create({ teamID: team.id, teamRole: "member" }) + const member2 = await Session.create({ teamID: team.id, teamRole: "member" }) + + const taskID = await TeamTask.create({ teamID: team.id, title: "Shared task" }) + + const results = await Promise.allSettled([ + TeamTask.claim(taskID, member1.id), + TeamTask.claim(taskID, member2.id), + ]) + + const fulfilled = results.filter((r) => r.status === "fulfilled") + const rejected = results.filter((r) => r.status === "rejected") + expect(fulfilled.length).toBe(1) + expect(rejected.length).toBe(1) + + await Session.remove(member1.id) + await Session.remove(member2.id) + await Session.remove(lead.id) + }, + }) + }) + + test("blocked task cannot be claimed", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const task1 = await TeamTask.create({ teamID: team.id, title: "First" }) + const task2 = await TeamTask.create({ + teamID: team.id, + title: "Second", + depends_on: [task1], + }) + + await expect(TeamTask.claim(task2, lead.id)).rejects.toThrow("blocked") + + await TeamTask.claim(task1, lead.id) + await TeamTask.complete(task1, lead.id) + + const claimed = await TeamTask.claim(task2, lead.id) + expect(claimed.status).toBe("in_progress") + + await Session.remove(lead.id) + }, + }) + }) + + test("reassign tasks when teammate exits", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member = await Session.create({ teamID: team.id, teamRole: "member" }) + const taskID = await TeamTask.create({ teamID: team.id, title: "WIP task" }) + await TeamTask.claim(taskID, member.id) + + const reassigned = await TeamTask.reassign(member.id) + expect(reassigned.length).toBe(1) + expect(reassigned[0].status).toBe("pending") + expect(reassigned[0].assigned_to).toBeNull() + + await Session.remove(member.id) + await Session.remove(lead.id) + }, + }) + }) +}) + +describe("team messaging", () => { + test("send and receive point-to-point message", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member = await Session.create({ teamID: team.id, teamRole: "member" }) + await Team.join(team.id, member.id) + + await TeamMessage.send({ + teamID: team.id, + fromSessionID: lead.id, + toSessionID: member.id, + content: "Hello teammate", + }) + + const pending = TeamMessage.pending(member.id, team.id) + expect(pending.length).toBe(1) + expect(pending[0].content).toBe("Hello teammate") + expect(pending[0].from_session_id).toBe(lead.id) + + TeamMessage.markRead(pending.map((m) => m.id)) + const after = TeamMessage.pending(member.id, team.id) + expect(after.length).toBe(0) + + await Session.remove(member.id) + await Session.remove(lead.id) + }, + }) + }) + + test("broadcast message reaches all members", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member1 = await Session.create({ teamID: team.id, teamRole: "member" }) + const member2 = await Session.create({ teamID: team.id, teamRole: "member" }) + + await TeamMessage.broadcast({ + teamID: team.id, + fromSessionID: lead.id, + content: "Everyone wrap up", + }) + + const p1 = TeamMessage.pending(member1.id, team.id) + const p2 = TeamMessage.pending(member2.id, team.id) + expect(p1.length).toBe(1) + expect(p2.length).toBe(1) + expect(p1[0].content).toBe("Everyone wrap up") + + const selfPending = TeamMessage.pending(lead.id, team.id) + expect(selfPending.length).toBe(0) + + await Session.remove(member1.id) + await Session.remove(member2.id) + await Session.remove(lead.id) + }, + }) + }) +}) + +describe("session team fields", () => { + test("session create with team fields", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const lead = await Session.create({}) + const team = await Team.create({ name: "test-team", sessionID: lead.id }) + + const member = await Session.create({ teamID: team.id, teamRole: "member" }) + expect(member.teamID).toBe(team.id) + expect(member.teamRole).toBe("member") + + const fetched = await Session.get(member.id) + expect(fetched.teamID).toBe(team.id) + expect(fetched.teamRole).toBe("member") + + await Session.remove(member.id) + await Session.remove(lead.id) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6165c0f7b096..34f3a86f5ee2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1024,6 +1024,8 @@ export class Session2 extends HeyApiClient { parentID?: string title?: string permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" }, options?: Options<never, ThrowOnError>, ) { @@ -1036,6 +1038,8 @@ export class Session2 extends HeyApiClient { { in: "body", key: "parentID" }, { in: "body", key: "title" }, { in: "body", key: "permission" }, + { in: "body", key: "teamID" }, + { in: "body", key: "teamRole" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index be6c00cf4457..9ed4dc3c4cdb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -715,6 +715,83 @@ export type EventTodoUpdated = { } } +export type EventTeamCreated = { + type: "team.created" + properties: { + info: { + id: string + name: string + leadSessionID: string + status: "active" | "archived" + time: { + created: number + updated: number + } + } + } +} + +export type EventTeamArchived = { + type: "team.archived" + properties: { + info: { + id: string + name: string + leadSessionID: string + status: "active" | "archived" + time: { + created: number + updated: number + } + } + } +} + +export type EventTeamMemberJoined = { + type: "team.member.joined" + properties: { + teamID: string + sessionID: string + role: "lead" | "member" + } +} + +export type EventTeamMemberLeft = { + type: "team.member.left" + properties: { + teamID: string + sessionID: string + } +} + +export type EventTeamTeammateIdle = { + type: "team.teammate.idle" + properties: { + teamID: string + sessionID: string + } +} + +export type EventTeamMessage = { + type: "team.message" + properties: { + teamID: string + messageID: string + fromSessionID: string + toSessionID: string | null + content: string + } +} + +export type EventTeamTaskCompleted = { + type: "team.task.completed" + properties: { + teamID: string + taskID: string + sessionID: string + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -828,6 +905,8 @@ export type Session = { archived?: number } permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" revert?: { messageID: string partID?: string @@ -966,6 +1045,13 @@ export type Event = | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated + | EventTeamCreated + | EventTeamArchived + | EventTeamMemberJoined + | EventTeamMemberLeft + | EventTeamTeammateIdle + | EventTeamMessage + | EventTeamTaskCompleted | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -1665,6 +1751,8 @@ export type GlobalSession = { archived?: number } permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" revert?: { messageID: string partID?: string @@ -2608,6 +2696,8 @@ export type SessionCreateData = { parentID?: string title?: string permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" } path?: never query?: { From d68122207091f746afb76f08bdc00b1d8146ad9a Mon Sep 17 00:00:00 2001 From: xiaodong <xiao.dong@trip.com> Date: Fri, 27 Feb 2026 16:52:48 +0800 Subject: [PATCH 2/6] feat(opencode): add team HTTP route, TUI tasks panel, and SDK client - Add GET /team/:teamID/tasks HTTP route for listing team tasks - Add TeamTasksPanel TUI component with pending/in-progress/completed sections - Add Ctrl+T shortcut to toggle team tasks panel visibility - Sync team tasks on team.task.completed events - Regenerate SDK with Team client class and types Made-with: Cursor --- .../cmd/tui/component/team-tasks-panel.tsx | 92 +++++++++++++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 16 ++++ .../src/cli/cmd/tui/routes/session/index.tsx | 32 ++++++- packages/opencode/src/server/routes/team.ts | 51 ++++++++++ packages/opencode/src/server/server.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 39 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 39 ++++++++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx create mode 100644 packages/opencode/src/server/routes/team.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx new file mode 100644 index 000000000000..519444f33769 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx @@ -0,0 +1,92 @@ +import { createMemo, For, Show } from "solid-js" +import { useSync } from "../context/sync" +import { useTheme } from "../context/theme" + +export function TeamTasksPanel(props: { teamID: string }) { + const sync = useSync() + const { theme } = useTheme() + + const tasks = createMemo(() => sync.data.team_task[props.teamID] ?? []) + + const pendingTasks = createMemo(() => tasks().filter((t) => t.status === "pending")) + const inProgressTasks = createMemo(() => tasks().filter((t) => t.status === "in_progress")) + const completedTasks = createMemo(() => tasks().filter((t) => t.status === "completed")) + + return ( + <box + backgroundColor={theme.backgroundPanel} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + paddingRight={2} + borderStyle="single" + borderColor={theme.border} + > + <text fg={theme.text}> + <b>Team Tasks</b> + <span style={{ fg: theme.textMuted }}> ({tasks().length} total)</span> + </text> + + <Show when={tasks().length === 0}> + <text fg={theme.textMuted}>No tasks in this team yet.</text> + </Show> + + <Show when={inProgressTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.warning}> + <b>In Progress ({inProgressTasks().length})</b> + </text> + <For each={inProgressTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.warning}>•</text> + <text fg={theme.text} wrapMode="word"> + {task.title} + <Show when={task.assigned_to}> + <span style={{ fg: theme.textMuted }}> → {task.assigned_to}</span> + </Show> + </text> + </box> + )} + </For> + </box> + </Show> + + <Show when={pendingTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.textMuted}> + <b>Pending ({pendingTasks().length})</b> + </text> + <For each={pendingTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.textMuted}>○</text> + <text fg={theme.textMuted} wrapMode="word"> + {task.title} + </text> + </box> + )} + </For> + </box> + </Show> + + <Show when={completedTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.success}> + <b>Completed ({completedTasks().length})</b> + </text> + <For each={completedTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.success}>✓</text> + <text fg={theme.textMuted} wrapMode="word"> + {task.title} + </text> + </box> + )} + </For> + </box> + </Show> + </box> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd1..a7a383856112 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, + TeamTasksResponse, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -73,6 +74,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path + team_task: { + [teamID: string]: TeamTasksResponse + } }>({ provider_next: { all: [], @@ -100,6 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, + team_task: {}, }) const sdk = useSDK() @@ -340,6 +345,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + case "team.task.completed": { + const teamID = event.properties.teamID + sdk.client.team + ?.tasks?.({ teamID }) + .then((x) => { + if (x.data) setStore("team_task", teamID, x.data) + }) + .catch((e) => Log.Default.error("Failed to load team tasks", { teamID, error: e })) + break + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 392504652f8a..2be2b6325a48 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -28,7 +28,7 @@ import { RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart, TeamTasksResponse } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -60,6 +60,7 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" +import { TeamTasksPanel } from "../../component/team-tasks-panel" import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" @@ -157,6 +158,7 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [teamTasksVisible, setTeamTasksVisible] = createSignal(false) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -196,6 +198,19 @@ export function Session() { }) }) + // Load team tasks when session has a team + createEffect(() => { + const teamID = session()?.teamID + if (teamID && !sync.data.team_task[teamID]) { + sdk.client.team + ?.tasks?.({ teamID }) + .then((x) => { + if (x.data) sync.set("team_task", teamID, x.data) + }) + .catch((e) => console.error("Failed to load team tasks", { teamID, error: e })) + } + }) + const toast = useToast() const sdk = useSDK() @@ -968,6 +983,18 @@ export function Session() { dialog.clear() }, }, + { + title: teamTasksVisible() ? "Hide team tasks" : "Show team tasks", + value: "session.toggle.team_tasks", + keybind: "team_tasks", + category: "Team", + hidden: !session()?.teamID, + enabled: !!session()?.teamID, + onSelect: (dialog) => { + setTeamTasksVisible((prev) => !prev) + dialog.clear() + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) @@ -1046,6 +1073,9 @@ export function Session() { <Show when={showHeader() && (!sidebarVisible() || !wide())}> <Header /> </Show> + <Show when={teamTasksVisible() && session()?.teamID}> + <TeamTasksPanel teamID={session()!.teamID!} /> + </Show> <scrollbox ref={(r) => (scroll = r)} viewportOptions={{ diff --git a/packages/opencode/src/server/routes/team.ts b/packages/opencode/src/server/routes/team.ts new file mode 100644 index 000000000000..4306a50ed9ab --- /dev/null +++ b/packages/opencode/src/server/routes/team.ts @@ -0,0 +1,51 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { TeamTask } from "@/team/task" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +const TeamTaskInfo = z.object({ + id: z.string(), + team_id: z.string(), + title: z.string(), + description: z.string().nullable(), + status: z.enum(["pending", "in_progress", "completed"]), + assigned_to: z.string().nullable(), + depends_on: z.array(z.string()).nullable(), + time_created: z.number(), + time_updated: z.number(), +}) + +export const TeamRoutes = lazy(() => + new Hono().get( + "/:teamID/tasks", + describeRoute({ + summary: "List team tasks", + description: "Get a list of all tasks for a specific team.", + operationId: "team.tasks", + responses: { + 200: { + description: "List of team tasks", + content: { + "application/json": { + schema: resolver(TeamTaskInfo.array()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + teamID: z.string(), + }), + ), + async (c) => { + const { teamID } = c.req.valid("param") + const tasks = TeamTask.list(teamID) + return c.json(tasks) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9fba9c1fe1a0..bd8ff9a2ca6e 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 { TeamRoutes } from "./routes/team" 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 @@ -229,6 +230,7 @@ export namespace Server { .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) + .route("/team", TeamRoutes()) .route("/permission", PermissionRoutes()) .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 34f3a86f5ee2..9c0601087b63 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -144,6 +144,8 @@ import type { SessionUpdateErrors, SessionUpdateResponses, SubtaskPartInput, + TeamTasksErrors, + TeamTasksResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -2037,6 +2039,38 @@ export class Permission extends HeyApiClient { } } +export class Team extends HeyApiClient { + /** + * List team tasks + * + * Get a list of all tasks for a specific team. + */ + public tasks<ThrowOnError extends boolean = false>( + parameters: { + teamID: string + directory?: string + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "teamID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get<TeamTasksResponses, TeamTasksErrors, ThrowOnError>({ + url: "/team/{teamID}/tasks", + ...options, + ...params, + }) + } +} + export class Question extends HeyApiClient { /** * List pending questions @@ -3336,6 +3370,11 @@ export class OpencodeClient extends HeyApiClient { return (this._permission ??= new Permission({ client: this.client })) } + private _team?: Team + get team(): Team { + return (this._team ??= new Team({ 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 9ed4dc3c4cdb..48315a8f9f3a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3672,6 +3672,45 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type TeamTasksData = { + body?: never + path: { + teamID: string + } + query?: { + directory?: string + } + url: "/team/{teamID}/tasks" +} + +export type TeamTasksErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TeamTasksError = TeamTasksErrors[keyof TeamTasksErrors] + +export type TeamTasksResponses = { + /** + * List of team tasks + */ + 200: Array<{ + id: string + team_id: string + title: string + description: string | null + status: "pending" | "in_progress" | "completed" + assigned_to: string | null + depends_on: Array<string> | null + time_created: number + time_updated: number + }> +} + +export type TeamTasksResponse = TeamTasksResponses[keyof TeamTasksResponses] + export type PermissionReplyData = { body?: { reply: "once" | "always" | "reject" From 12c0703ec67fb4adb3509030d31cb8531295673b Mon Sep 17 00:00:00 2001 From: xiaodong <xiao.dong@trip.com> Date: Fri, 27 Feb 2026 17:25:31 +0800 Subject: [PATCH 3/6] feat(opencode): add /team slash command for parallel task execution Add a built-in /team command that instructs the agent to break down a task into subtasks and execute them in parallel using agent teams. Made-with: Cursor --- packages/opencode/src/command/index.ts | 11 ++++++++ .../opencode/src/command/template/team.txt | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 packages/opencode/src/command/template/team.txt diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index dce7ac8bbc34..b25bffa175cb 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,6 +5,7 @@ import { Instance } from "../project/instance" import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" +import PROMPT_TEAM from "./template/team.txt" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -54,6 +55,7 @@ export namespace Command { export const Default = { INIT: "init", REVIEW: "review", + TEAM: "team", } as const const state = Instance.state(async () => { @@ -79,6 +81,15 @@ export namespace Command { subtask: true, hints: hints(PROMPT_REVIEW), }, + [Default.TEAM]: { + name: Default.TEAM, + description: "use agent teams to execute task in parallel", + source: "command", + get template() { + return PROMPT_TEAM + }, + hints: hints(PROMPT_TEAM), + }, } for (const [name, command] of Object.entries(cfg.command ?? {})) { diff --git a/packages/opencode/src/command/template/team.txt b/packages/opencode/src/command/template/team.txt new file mode 100644 index 000000000000..fc6c8ce4e73d --- /dev/null +++ b/packages/opencode/src/command/template/team.txt @@ -0,0 +1,26 @@ +You are coordinating a team of AI agents to work on a task in parallel. + +Use the `team` tool to create a team and spawn multiple teammates to work on different aspects of the task simultaneously. + +## Task +$ARGUMENTS + +## Workflow + +1. **Analyze** the task and break it down into 2-5 parallel subtasks +2. **Create** a team using: team({ action: "create", name: "task-team" }) +3. **Spawn** teammates for each subtask: + - team({ action: "spawn", agent: "build", prompt: "subtask description" }) + - Spawn multiple teammates before calling wait +4. **Wait** for all teammates to complete: team({ action: "wait" }) +5. **Review** the collected results and synthesize a final response +6. **Cleanup** the team: team({ action: "cleanup" }) + +## Guidelines + +- Each teammate runs in its own context, so be specific in the prompts +- Teammates can read/write files and run commands independently +- Use 2-5 teammates for most tasks (more teammates = more tokens) +- If subtasks have dependencies, consider using team tasks (tasks sub-action) + +Now analyze the task above and execute it using agent teams. From c2da48a8a466d9358ca832523d1352eb2c173c61 Mon Sep 17 00:00:00 2001 From: xiaodong <xiao.dong@trip.com> Date: Fri, 27 Feb 2026 18:14:54 +0800 Subject: [PATCH 4/6] fix(opencode): use Provider.defaultModel() for teammate model resolution Replace hardcoded { modelID: "default", providerID: "default" } fallback with Provider.defaultModel() in spawn and wakeForMessage, consistent with how the rest of the codebase resolves default models. Made-with: Cursor --- packages/opencode/src/tool/team.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/team.ts b/packages/opencode/src/tool/team.ts index 026a373cd91c..d17c620426e3 100644 --- a/packages/opencode/src/tool/team.ts +++ b/packages/opencode/src/tool/team.ts @@ -9,6 +9,7 @@ import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { Identifier } from "../id/id" import { Bus } from "../bus" +import { Provider } from "../provider/provider" import type { MessageV2 } from "../session/message-v2" const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes @@ -87,10 +88,7 @@ export const TeamTool = Tool.define("team", async () => { }) await Team.join(team.id, teammate.id) - const model = { - modelID: agent.model?.modelID ?? "default", - providerID: agent.model?.providerID ?? "default", - } + const model = agent.model ?? (await Provider.defaultModel()) const promise = withTimeout( runTeammate({ @@ -292,7 +290,7 @@ async function wakeForMessage(sessionID: string, teamID: string): Promise<string if (!last) return "" const isUserMessage = (msg: MessageV2.Info): msg is MessageV2.User => msg.role === "user" - const model = isUserMessage(last.info) ? last.info.model : { modelID: "default", providerID: "default" } + const model = isUserMessage(last.info) ? last.info.model : await Provider.defaultModel() const agentName = last.info.agent ?? "build" const pendingMsgs = TeamMessage.pending(sessionID, teamID) From 7a2974afccfc190a08220332706badc016685d80 Mon Sep 17 00:00:00 2001 From: xiaodong <xiao.dong@trip.com> Date: Fri, 6 Mar 2026 17:37:30 +0800 Subject: [PATCH 5/6] feat(opencode): enhance team task management and session navigation - Implement logic to switch to a lead session when a team member session is deleted. - Add a helper function to refresh team tasks upon task completion, creation, or claiming events. - Automatically display the team tasks panel for lead sessions. - Introduce new events for task creation and claiming to improve task tracking. Made-with: Cursor --- packages/opencode/src/cli/cmd/tui/app.tsx | 15 +++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 29 ++++++--- .../src/cli/cmd/tui/routes/session/index.tsx | 18 +++++- .../opencode/src/command/template/team.txt | 18 +++++- packages/opencode/src/team/index.ts | 15 +++++ packages/opencode/src/team/task.ts | 60 ++++++------------- packages/opencode/src/tool/team.txt | 8 ++- 7 files changed, 110 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 97c910a47d4b..ff16d49a3332 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -695,6 +695,21 @@ function App() { sdk.event.on(SessionApi.Event.Deleted.type, (evt) => { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { + const deletedSession = evt.properties.info + + // If deleted session is a team member, try to switch to lead session instead of going home + if (deletedSession.teamID && deletedSession.teamRole === "member") { + const leadSession = sync.data.session.find((s) => s.teamID === deletedSession.teamID && s.teamRole === "lead") + if (leadSession) { + route.navigate({ type: "session", sessionID: leadSession.id }) + toast.show({ + variant: "info", + message: "Teammate session removed, switched to lead session", + }) + return + } + } + route.navigate({ type: "home" }) toast.show({ variant: "info", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a7a383856112..82f61315393a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -109,6 +109,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() + // Helper to refresh team tasks + const refreshTeamTasks = (teamID: string) => { + sdk.client.team + ?.tasks?.({ teamID }) + .then((x) => { + if (x.data) setStore("team_task", teamID, x.data) + }) + .catch((e) => Log.Default.error("Failed to load team tasks", { teamID, error: e })) + } + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -347,13 +357,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "team.task.completed": { - const teamID = event.properties.teamID - sdk.client.team - ?.tasks?.({ teamID }) - .then((x) => { - if (x.data) setStore("team_task", teamID, x.data) - }) - .catch((e) => Log.Default.error("Failed to load team tasks", { teamID, error: e })) + refreshTeamTasks(event.properties.teamID) + break + } + + default: { + // Handle team.task.created and team.task.claimed events + // These have the same structure as team.task.completed + const type = event.type as string + if (type === "team.task.created" || type === "team.task.claimed") { + const props = event.properties as { teamID: string } + refreshTeamTasks(props.teamID) + } break } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2be2b6325a48..69a1b9f6ac38 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -28,7 +28,15 @@ import { RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart, TeamTasksResponse } from "@opencode-ai/sdk/v2" +import type { + AssistantMessage, + Part, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, + TeamTasksResponse, +} from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -211,6 +219,14 @@ export function Session() { } }) + // Auto-show team tasks panel when session is a team lead + createEffect(() => { + const s = session() + if (s?.teamID && s?.teamRole === "lead") { + setTeamTasksVisible(true) + } + }) + const toast = useToast() const sdk = useSDK() diff --git a/packages/opencode/src/command/template/team.txt b/packages/opencode/src/command/template/team.txt index fc6c8ce4e73d..f07738b8787c 100644 --- a/packages/opencode/src/command/template/team.txt +++ b/packages/opencode/src/command/template/team.txt @@ -5,12 +5,23 @@ Use the `team` tool to create a team and spawn multiple teammates to work on dif ## Task $ARGUMENTS +## Available Agents + +Choose the most appropriate agent for each subtask: + +- **explore**: Fast agent for exploring codebases - finding files, searching code, answering questions about the codebase. Use for: code analysis, finding patterns, understanding architecture. +- **build**: Default agent that can read/write files and run commands. Use for: implementing features, fixing bugs, writing code. +- **plan**: Planning agent that analyzes but doesn't modify files. Use for: architecture design, code review, technical analysis. +- **general**: General-purpose agent for research and multi-step tasks. Use for: complex investigations, documentation research. + ## Workflow 1. **Analyze** the task and break it down into 2-5 parallel subtasks 2. **Create** a team using: team({ action: "create", name: "task-team" }) -3. **Spawn** teammates for each subtask: - - team({ action: "spawn", agent: "build", prompt: "subtask description" }) +3. **Spawn** teammates for each subtask, choosing the appropriate agent: + - team({ action: "spawn", agent: "explore", prompt: "search for all API endpoints" }) + - team({ action: "spawn", agent: "build", prompt: "implement the new feature" }) + - team({ action: "spawn", agent: "plan", prompt: "analyze the architecture" }) - Spawn multiple teammates before calling wait 4. **Wait** for all teammates to complete: team({ action: "wait" }) 5. **Review** the collected results and synthesize a final response @@ -18,8 +29,9 @@ $ARGUMENTS ## Guidelines +- **Choose the right agent**: Use `explore` for read-only searches, `build` for code changes, `plan` for analysis - Each teammate runs in its own context, so be specific in the prompts -- Teammates can read/write files and run commands independently +- Teammates can read/write files and run commands independently (based on their agent type) - Use 2-5 teammates for most tasks (more teammates = more tokens) - If subtasks have dependencies, consider using team tasks (tasks sub-action) diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts index ec0bbcb75e55..2ab2546f75d0 100644 --- a/packages/opencode/src/team/index.ts +++ b/packages/opencode/src/team/index.ts @@ -67,6 +67,21 @@ export namespace Team { sessionID: z.string(), }), ), + TaskCreated: BusEvent.define( + "team.task.created", + z.object({ + teamID: z.string(), + taskID: z.string(), + }), + ), + TaskClaimed: BusEvent.define( + "team.task.claimed", + z.object({ + teamID: z.string(), + taskID: z.string(), + sessionID: z.string(), + }), + ), } function fromRow(row: typeof TeamTable.$inferSelect): Info { diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts index 508138e67551..e98d94991456 100644 --- a/packages/opencode/src/team/task.ts +++ b/packages/opencode/src/team/task.ts @@ -10,12 +10,7 @@ export namespace TeamTask { export type Info = typeof TeamTaskTable.$inferSelect - export async function create(input: { - teamID: string - title: string - description?: string - depends_on?: string[] - }) { + export async function create(input: { teamID: string; title: string; description?: string; depends_on?: string[] }) { const id = Identifier.ascending("team_task") const now = Date.now() Database.use((db) => { @@ -34,34 +29,27 @@ export namespace TeamTask { .run() }) log.info("created", { id, title: input.title }) + await Bus.publish(Team.Event.TaskCreated, { + teamID: input.teamID, + taskID: id, + }) return id } export async function claim(taskID: string, sessionID: string) { - return Database.use((db) => { - const task = db - .select() - .from(TeamTaskTable) - .where(eq(TeamTaskTable.id, taskID)) - .get() + const result = Database.use((db) => { + const task = db.select().from(TeamTaskTable).where(eq(TeamTaskTable.id, taskID)).get() if (!task) throw new Error(`Task not found: ${taskID}`) - if (task.status !== "pending") - throw new Error(`Task ${taskID} is not pending (status: ${task.status})`) + if (task.status !== "pending") throw new Error(`Task ${taskID} is not pending (status: ${task.status})`) if (task.depends_on && task.depends_on.length > 0) { - const deps = db - .select() - .from(TeamTaskTable) - .where(eq(TeamTaskTable.team_id, task.team_id)) - .all() + const deps = db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, task.team_id)).all() const incomplete = task.depends_on.filter((depID) => { const dep = deps.find((d) => d.id === depID) return !dep || dep.status !== "completed" }) if (incomplete.length > 0) - throw new Error( - `Task ${taskID} is blocked by unresolved dependencies: ${incomplete.join(", ")}`, - ) + throw new Error(`Task ${taskID} is blocked by unresolved dependencies: ${incomplete.join(", ")}`) } const result = db @@ -79,6 +67,12 @@ export namespace TeamTask { log.info("claimed", { id: taskID, by: sessionID }) return result }) + await Bus.publish(Team.Event.TaskClaimed, { + teamID: result.team_id, + taskID, + sessionID, + }) + return result } export async function complete(taskID: string, sessionID: string) { @@ -86,12 +80,7 @@ export namespace TeamTask { db .update(TeamTaskTable) .set({ status: "completed", time_updated: Date.now() }) - .where( - and( - eq(TeamTaskTable.id, taskID), - eq(TeamTaskTable.assigned_to, sessionID), - ), - ) + .where(and(eq(TeamTaskTable.id, taskID), eq(TeamTaskTable.assigned_to, sessionID))) .returning() .get(), ) @@ -106,13 +95,7 @@ export namespace TeamTask { } export function list(teamID: string) { - return Database.use((db) => - db - .select() - .from(TeamTaskTable) - .where(eq(TeamTaskTable.team_id, teamID)) - .all(), - ) + return Database.use((db) => db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all()) } export async function reassign(sessionID: string) { @@ -124,12 +107,7 @@ export namespace TeamTask { assigned_to: null, time_updated: Date.now(), }) - .where( - and( - eq(TeamTaskTable.assigned_to, sessionID), - eq(TeamTaskTable.status, "in_progress"), - ), - ) + .where(and(eq(TeamTaskTable.assigned_to, sessionID), eq(TeamTaskTable.status, "in_progress"))) .returning() .all(), ) diff --git a/packages/opencode/src/tool/team.txt b/packages/opencode/src/tool/team.txt index 4d8285aca6bc..63f7b57c59ee 100644 --- a/packages/opencode/src/tool/team.txt +++ b/packages/opencode/src/tool/team.txt @@ -13,9 +13,15 @@ Actions: - shutdown: Request a teammate to shut down gracefully - cleanup: Archive the team, delete teammate sessions, and clean up (lead only, all teammates must be idle) +Available agent types for spawn: +- explore: Fast read-only agent for searching code, finding files, understanding architecture +- build: Default agent that can read/write files and run commands +- plan: Analysis agent that examines code but doesn't modify files +- general: General-purpose agent for research and multi-step tasks + Typical workflow: 1. create a team -2. spawn teammate A with task X +2. spawn teammate A with task X (choose appropriate agent type) 3. spawn teammate B with task Y (both now running in parallel) 4. spawn teammate C with task Z 5. wait → collects results from A, B, C when they all finish From 90bf8f129d050b06c506c8b60327c4067bd99379 Mon Sep 17 00:00:00 2001 From: xiaodong <xiao.dong@trip.com> Date: Thu, 12 Mar 2026 17:24:52 +0800 Subject: [PATCH 6/6] feat(opencode): enhance team tasks panel and session management - Add scrollable view to TeamTasksPanel for better task visibility. - Implement session cleanup logic to remove foreign key references before session deletion. - Introduce publishSessionUpdated function to notify TUI of session updates. - Improve error handling and logging during session removal and team cleanup processes. Made-with: Cursor --- .../cmd/tui/component/team-tasks-panel.tsx | 134 ++++++++++-------- packages/opencode/src/session/index.ts | 31 +++- packages/opencode/src/team/index.ts | 41 +++++- packages/opencode/src/tool/team.ts | 19 +++ 4 files changed, 153 insertions(+), 72 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx index 519444f33769..adf10700cd05 100644 --- a/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx @@ -21,72 +21,84 @@ export function TeamTasksPanel(props: { teamID: string }) { paddingRight={2} borderStyle="single" borderColor={theme.border} + maxHeight={15} + flexShrink={0} > - <text fg={theme.text}> - <b>Team Tasks</b> - <span style={{ fg: theme.textMuted }}> ({tasks().length} total)</span> - </text> + <scrollbox + flexGrow={1} + verticalScrollbarOptions={{ + trackOptions: { + backgroundColor: theme.background, + foregroundColor: theme.borderActive, + }, + }} + > + <text fg={theme.text}> + <b>Team Tasks</b> + <span style={{ fg: theme.textMuted }}> ({tasks().length} total)</span> + </text> - <Show when={tasks().length === 0}> - <text fg={theme.textMuted}>No tasks in this team yet.</text> - </Show> + <Show when={tasks().length === 0}> + <text fg={theme.textMuted}>No tasks in this team yet.</text> + </Show> - <Show when={inProgressTasks().length > 0}> - <box marginTop={1}> - <text fg={theme.warning}> - <b>In Progress ({inProgressTasks().length})</b> - </text> - <For each={inProgressTasks()}> - {(task) => ( - <box flexDirection="row" gap={1}> - <text fg={theme.warning}>•</text> - <text fg={theme.text} wrapMode="word"> - {task.title} - <Show when={task.assigned_to}> - <span style={{ fg: theme.textMuted }}> → {task.assigned_to}</span> - </Show> - </text> - </box> - )} - </For> - </box> - </Show> + <Show when={inProgressTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.warning}> + <b>In Progress ({inProgressTasks().length})</b> + </text> + <For each={inProgressTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.warning}>•</text> + <text fg={theme.text} wrapMode="word"> + {task.title} + <Show when={task.assigned_to}> + <span style={{ fg: theme.textMuted }}> → {task.assigned_to}</span> + </Show> + </text> + </box> + )} + </For> + </box> + </Show> - <Show when={pendingTasks().length > 0}> - <box marginTop={1}> - <text fg={theme.textMuted}> - <b>Pending ({pendingTasks().length})</b> - </text> - <For each={pendingTasks()}> - {(task) => ( - <box flexDirection="row" gap={1}> - <text fg={theme.textMuted}>○</text> - <text fg={theme.textMuted} wrapMode="word"> - {task.title} - </text> - </box> - )} - </For> - </box> - </Show> + <Show when={pendingTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.textMuted}> + <b>Pending ({pendingTasks().length})</b> + </text> + <For each={pendingTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.textMuted}>○</text> + <text fg={theme.textMuted} wrapMode="word"> + {task.title} + </text> + </box> + )} + </For> + </box> + </Show> - <Show when={completedTasks().length > 0}> - <box marginTop={1}> - <text fg={theme.success}> - <b>Completed ({completedTasks().length})</b> - </text> - <For each={completedTasks()}> - {(task) => ( - <box flexDirection="row" gap={1}> - <text fg={theme.success}>✓</text> - <text fg={theme.textMuted} wrapMode="word"> - {task.title} - </text> - </box> - )} - </For> - </box> - </Show> + <Show when={completedTasks().length > 0}> + <box marginTop={1}> + <text fg={theme.success}> + <b>Completed ({completedTasks().length})</b> + </text> + <For each={completedTasks()}> + {(task) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.success}>✓</text> + <text fg={theme.textMuted} wrapMode="word"> + {task.title} + </text> + </box> + )} + </For> + </box> + </Show> + </scrollbox> </box> ) } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 749ecb249c2f..c9a840607b65 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -660,24 +660,41 @@ export namespace Session { }) export const remove = fn(Identifier.schema("session"), async (sessionID) => { - const project = Instance.project try { const session = await get(sessionID) + log.info("removing session", { sessionID, title: session.title }) for (const child of await children(sessionID)) { await remove(child.id) } await unshare(sessionID).catch(() => {}) + + // 在删除 session 前,先清理 team 相关表中的外键引用 + const { TeamTaskTable } = await import("@/team/team-task.sql") + const { TeamMessageTable } = await import("@/team/team-message.sql") + + Database.use((db) => { + // 将 assigned_to 设置为 null + db.update(TeamTaskTable).set({ assigned_to: null }).where(eq(TeamTaskTable.assigned_to, sessionID)).run() + // 将 to_session_id 设置为 null + db.update(TeamMessageTable) + .set({ to_session_id: null }) + .where(eq(TeamMessageTable.to_session_id, sessionID)) + .run() + }) + // CASCADE delete handles messages and parts automatically Database.use((db) => { db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run() - Database.effect(() => - Bus.publish(Event.Deleted, { - info: session, - }), - ) }) + // Publish event after successful deletion + await Bus.publish(Event.Deleted, { + info: session, + }) + log.info("session removed", { sessionID }) } catch (e) { - log.error(e) + log.error("failed to remove session", { sessionID, error: String(e) }) + // Re-throw to allow caller to handle the error + throw e } }) diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts index 2ab2546f75d0..ab7e0488541e 100644 --- a/packages/opencode/src/team/index.ts +++ b/packages/opencode/src/team/index.ts @@ -97,6 +97,14 @@ export namespace Team { } } + async function publishSessionUpdated(sessionID: string) { + const { Session: SessionModule } = await import("@/session") + const sessionRow = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (sessionRow) { + await Bus.publish(SessionModule.Event.Updated, { info: SessionModule.fromRow(sessionRow) }) + } + } + export async function create(input: { name: string; sessionID: string }) { const existing = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), @@ -117,8 +125,9 @@ export namespace Team { }) .run() db.update(SessionTable).set({ team_id: id, team_role: "lead" }).where(eq(SessionTable.id, input.sessionID)).run() - return db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()! + return db.select().from(TeamTable).where(eq(TeamTable.id, id)).get() }) + if (!row) throw new Error("Failed to create team") const info = fromRow(row) log.info("created", { id, name: input.name }) await Bus.publish(Event.Created, { info }) @@ -127,6 +136,10 @@ export namespace Team { sessionID: input.sessionID, role: "lead", }) + + // Publish session.updated event so TUI can update teamID + await publishSessionUpdated(input.sessionID) + return info } @@ -197,15 +210,28 @@ export namespace Team { if (running.length > 0) throw new Error(`Cannot cleanup: ${running.length} teammate(s) still running. Shut them down first.`) + log.info("cleanup team", { teamID, memberCount: active.length }) + const { Session: SessionModule } = await import("@/session") const memberSessions = active.filter((m) => m.team_role === "member") for (const m of memberSessions) { - await SessionModule.remove(m.id).catch((e) => log.error("remove member failed", { id: m.id, error: e })) + try { + await SessionModule.remove(m.id) + log.info("removed member session", { sessionID: m.id, title: m.title }) + } catch (e) { + log.error("failed to remove member session", { sessionID: m.id, title: m.title, error: String(e) }) + // Continue with cleanup even if one member fails + } } Database.use((db) => { db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.team_id, teamID)).run() }) + + // Publish session.updated event for lead session so TUI can update teamID + await publishSessionUpdated(leadSessionID) + + log.info("team cleaned up", { teamID }) return archive(teamID) } @@ -218,6 +244,9 @@ export namespace Team { sessionID, role: "member", }) + + // Publish session.updated event so TUI can update teamID + await publishSessionUpdated(sessionID) } export async function leave(sessionID: string) { @@ -228,12 +257,16 @@ export namespace Team { db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.id, sessionID)).run() }) await Bus.publish(Event.MemberLeft, { teamID, sessionID }) + + // Publish session.updated event so TUI can update teamID + await publishSessionUpdated(sessionID) } export function getBySession(sessionID: string) { const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) - if (!row?.team_id) return undefined - const team = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, row.team_id!)).get()) + const teamID = row?.team_id + if (!teamID) return undefined + const team = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, teamID)).get()) if (!team) return undefined return fromRow(team) } diff --git a/packages/opencode/src/tool/team.ts b/packages/opencode/src/tool/team.ts index d17c620426e3..bd7fff2052c3 100644 --- a/packages/opencode/src/tool/team.ts +++ b/packages/opencode/src/tool/team.ts @@ -10,8 +10,11 @@ import { SessionPrompt } from "../session/prompt" import { Identifier } from "../id/id" import { Bus } from "../bus" import { Provider } from "../provider/provider" +import { Log } from "@/util/log" import type { MessageV2 } from "../session/message-v2" +const log = Log.create({ service: "team" }) + const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const pending = new Map<string, Promise<string>>() @@ -88,6 +91,14 @@ export const TeamTool = Tool.define("team", async () => { }) await Team.join(team.id, teammate.id) + // Auto-create and claim task for the teammate + const taskID = await TeamTask.create({ + teamID: team.id, + title, + description: params.prompt, + }) + await TeamTask.claim(taskID, teammate.id) + const model = agent.model ?? (await Provider.defaultModel()) const promise = withTimeout( @@ -97,6 +108,7 @@ export const TeamTool = Tool.define("team", async () => { model, agentName: agent.name, prompt: params.prompt, + taskID, }), TIMEOUT_MS, ) @@ -266,6 +278,7 @@ async function runTeammate(input: { model: { modelID: string; providerID: string } agentName: string prompt: string + taskID?: string }): Promise<string> { SessionPromptState.mark(input.sessionID) const resp = await SessionPrompt.prompt({ @@ -277,6 +290,12 @@ async function runTeammate(input: { parts: [{ type: "text", text: input.prompt }], }).finally(() => { SessionPromptState.unmark(input.sessionID) + // Auto-complete spawned task (must be before reassign) + if (input.taskID) { + TeamTask.complete(input.taskID, input.sessionID).catch((err) => { + log.warn("failed to complete task", { taskID: input.taskID, error: String(err) }) + }) + } TeamTask.reassign(input.sessionID) }) const text = resp.parts.findLast((x) => x.type === "text")?.text ?? ""