From 191226756f22e04eff00326d3c18b5dfb4cd29c4 Mon Sep 17 00:00:00 2001 From: xiaodong Date: Thu, 26 Feb 2026 19:45:28 +0800 Subject: [PATCH] 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 00000000000..d9257724d07 --- /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 00000000000..ba742b3f632 --- /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 0c5ea9a8572..24a92d80ff0 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 f20267e0820..392504652f8 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 28aea4d6777..3e056663e19 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 db2920b0a45..866f6038eb3 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 22de477f8d1..749ecb249c2 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 75bd3c9dfac..288ff42cbba 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 9c5c72c4c57..760d8b88e9e 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 7961b0e3804..852addb886e 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 00000000000..ec0bbcb75e5 --- /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 00000000000..d4391e8e2be --- /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 00000000000..508138e6755 --- /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 00000000000..b5b5543c345 --- /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 00000000000..d6c91df814d --- /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 00000000000..ff3e39aa5f7 --- /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 c6d7fbc1e4b..8b8cf4881dc 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 00000000000..026a373cd91 --- /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 00000000000..4d8285aca6b --- /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 00000000000..811939b4148 --- /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 6165c0f7b09..34f3a86f5ee 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 be6c00cf445..9ed4dc3c4cd 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?: {