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/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8bb17ff13362..be903faaf0f7 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -699,6 +699,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/component/team-tasks-panel.tsx b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx new file mode 100644 index 000000000000..adf10700cd05 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/team-tasks-panel.tsx @@ -0,0 +1,104 @@ +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 ( + + + + Team Tasks + ({tasks().length} total) + + + + No tasks in this team yet. + + + 0}> + + + In Progress ({inProgressTasks().length}) + + + {(task) => ( + + + + {task.title} + + → {task.assigned_to} + + + + )} + + + + + 0}> + + + Pending ({pendingTasks().length}) + + + {(task) => ( + + + + {task.title} + + + )} + + + + + 0}> + + + Completed ({completedTasks().length}) + + + {(task) => ( + + + + {task.title} + + + )} + + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..b9757520777d 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" @@ -74,6 +75,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path + team_task: { + [teamID: string]: TeamTasksResponse + } workspaceList: Workspace[] }>({ provider_next: { @@ -102,11 +106,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, + team_task: {}, workspaceList: [], }) 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 })) + } + async function syncWorkspaces() { const result = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!result?.data) return @@ -349,6 +364,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + case "team.task.completed": { + 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/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index f64dbe533a74..cb28cc256cdb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -152,6 +152,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")} + + + + + {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? ( 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 7456742cdf36..463cadaf9cff 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -29,7 +29,15 @@ 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" @@ -62,6 +70,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" @@ -159,6 +168,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(() => { @@ -204,6 +214,27 @@ 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 })) + } + }) + + // 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() @@ -332,18 +363,24 @@ export function Session() { } } - function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) + direction + 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)) + }) - if (next >= sessions.length) next = 0 - if (next < 0) next = sessions.length - 1 - if (sessions[next]) { + 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: sessions[next].id, + sessionID: members[next].id, }) } } @@ -976,6 +1013,42 @@ 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() + }, + }, + { + 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) @@ -1051,6 +1124,9 @@ export function Session() {
+ + + (scroll = r)} viewportOptions={{ diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 2c47984fdd8f..b45f0f9a788d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,6 +6,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" @@ -55,6 +56,7 @@ export namespace Command { export const Default = { INIT: "init", REVIEW: "review", + TEAM: "team", } as const const state = Instance.state(async () => { @@ -80,6 +82,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..f07738b8787c --- /dev/null +++ b/packages/opencode/src/command/template/team.txt @@ -0,0 +1,38 @@ +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 + +## 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, 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 +6. **Cleanup** the team: team({ action: "cleanup" }) + +## 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 (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) + +Now analyze the task above and execute it using agent teams. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e186712..fe253fc9a8c4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -947,6 +947,9 @@ export namespace Config { session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), session_parent: z.string().optional().default("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("h").describe("Toggle tips on home screen"), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6673297cbfac..b52bae64798d 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", workspace: "wrk", } as const 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 55bcf2dfce16..bba879492881 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -43,6 +43,7 @@ import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { TeamRoutes } from "./routes/team" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" @@ -245,6 +246,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/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0879fe87fd3b..b06cff4e7540 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -76,6 +76,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, @@ -102,6 +104,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, @@ -149,6 +153,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: MessageID.zod, @@ -222,6 +228,8 @@ export namespace Session { parentID: SessionID.zod.optional(), title: z.string().optional(), permission: Info.shape.permission, + teamID: z.string().optional(), + teamRole: z.enum(["lead", "member"]).optional(), workspaceID: WorkspaceID.zod.optional(), }) .optional(), @@ -231,6 +239,8 @@ export namespace Session { directory: Instance.directory, title: input?.title, permission: input?.permission, + teamID: input?.teamID, + teamRole: input?.teamRole, workspaceID: input?.workspaceID, }) }, @@ -301,6 +311,8 @@ export namespace Session { workspaceID?: WorkspaceID directory: string permission?: PermissionNext.Ruleset + teamID?: string + teamRole?: "lead" | "member" }) { const result: Info = { id: SessionID.descending(input.id), @@ -312,6 +324,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(), @@ -665,21 +679,39 @@ export namespace Session { 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/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 743537f59871..7058fce8f01a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -557,6 +557,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 b3229edd1338..376ee81a2d1d 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -32,6 +32,8 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + team_id: text(), + team_role: text().$type<"lead" | "member">(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -40,6 +42,7 @@ export const SessionTable = sqliteTable( index("session_project_idx").on(table.project_id), index("session_workspace_idx").on(table.workspace_id), index("session_parent_idx").on(table.parent_id), + index("session_team_idx").on(table.team_id), ], ) diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee62201..9250eaa5b791 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -2,4 +2,7 @@ export { AccountTable, AccountStateTable, ControlAccountTable } from "../account export { ProjectTable } from "../project/project.sql" export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" +export { TeamTable } from "../team/team.sql" +export { TeamTaskTable } from "../team/team-task.sql" +export { TeamMessageTable } from "../team/team-message.sql" export { WorkspaceTable } from "../control-plane/workspace.sql" diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts new file mode 100644 index 000000000000..ab7e0488541e --- /dev/null +++ b/packages/opencode/src/team/index.ts @@ -0,0 +1,289 @@ +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 + + 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(), + }), + ), + 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 { + return { + id: row.id, + name: row.name, + leadSessionID: row.lead_session_id, + status: row.status, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + 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(), + ) + 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() + }) + 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 }) + await Bus.publish(Event.MemberJoined, { + teamID: id, + sessionID: input.sessionID, + role: "lead", + }) + + // Publish session.updated event so TUI can update teamID + await publishSessionUpdated(input.sessionID) + + 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.`) + + 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) { + 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) + } + + 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", + }) + + // Publish session.updated event so TUI can update teamID + await publishSessionUpdated(sessionID) + } + + 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 }) + + // 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()) + 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) + } +} + +export namespace SessionPromptState { + const running = new Set() + + 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..e98d94991456 --- /dev/null +++ b/packages/opencode/src/team/task.ts @@ -0,0 +1,121 @@ +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 }) + await Bus.publish(Team.Event.TaskCreated, { + teamID: input.teamID, + taskID: id, + }) + return id + } + + export async function claim(taskID: string, sessionID: string) { + 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.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 + }) + await Bus.publish(Team.Event.TaskClaimed, { + teamID: result.team_id, + taskID, + 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(), + ...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 3ea242a29d7c..81d4a053937d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -29,6 +29,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" @@ -118,6 +119,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..bd7fff2052c3 --- /dev/null +++ b/packages/opencode/src/tool/team.ts @@ -0,0 +1,334 @@ +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 { 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>() + +function withTimeout(promise: Promise, ms: number): Promise { + const timeout = new Promise((_, 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, ctx) { + const session = await Session.get(ctx.sessionID) + + function out(title: string, output: string, extra?: Record) { + 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) + + // 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( + runTeammate({ + sessionID: teammate.id, + teamID: team.id, + model, + agentName: agent.name, + prompt: params.prompt, + taskID, + }), + 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 + taskID?: string +}): Promise { + 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) + // 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 ?? "" + return text || "(teammate produced no text output)" +} + +async function wakeForMessage(sessionID: string, teamID: string): Promise { + 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 : await Provider.defaultModel() + 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..63f7b57c59ee --- /dev/null +++ b/packages/opencode/src/tool/team.txt @@ -0,0 +1,31 @@ +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) + +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 (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 +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 27c188838b9b..568424aa8ead 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -150,6 +150,8 @@ import type { SessionUpdateErrors, SessionUpdateResponses, SubtaskPartInput, + TeamTasksErrors, + TeamTasksResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -1295,6 +1297,8 @@ export class Session2 extends HeyApiClient { parentID?: string title?: string permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" workspaceID?: string }, options?: Options, @@ -1309,6 +1313,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" }, { in: "body", key: "workspaceID" }, ], }, @@ -2381,6 +2387,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( + parameters: { + teamID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "teamID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/team/{teamID}/tasks", + ...options, + ...params, + }) + } +} + export class Question extends HeyApiClient { /** * List pending questions @@ -3957,6 +3995,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 9ab71bd8f581..971160e2ca55 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -716,6 +716,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: { @@ -830,6 +907,8 @@ export type Session = { archived?: number } permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" revert?: { messageID: string partID?: string @@ -982,6 +1061,13 @@ export type Event = | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated + | EventTeamCreated + | EventTeamArchived + | EventTeamMemberJoined + | EventTeamMemberLeft + | EventTeamTeammateIdle + | EventTeamMessage + | EventTeamTaskCompleted | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -1698,6 +1784,8 @@ export type GlobalSession = { archived?: number } permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" revert?: { messageID: string partID?: string @@ -2768,6 +2856,8 @@ export type SessionCreateData = { parentID?: string title?: string permission?: PermissionRuleset + teamID?: string + teamRole?: "lead" | "member" workspaceID?: string } path?: never @@ -3719,6 +3809,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 | null + time_created: number + time_updated: number + }> +} + +export type TeamTasksResponse = TeamTasksResponses[keyof TeamTasksResponses] + export type PermissionReplyData = { body?: { reply: "once" | "always" | "reject"