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"