diff --git a/packages/opencode/migration/20260226093432_agent-teams/migration.sql b/packages/opencode/migration/20260226093432_agent-teams/migration.sql
new file mode 100644
index 00000000000..d9257724d07
--- /dev/null
+++ b/packages/opencode/migration/20260226093432_agent-teams/migration.sql
@@ -0,0 +1,47 @@
+CREATE TABLE `team_message` (
+ `id` text PRIMARY KEY,
+ `team_id` text NOT NULL,
+ `from_session_id` text NOT NULL,
+ `to_session_id` text,
+ `content` text NOT NULL,
+ `read` integer DEFAULT false NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_team_message_team_id_team_id_fk` FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_team_message_from_session_id_session_id_fk` FOREIGN KEY (`from_session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_team_message_to_session_id_session_id_fk` FOREIGN KEY (`to_session_id`) REFERENCES `session`(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `team_task` (
+ `id` text PRIMARY KEY,
+ `team_id` text NOT NULL,
+ `title` text NOT NULL,
+ `description` text,
+ `status` text NOT NULL,
+ `assigned_to` text,
+ `depends_on` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_team_task_team_id_team_id_fk` FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_team_task_assigned_to_session_id_fk` FOREIGN KEY (`assigned_to`) REFERENCES `session`(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `team` (
+ `id` text PRIMARY KEY,
+ `name` text NOT NULL,
+ `lead_session_id` text NOT NULL,
+ `status` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_team_lead_session_id_session_id_fk` FOREIGN KEY (`lead_session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+ALTER TABLE `session` ADD `team_id` text;--> statement-breakpoint
+ALTER TABLE `session` ADD `team_role` text;--> statement-breakpoint
+CREATE INDEX `session_team_idx` ON `session` (`team_id`);--> statement-breakpoint
+CREATE INDEX `team_message_team_idx` ON `team_message` (`team_id`);--> statement-breakpoint
+CREATE INDEX `team_message_to_idx` ON `team_message` (`to_session_id`);--> statement-breakpoint
+CREATE INDEX `team_message_from_idx` ON `team_message` (`from_session_id`);--> statement-breakpoint
+CREATE INDEX `team_task_team_idx` ON `team_task` (`team_id`);--> statement-breakpoint
+CREATE INDEX `team_task_assigned_idx` ON `team_task` (`assigned_to`);--> statement-breakpoint
+CREATE INDEX `team_lead_idx` ON `team` (`lead_session_id`);
\ No newline at end of file
diff --git a/packages/opencode/migration/20260226093432_agent-teams/snapshot.json b/packages/opencode/migration/20260226093432_agent-teams/snapshot.json
new file mode 100644
index 00000000000..ba742b3f632
--- /dev/null
+++ b/packages/opencode/migration/20260226093432_agent-teams/snapshot.json
@@ -0,0 +1,1418 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "76b175f3-f496-45ef-944e-92e9ee1a8c6d",
+ "prevIds": [
+ "d2736e43-700f-4e9e-8151-9f2f0d967bc8"
+ ],
+ "ddl": [
+ {
+ "name": "control_account",
+ "entityType": "tables"
+ },
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "name": "team_message",
+ "entityType": "tables"
+ },
+ {
+ "name": "team_task",
+ "entityType": "tables"
+ },
+ {
+ "name": "team",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token_expiry",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "team_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "team_role",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "team_id",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "from_session_id",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "to_session_id",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": "false",
+ "generated": null,
+ "name": "read",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "team_message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "team_id",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "description",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "assigned_to",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "depends_on",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "team_task"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "lead_session_id",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "team"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": [
+ "message_id"
+ ],
+ "tableTo": "message",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "tableTo": "project",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": [
+ "team_id"
+ ],
+ "tableTo": "team",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_team_message_team_id_team_id_fk",
+ "entityType": "fks",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ "from_session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_team_message_from_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ "to_session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "nameExplicit": false,
+ "name": "fk_team_message_to_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ "team_id"
+ ],
+ "tableTo": "team",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_team_task_team_id_team_id_fk",
+ "entityType": "fks",
+ "table": "team_task"
+ },
+ {
+ "columns": [
+ "assigned_to"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "NO ACTION",
+ "nameExplicit": false,
+ "name": "fk_team_task_assigned_to_session_id_fk",
+ "entityType": "fks",
+ "table": "team_task"
+ },
+ {
+ "columns": [
+ "lead_session_id"
+ ],
+ "tableTo": "session",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_team_lead_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "team"
+ },
+ {
+ "columns": [
+ "email",
+ "url"
+ ],
+ "nameExplicit": false,
+ "name": "control_account_pk",
+ "entityType": "pks",
+ "table": "control_account"
+ },
+ {
+ "columns": [
+ "session_id",
+ "position"
+ ],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "project_id"
+ ],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "session_id"
+ ],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "team_message_pk",
+ "table": "team_message",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "team_task_pk",
+ "table": "team_task",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "team_pk",
+ "table": "team",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "team_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_team_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ },
+ {
+ "columns": [
+ {
+ "value": "team_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_message_team_idx",
+ "entityType": "indexes",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ {
+ "value": "to_session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_message_to_idx",
+ "entityType": "indexes",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ {
+ "value": "from_session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_message_from_idx",
+ "entityType": "indexes",
+ "table": "team_message"
+ },
+ {
+ "columns": [
+ {
+ "value": "team_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_task_team_idx",
+ "entityType": "indexes",
+ "table": "team_task"
+ },
+ {
+ "columns": [
+ {
+ "value": "assigned_to",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_task_assigned_idx",
+ "entityType": "indexes",
+ "table": "team_task"
+ },
+ {
+ "columns": [
+ {
+ "value": "lead_session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "team_lead_idx",
+ "entityType": "indexes",
+ "table": "team"
+ }
+ ],
+ "renames": []
+}
\ No newline at end of file
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
index 0c5ea9a8572..24a92d80ff0 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
@@ -122,6 +122,39 @@ export function Header() {
+
+
+
+
+ Team session{" "}
+ ({session()?.teamRole})
+
+
+
+
+ setHover("prev")}
+ onMouseOut={() => setHover(null)}
+ onMouseUp={() => command.trigger("team.prev")}
+ backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
+ >
+
+ Prev {keybind.print("team_cycle_reverse")}
+
+
+ setHover("next")}
+ onMouseOut={() => setHover(null)}
+ onMouseUp={() => command.trigger("team.next")}
+ backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
+ >
+
+ Next {keybind.print("team_cycle")}
+
+
+
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f20267e0820..392504652f8 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -325,6 +325,28 @@ export function Session() {
}
}
+ const teamMembers = createMemo(() => {
+ const tid = session()?.teamID
+ if (!tid) return []
+ return sync.data.session
+ .filter((x) => x.teamID === tid)
+ .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ })
+
+ function moveTeam(direction: number) {
+ const members = teamMembers()
+ if (members.length <= 1) return
+ let next = members.findIndex((x) => x.id === session()?.id) + direction
+ if (next >= members.length) next = 0
+ if (next < 0) next = members.length - 1
+ if (members[next]) {
+ navigate({
+ type: "session",
+ sessionID: members[next].id,
+ })
+ }
+ }
+
const command = useCommandDialog()
command.register(() => [
{
@@ -922,6 +944,30 @@ export function Session() {
dialog.clear()
},
},
+ {
+ title: "Next team member",
+ value: "team.next",
+ keybind: "team_cycle",
+ category: "Team",
+ hidden: true,
+ enabled: !!session()?.teamID,
+ onSelect: (dialog) => {
+ moveTeam(1)
+ dialog.clear()
+ },
+ },
+ {
+ title: "Previous team member",
+ value: "team.prev",
+ keybind: "team_cycle_reverse",
+ category: "Team",
+ hidden: true,
+ enabled: !!session()?.teamID,
+ onSelect: (dialog) => {
+ moveTeam(-1)
+ dialog.clear()
+ },
+ },
])
const revertInfo = createMemo(() => session()?.revert)
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 28aea4d6777..3e056663e19 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -899,6 +899,9 @@ export namespace Config {
session_child_cycle: z.string().optional().default("right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("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 db2920b0a45..866f6038eb3 100644
--- a/packages/opencode/src/id/id.ts
+++ b/packages/opencode/src/id/id.ts
@@ -11,6 +11,9 @@ export namespace Identifier {
part: "prt",
pty: "pty",
tool: "tool",
+ team: "tea",
+ team_task: "ttk",
+ team_message: "tmg",
} as const
export function schema(prefix: keyof typeof prefixes) {
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 22de477f8d1..749ecb249c2 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -71,6 +71,8 @@ export namespace Session {
share,
revert,
permission: row.permission ?? undefined,
+ teamID: row.team_id ?? undefined,
+ teamRole: row.team_role ?? undefined,
time: {
created: row.time_created,
updated: row.time_updated,
@@ -96,6 +98,8 @@ export namespace Session {
summary_diffs: info.summary?.diffs,
revert: info.revert ?? null,
permission: info.permission,
+ team_id: info.teamID,
+ team_role: info.teamRole,
time_created: info.time.created,
time_updated: info.time.updated,
time_compacting: info.time.compacting,
@@ -142,6 +146,8 @@ export namespace Session {
archived: z.number().optional(),
}),
permission: PermissionNext.Ruleset.optional(),
+ teamID: z.string().optional(),
+ teamRole: z.enum(["lead", "member"]).optional(),
revert: z
.object({
messageID: z.string(),
@@ -215,6 +221,8 @@ export namespace Session {
parentID: Identifier.schema("session").optional(),
title: z.string().optional(),
permission: Info.shape.permission,
+ teamID: z.string().optional(),
+ teamRole: z.enum(["lead", "member"]).optional(),
})
.optional(),
async (input) => {
@@ -223,6 +231,8 @@ export namespace Session {
directory: Instance.directory,
title: input?.title,
permission: input?.permission,
+ teamID: input?.teamID,
+ teamRole: input?.teamRole,
})
},
)
@@ -290,6 +300,8 @@ export namespace Session {
parentID?: string
directory: string
permission?: PermissionNext.Ruleset
+ teamID?: string
+ teamRole?: "lead" | "member"
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
@@ -300,6 +312,8 @@ export namespace Session {
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
+ teamID: input.teamID,
+ teamRole: input.teamRole,
time: {
created: Date.now(),
updated: Date.now(),
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 75bd3c9dfac..288ff42cbba 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -553,6 +553,36 @@ export namespace SessionPrompt {
continue
}
+ // inject pending team messages
+ if (session.teamID) {
+ const { TeamMessage } = await import("@/team/message")
+ const pendingTeamMsgs = TeamMessage.pending(sessionID, session.teamID)
+ if (pendingTeamMsgs.length > 0) {
+ const teamText = pendingTeamMsgs
+ .map((m) => `[Team message from ${m.from_session_id}]: ${m.content}`)
+ .join("\n")
+ const teamUserMsg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ sessionID,
+ role: "user",
+ time: { created: Date.now() },
+ agent: lastUser.agent,
+ model: lastUser.model,
+ }
+ await Session.updateMessage(teamUserMsg)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: teamUserMsg.id,
+ sessionID,
+ type: "text",
+ text: teamText,
+ synthetic: true,
+ } satisfies MessageV2.TextPart)
+ TeamMessage.markRead(pendingTeamMsgs.map((m) => m.id))
+ continue
+ }
+ }
+
// normal processing
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
index 9c5c72c4c57..760d8b88e9e 100644
--- a/packages/opencode/src/session/session.sql.ts
+++ b/packages/opencode/src/session/session.sql.ts
@@ -27,11 +27,17 @@ export const SessionTable = sqliteTable(
summary_diffs: text({ mode: "json" }).$type(),
revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; 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(),
},
- (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)],
+ (table) => [
+ index("session_project_idx").on(table.project_id),
+ index("session_parent_idx").on(table.parent_id),
+ index("session_team_idx").on(table.team_id),
+ ],
)
export const MessageTable = sqliteTable(
diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts
index 7961b0e3804..852addb886e 100644
--- a/packages/opencode/src/storage/schema.ts
+++ b/packages/opencode/src/storage/schema.ts
@@ -2,3 +2,6 @@ export { ControlAccountTable } from "../control/control.sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
export { SessionShareTable } from "../share/share.sql"
export { ProjectTable } from "../project/project.sql"
+export { TeamTable } from "../team/team.sql"
+export { TeamTaskTable } from "../team/team-task.sql"
+export { TeamMessageTable } from "../team/team-message.sql"
diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts
new file mode 100644
index 00000000000..ec0bbcb75e5
--- /dev/null
+++ b/packages/opencode/src/team/index.ts
@@ -0,0 +1,241 @@
+import z from "zod"
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
+import { Database, eq, and } from "@/storage/db"
+import { Identifier } from "@/id/id"
+import { SessionTable } from "@/session/session.sql"
+import { TeamTable } from "./team.sql"
+import { TeamTaskTable } from "./team-task.sql"
+import { TeamMessageTable } from "./team-message.sql"
+import { Log } from "@/util/log"
+
+export namespace Team {
+ const log = Log.create({ service: "team" })
+
+ export const Info = z.object({
+ id: z.string(),
+ name: z.string(),
+ leadSessionID: z.string(),
+ status: z.enum(["active", "archived"]),
+ time: z.object({
+ created: z.number(),
+ updated: z.number(),
+ }),
+ })
+ export type Info = z.infer
+
+ export const Event = {
+ Created: BusEvent.define("team.created", z.object({ info: Info })),
+ Archived: BusEvent.define("team.archived", z.object({ info: Info })),
+ MemberJoined: BusEvent.define(
+ "team.member.joined",
+ z.object({
+ teamID: z.string(),
+ sessionID: z.string(),
+ role: z.enum(["lead", "member"]),
+ }),
+ ),
+ MemberLeft: BusEvent.define(
+ "team.member.left",
+ z.object({
+ teamID: z.string(),
+ sessionID: z.string(),
+ }),
+ ),
+ TeammateIdle: BusEvent.define(
+ "team.teammate.idle",
+ z.object({
+ teamID: z.string(),
+ sessionID: z.string(),
+ }),
+ ),
+ Message: BusEvent.define(
+ "team.message",
+ z.object({
+ teamID: z.string(),
+ messageID: z.string(),
+ fromSessionID: z.string(),
+ toSessionID: z.string().nullable(),
+ content: z.string(),
+ }),
+ ),
+ TaskCompleted: BusEvent.define(
+ "team.task.completed",
+ z.object({
+ teamID: z.string(),
+ taskID: z.string(),
+ sessionID: z.string(),
+ }),
+ ),
+ }
+
+ function fromRow(row: typeof TeamTable.$inferSelect): Info {
+ return {
+ id: row.id,
+ name: row.name,
+ leadSessionID: row.lead_session_id,
+ status: row.status,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ },
+ }
+ }
+
+ export async function create(input: { name: string; sessionID: string }) {
+ const existing = Database.use((db) =>
+ db.select().from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(),
+ )
+ if (existing?.team_id) throw new Error("Session is already in a team")
+
+ const id = Identifier.ascending("team")
+ const now = Date.now()
+ const row = Database.use((db) => {
+ db.insert(TeamTable)
+ .values({
+ id,
+ name: input.name,
+ lead_session_id: input.sessionID,
+ status: "active",
+ time_created: now,
+ time_updated: now,
+ })
+ .run()
+ db.update(SessionTable).set({ team_id: id, team_role: "lead" }).where(eq(SessionTable.id, input.sessionID)).run()
+ return db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()!
+ })
+ const info = fromRow(row)
+ log.info("created", { id, name: input.name })
+ await Bus.publish(Event.Created, { info })
+ await Bus.publish(Event.MemberJoined, {
+ teamID: id,
+ sessionID: input.sessionID,
+ role: "lead",
+ })
+ return info
+ }
+
+ export async function get(id: string) {
+ const row = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, id)).get())
+ if (!row) throw new Error(`Team not found: ${id}`)
+ return fromRow(row)
+ }
+
+ export async function list() {
+ const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all())
+ return rows.map(fromRow)
+ }
+
+ export async function members(teamID: string) {
+ return Database.use((db) =>
+ db
+ .select({
+ id: SessionTable.id,
+ title: SessionTable.title,
+ team_role: SessionTable.team_role,
+ time_updated: SessionTable.time_updated,
+ })
+ .from(SessionTable)
+ .where(eq(SessionTable.team_id, teamID))
+ .all(),
+ )
+ }
+
+ export async function status(teamID: string) {
+ const info = await get(teamID)
+ const teamMembers = await members(teamID)
+ const tasks = Database.use((db) => db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all())
+ return {
+ ...info,
+ members: teamMembers,
+ tasks: {
+ total: tasks.length,
+ pending: tasks.filter((t) => t.status === "pending").length,
+ in_progress: tasks.filter((t) => t.status === "in_progress").length,
+ completed: tasks.filter((t) => t.status === "completed").length,
+ },
+ }
+ }
+
+ export async function archive(teamID: string) {
+ const row = Database.use((db) =>
+ db
+ .update(TeamTable)
+ .set({ status: "archived", time_updated: Date.now() })
+ .where(eq(TeamTable.id, teamID))
+ .returning()
+ .get(),
+ )
+ if (!row) throw new Error(`Team not found: ${teamID}`)
+ const info = fromRow(row)
+ log.info("archived", { id: teamID })
+ await Bus.publish(Event.Archived, { info })
+ return info
+ }
+
+ export async function cleanup(teamID: string, leadSessionID: string) {
+ const info = await get(teamID)
+ if (info.leadSessionID !== leadSessionID) throw new Error("Only the lead session can clean up the team")
+
+ const active = await members(teamID)
+ const running = active.filter((m) => m.team_role === "member" && SessionPromptState.isRunning(m.id))
+ if (running.length > 0)
+ throw new Error(`Cannot cleanup: ${running.length} teammate(s) still running. Shut them down first.`)
+
+ const { Session: SessionModule } = await import("@/session")
+ const memberSessions = active.filter((m) => m.team_role === "member")
+ for (const m of memberSessions) {
+ await SessionModule.remove(m.id).catch((e) => log.error("remove member failed", { id: m.id, error: e }))
+ }
+
+ Database.use((db) => {
+ db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.team_id, teamID)).run()
+ })
+ return archive(teamID)
+ }
+
+ export async function join(teamID: string, sessionID: string) {
+ Database.use((db) => {
+ db.update(SessionTable).set({ team_id: teamID, team_role: "member" }).where(eq(SessionTable.id, sessionID)).run()
+ })
+ await Bus.publish(Event.MemberJoined, {
+ teamID,
+ sessionID,
+ role: "member",
+ })
+ }
+
+ export async function leave(sessionID: string) {
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
+ if (!row?.team_id) return
+ const teamID = row.team_id
+ Database.use((db) => {
+ db.update(SessionTable).set({ team_id: null, team_role: null }).where(eq(SessionTable.id, sessionID)).run()
+ })
+ await Bus.publish(Event.MemberLeft, { teamID, sessionID })
+ }
+
+ export function getBySession(sessionID: string) {
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
+ if (!row?.team_id) return undefined
+ const team = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, row.team_id!)).get())
+ if (!team) return undefined
+ return fromRow(team)
+ }
+}
+
+export namespace SessionPromptState {
+ const running = new Set()
+
+ export function mark(sessionID: string) {
+ running.add(sessionID)
+ }
+
+ export function unmark(sessionID: string) {
+ running.delete(sessionID)
+ }
+
+ export function isRunning(sessionID: string) {
+ return running.has(sessionID)
+ }
+}
diff --git a/packages/opencode/src/team/message.ts b/packages/opencode/src/team/message.ts
new file mode 100644
index 00000000000..d4391e8e2be
--- /dev/null
+++ b/packages/opencode/src/team/message.ts
@@ -0,0 +1,99 @@
+import { Database, eq, and, isNull, or, inArray } from "@/storage/db"
+import { Bus } from "@/bus"
+import { Identifier } from "@/id/id"
+import { TeamMessageTable } from "./team-message.sql"
+import { Team } from "./index"
+import { Log } from "@/util/log"
+
+export namespace TeamMessage {
+ const log = Log.create({ service: "team-message" })
+
+ export type Info = typeof TeamMessageTable.$inferSelect
+
+ export async function send(input: { teamID: string; fromSessionID: string; toSessionID: string; content: string }) {
+ const id = Identifier.ascending("team_message")
+ const now = Date.now()
+ Database.use((db) => {
+ db.insert(TeamMessageTable)
+ .values({
+ id,
+ team_id: input.teamID,
+ from_session_id: input.fromSessionID,
+ to_session_id: input.toSessionID,
+ content: input.content,
+ read: false,
+ time_created: now,
+ time_updated: now,
+ })
+ .run()
+ })
+ log.info("sent", { id, from: input.fromSessionID, to: input.toSessionID })
+ await Bus.publish(Team.Event.Message, {
+ teamID: input.teamID,
+ messageID: id,
+ fromSessionID: input.fromSessionID,
+ toSessionID: input.toSessionID,
+ content: input.content,
+ })
+ return id
+ }
+
+ export async function broadcast(input: { teamID: string; fromSessionID: string; content: string }) {
+ const id = Identifier.ascending("team_message")
+ const now = Date.now()
+ Database.use((db) => {
+ db.insert(TeamMessageTable)
+ .values({
+ id,
+ team_id: input.teamID,
+ from_session_id: input.fromSessionID,
+ to_session_id: null,
+ content: input.content,
+ read: false,
+ time_created: now,
+ time_updated: now,
+ })
+ .run()
+ })
+ log.info("broadcast", { id, from: input.fromSessionID })
+ await Bus.publish(Team.Event.Message, {
+ teamID: input.teamID,
+ messageID: id,
+ fromSessionID: input.fromSessionID,
+ toSessionID: null,
+ content: input.content,
+ })
+ return id
+ }
+
+ export function list(teamID: string) {
+ return Database.use((db) => db.select().from(TeamMessageTable).where(eq(TeamMessageTable.team_id, teamID)).all())
+ }
+
+ export function pending(sessionID: string, teamID: string) {
+ return Database.use((db) =>
+ db
+ .select()
+ .from(TeamMessageTable)
+ .where(
+ and(
+ eq(TeamMessageTable.team_id, teamID),
+ eq(TeamMessageTable.read, false),
+ or(eq(TeamMessageTable.to_session_id, sessionID), isNull(TeamMessageTable.to_session_id)),
+ ),
+ )
+ .all()
+ .filter((m) => m.from_session_id !== sessionID),
+ )
+ }
+
+ export function markRead(ids: string[]) {
+ if (ids.length === 0) return
+ Database.use((db) => {
+ db.update(TeamMessageTable)
+ .set({ read: true, time_updated: Date.now() })
+ .where(inArray(TeamMessageTable.id, ids))
+ .run()
+ })
+ }
+}
diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts
new file mode 100644
index 00000000000..508138e6755
--- /dev/null
+++ b/packages/opencode/src/team/task.ts
@@ -0,0 +1,143 @@
+import { Database, eq, and } from "@/storage/db"
+import { Bus } from "@/bus"
+import { Identifier } from "@/id/id"
+import { TeamTaskTable } from "./team-task.sql"
+import { Team } from "./index"
+import { Log } from "@/util/log"
+
+export namespace TeamTask {
+ const log = Log.create({ service: "team-task" })
+
+ export type Info = typeof TeamTaskTable.$inferSelect
+
+ export async function create(input: {
+ teamID: string
+ title: string
+ description?: string
+ depends_on?: string[]
+ }) {
+ const id = Identifier.ascending("team_task")
+ const now = Date.now()
+ Database.use((db) => {
+ db.insert(TeamTaskTable)
+ .values({
+ id,
+ team_id: input.teamID,
+ title: input.title,
+ description: input.description ?? null,
+ status: "pending",
+ assigned_to: null,
+ depends_on: input.depends_on ?? null,
+ time_created: now,
+ time_updated: now,
+ })
+ .run()
+ })
+ log.info("created", { id, title: input.title })
+ return id
+ }
+
+ export async function claim(taskID: string, sessionID: string) {
+ return Database.use((db) => {
+ const task = db
+ .select()
+ .from(TeamTaskTable)
+ .where(eq(TeamTaskTable.id, taskID))
+ .get()
+ if (!task) throw new Error(`Task not found: ${taskID}`)
+ if (task.status !== "pending")
+ throw new Error(`Task ${taskID} is not pending (status: ${task.status})`)
+
+ if (task.depends_on && task.depends_on.length > 0) {
+ const deps = db
+ .select()
+ .from(TeamTaskTable)
+ .where(eq(TeamTaskTable.team_id, task.team_id))
+ .all()
+ const incomplete = task.depends_on.filter((depID) => {
+ const dep = deps.find((d) => d.id === depID)
+ return !dep || dep.status !== "completed"
+ })
+ if (incomplete.length > 0)
+ throw new Error(
+ `Task ${taskID} is blocked by unresolved dependencies: ${incomplete.join(", ")}`,
+ )
+ }
+
+ const result = db
+ .update(TeamTaskTable)
+ .set({
+ status: "in_progress",
+ assigned_to: sessionID,
+ time_updated: Date.now(),
+ })
+ .where(and(eq(TeamTaskTable.id, taskID), eq(TeamTaskTable.status, "pending")))
+ .returning()
+ .get()
+
+ if (!result) throw new Error(`Task ${taskID} was already claimed`)
+ log.info("claimed", { id: taskID, by: sessionID })
+ return result
+ })
+ }
+
+ export async function complete(taskID: string, sessionID: string) {
+ const result = Database.use((db) =>
+ db
+ .update(TeamTaskTable)
+ .set({ status: "completed", time_updated: Date.now() })
+ .where(
+ and(
+ eq(TeamTaskTable.id, taskID),
+ eq(TeamTaskTable.assigned_to, sessionID),
+ ),
+ )
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Task ${taskID} not found or not assigned to this session`)
+ log.info("completed", { id: taskID, by: sessionID })
+ await Bus.publish(Team.Event.TaskCompleted, {
+ teamID: result.team_id,
+ taskID,
+ sessionID,
+ })
+ return result
+ }
+
+ export function list(teamID: string) {
+ return Database.use((db) =>
+ db
+ .select()
+ .from(TeamTaskTable)
+ .where(eq(TeamTaskTable.team_id, teamID))
+ .all(),
+ )
+ }
+
+ export async function reassign(sessionID: string) {
+ const updated = Database.use((db) =>
+ db
+ .update(TeamTaskTable)
+ .set({
+ status: "pending",
+ assigned_to: null,
+ time_updated: Date.now(),
+ })
+ .where(
+ and(
+ eq(TeamTaskTable.assigned_to, sessionID),
+ eq(TeamTaskTable.status, "in_progress"),
+ ),
+ )
+ .returning()
+ .all(),
+ )
+ if (updated.length > 0)
+ log.info("reassigned", {
+ count: updated.length,
+ from: sessionID,
+ })
+ return updated
+ }
+}
diff --git a/packages/opencode/src/team/team-message.sql.ts b/packages/opencode/src/team/team-message.sql.ts
new file mode 100644
index 00000000000..b5b5543c345
--- /dev/null
+++ b/packages/opencode/src/team/team-message.sql.ts
@@ -0,0 +1,26 @@
+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
+import { TeamTable } from "./team.sql"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const TeamMessageTable = sqliteTable(
+ "team_message",
+ {
+ id: text().primaryKey(),
+ team_id: text()
+ .notNull()
+ .references(() => TeamTable.id, { onDelete: "cascade" }),
+ from_session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ to_session_id: text().references(() => SessionTable.id),
+ content: text().notNull(),
+ read: integer({ mode: "boolean" }).notNull().default(false),
+ ...Timestamps,
+ },
+ (table) => [
+ index("team_message_team_idx").on(table.team_id),
+ index("team_message_to_idx").on(table.to_session_id),
+ index("team_message_from_idx").on(table.from_session_id),
+ ],
+)
diff --git a/packages/opencode/src/team/team-task.sql.ts b/packages/opencode/src/team/team-task.sql.ts
new file mode 100644
index 00000000000..d6c91df814d
--- /dev/null
+++ b/packages/opencode/src/team/team-task.sql.ts
@@ -0,0 +1,24 @@
+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
+import { TeamTable } from "./team.sql"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const TeamTaskTable = sqliteTable(
+ "team_task",
+ {
+ id: text().primaryKey(),
+ team_id: text()
+ .notNull()
+ .references(() => TeamTable.id, { onDelete: "cascade" }),
+ title: text().notNull(),
+ description: text(),
+ status: text().notNull().$type<"pending" | "in_progress" | "completed">(),
+ assigned_to: text().references(() => SessionTable.id),
+ depends_on: text({ mode: "json" }).$type(),
+ ...Timestamps,
+ },
+ (table) => [
+ index("team_task_team_idx").on(table.team_id),
+ index("team_task_assigned_idx").on(table.assigned_to),
+ ],
+)
diff --git a/packages/opencode/src/team/team.sql.ts b/packages/opencode/src/team/team.sql.ts
new file mode 100644
index 00000000000..ff3e39aa5f7
--- /dev/null
+++ b/packages/opencode/src/team/team.sql.ts
@@ -0,0 +1,17 @@
+import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const TeamTable = sqliteTable(
+ "team",
+ {
+ id: text().primaryKey(),
+ name: text().notNull(),
+ lead_session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ status: text().notNull().$type<"active" | "archived">(),
+ ...Timestamps,
+ },
+ (table) => [index("team_lead_idx").on(table.lead_session_id)],
+)
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index c6d7fbc1e4b..8b8cf4881dc 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -28,6 +28,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { ApplyPatchTool } from "./apply_patch"
+import { TeamTool } from "./team"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
@@ -117,6 +118,7 @@ export namespace ToolRegistry {
CodeSearchTool,
SkillTool,
ApplyPatchTool,
+ TeamTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
diff --git a/packages/opencode/src/tool/team.ts b/packages/opencode/src/tool/team.ts
new file mode 100644
index 00000000000..026a373cd91
--- /dev/null
+++ b/packages/opencode/src/tool/team.ts
@@ -0,0 +1,317 @@
+import { Tool } from "./tool"
+import DESCRIPTION from "./team.txt"
+import z from "zod"
+import { Session } from "../session"
+import { Team, SessionPromptState } from "../team"
+import { TeamMessage } from "../team/message"
+import { TeamTask } from "../team/task"
+import { Agent } from "../agent/agent"
+import { SessionPrompt } from "../session/prompt"
+import { Identifier } from "../id/id"
+import { Bus } from "../bus"
+import type { MessageV2 } from "../session/message-v2"
+
+const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
+const pending = new Map>()
+
+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)
+
+ const model = {
+ modelID: agent.model?.modelID ?? "default",
+ providerID: agent.model?.providerID ?? "default",
+ }
+
+ const promise = withTimeout(
+ runTeammate({
+ sessionID: teammate.id,
+ teamID: team.id,
+ model,
+ agentName: agent.name,
+ prompt: params.prompt,
+ }),
+ TIMEOUT_MS,
+ )
+ pending.set(teammate.id, promise)
+
+ return out(
+ `Spawned teammate: ${title}`,
+ `Teammate "${title}" spawned (session: ${teammate.id}, agent: ${params.agent}). Working on: ${params.prompt}\n\nThe teammate is running in parallel. Spawn more teammates or use 'wait' to collect all results.`,
+ { sessionID: teammate.id },
+ )
+ }
+
+ case "wait": {
+ const team = await requireTeam(session)
+ const members = await Team.members(team.id)
+ const teammates = members.filter((m) => m.team_role === "member")
+
+ if (teammates.length === 0) return out("No teammates", "No teammates to wait for.")
+
+ const results: string[] = []
+ for (const m of teammates) {
+ const p = pending.get(m.id)
+ if (p) {
+ const text = await p
+ pending.delete(m.id)
+ results.push(`### ${m.title} (${m.id})\n${text}`)
+ } else if (SessionPromptState.isRunning(m.id)) {
+ results.push(`### ${m.title} (${m.id})\n(still running, no pending promise)`)
+ } else {
+ results.push(`### ${m.title} (${m.id})\n(already idle)`)
+ }
+ }
+
+ return out(`Collected ${results.length} teammate results`, results.join("\n\n"))
+ }
+
+ case "message": {
+ if (!params.to) throw new Error("to is required for message action")
+ if (!params.content) throw new Error("content is required for message action")
+ const team = await requireTeam(session)
+ await TeamMessage.send({
+ teamID: team.id,
+ fromSessionID: ctx.sessionID,
+ toSessionID: params.to,
+ content: params.content,
+ })
+ if (!SessionPromptState.isRunning(params.to)) {
+ const reply = await wakeForMessage(params.to, team.id)
+ return out("Message sent & reply received", `Message sent to ${params.to}.\n\nReply:\n${reply}`)
+ }
+ return out("Message sent", `Message sent to ${params.to}. (teammate is busy, will process when ready)`)
+ }
+
+ case "broadcast": {
+ if (!params.content) throw new Error("content is required for broadcast action")
+ const team = await requireTeam(session)
+ await TeamMessage.broadcast({
+ teamID: team.id,
+ fromSessionID: ctx.sessionID,
+ content: params.content,
+ })
+ const members = await Team.members(team.id)
+ const idle = members.filter((m) => m.id !== ctx.sessionID && !SessionPromptState.isRunning(m.id))
+ const replies: string[] = []
+ await Promise.all(
+ idle.map(async (m) => {
+ const reply = await wakeForMessage(m.id, team.id)
+ if (reply) replies.push(`[${m.title}]: ${reply}`)
+ }),
+ )
+ const body =
+ replies.length > 0
+ ? `Broadcast sent. Replies from idle teammates:\n${replies.join("\n\n")}`
+ : "Broadcast sent to all teammates. Active teammates will process when ready."
+ return out("Broadcast sent", body)
+ }
+
+ case "tasks": {
+ if (!params.sub_action) throw new Error("sub_action is required for tasks action")
+ const team = await requireTeam(session)
+ switch (params.sub_action) {
+ case "create": {
+ if (!params.title) throw new Error("title is required for task creation")
+ const id = await TeamTask.create({
+ teamID: team.id,
+ title: params.title,
+ description: params.description,
+ depends_on: params.depends_on,
+ })
+ return out(`Created task: ${params.title}`, `Task created (ID: ${id}): ${params.title}`, { taskID: id })
+ }
+ case "claim": {
+ if (!params.task_id) throw new Error("task_id is required")
+ const task = await TeamTask.claim(params.task_id, ctx.sessionID)
+ return out(`Claimed task: ${task.title}`, `Task claimed: ${task.title} (${task.id})`, { taskID: task.id })
+ }
+ case "complete": {
+ if (!params.task_id) throw new Error("task_id is required")
+ const task = await TeamTask.complete(params.task_id, ctx.sessionID)
+ return out(`Completed task: ${task.title}`, `Task completed: ${task.title} (${task.id})`, {
+ taskID: task.id,
+ })
+ }
+ case "list": {
+ const tasks = TeamTask.list(team.id)
+ const lines = tasks.map(
+ (t) => `- [${t.status}] ${t.title} (${t.id})${t.assigned_to ? ` → ${t.assigned_to}` : ""}`,
+ )
+ return out("Task list", tasks.length > 0 ? lines.join("\n") : "No tasks in the team.", {
+ count: tasks.length,
+ })
+ }
+ }
+ throw new Error(`Unknown tasks sub_action`)
+ }
+
+ case "status": {
+ const team = await requireTeam(session)
+ const info = await Team.status(team.id)
+ const memberLines = info.members.map(
+ (m) =>
+ `- ${m.title} (${m.id}) [${m.team_role}] ${SessionPromptState.isRunning(m.id) ? "🟢 active" : "⚪ idle"}`,
+ )
+ return out(
+ "Team status",
+ [
+ `Team: ${info.name} (${info.id})`,
+ `Status: ${info.status}`,
+ `Members (${info.members.length}):`,
+ ...memberLines,
+ `Tasks: ${info.tasks.total} total, ${info.tasks.pending} pending, ${info.tasks.in_progress} in progress, ${info.tasks.completed} completed`,
+ ].join("\n"),
+ )
+ }
+
+ case "shutdown": {
+ if (!params.session_id) throw new Error("session_id is required for shutdown action")
+ const team = await requireTeam(session)
+ if (session.teamRole !== "lead") throw new Error("Only the lead can shut down teammates")
+ SessionPrompt.cancel(params.session_id)
+ pending.delete(params.session_id)
+ return out(
+ `Shutdown requested: ${params.session_id}`,
+ `Shutdown signal sent to ${params.session_id}. They will finish their current work and stop.`,
+ )
+ }
+
+ case "cleanup": {
+ const team = await requireTeam(session)
+ await Team.cleanup(team.id, ctx.sessionID)
+ for (const [id] of pending) pending.delete(id)
+ return out("Team cleaned up", `Team "${team.name}" has been archived and cleaned up.`)
+ }
+ }
+ },
+ }
+})
+
+async function requireTeam(session: Session.Info) {
+ if (!session.teamID) throw new Error("This session is not in a team. Use 'create' first.")
+ return Team.get(session.teamID)
+}
+
+async function runTeammate(input: {
+ sessionID: string
+ teamID: string
+ model: { modelID: string; providerID: string }
+ agentName: string
+ prompt: string
+}): Promise {
+ SessionPromptState.mark(input.sessionID)
+ const resp = await SessionPrompt.prompt({
+ messageID: Identifier.ascending("message"),
+ sessionID: input.sessionID,
+ model: input.model,
+ agent: input.agentName,
+ tools: {},
+ parts: [{ type: "text", text: input.prompt }],
+ }).finally(() => {
+ SessionPromptState.unmark(input.sessionID)
+ TeamTask.reassign(input.sessionID)
+ })
+ const text = resp.parts.findLast((x) => x.type === "text")?.text ?? ""
+ return text || "(teammate produced no text output)"
+}
+
+async function wakeForMessage(sessionID: string, teamID: string): Promise {
+ const { MessageV2 } = await import("../session/message-v2")
+ const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
+ const last = msgs.findLast((m) => m.info.role === "user")
+ if (!last) return ""
+
+ const isUserMessage = (msg: MessageV2.Info): msg is MessageV2.User => msg.role === "user"
+ const model = isUserMessage(last.info) ? last.info.model : { modelID: "default", providerID: "default" }
+ const agentName = last.info.agent ?? "build"
+
+ const pendingMsgs = TeamMessage.pending(sessionID, teamID)
+ if (pendingMsgs.length === 0) return ""
+
+ const text = pendingMsgs.map((m) => `[Team message from ${m.from_session_id}]: ${m.content}`).join("\n")
+ TeamMessage.markRead(pendingMsgs.map((m) => m.id))
+
+ SessionPromptState.mark(sessionID)
+ const resp = await SessionPrompt.prompt({
+ messageID: Identifier.ascending("message"),
+ sessionID,
+ model,
+ agent: agentName,
+ tools: {},
+ parts: [{ type: "text", text }],
+ }).finally(() => {
+ SessionPromptState.unmark(sessionID)
+ TeamTask.reassign(sessionID)
+ })
+ return resp.parts.findLast((x) => x.type === "text")?.text ?? ""
+}
diff --git a/packages/opencode/src/tool/team.txt b/packages/opencode/src/tool/team.txt
new file mode 100644
index 00000000000..4d8285aca6b
--- /dev/null
+++ b/packages/opencode/src/tool/team.txt
@@ -0,0 +1,25 @@
+Manage agent teams for parallel collaborative work. Teams let multiple AI sessions work together on complex tasks.
+
+IMPORTANT: Agent teams use significantly more tokens than a single session. Each teammate runs its own context window. Use teams for tasks that genuinely benefit from parallel exploration (3-5 teammates recommended).
+
+Actions:
+- create: Create a new team (current session becomes lead)
+- spawn: Add a teammate - starts working immediately in parallel (non-blocking). Spawn multiple teammates before calling wait.
+- wait: Block until ALL teammates finish and collect their results. Call this after spawning all teammates.
+- message: Send a point-to-point message to a specific teammate. If teammate is idle, wakes them up and waits for reply.
+- broadcast: Send a message to all teammates. Wakes up idle teammates and collects their replies.
+- tasks: Manage the shared task list (sub-actions: create, claim, complete, list)
+- status: View team status, members (active/idle), and task summary
+- shutdown: Request a teammate to shut down gracefully
+- cleanup: Archive the team, delete teammate sessions, and clean up (lead only, all teammates must be idle)
+
+Typical workflow:
+1. create a team
+2. spawn teammate A with task X
+3. spawn teammate B with task Y (both now running in parallel)
+4. spawn teammate C with task Z
+5. wait → collects results from A, B, C when they all finish
+6. Teammates can communicate with each other via message/broadcast during execution
+7. cleanup when done
+
+Teammates have their own tools and can read/write files, run commands, and communicate with each other via the team tool.
diff --git a/packages/opencode/test/team/team.test.ts b/packages/opencode/test/team/team.test.ts
new file mode 100644
index 00000000000..811939b4148
--- /dev/null
+++ b/packages/opencode/test/team/team.test.ts
@@ -0,0 +1,289 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Session } from "../../src/session"
+import { Team, SessionPromptState } from "../../src/team"
+import { TeamMessage } from "../../src/team/message"
+import { TeamTask } from "../../src/team/task"
+import { Bus } from "../../src/bus"
+import { Log } from "../../src/util/log"
+import { Instance } from "../../src/project/instance"
+
+const projectRoot = path.join(__dirname, "../..")
+Log.init({ print: false })
+
+describe("team lifecycle", () => {
+ test("create and cleanup a team", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const session = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: session.id })
+
+ expect(team.name).toBe("test-team")
+ expect(team.status).toBe("active")
+ expect(team.leadSessionID).toBe(session.id)
+
+ const updated = await Session.get(session.id)
+ expect(updated.teamID).toBe(team.id)
+ expect(updated.teamRole).toBe("lead")
+
+ await Team.cleanup(team.id, session.id)
+ const archived = await Team.get(team.id)
+ expect(archived.status).toBe("archived")
+
+ await Session.remove(session.id)
+ },
+ })
+ })
+
+ test("session cannot be in two teams", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const session = await Session.create({})
+ await Team.create({ name: "team-a", sessionID: session.id })
+
+ await expect(Team.create({ name: "team-b", sessionID: session.id })).rejects.toThrow(
+ "already in a team",
+ )
+
+ await Session.remove(session.id)
+ },
+ })
+ })
+
+ test("members returns all team members", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member = await Session.create({ teamID: team.id, teamRole: "member" })
+ await Team.join(team.id, member.id)
+
+ const members = await Team.members(team.id)
+ expect(members.length).toBe(2)
+ expect(members.find((m) => m.id === lead.id)?.team_role).toBe("lead")
+ expect(members.find((m) => m.id === member.id)?.team_role).toBe("member")
+
+ await Session.remove(member.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+
+ test("status returns team summary", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ await TeamTask.create({ teamID: team.id, title: "Task 1" })
+ await TeamTask.create({ teamID: team.id, title: "Task 2" })
+
+ const info = await Team.status(team.id)
+ expect(info.name).toBe("test-team")
+ expect(info.tasks.total).toBe(2)
+ expect(info.tasks.pending).toBe(2)
+ expect(info.members.length).toBe(1)
+
+ await Session.remove(lead.id)
+ },
+ })
+ })
+})
+
+describe("team task list", () => {
+ test("create, claim, and complete tasks", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const taskID = await TeamTask.create({ teamID: team.id, title: "Review auth" })
+ const tasks = TeamTask.list(team.id)
+ expect(tasks.length).toBe(1)
+ expect(tasks[0].status).toBe("pending")
+
+ const claimed = await TeamTask.claim(taskID, lead.id)
+ expect(claimed.status).toBe("in_progress")
+ expect(claimed.assigned_to).toBe(lead.id)
+
+ const completed = await TeamTask.complete(taskID, lead.id)
+ expect(completed.status).toBe("completed")
+
+ await Session.remove(lead.id)
+ },
+ })
+ })
+
+ test("concurrent claim is atomic", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member1 = await Session.create({ teamID: team.id, teamRole: "member" })
+ const member2 = await Session.create({ teamID: team.id, teamRole: "member" })
+
+ const taskID = await TeamTask.create({ teamID: team.id, title: "Shared task" })
+
+ const results = await Promise.allSettled([
+ TeamTask.claim(taskID, member1.id),
+ TeamTask.claim(taskID, member2.id),
+ ])
+
+ const fulfilled = results.filter((r) => r.status === "fulfilled")
+ const rejected = results.filter((r) => r.status === "rejected")
+ expect(fulfilled.length).toBe(1)
+ expect(rejected.length).toBe(1)
+
+ await Session.remove(member1.id)
+ await Session.remove(member2.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+
+ test("blocked task cannot be claimed", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const task1 = await TeamTask.create({ teamID: team.id, title: "First" })
+ const task2 = await TeamTask.create({
+ teamID: team.id,
+ title: "Second",
+ depends_on: [task1],
+ })
+
+ await expect(TeamTask.claim(task2, lead.id)).rejects.toThrow("blocked")
+
+ await TeamTask.claim(task1, lead.id)
+ await TeamTask.complete(task1, lead.id)
+
+ const claimed = await TeamTask.claim(task2, lead.id)
+ expect(claimed.status).toBe("in_progress")
+
+ await Session.remove(lead.id)
+ },
+ })
+ })
+
+ test("reassign tasks when teammate exits", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member = await Session.create({ teamID: team.id, teamRole: "member" })
+ const taskID = await TeamTask.create({ teamID: team.id, title: "WIP task" })
+ await TeamTask.claim(taskID, member.id)
+
+ const reassigned = await TeamTask.reassign(member.id)
+ expect(reassigned.length).toBe(1)
+ expect(reassigned[0].status).toBe("pending")
+ expect(reassigned[0].assigned_to).toBeNull()
+
+ await Session.remove(member.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+})
+
+describe("team messaging", () => {
+ test("send and receive point-to-point message", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member = await Session.create({ teamID: team.id, teamRole: "member" })
+ await Team.join(team.id, member.id)
+
+ await TeamMessage.send({
+ teamID: team.id,
+ fromSessionID: lead.id,
+ toSessionID: member.id,
+ content: "Hello teammate",
+ })
+
+ const pending = TeamMessage.pending(member.id, team.id)
+ expect(pending.length).toBe(1)
+ expect(pending[0].content).toBe("Hello teammate")
+ expect(pending[0].from_session_id).toBe(lead.id)
+
+ TeamMessage.markRead(pending.map((m) => m.id))
+ const after = TeamMessage.pending(member.id, team.id)
+ expect(after.length).toBe(0)
+
+ await Session.remove(member.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+
+ test("broadcast message reaches all members", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member1 = await Session.create({ teamID: team.id, teamRole: "member" })
+ const member2 = await Session.create({ teamID: team.id, teamRole: "member" })
+
+ await TeamMessage.broadcast({
+ teamID: team.id,
+ fromSessionID: lead.id,
+ content: "Everyone wrap up",
+ })
+
+ const p1 = TeamMessage.pending(member1.id, team.id)
+ const p2 = TeamMessage.pending(member2.id, team.id)
+ expect(p1.length).toBe(1)
+ expect(p2.length).toBe(1)
+ expect(p1[0].content).toBe("Everyone wrap up")
+
+ const selfPending = TeamMessage.pending(lead.id, team.id)
+ expect(selfPending.length).toBe(0)
+
+ await Session.remove(member1.id)
+ await Session.remove(member2.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+})
+
+describe("session team fields", () => {
+ test("session create with team fields", async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const lead = await Session.create({})
+ const team = await Team.create({ name: "test-team", sessionID: lead.id })
+
+ const member = await Session.create({ teamID: team.id, teamRole: "member" })
+ expect(member.teamID).toBe(team.id)
+ expect(member.teamRole).toBe("member")
+
+ const fetched = await Session.get(member.id)
+ expect(fetched.teamID).toBe(team.id)
+ expect(fetched.teamRole).toBe("member")
+
+ await Session.remove(member.id)
+ await Session.remove(lead.id)
+ },
+ })
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 6165c0f7b09..34f3a86f5ee 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -1024,6 +1024,8 @@ export class Session2 extends HeyApiClient {
parentID?: string
title?: string
permission?: PermissionRuleset
+ teamID?: string
+ teamRole?: "lead" | "member"
},
options?: Options,
) {
@@ -1036,6 +1038,8 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
{ in: "body", key: "permission" },
+ { in: "body", key: "teamID" },
+ { in: "body", key: "teamRole" },
],
},
],
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index be6c00cf445..9ed4dc3c4cd 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -715,6 +715,83 @@ export type EventTodoUpdated = {
}
}
+export type EventTeamCreated = {
+ type: "team.created"
+ properties: {
+ info: {
+ id: string
+ name: string
+ leadSessionID: string
+ status: "active" | "archived"
+ time: {
+ created: number
+ updated: number
+ }
+ }
+ }
+}
+
+export type EventTeamArchived = {
+ type: "team.archived"
+ properties: {
+ info: {
+ id: string
+ name: string
+ leadSessionID: string
+ status: "active" | "archived"
+ time: {
+ created: number
+ updated: number
+ }
+ }
+ }
+}
+
+export type EventTeamMemberJoined = {
+ type: "team.member.joined"
+ properties: {
+ teamID: string
+ sessionID: string
+ role: "lead" | "member"
+ }
+}
+
+export type EventTeamMemberLeft = {
+ type: "team.member.left"
+ properties: {
+ teamID: string
+ sessionID: string
+ }
+}
+
+export type EventTeamTeammateIdle = {
+ type: "team.teammate.idle"
+ properties: {
+ teamID: string
+ sessionID: string
+ }
+}
+
+export type EventTeamMessage = {
+ type: "team.message"
+ properties: {
+ teamID: string
+ messageID: string
+ fromSessionID: string
+ toSessionID: string | null
+ content: string
+ }
+}
+
+export type EventTeamTaskCompleted = {
+ type: "team.task.completed"
+ properties: {
+ teamID: string
+ taskID: string
+ sessionID: string
+ }
+}
+
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -828,6 +905,8 @@ export type Session = {
archived?: number
}
permission?: PermissionRuleset
+ teamID?: string
+ teamRole?: "lead" | "member"
revert?: {
messageID: string
partID?: string
@@ -966,6 +1045,13 @@ export type Event =
| EventSessionCompacted
| EventFileWatcherUpdated
| EventTodoUpdated
+ | EventTeamCreated
+ | EventTeamArchived
+ | EventTeamMemberJoined
+ | EventTeamMemberLeft
+ | EventTeamTeammateIdle
+ | EventTeamMessage
+ | EventTeamTaskCompleted
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -1665,6 +1751,8 @@ export type GlobalSession = {
archived?: number
}
permission?: PermissionRuleset
+ teamID?: string
+ teamRole?: "lead" | "member"
revert?: {
messageID: string
partID?: string
@@ -2608,6 +2696,8 @@ export type SessionCreateData = {
parentID?: string
title?: string
permission?: PermissionRuleset
+ teamID?: string
+ teamRole?: "lead" | "member"
}
path?: never
query?: {