diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts
index edff904e01..23ae6e44bf 100644
--- a/packages/enterprise/sst-env.d.ts
+++ b/packages/enterprise/sst-env.d.ts
@@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
- "ZEN_BLACK_LIMITS": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
- "ZEN_LITE_LIMITS": {
+ "ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index e9f246af89..1637c58a4d 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.2.15"
+version = "1.2.19"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index 63e50b9921..efaae65f53 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.2.15",
+ "version": "1.2.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index edff904e01..23ae6e44bf 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -119,10 +119,6 @@ declare module "sst" {
"type": "sst.cloudflare.StaticSite"
"url": string
}
- "ZEN_BLACK_LIMITS": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_BLACK_PRICE": {
"plan100": string
"plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
"product": string
"type": "sst.sst.Linkable"
}
- "ZEN_LITE_LIMITS": {
+ "ZEN_LIMITS": {
"type": "sst.sst.Secret"
"value": string
}
diff --git a/packages/opencode/migration/20260225215848_workspace/migration.sql b/packages/opencode/migration/20260225215848_workspace/migration.sql
new file mode 100644
index 0000000000..5b1b4e5a47
--- /dev/null
+++ b/packages/opencode/migration/20260225215848_workspace/migration.sql
@@ -0,0 +1,7 @@
+CREATE TABLE `workspace` (
+ `id` text PRIMARY KEY,
+ `branch` text,
+ `project_id` text NOT NULL,
+ `config` text NOT NULL,
+ CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
diff --git a/packages/opencode/migration/20260225215848_workspace/snapshot.json b/packages/opencode/migration/20260225215848_workspace/snapshot.json
new file mode 100644
index 0000000000..a75001d58f
--- /dev/null
+++ b/packages/opencode/migration/20260225215848_workspace/snapshot.json
@@ -0,0 +1,959 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40",
+ "prevIds": ["d2736e43-700f-4e9e-8151-9f2f0d967bc8"],
+ "ddl": [
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "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"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "branch",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "config",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "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": "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"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_workspace_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "workspace"
+ },
+ {
+ "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": ["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": "workspace_pk",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "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": [
+ {
+ "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": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql b/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql
new file mode 100644
index 0000000000..f5488af218
--- /dev/null
+++ b/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `session` ADD `workspace_id` text;--> statement-breakpoint
+CREATE INDEX `session_workspace_idx` ON `session` (`workspace_id`);
\ No newline at end of file
diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json b/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json
new file mode 100644
index 0000000000..8cd94d0052
--- /dev/null
+++ b/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json
@@ -0,0 +1,983 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "572fb732-56f4-4b1e-b981-77152c9980dd",
+ "prevIds": ["1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40"],
+ "ddl": [
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "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"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "branch",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "config",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "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": "workspace_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": "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"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_workspace_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "workspace"
+ },
+ {
+ "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": ["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": "workspace_pk",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "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": [
+ {
+ "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": "workspace_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_workspace_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": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql b/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql
new file mode 100644
index 0000000000..185de59133
--- /dev/null
+++ b/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `workspace` ADD `type` text NOT NULL;--> statement-breakpoint
+ALTER TABLE `workspace` ADD `name` text;--> statement-breakpoint
+ALTER TABLE `workspace` ADD `directory` text;--> statement-breakpoint
+ALTER TABLE `workspace` ADD `extra` text;--> statement-breakpoint
+ALTER TABLE `workspace` DROP COLUMN `config`;
\ No newline at end of file
diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json b/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json
new file mode 100644
index 0000000000..4fe320a2cc
--- /dev/null
+++ b/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json
@@ -0,0 +1,1013 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "4ec9de62-88a7-4bec-91cc-0a759e84db21",
+ "prevIds": ["572fb732-56f4-4b1e-b981-77152c9980dd"],
+ "ddl": [
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "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"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "branch",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "extra",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "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": "workspace_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": "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"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_workspace_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "workspace"
+ },
+ {
+ "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": ["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": "workspace_pk",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "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": [
+ {
+ "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": "workspace_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_workspace_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": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 9252468153..30af3c347c 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.2.15",
+ "version": "1.2.19",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -89,8 +89,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
- "@opentui/core": "0.1.81",
- "@opentui/solid": "0.1.81",
+ "@opentui/core": "0.1.86",
+ "@opentui/solid": "0.1.86",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 8b338f1b57..d518dd12a1 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -31,6 +31,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
+import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -281,7 +282,7 @@ export namespace ACP {
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
- const hash = String(Bun.hash(output))
+ const hash = Hash.fast(output)
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
index 776cc99b44..80253a665e 100644
--- a/packages/opencode/src/auth/index.ts
+++ b/packages/opencode/src/auth/index.ts
@@ -56,13 +56,18 @@ export namespace Auth {
}
export async function set(key: string, info: Info) {
+ const normalized = key.replace(/\/+$/, "")
const data = await all()
- await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600)
+ if (normalized !== key) delete data[key]
+ delete data[normalized + "/"]
+ await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
}
export async function remove(key: string) {
+ const normalized = key.replace(/\/+$/, "")
const data = await all()
delete data[key]
+ delete data[normalized]
await Filesystem.writeJson(filepath, data, 0o600)
}
}
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index 9563591641..38fba0ce70 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
+import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable
@@ -20,10 +21,19 @@ type PluginAuth = NonNullable
* Handle plugin-based authentication flow.
* Returns true if auth was handled, false if it should fall through to default handling.
*/
-async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise {
+async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise {
let index = 0
- if (plugin.auth.methods.length > 1) {
- const method = await prompts.select({
+ if (methodName) {
+ const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
+ if (match === -1) {
+ prompts.log.error(
+ `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
+ )
+ process.exit(1)
+ }
+ index = match
+ } else if (plugin.auth.methods.length > 1) {
+ const selected = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -32,13 +42,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
})),
],
})
- if (prompts.isCancel(method)) throw new UI.CancelledError()
- index = parseInt(method)
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
+ index = parseInt(selected)
}
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
- await Bun.sleep(10)
+ await sleep(10)
const inputs: Record = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -252,10 +262,21 @@ export const AuthLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
- yargs.positional("url", {
- describe: "opencode auth provider",
- type: "string",
- }),
+ yargs
+ .positional("url", {
+ describe: "opencode auth provider",
+ type: "string",
+ })
+ .option("provider", {
+ alias: ["p"],
+ describe: "provider id or name to log in to (skips provider selection)",
+ type: "string",
+ })
+ .option("method", {
+ alias: ["m"],
+ describe: "login method label (skips method selection)",
+ type: "string",
+ }),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
@@ -263,7 +284,8 @@ export const AuthLoginCommand = cmd({
UI.empty()
prompts.intro("Add credential")
if (args.url) {
- const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
+ const url = args.url.replace(/\/+$/, "")
+ const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
@@ -279,12 +301,12 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
- await Auth.set(args.url, {
+ await Auth.set(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
- prompts.log.success("Logged into " + args.url)
+ prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
@@ -321,60 +343,76 @@ export const AuthLoginCommand = cmd({
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
- let provider = await prompts.autocomplete({
- message: "Select provider",
- maxItems: 8,
- options: [
- ...pipe(
- providers,
- values(),
- sortBy(
- (x) => priority[x.id] ?? 99,
- (x) => x.name ?? x.id,
- ),
- map((x) => ({
- label: x.name,
- value: x.id,
- hint: {
- opencode: "recommended",
- anthropic: "Claude Max or API key",
- openai: "ChatGPT Plus/Pro or API key",
- }[x.id],
- })),
+ const options = [
+ ...pipe(
+ providers,
+ values(),
+ sortBy(
+ (x) => priority[x.id] ?? 99,
+ (x) => x.name ?? x.id,
),
- ...pluginProviders.map((x) => ({
+ map((x) => ({
label: x.name,
value: x.id,
- hint: "plugin",
+ hint: {
+ opencode: "recommended",
+ anthropic: "Claude Max or API key",
+ openai: "ChatGPT Plus/Pro or API key",
+ }[x.id],
})),
- {
- value: "other",
- label: "Other",
- },
- ],
- })
+ ),
+ ...pluginProviders.map((x) => ({
+ label: x.name,
+ value: x.id,
+ hint: "plugin",
+ })),
+ ]
- if (prompts.isCancel(provider)) throw new UI.CancelledError()
+ let provider: string
+ if (args.provider) {
+ const input = args.provider
+ const byID = options.find((x) => x.value === input)
+ const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
+ const match = byID ?? byName
+ if (!match) {
+ prompts.log.error(`Unknown provider "${input}"`)
+ process.exit(1)
+ }
+ provider = match.value
+ } else {
+ const selected = await prompts.autocomplete({
+ message: "Select provider",
+ maxItems: 8,
+ options: [
+ ...options,
+ {
+ value: "other",
+ label: "Other",
+ },
+ ],
+ })
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
+ provider = selected as string
+ }
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
- const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
+ const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
- provider = await prompts.text({
+ const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
- if (prompts.isCancel(provider)) throw new UI.CancelledError()
- provider = provider.replace(/^@ai-sdk\//, "")
- if (prompts.isCancel(provider)) throw new UI.CancelledError()
+ if (prompts.isCancel(custom)) throw new UI.CancelledError()
+ provider = custom.replace(/^@ai-sdk\//, "")
// Check if a plugin provides auth for this custom provider
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
- const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
+ const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index d83c4ed8a4..4b8a3e7d45 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/packages/opencode/src/cli/cmd/debug/lsp.ts
@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
+import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
- await Bun.sleep(1000)
+ await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index 672e73d49a..2491abc567 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/packages/opencode/src/cli/cmd/github.ts
@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
+import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
}
retries++
- await Bun.sleep(1000)
+ await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
- await Bun.sleep(delayMs)
+ await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 4d65060f18..58c1928256 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -131,7 +131,14 @@ export const ImportCommand = cmd({
return
}
- Database.use((db) => db.insert(SessionTable).values(Session.toRow(exportData.info)).onConflictDoNothing().run())
+ const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id }
+ Database.use((db) =>
+ db
+ .insert(SessionTable)
+ .values(row)
+ .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
+ .run(),
+ )
for (const msg of exportData.messages) {
Database.use((db) =>
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index f3781f1abd..d74eb2aa4c 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
+import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
}
}
- if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
+ if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
@@ -555,6 +556,45 @@ export const RunCommand = cmd({
// Validate agent if specified
const agent = await (async () => {
if (!args.agent) return undefined
+
+ // When attaching, validate against the running server instead of local Instance state.
+ if (args.attach) {
+ const modes = await sdk.app
+ .agents(undefined, { throwOnError: true })
+ .then((x) => x.data ?? [])
+ .catch(() => undefined)
+
+ if (!modes) {
+ UI.println(
+ UI.Style.TEXT_WARNING_BOLD + "!",
+ UI.Style.TEXT_NORMAL,
+ `failed to list agents from ${args.attach}. Falling back to default agent`,
+ )
+ return undefined
+ }
+
+ const agent = modes.find((a) => a.name === args.agent)
+ if (!agent) {
+ UI.println(
+ UI.Style.TEXT_WARNING_BOLD + "!",
+ UI.Style.TEXT_NORMAL,
+ `agent "${args.agent}" not found. Falling back to default agent`,
+ )
+ return undefined
+ }
+
+ if (agent.mode === "subagent") {
+ UI.println(
+ UI.Style.TEXT_WARNING_BOLD + "!",
+ UI.Style.TEXT_NORMAL,
+ `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
+ )
+ return undefined
+ }
+
+ return args.agent
+ }
+
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts
index bee2c8f711..ab51fe8c3e 100644
--- a/packages/opencode/src/cli/cmd/serve.ts
+++ b/packages/opencode/src/cli/cmd/serve.ts
@@ -2,6 +2,9 @@ import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
+import { Workspace } from "../../control-plane/workspace"
+import { Project } from "../../project/project"
+import { Installation } from "../../installation"
export const ServeCommand = cmd({
command: "serve",
@@ -14,6 +17,7 @@ export const ServeCommand = cmd({
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
+
await new Promise(() => {})
await server.stop()
},
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 05b16b61a4..033f4bab81 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -7,6 +7,7 @@ import {
For,
Match,
on,
+ onMount,
Show,
Switch,
useContext,
@@ -48,6 +49,7 @@ import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
+import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Header } from "./header"
import { parsePatch } from "diff"
@@ -152,7 +154,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
- const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
+ const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
@@ -226,6 +228,8 @@ export function Session() {
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
+ const dialog = useDialog()
+ const renderer = useRenderer()
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
@@ -237,7 +241,6 @@ export function Session() {
const logo = UI.logo(" ").split(/\r?\n/)
return exit.message.set(
[
- ``,
`${logo[0] ?? ""}`,
`${logo[1] ?? ""}`,
`${logo[2] ?? ""}`,
@@ -312,19 +315,40 @@ export function Session() {
const local = useLocal()
- function moveChild(direction: number) {
+ function moveFirstChild() {
if (children().length === 1) return
- let next = children().findIndex((x) => x.id === session()?.id) + direction
- if (next >= children().length) next = 0
- if (next < 0) next = children().length - 1
- if (children()[next]) {
+ const next = children().find((x) => !!x.parentID)
+ if (next) {
navigate({
type: "session",
- sessionID: children()[next].id,
+ sessionID: next.id,
})
}
}
+ 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
+
+ if (next >= sessions.length) next = 0
+ if (next < 0) next = sessions.length - 1
+ if (sessions[next]) {
+ navigate({
+ type: "session",
+ sessionID: sessions[next].id,
+ })
+ }
+ }
+
+ function childSessionHandler(func: (dialog: DialogContext) => void) {
+ return (dialog: DialogContext) => {
+ if (!session()?.parentID || dialog.stack.length > 0) return
+ func(dialog)
+ }
+ }
+
const command = useCommandDialog()
command.register(() => [
{
@@ -884,24 +908,13 @@ export function Session() {
},
},
{
- title: "Next child session",
- value: "session.child.next",
- keybind: "session_child_cycle",
+ title: "Go to child session",
+ value: "session.child.first",
+ keybind: "session_child_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
- moveChild(1)
- dialog.clear()
- },
- },
- {
- title: "Previous child session",
- value: "session.child.previous",
- keybind: "session_child_cycle_reverse",
- category: "Session",
- hidden: true,
- onSelect: (dialog) => {
- moveChild(-1)
+ moveFirstChild()
dialog.clear()
},
},
@@ -911,7 +924,8 @@ export function Session() {
keybind: "session_parent",
category: "Session",
hidden: true,
- onSelect: (dialog) => {
+ enabled: !!session()?.parentID,
+ onSelect: childSessionHandler((dialog) => {
const parentID = session()?.parentID
if (parentID) {
navigate({
@@ -920,7 +934,31 @@ export function Session() {
})
}
dialog.clear()
- },
+ }),
+ },
+ {
+ title: "Next child session",
+ value: "session.child.next",
+ keybind: "session_child_cycle",
+ category: "Session",
+ hidden: true,
+ enabled: !!session()?.parentID,
+ onSelect: childSessionHandler((dialog) => {
+ moveChild(1)
+ dialog.clear()
+ }),
+ },
+ {
+ title: "Previous child session",
+ value: "session.child.previous",
+ keybind: "session_child_cycle_reverse",
+ category: "Session",
+ hidden: true,
+ enabled: !!session()?.parentID,
+ onSelect: childSessionHandler((dialog) => {
+ moveChild(-1)
+ dialog.clear()
+ }),
},
])
@@ -971,9 +1009,6 @@ export function Session() {
}
})
- const dialog = useDialog()
- const renderer = useRenderer()
-
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
@@ -1291,6 +1326,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
+ const keybind = useKeybind()
+
return (
<>
@@ -1308,6 +1345,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
+ x.type === "tool" && x.tool === "task")}>
+
+
+ {keybind.print("session_child_first")}
+ view subagents
+
+
+
void
}) {
const [margin, setMargin] = createSignal(0)
const { theme } = useTheme()
const ctx = use()
const sync = useSync()
+ const renderer = useRenderer()
+ const [hover, setHover] = createSignal(false)
const permission = createMemo(() => {
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
@@ -1593,6 +1642,7 @@ function InlineTool(props: {
const fg = createMemo(() => {
if (permission()) return theme.warning
+ if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
return theme.text
})
@@ -1610,6 +1660,12 @@ function InlineTool(props: {
props.onClick && setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={() => {
+ if (renderer.getSelection()?.getSelectedText()) return
+ props.onClick?.()
+ }}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
@@ -1633,11 +1689,18 @@ function InlineTool(props: {
}
}}
>
-
- ~ {props.pending}>} when={props.complete}>
- {props.icon} {props.children}
-
-
+
+
+
+
+
+
+ ~ {props.pending}>} when={props.complete}>
+ {props.icon} {props.children}
+
+
+
+
{error()}
@@ -1804,6 +1867,7 @@ function Glob(props: ToolProps) {
function Read(props: ToolProps) {
const { theme } = useTheme()
+ const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
if (props.part.state.time.compacted) return []
@@ -1813,7 +1877,13 @@ function Read(props: ToolProps) {
})
return (
<>
-
+
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
@@ -1889,62 +1959,64 @@ function Task(props: ToolProps) {
const local = useLocal()
const sync = useSync()
+ onMount(() => {
+ if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
+ sync.session.sync(props.metadata.sessionId)
+ })
+
+ const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])
+
const tools = createMemo(() => {
- const sessionID = props.metadata.sessionId
- const msgs = sync.data.message[sessionID ?? ""] ?? []
- return msgs.flatMap((msg) =>
+ return messages().flatMap((msg) =>
(sync.data.part[msg.id] ?? [])
.filter((part): part is ToolPart => part.type === "tool")
.map((part) => ({ tool: part.tool, state: part.state })),
)
})
- const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
+ const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
const isRunning = createMemo(() => props.part.state.status === "running")
+ const duration = createMemo(() => {
+ const first = messages().find((x) => x.role === "user")?.time.created
+ const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
+ if (!first || !assistant) return 0
+ return assistant - first
+ })
+
+ const content = createMemo(() => {
+ if (!props.input.description) return ""
+ let content = [`Task ${props.input.description}`]
+
+ if (isRunning() && tools().length > 0) {
+ // content[0] += ` · ${tools().length} toolcalls`
+ if (current()) content.push(`↳ ${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`)
+ else content.push(`↳ ${tools().length} toolcalls`)
+ }
+
+ if (props.part.state.status === "completed") {
+ content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
+ }
+
+ return content.join("\n")
+ })
+
return (
-
-
- navigate({ type: "session", sessionID: props.metadata.sessionId! })
- : undefined
- }
- part={props.part}
- spinner={isRunning()}
- >
-
-
- {props.input.description} ({tools().length} toolcalls)
-
-
- {(item) => {
- const title = item().state.status === "completed" ? (item().state as any).title : ""
- return (
-
- └ {Locale.titlecase(item().tool)} {title}
-
- )
- }}
-
-
-
-
- {keybind.print("session_child_cycle")}
- view subagents
-
-
-
-
-
-
- {props.input.subagent_type} Task {props.input.description}
-
-
-
+ {
+ if (props.metadata.sessionId) {
+ navigate({ type: "session", sessionID: props.metadata.sessionId })
+ }
+ }}
+ >
+ {content()}
+
)
}
@@ -2165,10 +2237,16 @@ function Diagnostics(props: { diagnostics?: Record[]
function normalizePath(input?: string) {
if (!input) return ""
- if (path.isAbsolute(input)) {
- return path.relative(process.cwd(), input) || "."
- }
- return input
+
+ const cwd = process.cwd()
+ const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
+ const relative = path.relative(cwd, absolute)
+
+ if (!relative) return "."
+ if (!relative.startsWith("..")) return relative
+
+ // outside cwd - use absolute
+ return absolute
}
function input(input: Record, omit?: string[]): string {
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 750347d9d6..57acfd199e 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -3,10 +3,11 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
+import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
-import { iife } from "@/util/iife"
import { Log } from "@/util/log"
+import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
@@ -45,6 +46,20 @@ function createEventSource(client: RpcClient): EventSource {
}
}
+async function target() {
+ if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
+ const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
+ if (await Filesystem.exists(fileURLToPath(dist))) return dist
+ return new URL("./worker.ts", import.meta.url)
+}
+
+async function input(value?: string) {
+ const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
+ if (!value) return piped
+ if (!piped) return value
+ return piped + "\n" + value
+}
+
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@@ -97,23 +112,17 @@ export const TuiThreadCommand = cmd({
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
- const baseCwd = process.env.PWD ?? process.cwd()
- const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
- const localWorker = new URL("./worker.ts", import.meta.url)
- const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
- const workerPath = await iife(async () => {
- if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
- if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
- return localWorker
- })
+ const root = process.env.PWD ?? process.cwd()
+ const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
+ const file = await target()
try {
process.chdir(cwd)
- } catch (e) {
+ } catch {
UI.error("Failed to change directory to " + cwd)
return
}
- const worker = new Worker(workerPath, {
+ const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
@@ -121,76 +130,88 @@ export const TuiThreadCommand = cmd({
worker.onerror = (e) => {
Log.Default.error(e)
}
- const client = Rpc.client(worker)
- process.on("uncaughtException", (e) => {
- Log.Default.error(e)
- })
- process.on("unhandledRejection", (e) => {
- Log.Default.error(e)
- })
- process.on("SIGUSR2", async () => {
- await client.call("reload", undefined)
- })
- const prompt = await iife(async () => {
- const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
- if (!args.prompt) return piped
- return piped ? piped + "\n" + args.prompt : args.prompt
- })
+ const client = Rpc.client(worker)
+ const error = (e: unknown) => {
+ Log.Default.error(e)
+ }
+ const reload = () => {
+ client.call("reload", undefined).catch((err) => {
+ Log.Default.warn("worker reload failed", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ })
+ }
+ process.on("uncaughtException", error)
+ process.on("unhandledRejection", error)
+ process.on("SIGUSR2", reload)
+
+ let stopped = false
+ const stop = async () => {
+ if (stopped) return
+ stopped = true
+ process.off("uncaughtException", error)
+ process.off("unhandledRejection", error)
+ process.off("SIGUSR2", reload)
+ await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
+ Log.Default.warn("worker shutdown failed", {
+ error: error instanceof Error ? error.message : String(error),
+ })
+ })
+ worker.terminate()
+ }
+
+ const prompt = await input(args.prompt)
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
- // Check if server should be started (port or hostname explicitly set in CLI or config)
- const networkOpts = await resolveNetworkOptions(args)
- const shouldStartServer =
+ const network = await resolveNetworkOptions(args)
+ const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
- networkOpts.mdns ||
- networkOpts.port !== 0 ||
- networkOpts.hostname !== "127.0.0.1"
+ network.mdns ||
+ network.port !== 0 ||
+ network.hostname !== "127.0.0.1"
- let url: string
- let customFetch: typeof fetch | undefined
- let events: EventSource | undefined
-
- if (shouldStartServer) {
- // Start HTTP server for external access
- const server = await client.call("server", networkOpts)
- url = server.url
- } else {
- // Use direct RPC communication (no HTTP)
- url = "http://opencode.internal"
- customFetch = createWorkerFetch(client)
- events = createEventSource(client)
- }
-
- const tuiPromise = tui({
- url,
- config,
- directory: cwd,
- fetch: customFetch,
- events,
- args: {
- continue: args.continue,
- sessionID: args.session,
- agent: args.agent,
- model: args.model,
- prompt,
- fork: args.fork,
- },
- onExit: async () => {
- await client.call("shutdown", undefined)
- },
- })
+ const transport = external
+ ? {
+ url: (await client.call("server", network)).url,
+ fetch: undefined,
+ events: undefined,
+ }
+ : {
+ url: "http://opencode.internal",
+ fetch: createWorkerFetch(client),
+ events: createEventSource(client),
+ }
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
- }, 1000)
+ }, 1000).unref?.()
- await tuiPromise
+ try {
+ await tui({
+ url: transport.url,
+ config,
+ directory: cwd,
+ fetch: transport.fetch,
+ events: transport.events,
+ args: {
+ continue: args.continue,
+ sessionID: args.session,
+ agent: args.agent,
+ model: args.model,
+ prompt,
+ fork: args.fork,
+ },
+ onExit: stop,
+ })
+ } finally {
+ await stop()
+ }
} finally {
unguard?.()
}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index bb5495c481..4452d6d764 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
+import { setTimeout as sleep } from "node:timers/promises"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
- await Bun.sleep(250)
+ await sleep(250)
continue
}
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
- await Bun.sleep(250)
+ await sleep(250)
}
}
})().catch((error) => {
@@ -137,12 +138,7 @@ export const rpc = {
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
- await Promise.race([
- Instance.disposeAll(),
- new Promise((resolve) => {
- setTimeout(resolve, 5000)
- }),
- ])
+ await Instance.disposeAll()
if (server) server.stop(true)
},
}
diff --git a/packages/opencode/src/cli/cmd/workspace-serve.ts b/packages/opencode/src/cli/cmd/workspace-serve.ts
index 9b47defd39..cb5c304e4b 100644
--- a/packages/opencode/src/cli/cmd/workspace-serve.ts
+++ b/packages/opencode/src/cli/cmd/workspace-serve.ts
@@ -1,59 +1,16 @@
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
-import { Installation } from "../../installation"
+import { WorkspaceServer } from "../../control-plane/workspace-server/server"
export const WorkspaceServeCommand = cmd({
command: "workspace-serve",
builder: (yargs) => withNetworkOptions(yargs),
- describe: "starts a remote workspace websocket server",
+ describe: "starts a remote workspace event server",
handler: async (args) => {
const opts = await resolveNetworkOptions(args)
- const server = Bun.serve<{ id: string }>({
- hostname: opts.hostname,
- port: opts.port,
- fetch(req, server) {
- const url = new URL(req.url)
- if (url.pathname === "/ws") {
- const id = Bun.randomUUIDv7()
- if (server.upgrade(req, { data: { id } })) return
- return new Response("Upgrade failed", { status: 400 })
- }
-
- if (url.pathname === "/health") {
- return new Response("ok", {
- status: 200,
- headers: {
- "content-type": "text/plain; charset=utf-8",
- },
- })
- }
-
- return new Response(
- JSON.stringify({
- service: "workspace-server",
- ws: `ws://${server.hostname}:${server.port}/ws`,
- }),
- {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- },
- },
- )
- },
- websocket: {
- open(ws) {
- ws.send(JSON.stringify({ type: "ready", id: ws.data.id }))
- },
- message(ws, msg) {
- const text = typeof msg === "string" ? msg : msg.toString()
- ws.send(JSON.stringify({ type: "message", id: ws.data.id, text }))
- },
- close() {},
- },
- })
-
- console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`)
+ const server = WorkspaceServer.Listen(opts)
+ console.log(`workspace event server listening on http://${server.hostname}:${server.port}/event`)
await new Promise(() => {})
+ await server.stop()
},
})
diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts
index f242a77f6c..39396997c6 100644
--- a/packages/opencode/src/cli/ui.ts
+++ b/packages/opencode/src/cli/ui.ts
@@ -25,12 +25,12 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
- Bun.stderr.write(EOL)
+ process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
- Bun.stderr.write(message.join(" "))
+ process.stderr.write(message.join(" "))
}
let blank = false
@@ -44,7 +44,7 @@ export namespace UI {
const result: string[] = []
const reset = "\x1b[0m"
const left = {
- fg: Bun.color("gray", "ansi") ?? "",
+ fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index e46068a478..bf7eb27b08 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -86,11 +86,12 @@ export namespace Config {
let result: Info = {}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
+ const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
- log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
- const response = await fetch(`${key}/.well-known/opencode`)
+ log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
+ const response = await fetch(`${url}/.well-known/opencode`)
if (!response.ok) {
- throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
+ throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
@@ -99,11 +100,11 @@ export namespace Config {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
- dir: path.dirname(`${key}/.well-known/opencode`),
- source: `${key}/.well-known/opencode`,
+ dir: path.dirname(`${url}/.well-known/opencode`),
+ source: `${url}/.well-known/opencode`,
}),
)
- log.debug("loaded remote config from well-known", { url: key })
+ log.debug("loaded remote config from well-known", { url })
}
}
@@ -896,9 +897,10 @@ export namespace Config {
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
- 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"),
+ session_child_first: z.string().optional().default("down").describe("Go to first child session"),
+ 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"),
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"),
@@ -1248,7 +1250,7 @@ export namespace Config {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
- await Bun.write(options.path, updated).catch(() => {})
+ await Filesystem.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin && isFile) {
@@ -1409,3 +1411,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
+Filesystem.write
+Filesystem.write
diff --git a/packages/opencode/src/config/migrate-tui-config.ts b/packages/opencode/src/config/migrate-tui-config.ts
index b426e4fbd1..dbe33ffb42 100644
--- a/packages/opencode/src/config/migrate-tui-config.ts
+++ b/packages/opencode/src/config/migrate-tui-config.ts
@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
- const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
+ const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
- : await Bun.write(backup, source)
+ : await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
return applyEdits(acc, edits)
}, source)
- return Bun.write(file, text)
+ return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts
new file mode 100644
index 0000000000..a43fce2486
--- /dev/null
+++ b/packages/opencode/src/control-plane/adaptors/index.ts
@@ -0,0 +1,20 @@
+import { lazy } from "@/util/lazy"
+import type { Adaptor } from "../types"
+
+const ADAPTORS: Record Promise> = {
+ worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
+}
+
+export function getAdaptor(type: string): Promise {
+ return ADAPTORS[type]()
+}
+
+export function installAdaptor(type: string, adaptor: Adaptor) {
+ // This is experimental: mostly used for testing right now, but we
+ // will likely allow this in the future. Need to figure out the
+ // TypeScript story
+
+ // @ts-expect-error we force the builtin types right now, but we
+ // will implement a way to extend the types for custom adaptors
+ ADAPTORS[type] = () => adaptor
+}
diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts
new file mode 100644
index 0000000000..f848909501
--- /dev/null
+++ b/packages/opencode/src/control-plane/adaptors/worktree.ts
@@ -0,0 +1,46 @@
+import z from "zod"
+import { Worktree } from "@/worktree"
+import { type Adaptor, WorkspaceInfo } from "../types"
+
+const Config = WorkspaceInfo.extend({
+ name: WorkspaceInfo.shape.name.unwrap(),
+ branch: WorkspaceInfo.shape.branch.unwrap(),
+ directory: WorkspaceInfo.shape.directory.unwrap(),
+})
+
+type Config = z.infer
+
+export const WorktreeAdaptor: Adaptor = {
+ async configure(info) {
+ const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
+ return {
+ ...info,
+ name: worktree.name,
+ branch: worktree.branch,
+ directory: worktree.directory,
+ }
+ },
+ async create(info) {
+ const config = Config.parse(info)
+ const bootstrap = await Worktree.createFromInfo({
+ name: config.name,
+ directory: config.directory,
+ branch: config.branch,
+ })
+ return bootstrap()
+ },
+ async remove(info) {
+ const config = Config.parse(info)
+ await Worktree.remove({ directory: config.directory })
+ },
+ async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
+ const config = Config.parse(info)
+ const { WorkspaceServer } = await import("../workspace-server/server")
+ const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
+ headers.set("x-opencode-directory", config.directory)
+
+ const request = new Request(url, { ...init, headers })
+ return WorkspaceServer.App().fetch(request)
+ },
+}
diff --git a/packages/opencode/src/control-plane/sse.ts b/packages/opencode/src/control-plane/sse.ts
new file mode 100644
index 0000000000..003093a003
--- /dev/null
+++ b/packages/opencode/src/control-plane/sse.ts
@@ -0,0 +1,66 @@
+export async function parseSSE(
+ body: ReadableStream,
+ signal: AbortSignal,
+ onEvent: (event: unknown) => void,
+) {
+ const reader = body.getReader()
+ const decoder = new TextDecoder()
+ let buf = ""
+ let last = ""
+ let retry = 1000
+
+ const abort = () => {
+ void reader.cancel().catch(() => undefined)
+ }
+
+ signal.addEventListener("abort", abort)
+
+ try {
+ while (!signal.aborted) {
+ const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
+ if (chunk.done) break
+
+ buf += decoder.decode(chunk.value, { stream: true })
+ buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
+
+ const chunks = buf.split("\n\n")
+ buf = chunks.pop() ?? ""
+
+ chunks.forEach((chunk) => {
+ const data: string[] = []
+ chunk.split("\n").forEach((line) => {
+ if (line.startsWith("data:")) {
+ data.push(line.replace(/^data:\s*/, ""))
+ return
+ }
+ if (line.startsWith("id:")) {
+ last = line.replace(/^id:\s*/, "")
+ return
+ }
+ if (line.startsWith("retry:")) {
+ const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
+ if (!Number.isNaN(parsed)) retry = parsed
+ }
+ })
+
+ if (!data.length) return
+ const raw = data.join("\n")
+ try {
+ onEvent(JSON.parse(raw))
+ } catch {
+ onEvent({
+ type: "sse.message",
+ properties: {
+ data: raw,
+ id: last || undefined,
+ retry,
+ },
+ })
+ }
+ })
+ }
+ } finally {
+ signal.removeEventListener("abort", abort)
+ reader.releaseLock()
+ }
+}
diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts
new file mode 100644
index 0000000000..3d27757fd1
--- /dev/null
+++ b/packages/opencode/src/control-plane/types.ts
@@ -0,0 +1,20 @@
+import z from "zod"
+import { Identifier } from "@/id/id"
+
+export const WorkspaceInfo = z.object({
+ id: Identifier.schema("workspace"),
+ type: z.string(),
+ branch: z.string().nullable(),
+ name: z.string().nullable(),
+ directory: z.string().nullable(),
+ extra: z.unknown().nullable(),
+ projectID: z.string(),
+})
+export type WorkspaceInfo = z.infer
+
+export type Adaptor = {
+ configure(input: WorkspaceInfo): WorkspaceInfo | Promise
+ create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise
+ remove(config: WorkspaceInfo): Promise
+ fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise
+}
diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts
new file mode 100644
index 0000000000..f7297b3f4b
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace-context.ts
@@ -0,0 +1,23 @@
+import { Context } from "../util/context"
+
+interface Context {
+ workspaceID?: string
+}
+
+const context = Context.create("workspace")
+
+export const WorkspaceContext = {
+ async provide(input: { workspaceID?: string; fn: () => R }): Promise {
+ return context.provide({ workspaceID: input.workspaceID }, async () => {
+ return input.fn()
+ })
+ },
+
+ get workspaceID() {
+ try {
+ return context.use().workspaceID
+ } catch (e) {
+ return undefined
+ }
+ },
+}
diff --git a/packages/opencode/src/control-plane/workspace-router-middleware.ts b/packages/opencode/src/control-plane/workspace-router-middleware.ts
new file mode 100644
index 0000000000..b48f2fd2b7
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace-router-middleware.ts
@@ -0,0 +1,50 @@
+import { Instance } from "@/project/instance"
+import type { MiddlewareHandler } from "hono"
+import { Installation } from "../installation"
+import { getAdaptor } from "./adaptors"
+import { Workspace } from "./workspace"
+import { WorkspaceContext } from "./workspace-context"
+
+// This middleware forwards all non-GET requests if the workspace is a
+// remote. The remote workspace needs to handle session mutations
+async function routeRequest(req: Request) {
+ // Right now, we need to forward all requests to the workspace
+ // because we don't have syncing. In the future all GET requests
+ // which don't mutate anything will be handled locally
+ //
+ // if (req.method === "GET") return
+
+ if (!WorkspaceContext.workspaceID) return
+
+ const workspace = await Workspace.get(WorkspaceContext.workspaceID)
+ if (!workspace) {
+ return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, {
+ status: 500,
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ },
+ })
+ }
+
+ const adaptor = await getAdaptor(workspace.type)
+
+ return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, {
+ method: req.method,
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
+ signal: req.signal,
+ headers: req.headers,
+ })
+}
+
+export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
+ // Only available in development for now
+ if (!Installation.isLocal()) {
+ return next()
+ }
+
+ const response = await routeRequest(c.req.raw)
+ if (response) {
+ return response
+ }
+ return next()
+}
diff --git a/packages/opencode/src/control-plane/workspace-server/routes.ts b/packages/opencode/src/control-plane/workspace-server/routes.ts
new file mode 100644
index 0000000000..353e5d50af
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace-server/routes.ts
@@ -0,0 +1,33 @@
+import { GlobalBus } from "../../bus/global"
+import { Hono } from "hono"
+import { streamSSE } from "hono/streaming"
+
+export function WorkspaceServerRoutes() {
+ return new Hono().get("/event", async (c) => {
+ c.header("X-Accel-Buffering", "no")
+ c.header("X-Content-Type-Options", "nosniff")
+ return streamSSE(c, async (stream) => {
+ const send = async (event: unknown) => {
+ await stream.writeSSE({
+ data: JSON.stringify(event),
+ })
+ }
+ const handler = async (event: { directory?: string; payload: unknown }) => {
+ await send(event.payload)
+ }
+ GlobalBus.on("event", handler)
+ await send({ type: "server.connected", properties: {} })
+ const heartbeat = setInterval(() => {
+ void send({ type: "server.heartbeat", properties: {} })
+ }, 10_000)
+
+ await new Promise((resolve) => {
+ stream.onAbort(() => {
+ clearInterval(heartbeat)
+ GlobalBus.off("event", handler)
+ resolve()
+ })
+ })
+ })
+ })
+}
diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts
new file mode 100644
index 0000000000..fd7fd93086
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace-server/server.ts
@@ -0,0 +1,64 @@
+import { Hono } from "hono"
+import { Instance } from "../../project/instance"
+import { InstanceBootstrap } from "../../project/bootstrap"
+import { SessionRoutes } from "../../server/routes/session"
+import { WorkspaceServerRoutes } from "./routes"
+import { WorkspaceContext } from "../workspace-context"
+
+export namespace WorkspaceServer {
+ export function App() {
+ const session = new Hono()
+ .use(async (c, next) => {
+ // Right now, we need handle all requests because we don't
+ // have syncing. In the future all GET requests will handled
+ // by the control plane
+ //
+ // if (c.req.method === "GET") return c.notFound()
+ await next()
+ })
+ .route("/", SessionRoutes())
+
+ return new Hono()
+ .use(async (c, next) => {
+ const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
+ const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
+ if (workspaceID == null) {
+ throw new Error("workspaceID parameter is required")
+ }
+ if (raw == null) {
+ throw new Error("directory parameter is required")
+ }
+
+ const directory = (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })()
+
+ return WorkspaceContext.provide({
+ workspaceID,
+ async fn() {
+ return Instance.provide({
+ directory,
+ init: InstanceBootstrap,
+ async fn() {
+ return next()
+ },
+ })
+ },
+ })
+ })
+ .route("/session", session)
+ .route("/", WorkspaceServerRoutes())
+ }
+
+ export function Listen(opts: { hostname: string; port: number }) {
+ return Bun.serve({
+ hostname: opts.hostname,
+ port: opts.port,
+ fetch: App().fetch,
+ })
+ }
+}
diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts
new file mode 100644
index 0000000000..1ba1605f8e
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace.sql.ts
@@ -0,0 +1,14 @@
+import { sqliteTable, text } from "drizzle-orm/sqlite-core"
+import { ProjectTable } from "@/project/project.sql"
+
+export const WorkspaceTable = sqliteTable("workspace", {
+ id: text().primaryKey(),
+ type: text().notNull(),
+ branch: text(),
+ name: text(),
+ directory: text(),
+ extra: text({ mode: "json" }),
+ project_id: text()
+ .notNull()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+})
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
new file mode 100644
index 0000000000..8c76fbdab9
--- /dev/null
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -0,0 +1,152 @@
+import z from "zod"
+import { Identifier } from "@/id/id"
+import { fn } from "@/util/fn"
+import { Database, eq } from "@/storage/db"
+import { Project } from "@/project/project"
+import { BusEvent } from "@/bus/bus-event"
+import { GlobalBus } from "@/bus/global"
+import { Log } from "@/util/log"
+import { WorkspaceTable } from "./workspace.sql"
+import { getAdaptor } from "./adaptors"
+import { WorkspaceInfo } from "./types"
+import { parseSSE } from "./sse"
+
+export namespace Workspace {
+ export const Event = {
+ Ready: BusEvent.define(
+ "workspace.ready",
+ z.object({
+ name: z.string(),
+ }),
+ ),
+ Failed: BusEvent.define(
+ "workspace.failed",
+ z.object({
+ message: z.string(),
+ }),
+ ),
+ }
+
+ export const Info = WorkspaceInfo.meta({
+ ref: "Workspace",
+ })
+ export type Info = z.infer
+
+ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
+ return {
+ id: row.id,
+ type: row.type,
+ branch: row.branch,
+ name: row.name,
+ directory: row.directory,
+ extra: row.extra,
+ projectID: row.project_id,
+ }
+ }
+
+ const CreateInput = z.object({
+ id: Identifier.schema("workspace").optional(),
+ type: Info.shape.type,
+ branch: Info.shape.branch,
+ projectID: Info.shape.projectID,
+ extra: Info.shape.extra,
+ })
+
+ export const create = fn(CreateInput, async (input) => {
+ const id = Identifier.ascending("workspace", input.id)
+ const adaptor = await getAdaptor(input.type)
+
+ const config = await adaptor.configure({ ...input, id, name: null, directory: null })
+
+ const info: Info = {
+ id,
+ type: config.type,
+ branch: config.branch ?? null,
+ name: config.name ?? null,
+ directory: config.directory ?? null,
+ extra: config.extra ?? null,
+ projectID: input.projectID,
+ }
+
+ Database.use((db) => {
+ db.insert(WorkspaceTable)
+ .values({
+ id: info.id,
+ type: info.type,
+ branch: info.branch,
+ name: info.name,
+ directory: info.directory,
+ extra: info.extra,
+ project_id: info.projectID,
+ })
+ .run()
+ })
+
+ await adaptor.create(config)
+ return info
+ })
+
+ export function list(project: Project.Info) {
+ const rows = Database.use((db) =>
+ db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
+ )
+ return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
+ }
+
+ export const get = fn(Identifier.schema("workspace"), async (id) => {
+ const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
+ if (!row) return
+ return fromRow(row)
+ })
+
+ export const remove = fn(Identifier.schema("workspace"), async (id) => {
+ const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
+ if (row) {
+ const info = fromRow(row)
+ const adaptor = await getAdaptor(row.type)
+ adaptor.remove(info)
+ Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
+ return info
+ }
+ })
+ const log = Log.create({ service: "workspace-sync" })
+
+ async function workspaceEventLoop(space: Info, stop: AbortSignal) {
+ while (!stop.aborted) {
+ const adaptor = await getAdaptor(space.type)
+ const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
+ if (!res || !res.ok || !res.body) {
+ await Bun.sleep(1000)
+ continue
+ }
+ await parseSSE(res.body, stop, (event) => {
+ GlobalBus.emit("event", {
+ directory: space.id,
+ payload: event,
+ })
+ })
+ // Wait 250ms and retry if SSE connection fails
+ await Bun.sleep(250)
+ }
+ }
+
+ export function startSyncing(project: Project.Info) {
+ const stop = new AbortController()
+ const spaces = list(project).filter((space) => space.type !== "worktree")
+
+ spaces.forEach((space) => {
+ void workspaceEventLoop(space, stop.signal).catch((error) => {
+ log.warn("workspace sync listener failed", {
+ workspaceID: space.id,
+ error,
+ })
+ })
+ })
+
+ return {
+ async stop() {
+ stop.abort()
+ },
+ }
+ }
+}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index e02f191c70..22eba6320e 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -3,6 +3,11 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
+function falsy(key: string) {
+ const value = process.env[key]?.toLowerCase()
+ return value === "false" || value === "0"
+}
+
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
@@ -52,7 +57,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
- export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
+ export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts
index db2920b0a4..6673297cbf 100644
--- a/packages/opencode/src/id/id.ts
+++ b/packages/opencode/src/id/id.ts
@@ -11,6 +11,7 @@ export namespace Identifier {
part: "prt",
pty: "pty",
tool: "tool",
+ workspace: "wrk",
} as const
export function schema(prefix: keyof typeof prefixes) {
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 9af79278c0..4fd5f0e67b 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -46,6 +46,11 @@ process.on("uncaughtException", (e) => {
})
})
+// Ensure the process exits on terminal hangup (eg. closing the terminal tab).
+// Without this, long-running commands like `serve` block on a never-resolving
+// promise and survive as orphaned processes.
+process.on("SIGHUP", () => process.exit())
+
let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
@@ -76,6 +81,7 @@ let cli = yargs(hideBin(process.argv))
process.env.AGENT = "1"
process.env.OPENCODE = "1"
+ process.env.OPENCODE_PID = String(process.pid)
Log.Default.info("opencode", {
version: Installation.VERSION,
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 3c29fe03d3..0dca27d651 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -160,6 +160,28 @@ export namespace MCP {
return typeof entry === "object" && entry !== null && "type" in entry
}
+ async function descendants(pid: number): Promise {
+ if (process.platform === "win32") return []
+ const pids: number[] = []
+ const queue = [pid]
+ while (queue.length > 0) {
+ const current = queue.shift()!
+ const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
+ const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
+ () => [-1, ""] as const,
+ )
+ if (code !== 0) continue
+ for (const tok of out.trim().split(/\s+/)) {
+ const cpid = parseInt(tok, 10)
+ if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
+ pids.push(cpid)
+ queue.push(cpid)
+ }
+ }
+ }
+ return pids
+ }
+
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -196,6 +218,21 @@ export namespace MCP {
}
},
async (state) => {
+ // The MCP SDK only signals the direct child process on close.
+ // Servers like chrome-devtools-mcp spawn grandchild processes
+ // (e.g. Chrome) that the SDK never reaches, leaving them orphaned.
+ // Kill the full descendant tree first so the server exits promptly
+ // and no processes are left behind.
+ for (const client of Object.values(state.clients)) {
+ const pid = (client.transport as any)?.pid
+ if (typeof pid !== "number") continue
+ for (const dpid of await descendants(pid)) {
+ try {
+ process.kill(dpid, "SIGTERM")
+ } catch {}
+ }
+ }
+
await Promise.all(
Object.values(state.clients).map((client) =>
client.close().catch((error) => {
diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts
index bb3b56f2e9..db8e621d6c 100644
--- a/packages/opencode/src/mcp/oauth-callback.ts
+++ b/packages/opencode/src/mcp/oauth-callback.ts
@@ -1,3 +1,4 @@
+import { createConnection } from "net"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -160,21 +161,12 @@ export namespace McpOAuthCallback {
export async function isPortInUse(): Promise {
return new Promise((resolve) => {
- Bun.connect({
- hostname: "127.0.0.1",
- port: OAUTH_CALLBACK_PORT,
- socket: {
- open(socket) {
- socket.end()
- resolve(true)
- },
- error() {
- resolve(false)
- },
- data() {},
- close() {},
- },
- }).catch(() => {
+ const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
+ socket.on("connect", () => {
+ socket.destroy()
+ resolve(true)
+ })
+ socket.on("error", () => {
resolve(false)
})
})
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index 56931b2ed6..5c0140e570 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -4,6 +4,7 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
+import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -361,6 +362,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
+ "gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.1-codex",
@@ -602,7 +604,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
return { type: "failed" as const }
}
- await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
+ await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}
diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts
index 39ea0d00d2..3945c63ce2 100644
--- a/packages/opencode/src/plugin/copilot.ts
+++ b/packages/opencode/src/plugin/copilot.ts
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
+import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise {
}
if (data.error === "authorization_pending") {
- await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
+ await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise {
newInterval = serverInterval * 1000
}
- await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
+ await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
- await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
+ await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},
diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts
index 0db03576e9..945d29f97f 100644
--- a/packages/opencode/src/provider/error.ts
+++ b/packages/opencode/src/provider/error.ts
@@ -19,6 +19,7 @@ export namespace ProviderError {
/context window exceeds limit/i, // MiniMax
/exceeded model token limit/i, // Kimi For Coding, Moonshot
/context[_ ]length[_ ]exceeded/i, // Generic fallback
+ /request entity too large/i, // HTTP 413
]
function isOpenAiErrorRetryable(e: APICallError) {
@@ -76,6 +77,18 @@ export namespace ProviderError {
}
} catch {}
+ // If responseBody is HTML (e.g. from a gateway or proxy error page),
+ // provide a human-readable message instead of dumping raw markup
+ if (/^\s*` to re-authenticate."
+ }
+ if (e.statusCode === 403) {
+ return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings."
+ }
+ return msg
+ }
+
return `${msg}: ${e.responseBody}`
}).trim()
}
@@ -165,7 +178,7 @@ export namespace ProviderError {
export function parseAPICallError(input: { providerID: string; error: APICallError }): ParsedAPICallError {
const m = message(input.providerID, input.error)
- if (isOverflow(m)) {
+ if (isOverflow(m) || input.error.statusCode === 413) {
return {
type: "context_overflow",
message: m,
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 022ec31679..b4836ae047 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -6,9 +6,10 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
+import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
-import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
+import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
@@ -555,7 +556,28 @@ export namespace Provider {
const { createAiGateway } = await import("ai-gateway-provider")
const { createUnified } = await import("ai-gateway-provider/providers/unified")
- const aigateway = createAiGateway({ accountId, gateway, apiKey: apiToken })
+ const metadata = iife(() => {
+ if (input.options?.metadata) return input.options.metadata
+ try {
+ return JSON.parse(input.options?.headers?.["cf-aig-metadata"])
+ } catch {
+ return undefined
+ }
+ })
+ const opts = {
+ metadata,
+ cacheTtl: input.options?.cacheTtl,
+ cacheKey: input.options?.cacheKey,
+ skipCache: input.options?.skipCache,
+ collectLog: input.options?.collectLog,
+ }
+
+ const aigateway = createAiGateway({
+ accountId,
+ gateway,
+ apiKey: apiToken,
+ ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
+ })
const unified = createUnified()
return {
@@ -774,7 +796,7 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
- const sdk = new Map()
+ const sdk = new Map()
log.info("init")
@@ -1064,7 +1086,7 @@ export namespace Provider {
...model.headers,
}
- const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
+ const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index b659799c1b..6980be0518 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -897,6 +897,31 @@ export namespace ProviderTransform {
// Convert integer enums to string enums for Google/Gemini
if (model.providerID === "google" || model.api.id.includes("gemini")) {
+ const isPlainObject = (node: unknown): node is Record =>
+ typeof node === "object" && node !== null && !Array.isArray(node)
+ const hasCombiner = (node: unknown) =>
+ isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
+ const hasSchemaIntent = (node: unknown) => {
+ if (!isPlainObject(node)) return false
+ if (hasCombiner(node)) return true
+ return [
+ "type",
+ "properties",
+ "items",
+ "prefixItems",
+ "enum",
+ "const",
+ "$ref",
+ "additionalProperties",
+ "patternProperties",
+ "required",
+ "not",
+ "if",
+ "then",
+ "else",
+ ].some((key) => key in node)
+ }
+
const sanitizeGemini = (obj: any): any => {
if (obj === null || typeof obj !== "object") {
return obj
@@ -927,19 +952,18 @@ export namespace ProviderTransform {
result.required = result.required.filter((field: any) => field in result.properties)
}
- if (result.type === "array") {
+ if (result.type === "array" && !hasCombiner(result)) {
if (result.items == null) {
result.items = {}
}
- // Ensure items has at least a type if it's an empty object
- // This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
- if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
+ // Ensure items has a type only when it's still schema-empty.
+ if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
result.items.type = "string"
}
}
// Remove properties/required from non-object types (Gemini rejects these)
- if (result.type && result.type !== "object") {
+ if (result.type && result.type !== "object" && !hasCombiner(result)) {
delete result.properties
delete result.required
}
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 8d156c03d8..98c7ece105 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -10,6 +10,7 @@ import { Session } from "../../session"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
+import { WorkspaceRoutes } from "./workspace"
export const ExperimentalRoutes = lazy(() =>
new Hono()
@@ -87,6 +88,7 @@ export const ExperimentalRoutes = lazy(() =>
)
},
)
+ .route("/workspace", WorkspaceRoutes())
.post(
"/worktree",
describeRoute({
diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/routes/workspace.ts
new file mode 100644
index 0000000000..cd2d844aed
--- /dev/null
+++ b/packages/opencode/src/server/routes/workspace.ts
@@ -0,0 +1,94 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { Workspace } from "../../control-plane/workspace"
+import { Instance } from "../../project/instance"
+import { errors } from "../error"
+import { lazy } from "../../util/lazy"
+
+export const WorkspaceRoutes = lazy(() =>
+ new Hono()
+ .post(
+ "/",
+ describeRoute({
+ summary: "Create workspace",
+ description: "Create a workspace for the current project.",
+ operationId: "experimental.workspace.create",
+ responses: {
+ 200: {
+ description: "Workspace created",
+ content: {
+ "application/json": {
+ schema: resolver(Workspace.Info),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ Workspace.create.schema.omit({
+ projectID: true,
+ }),
+ ),
+ async (c) => {
+ const body = c.req.valid("json")
+ const workspace = await Workspace.create({
+ projectID: Instance.project.id,
+ ...body,
+ })
+ return c.json(workspace)
+ },
+ )
+ .get(
+ "/",
+ describeRoute({
+ summary: "List workspaces",
+ description: "List all workspaces.",
+ operationId: "experimental.workspace.list",
+ responses: {
+ 200: {
+ description: "Workspaces",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(Workspace.Info)),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(Workspace.list(Instance.project))
+ },
+ )
+ .delete(
+ "/:id",
+ describeRoute({
+ summary: "Remove workspace",
+ description: "Remove an existing workspace.",
+ operationId: "experimental.workspace.remove",
+ responses: {
+ 200: {
+ description: "Workspace removed",
+ content: {
+ "application/json": {
+ schema: resolver(Workspace.Info.optional()),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ id: Workspace.Info.shape.id,
+ }),
+ ),
+ async (c) => {
+ const { id } = c.req.valid("param")
+ return c.json(await Workspace.remove(id))
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 9fba9c1fe1..6ea66be985 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -21,6 +21,8 @@ import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
import { Global } from "../global"
+import { WorkspaceContext } from "../control-plane/workspace-context"
+import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session"
import { PtyRoutes } from "./routes/pty"
@@ -194,6 +196,7 @@ export namespace Server {
)
.use(async (c, next) => {
if (c.req.path === "/log") return next()
+ const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = (() => {
try {
@@ -202,14 +205,21 @@ export namespace Server {
return raw
}
})()
- return Instance.provide({
- directory,
- init: InstanceBootstrap,
+
+ return WorkspaceContext.provide({
+ workspaceID,
async fn() {
- return next()
+ return Instance.provide({
+ directory,
+ init: InstanceBootstrap,
+ async fn() {
+ return next()
+ },
+ })
},
})
})
+ .use(WorkspaceRouterMiddleware)
.get(
"/doc",
openAPIRouteHandler(app, {
@@ -223,7 +233,15 @@ export namespace Server {
},
}),
)
- .use(validator("query", z.object({ directory: z.string().optional() })))
+ .use(
+ validator(
+ "query",
+ z.object({
+ directory: z.string().optional(),
+ workspace: z.string().optional(),
+ }),
+ ),
+ )
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes())
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 9245426057..79884d641e 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -104,8 +104,30 @@ export namespace SessionCompaction {
sessionID: string
abort: AbortSignal
auto: boolean
+ overflow?: boolean
}) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
+
+ let messages = input.messages
+ let replay: MessageV2.WithParts | undefined
+ if (input.overflow) {
+ const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
+ for (let i = idx - 1; i >= 0; i--) {
+ const msg = input.messages[i]
+ if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
+ replay = msg
+ messages = input.messages.slice(0, i)
+ break
+ }
+ }
+ const hasContent =
+ replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
+ if (!hasContent) {
+ replay = undefined
+ messages = input.messages
+ }
+ }
+
const agent = await Agent.get("compaction")
const model = agent.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
@@ -185,7 +207,7 @@ When constructing the summary, try to stick to this template:
tools: {},
system: [],
messages: [
- ...MessageV2.toModelMessages(input.messages, model),
+ ...MessageV2.toModelMessages(messages, model, { stripMedia: true }),
{
role: "user",
content: [
@@ -199,29 +221,72 @@ When constructing the summary, try to stick to this template:
model,
})
+ if (result === "compact") {
+ processor.message.error = new MessageV2.ContextOverflowError({
+ message: replay
+ ? "Conversation history too large to compact - exceeds model context limit"
+ : "Session too large to compact - context exceeds model limit even after stripping media",
+ }).toObject()
+ processor.message.finish = "error"
+ await Session.updateMessage(processor.message)
+ return "stop"
+ }
+
if (result === "continue" && input.auto) {
- const continueMsg = await Session.updateMessage({
- id: Identifier.ascending("message"),
- role: "user",
- sessionID: input.sessionID,
- time: {
- created: Date.now(),
- },
- agent: userMessage.agent,
- model: userMessage.model,
- })
- await Session.updatePart({
- id: Identifier.ascending("part"),
- messageID: continueMsg.id,
- sessionID: input.sessionID,
- type: "text",
- synthetic: true,
- text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
- time: {
- start: Date.now(),
- end: Date.now(),
- },
- })
+ if (replay) {
+ const original = replay.info as MessageV2.User
+ const replayMsg = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID: input.sessionID,
+ time: { created: Date.now() },
+ agent: original.agent,
+ model: original.model,
+ format: original.format,
+ tools: original.tools,
+ system: original.system,
+ variant: original.variant,
+ })
+ for (const part of replay.parts) {
+ if (part.type === "compaction") continue
+ const replayPart =
+ part.type === "file" && MessageV2.isMedia(part.mime)
+ ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
+ : part
+ await Session.updatePart({
+ ...replayPart,
+ id: Identifier.ascending("part"),
+ messageID: replayMsg.id,
+ sessionID: input.sessionID,
+ })
+ }
+ } else {
+ const continueMsg = await Session.updateMessage({
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID: input.sessionID,
+ time: { created: Date.now() },
+ agent: userMessage.agent,
+ model: userMessage.model,
+ })
+ const text =
+ (input.overflow
+ ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
+ : "") +
+ "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: continueMsg.id,
+ sessionID: input.sessionID,
+ type: "text",
+ synthetic: true,
+ text,
+ time: {
+ start: Date.now(),
+ end: Date.now(),
+ },
+ })
+ }
}
if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
@@ -237,6 +302,7 @@ When constructing the summary, try to stick to this template:
modelID: z.string(),
}),
auto: z.boolean(),
+ overflow: z.boolean().optional(),
}),
async (input) => {
const msg = await Session.updateMessage({
@@ -255,6 +321,7 @@ When constructing the summary, try to stick to this template:
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
+ overflow: input.overflow,
})
},
)
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 22de477f8d..b117632051 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -22,6 +22,7 @@ import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
+import { WorkspaceContext } from "../control-plane/workspace-context"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
@@ -63,6 +64,7 @@ export namespace Session {
id: row.id,
slug: row.slug,
projectID: row.project_id,
+ workspaceID: row.workspace_id ?? undefined,
directory: row.directory,
parentID: row.parent_id ?? undefined,
title: row.title,
@@ -84,6 +86,7 @@ export namespace Session {
return {
id: info.id,
project_id: info.projectID,
+ workspace_id: info.workspaceID,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
@@ -118,6 +121,7 @@ export namespace Session {
id: Identifier.schema("session"),
slug: z.string(),
projectID: z.string(),
+ workspaceID: z.string().optional(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
summary: z
@@ -297,6 +301,7 @@ export namespace Session {
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
+ workspaceID: WorkspaceContext.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
@@ -527,6 +532,7 @@ export namespace Session {
export function* list(input?: {
directory?: string
+ workspaceID?: string
roots?: boolean
start?: number
search?: string
@@ -535,6 +541,9 @@ export namespace Session {
const project = Instance.project
const conditions = [eq(SessionTable.project_id, project.id)]
+ if (WorkspaceContext.workspaceID) {
+ conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID))
+ }
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
@@ -752,7 +761,7 @@ export namespace Session {
.run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, {
- part,
+ part: structuredClone(part),
}),
)
})
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 178751a222..5b4e7bdbc0 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -17,6 +17,10 @@ import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider"
export namespace MessageV2 {
+ export function isMedia(mime: string) {
+ return mime.startsWith("image/") || mime === "application/pdf"
+ }
+
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
export const StructuredOutputError = NamedError.create(
@@ -196,6 +200,7 @@ export namespace MessageV2 {
export const CompactionPart = PartBase.extend({
type: z.literal("compaction"),
auto: z.boolean(),
+ overflow: z.boolean().optional(),
}).meta({
ref: "CompactionPart",
})
@@ -488,7 +493,11 @@ export namespace MessageV2 {
})
export type WithParts = z.infer
- export function toModelMessages(input: WithParts[], model: Provider.Model): ModelMessage[] {
+ export function toModelMessages(
+ input: WithParts[],
+ model: Provider.Model,
+ options?: { stripMedia?: boolean },
+ ): ModelMessage[] {
const result: UIMessage[] = []
const toolNames = new Set()
// Track media from tool results that need to be injected as user messages
@@ -562,13 +571,21 @@ export namespace MessageV2 {
text: part.text,
})
// text/plain and directory files are converted into text parts, ignore them
- if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory")
- userMessage.parts.push({
- type: "file",
- url: part.url,
- mediaType: part.mime,
- filename: part.filename,
- })
+ if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
+ if (options?.stripMedia && isMedia(part.mime)) {
+ userMessage.parts.push({
+ type: "text",
+ text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`,
+ })
+ } else {
+ userMessage.parts.push({
+ type: "file",
+ url: part.url,
+ mediaType: part.mime,
+ filename: part.filename,
+ })
+ }
+ }
if (part.type === "compaction") {
userMessage.parts.push({
@@ -618,14 +635,12 @@ export namespace MessageV2 {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
- const attachments = part.state.time.compacted ? [] : (part.state.attachments ?? [])
+ const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
- const isMediaAttachment = (a: { mime: string }) =>
- a.mime.startsWith("image/") || a.mime === "application/pdf"
- const mediaAttachments = attachments.filter(isMediaAttachment)
- const nonMediaAttachments = attachments.filter((a) => !isMediaAttachment(a))
+ const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
+ const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime))
if (!supportsMediaInToolResults && mediaAttachments.length > 0) {
media.push(...mediaAttachments)
}
@@ -802,7 +817,8 @@ export namespace MessageV2 {
msg.parts.some((part) => part.type === "compaction")
)
break
- if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID)
+ if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
+ completed.add(msg.info.parentID)
}
result.reverse()
return result
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index e7532d2007..67edc0ecfe 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -279,7 +279,10 @@ export namespace SessionProcessor {
sessionID: input.sessionID,
messageID: input.assistantMessage.parentID,
})
- if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) {
+ if (
+ !input.assistantMessage.summary &&
+ (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model }))
+ ) {
needsCompaction = true
}
break
@@ -354,27 +357,32 @@ export namespace SessionProcessor {
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
if (MessageV2.ContextOverflowError.isInstance(error)) {
- // TODO: Handle context overflow error
- }
- const retry = SessionRetry.retryable(error)
- if (retry !== undefined) {
- attempt++
- const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
- SessionStatus.set(input.sessionID, {
- type: "retry",
- attempt,
- message: retry,
- next: Date.now() + delay,
+ needsCompaction = true
+ Bus.publish(Session.Event.Error, {
+ sessionID: input.sessionID,
+ error,
})
- await SessionRetry.sleep(delay, input.abort).catch(() => {})
- continue
+ } else {
+ const retry = SessionRetry.retryable(error)
+ if (retry !== undefined) {
+ attempt++
+ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
+ SessionStatus.set(input.sessionID, {
+ type: "retry",
+ attempt,
+ message: retry,
+ next: Date.now() + delay,
+ })
+ await SessionRetry.sleep(delay, input.abort).catch(() => {})
+ continue
+ }
+ input.assistantMessage.error = error
+ Bus.publish(Session.Event.Error, {
+ sessionID: input.assistantMessage.sessionID,
+ error: input.assistantMessage.error,
+ })
+ SessionStatus.set(input.sessionID, { type: "idle" })
}
- input.assistantMessage.error = error
- Bus.publish(Session.Event.Error, {
- sessionID: input.assistantMessage.sessionID,
- error: input.assistantMessage.error,
- })
- SessionStatus.set(input.sessionID, { type: "idle" })
}
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 75bd3c9dfa..4f77920cc9 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -533,6 +533,7 @@ export namespace SessionPrompt {
abort,
sessionID,
auto: task.auto,
+ overflow: task.overflow,
})
if (result === "stop") break
continue
@@ -707,6 +708,7 @@ export namespace SessionPrompt {
agent: lastUser.agent,
model: lastUser.model,
auto: true,
+ overflow: !processor.message.finish,
})
}
continue
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
index 9c5c72c4c5..0630760f3b 100644
--- a/packages/opencode/src/session/session.sql.ts
+++ b/packages/opencode/src/session/session.sql.ts
@@ -15,6 +15,7 @@ export const SessionTable = sqliteTable(
project_id: text()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
+ workspace_id: text(),
parent_id: text(),
slug: text().notNull(),
directory: text().notNull(),
@@ -31,7 +32,11 @@ export const SessionTable = sqliteTable(
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_workspace_idx").on(table.workspace_id),
+ index("session_parent_idx").on(table.parent_id),
+ ],
)
export const MessageTable = sqliteTable(
diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts
index e7b7cdb3e4..4779cfef75 100644
--- a/packages/opencode/src/shell/shell.ts
+++ b/packages/opencode/src/shell/shell.ts
@@ -3,6 +3,7 @@ import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
+import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -22,13 +23,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
- await Bun.sleep(SIGKILL_TIMEOUT_MS)
+ await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
- await Bun.sleep(SIGKILL_TIMEOUT_MS)
+ await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index cf254b4cef..1acbdba092 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,6 +1,7 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
+import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
@@ -271,13 +272,12 @@ export namespace Snapshot {
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
- await Bun.write(target, "")
+ await Filesystem.write(target, "")
return
}
- const text = await Bun.file(file)
- .text()
- .catch(() => "")
- await Bun.write(target, text)
+ const text = await Filesystem.readText(file).catch(() => "")
+
+ await Filesystem.write(target, text)
}
async function excludes() {
diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts
index 7961b0e380..4c1c2490e3 100644
--- a/packages/opencode/src/storage/schema.ts
+++ b/packages/opencode/src/storage/schema.ts
@@ -2,3 +2,4 @@ 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 { WorkspaceTable } from "../control-plane/workspace.sql"
diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt
index 47e9378e75..baafb00810 100644
--- a/packages/opencode/src/tool/bash.txt
+++ b/packages/opencode/src/tool/bash.txt
@@ -24,7 +24,7 @@ Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.
+ - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
diff --git a/packages/opencode/src/util/hash.ts b/packages/opencode/src/util/hash.ts
new file mode 100644
index 0000000000..680e0f40bc
--- /dev/null
+++ b/packages/opencode/src/util/hash.ts
@@ -0,0 +1,7 @@
+import { createHash } from "crypto"
+
+export namespace Hash {
+ export function fast(input: string | Buffer): string {
+ return createHash("sha1").update(input).digest("hex")
+ }
+}
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index d85a0843fb..2267322494 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -331,7 +331,7 @@ export namespace Worktree {
}, 0)
}
- export const create = fn(CreateInput.optional(), async (input) => {
+ export async function makeWorktreeInfo(name?: string): Promise {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
@@ -339,9 +339,11 @@ export namespace Worktree {
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
await fs.mkdir(root, { recursive: true })
- const base = input?.name ? slug(input.name) : ""
- const info = await candidate(root, base || undefined)
+ const base = name ? slug(name) : ""
+ return candidate(root, base || undefined)
+ }
+ export async function createFromInfo(info: Info, startCommand?: string) {
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
.quiet()
.nothrow()
@@ -353,8 +355,9 @@ export namespace Worktree {
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
const projectID = Instance.project.id
- const extra = input?.startCommand?.trim()
- setTimeout(() => {
+ const extra = startCommand?.trim()
+
+ return () => {
const start = async () => {
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
if (populated.exitCode !== 0) {
@@ -411,8 +414,17 @@ export namespace Worktree {
void start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
- }, 0)
+ }
+ }
+ export const create = fn(CreateInput.optional(), async (input) => {
+ const info = await makeWorktreeInfo(input?.name)
+ const bootstrap = await createFromInfo(info, input?.startCommand)
+ // This is needed due to how worktrees currently work in the
+ // desktop app
+ setTimeout(() => {
+ bootstrap()
+ }, 0)
return info
})
diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts
new file mode 100644
index 0000000000..a569c71139
--- /dev/null
+++ b/packages/opencode/test/auth/auth.test.ts
@@ -0,0 +1,58 @@
+import { test, expect } from "bun:test"
+import { Auth } from "../../src/auth"
+
+test("set normalizes trailing slashes in keys", async () => {
+ await Auth.set("https://example.com/", {
+ type: "wellknown",
+ key: "TOKEN",
+ token: "abc",
+ })
+ const data = await Auth.all()
+ expect(data["https://example.com"]).toBeDefined()
+ expect(data["https://example.com/"]).toBeUndefined()
+})
+
+test("set cleans up pre-existing trailing-slash entry", async () => {
+ // Simulate a pre-fix entry with trailing slash
+ await Auth.set("https://example.com/", {
+ type: "wellknown",
+ key: "TOKEN",
+ token: "old",
+ })
+ // Re-login with normalized key (as the CLI does post-fix)
+ await Auth.set("https://example.com", {
+ type: "wellknown",
+ key: "TOKEN",
+ token: "new",
+ })
+ const data = await Auth.all()
+ const keys = Object.keys(data).filter((k) => k.includes("example.com"))
+ expect(keys).toEqual(["https://example.com"])
+ const entry = data["https://example.com"]!
+ expect(entry.type).toBe("wellknown")
+ if (entry.type === "wellknown") expect(entry.token).toBe("new")
+})
+
+test("remove deletes both trailing-slash and normalized keys", async () => {
+ await Auth.set("https://example.com", {
+ type: "wellknown",
+ key: "TOKEN",
+ token: "abc",
+ })
+ await Auth.remove("https://example.com/")
+ const data = await Auth.all()
+ expect(data["https://example.com"]).toBeUndefined()
+ expect(data["https://example.com/"]).toBeUndefined()
+})
+
+test("set and remove are no-ops on keys without trailing slashes", async () => {
+ await Auth.set("anthropic", {
+ type: "api",
+ key: "sk-test",
+ })
+ const data = await Auth.all()
+ expect(data["anthropic"]).toBeDefined()
+ await Auth.remove("anthropic")
+ const after = await Auth.all()
+ expect(after["anthropic"]).toBeUndefined()
+})
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 10fc513e72..6b7ec5187d 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -1557,6 +1557,71 @@ test("project config overrides remote well-known config", async () => {
}
})
+test("wellknown URL with trailing slash is normalized", async () => {
+ const originalFetch = globalThis.fetch
+ let fetchedUrl: string | undefined
+ const mockFetch = mock((url: string | URL | Request) => {
+ const urlStr = url.toString()
+ if (urlStr.includes(".well-known/opencode")) {
+ fetchedUrl = urlStr
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ config: {
+ mcp: {
+ slack: {
+ type: "remote",
+ url: "https://slack.example.com/mcp",
+ enabled: true,
+ },
+ },
+ },
+ }),
+ { status: 200 },
+ ),
+ )
+ }
+ return originalFetch(url)
+ })
+ globalThis.fetch = mockFetch as unknown as typeof fetch
+
+ const originalAuthAll = Auth.all
+ Auth.all = mock(() =>
+ Promise.resolve({
+ "https://example.com/": {
+ type: "wellknown" as const,
+ key: "TEST_TOKEN",
+ token: "test-token",
+ },
+ }),
+ )
+
+ try {
+ await using tmp = await tmpdir({
+ git: true,
+ init: async (dir) => {
+ await Filesystem.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Config.get()
+ // Trailing slash should be stripped — no double slash in the fetch URL
+ expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
+ },
+ })
+ } finally {
+ globalThis.fetch = originalFetch
+ Auth.all = originalAuthAll
+ }
+})
+
describe("getPluginName", () => {
test("extracts name from file:// URL", () => {
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts
new file mode 100644
index 0000000000..369b9152ae
--- /dev/null
+++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts
@@ -0,0 +1,149 @@
+import { afterEach, describe, expect, mock, test } from "bun:test"
+import { Identifier } from "../../src/id/id"
+import { Hono } from "hono"
+import { tmpdir } from "../fixture/fixture"
+import { Project } from "../../src/project/project"
+import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
+import { Instance } from "../../src/project/instance"
+import { WorkspaceContext } from "../../src/control-plane/workspace-context"
+import { Database } from "../../src/storage/db"
+import { resetDatabase } from "../fixture/db"
+import * as adaptors from "../../src/control-plane/adaptors"
+import type { Adaptor } from "../../src/control-plane/types"
+
+afterEach(async () => {
+ mock.restore()
+ await resetDatabase()
+})
+
+type State = {
+ workspace?: "first" | "second"
+ calls: Array<{ method: string; url: string; body?: string }>
+}
+
+const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
+
+async function setup(state: State) {
+ const TestAdaptor: Adaptor = {
+ configure(config) {
+ return config
+ },
+ async create() {
+ throw new Error("not used")
+ },
+ async remove() {},
+
+ async fetch(_config: unknown, input: RequestInfo | URL, init?: RequestInit) {
+ const url =
+ input instanceof Request || input instanceof URL
+ ? input.toString()
+ : new URL(input, "http://workspace.test").toString()
+ const request = new Request(url, init)
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
+ state.calls.push({
+ method: request.method,
+ url: `${new URL(request.url).pathname}${new URL(request.url).search}`,
+ body,
+ })
+ return new Response("proxied", { status: 202 })
+ },
+ }
+
+ adaptors.installAdaptor("testing", TestAdaptor)
+
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const id1 = Identifier.descending("workspace")
+ const id2 = Identifier.descending("workspace")
+
+ Database.use((db) =>
+ db
+ .insert(WorkspaceTable)
+ .values([
+ {
+ id: id1,
+ branch: "main",
+ project_id: project.id,
+ type: remote.type,
+ name: remote.name,
+ },
+ {
+ id: id2,
+ branch: "main",
+ project_id: project.id,
+ type: "worktree",
+ directory: tmp.path,
+ name: "local",
+ },
+ ])
+ .run(),
+ )
+
+ const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware")
+ const app = new Hono().use(WorkspaceRouterMiddleware)
+
+ return {
+ id1,
+ id2,
+ app,
+ async request(input: RequestInfo | URL, init?: RequestInit) {
+ return Instance.provide({
+ directory: tmp.path,
+ fn: async () =>
+ WorkspaceContext.provide({
+ workspaceID: state.workspace === "first" ? id1 : id2,
+ fn: () => app.request(input, init),
+ }),
+ })
+ },
+ }
+}
+
+describe("control-plane/session-proxy-middleware", () => {
+ test("forwards non-GET session requests for workspaces", async () => {
+ const state: State = {
+ workspace: "first",
+ calls: [],
+ }
+
+ const ctx = await setup(state)
+
+ ctx.app.post("/session/foo", (c) => c.text("local", 200))
+ const response = await ctx.request("http://workspace.test/session/foo?x=1", {
+ method: "POST",
+ body: JSON.stringify({ hello: "world" }),
+ headers: {
+ "content-type": "application/json",
+ },
+ })
+
+ expect(response.status).toBe(202)
+ expect(await response.text()).toBe("proxied")
+ expect(state.calls).toEqual([
+ {
+ method: "POST",
+ url: "/session/foo?x=1",
+ body: '{"hello":"world"}',
+ },
+ ])
+ })
+
+ // It will behave this way when we have syncing
+ //
+ // test("does not forward GET requests", async () => {
+ // const state: State = {
+ // workspace: "first",
+ // calls: [],
+ // }
+
+ // const ctx = await setup(state)
+
+ // ctx.app.get("/session/foo", (c) => c.text("local", 200))
+ // const response = await ctx.request("http://workspace.test/session/foo?x=1")
+
+ // expect(response.status).toBe(200)
+ // expect(await response.text()).toBe("local")
+ // expect(state.calls).toEqual([])
+ // })
+})
diff --git a/packages/opencode/test/control-plane/sse.test.ts b/packages/opencode/test/control-plane/sse.test.ts
new file mode 100644
index 0000000000..78a8341c0e
--- /dev/null
+++ b/packages/opencode/test/control-plane/sse.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { parseSSE } from "../../src/control-plane/sse"
+import { resetDatabase } from "../fixture/db"
+
+afterEach(async () => {
+ await resetDatabase()
+})
+
+function stream(chunks: string[]) {
+ return new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder()
+ chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk)))
+ controller.close()
+ },
+ })
+}
+
+describe("control-plane/sse", () => {
+ test("parses JSON events with CRLF and multiline data blocks", async () => {
+ const events: unknown[] = []
+ const stop = new AbortController()
+
+ await parseSSE(
+ stream([
+ 'data: {"type":"one","properties":{"ok":true}}\r\n\r\n',
+ 'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n',
+ ]),
+ stop.signal,
+ (event) => events.push(event),
+ )
+
+ expect(events).toEqual([
+ { type: "one", properties: { ok: true } },
+ { type: "two", properties: { n: 2 } },
+ ])
+ })
+
+ test("falls back to sse.message for non-json payload", async () => {
+ const events: unknown[] = []
+ const stop = new AbortController()
+
+ await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event))
+
+ expect(events).toEqual([
+ {
+ type: "sse.message",
+ properties: {
+ data: "hello world",
+ id: "abc",
+ retry: 1500,
+ },
+ },
+ ])
+ })
+})
diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts
new file mode 100644
index 0000000000..7e7cddb140
--- /dev/null
+++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Log } from "../../src/util/log"
+import { WorkspaceServer } from "../../src/control-plane/workspace-server/server"
+import { parseSSE } from "../../src/control-plane/sse"
+import { GlobalBus } from "../../src/bus/global"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+afterEach(async () => {
+ await resetDatabase()
+})
+
+Log.init({ print: false })
+
+describe("control-plane/workspace-server SSE", () => {
+ test("streams GlobalBus events and parseSSE reads them", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const app = WorkspaceServer.App()
+ const stop = new AbortController()
+ const seen: unknown[] = []
+ try {
+ const response = await app.request("/event", {
+ signal: stop.signal,
+ headers: {
+ "x-opencode-workspace": "wrk_test_workspace",
+ "x-opencode-directory": tmp.path,
+ },
+ })
+
+ expect(response.status).toBe(200)
+ expect(response.body).toBeDefined()
+
+ const done = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error("timed out waiting for workspace.test event"))
+ }, 3000)
+
+ void parseSSE(response.body!, stop.signal, (event) => {
+ seen.push(event)
+ const next = event as { type?: string }
+ if (next.type === "server.connected") {
+ GlobalBus.emit("event", {
+ payload: {
+ type: "workspace.test",
+ properties: { ok: true },
+ },
+ })
+ return
+ }
+ if (next.type !== "workspace.test") return
+ clearTimeout(timeout)
+ resolve()
+ }).catch((error) => {
+ clearTimeout(timeout)
+ reject(error)
+ })
+ })
+
+ await done
+
+ expect(seen.some((event) => (event as { type?: string }).type === "server.connected")).toBe(true)
+ expect(seen).toContainEqual({
+ type: "workspace.test",
+ properties: { ok: true },
+ })
+ } finally {
+ stop.abort()
+ }
+ })
+})
diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts
new file mode 100644
index 0000000000..899118920f
--- /dev/null
+++ b/packages/opencode/test/control-plane/workspace-sync.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, describe, expect, mock, test } from "bun:test"
+import { Identifier } from "../../src/id/id"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+import { Project } from "../../src/project/project"
+import { Database } from "../../src/storage/db"
+import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
+import { GlobalBus } from "../../src/bus/global"
+import { resetDatabase } from "../fixture/db"
+import * as adaptors from "../../src/control-plane/adaptors"
+import type { Adaptor } from "../../src/control-plane/types"
+
+afterEach(async () => {
+ mock.restore()
+ await resetDatabase()
+})
+
+Log.init({ print: false })
+
+const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
+
+const TestAdaptor: Adaptor = {
+ configure(config) {
+ return config
+ },
+ async create() {
+ throw new Error("not used")
+ },
+ async remove() {},
+ async fetch(_config: unknown, _input: RequestInfo | URL, _init?: RequestInit) {
+ const body = new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder()
+ controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n'))
+ controller.close()
+ },
+ })
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "content-type": "text/event-stream",
+ },
+ })
+ },
+}
+
+adaptors.installAdaptor("testing", TestAdaptor)
+
+describe("control-plane/workspace.startSyncing", () => {
+ test("syncs only remote workspaces and emits remote SSE events", async () => {
+ const { Workspace } = await import("../../src/control-plane/workspace")
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const id1 = Identifier.descending("workspace")
+ const id2 = Identifier.descending("workspace")
+
+ Database.use((db) =>
+ db
+ .insert(WorkspaceTable)
+ .values([
+ {
+ id: id1,
+ branch: "main",
+ project_id: project.id,
+ type: remote.type,
+ name: remote.name,
+ },
+ {
+ id: id2,
+ branch: "main",
+ project_id: project.id,
+ type: "worktree",
+ directory: tmp.path,
+ name: "local",
+ },
+ ])
+ .run(),
+ )
+
+ const done = new Promise((resolve) => {
+ const listener = (event: { directory?: string; payload: { type: string } }) => {
+ if (event.directory !== id1) return
+ if (event.payload.type !== "remote.ready") return
+ GlobalBus.off("event", listener)
+ resolve()
+ }
+ GlobalBus.on("event", listener)
+ })
+
+ const sync = Workspace.startSyncing(project)
+ await Promise.race([
+ done,
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for sync event")), 2000)),
+ ])
+
+ await sync.stop()
+ })
+})
diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts
new file mode 100644
index 0000000000..f11f0b9036
--- /dev/null
+++ b/packages/opencode/test/fixture/db.ts
@@ -0,0 +1,11 @@
+import { rm } from "fs/promises"
+import { Instance } from "../../src/project/instance"
+import { Database } from "../../src/storage/db"
+
+export async function resetDatabase() {
+ await Instance.disposeAll().catch(() => undefined)
+ Database.close()
+ await rm(Database.Path, { force: true }).catch(() => undefined)
+ await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined)
+ await rm(`${Database.Path}-shm`, { force: true }).catch(() => undefined)
+}
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index 41028633e8..caac3bb0de 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -3,6 +3,7 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
+import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
@@ -15,7 +16,7 @@ afterAll(async () => {
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise => {
Bun.gc(true)
- await Bun.sleep(100)
+ await sleep(100)
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
if (!busy(error)) throw error
if (left <= 1) throw error
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 0a5aa41513..11c943db6f 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -2218,3 +2218,64 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
},
})
})
+
+test("cloudflare-ai-gateway loads with env variables", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("CLOUDFLARE_ACCOUNT_ID", "test-account")
+ Env.set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
+ Env.set("CLOUDFLARE_API_TOKEN", "test-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["cloudflare-ai-gateway"]).toBeDefined()
+ },
+ })
+})
+
+test("cloudflare-ai-gateway forwards config metadata options", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(
+ path.join(dir, "opencode.json"),
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ provider: {
+ "cloudflare-ai-gateway": {
+ options: {
+ metadata: { invoked_by: "test", project: "opencode" },
+ },
+ },
+ },
+ }),
+ )
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ init: async () => {
+ Env.set("CLOUDFLARE_ACCOUNT_ID", "test-account")
+ Env.set("CLOUDFLARE_GATEWAY_ID", "test-gateway")
+ Env.set("CLOUDFLARE_API_TOKEN", "test-token")
+ },
+ fn: async () => {
+ const providers = await Provider.list()
+ expect(providers["cloudflare-ai-gateway"]).toBeDefined()
+ expect(providers["cloudflare-ai-gateway"].options.metadata).toEqual({
+ invoked_by: "test",
+ project: "opencode",
+ })
+ },
+ })
+})
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 189bdfd32b..2329846351 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -510,6 +510,106 @@ describe("ProviderTransform.schema - gemini nested array items", () => {
})
})
+describe("ProviderTransform.schema - gemini combiner nodes", () => {
+ const geminiModel = {
+ providerID: "google",
+ api: {
+ id: "gemini-3-pro",
+ },
+ } as any
+
+ const walk = (node: any, cb: (node: any, path: (string | number)[]) => void, path: (string | number)[] = []) => {
+ if (node === null || typeof node !== "object") {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach((item, i) => walk(item, cb, [...path, i]))
+ return
+ }
+ cb(node, path)
+ Object.entries(node).forEach(([key, value]) => walk(value, cb, [...path, key]))
+ }
+
+ test("keeps edits.items.anyOf without adding type", () => {
+ const schema = {
+ type: "object",
+ properties: {
+ edits: {
+ type: "array",
+ items: {
+ anyOf: [
+ {
+ type: "object",
+ properties: {
+ old_string: { type: "string" },
+ new_string: { type: "string" },
+ },
+ required: ["old_string", "new_string"],
+ },
+ {
+ type: "object",
+ properties: {
+ old_string: { type: "string" },
+ new_string: { type: "string" },
+ replace_all: { type: "boolean" },
+ },
+ required: ["old_string", "new_string"],
+ },
+ ],
+ },
+ },
+ },
+ required: ["edits"],
+ } as any
+
+ const result = ProviderTransform.schema(geminiModel, schema) as any
+
+ expect(Array.isArray(result.properties.edits.items.anyOf)).toBe(true)
+ expect(result.properties.edits.items.type).toBeUndefined()
+ })
+
+ test("does not add sibling keys to combiner nodes during sanitize", () => {
+ const schema = {
+ type: "object",
+ properties: {
+ edits: {
+ type: "array",
+ items: {
+ anyOf: [{ type: "string" }, { type: "number" }],
+ },
+ },
+ value: {
+ oneOf: [{ type: "string" }, { type: "boolean" }],
+ },
+ meta: {
+ allOf: [
+ {
+ type: "object",
+ properties: { a: { type: "string" } },
+ },
+ {
+ type: "object",
+ properties: { b: { type: "string" } },
+ },
+ ],
+ },
+ },
+ } as any
+ const input = JSON.parse(JSON.stringify(schema))
+ const result = ProviderTransform.schema(geminiModel, schema) as any
+
+ walk(result, (node, path) => {
+ const hasCombiner = Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf)
+ if (!hasCombiner) {
+ return
+ }
+ const before = path.reduce((acc: any, key) => acc?.[key], input)
+ const added = Object.keys(node).filter((key) => !(key in before))
+ expect(added).toEqual([])
+ })
+ })
+})
+
describe("ProviderTransform.schema - gemini non-object properties removal", () => {
const geminiModel = {
providerID: "google",
diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts
index 44858a0ed2..ec1bbd4690 100644
--- a/packages/opencode/test/pty/pty-output-isolation.test.ts
+++ b/packages/opencode/test/pty/pty-output-isolation.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
+import { setTimeout as sleep } from "node:timers/promises"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
@@ -43,7 +44,7 @@ describe("pty", () => {
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
- await Bun.sleep(100)
+ await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -88,7 +89,7 @@ describe("pty", () => {
}
Pty.write(a.id, "AAA\n")
- await Bun.sleep(100)
+ await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -128,7 +129,7 @@ describe("pty", () => {
ctx.connId = 2
Pty.write(a.id, "AAA\n")
- await Bun.sleep(100)
+ await sleep(100)
expect(out.join("")).toContain("AAA")
} finally {
diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts
index 6768e72d95..eba4a99505 100644
--- a/packages/opencode/test/session/retry.test.ts
+++ b/packages/opencode/test/session/retry.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
+import { setTimeout as sleep } from "node:timers/promises"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
- await Bun.sleep(10000)
+ await sleep(10000)
controller.enqueue(" World!")
controller.close()
},
diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts
index 219cef1271..aa9ca05d04 100644
--- a/packages/opencode/test/session/session.test.ts
+++ b/packages/opencode/test/session/session.test.ts
@@ -4,6 +4,8 @@ import { Session } from "../../src/session"
import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Identifier } from "../../src/id/id"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
@@ -69,3 +71,72 @@ describe("session.started event", () => {
})
})
})
+
+describe("step-finish token propagation via Bus event", () => {
+ test(
+ "non-zero tokens propagate through PartUpdated event",
+ async () => {
+ await Instance.provide({
+ directory: projectRoot,
+ fn: async () => {
+ const session = await Session.create({})
+
+ const messageID = Identifier.ascending("message")
+ await Session.updateMessage({
+ id: messageID,
+ sessionID: session.id,
+ role: "user",
+ time: { created: Date.now() },
+ agent: "user",
+ model: { providerID: "test", modelID: "test" },
+ tools: {},
+ mode: "",
+ } as unknown as MessageV2.Info)
+
+ let received: MessageV2.Part | undefined
+ const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
+ received = event.properties.part
+ })
+
+ const tokens = {
+ total: 1500,
+ input: 500,
+ output: 800,
+ reasoning: 200,
+ cache: { read: 100, write: 50 },
+ }
+
+ const partInput = {
+ id: Identifier.ascending("part"),
+ messageID,
+ sessionID: session.id,
+ type: "step-finish" as const,
+ reason: "stop",
+ cost: 0.005,
+ tokens,
+ }
+
+ await Session.updatePart(partInput)
+
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ expect(received).toBeDefined()
+ expect(received!.type).toBe("step-finish")
+ const finish = received as MessageV2.StepFinishPart
+ expect(finish.tokens.input).toBe(500)
+ expect(finish.tokens.output).toBe(800)
+ expect(finish.tokens.reasoning).toBe(200)
+ expect(finish.tokens.total).toBe(1500)
+ expect(finish.tokens.cache.read).toBe(100)
+ expect(finish.tokens.cache.write).toBe(50)
+ expect(finish.cost).toBe(0.005)
+ expect(received).not.toBe(partInput)
+
+ unsub()
+ await Session.remove(session.id)
+ },
+ })
+ },
+ { timeout: 30000 },
+ )
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index e476c41e2f..d8911922ec 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.2.15",
+ "version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts
index 1f39d31388..ee4bc1e465 100644
--- a/packages/script/src/index.ts
+++ b/packages/script/src/index.ts
@@ -46,23 +46,14 @@ const VERSION = await (async () => {
return `${major}.${minor}.${patch + 1}`
})()
+const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
+const teamPath = path.resolve(import.meta.dir, "../../../.github/TEAM_MEMBERS")
const team = [
- "actions-user",
- "opencode",
- "rekram1-node",
- "thdxr",
- "kommander",
- "jayair",
- "fwang",
- "MrMushrooooom",
- "adamdotdevin",
- "iamdavidhill",
- "Brendonovich",
- "nexxeln",
- "Hona",
- "jlongster",
- "opencode-agent[bot]",
- "R44VC0RP",
+ ...(await Bun.file(teamPath)
+ .text()
+ .then((x) => x.split(/\r?\n/).map((x) => x.trim()))
+ .then((x) => x.filter((x) => x && !x.startsWith("#")))),
+ ...bot,
]
export const Script = {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index ffbdf21982..fe3ce393d6 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.2.15",
+ "version": "1.2.19",
"type": "module",
"license": "MIT",
"scripts": {
@@ -12,18 +12,9 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts",
- "./v2": {
- "types": "./dist/v2/index.d.ts",
- "default": "./src/v2/index.ts"
- },
- "./v2/client": {
- "types": "./dist/v2/client.d.ts",
- "default": "./src/v2/client.ts"
- },
- "./v2/gen/client": {
- "types": "./dist/v2/gen/client/index.d.ts",
- "default": "./src/v2/gen/client/index.ts"
- },
+ "./v2": "./src/v2/index.ts",
+ "./v2/client": "./src/v2/client.ts",
+ "./v2/gen/client": "./src/v2/gen/client/index.ts",
"./v2/server": "./src/v2/server.ts"
},
"files": [
@@ -36,8 +27,5 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
},
- "dependencies": {},
- "publishConfig": {
- "directory": "dist"
- }
+ "dependencies": {}
}
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 6165c0f7b0..1c1b31e46f 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -26,6 +26,11 @@ import type {
EventTuiToastShow,
ExperimentalResourceListResponses,
ExperimentalSessionListResponses,
+ ExperimentalWorkspaceCreateErrors,
+ ExperimentalWorkspaceCreateResponses,
+ ExperimentalWorkspaceListResponses,
+ ExperimentalWorkspaceRemoveErrors,
+ ExperimentalWorkspaceRemoveResponses,
FileListResponses,
FilePartInput,
FilePartSource,
@@ -368,10 +373,21 @@ export class Project extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/project",
...options,
@@ -387,10 +403,21 @@ export class Project extends HeyApiClient {
public current(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/project/current",
...options,
@@ -407,6 +434,7 @@ export class Project extends HeyApiClient {
parameters: {
projectID: string
directory?: string
+ workspace?: string
name?: string
icon?: {
url?: string
@@ -429,6 +457,7 @@ export class Project extends HeyApiClient {
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "name" },
{ in: "body", key: "icon" },
{ in: "body", key: "commands" },
@@ -458,10 +487,21 @@ export class Pty extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/pty",
...options,
@@ -477,6 +517,7 @@ export class Pty extends HeyApiClient {
public create(
parameters?: {
directory?: string
+ workspace?: string
command?: string
args?: Array
cwd?: string
@@ -493,6 +534,7 @@ export class Pty extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "command" },
{ in: "body", key: "args" },
{ in: "body", key: "cwd" },
@@ -523,6 +565,7 @@ export class Pty extends HeyApiClient {
parameters: {
ptyID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -533,6 +576,7 @@ export class Pty extends HeyApiClient {
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -553,6 +597,7 @@ export class Pty extends HeyApiClient {
parameters: {
ptyID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -563,6 +608,7 @@ export class Pty extends HeyApiClient {
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -583,6 +629,7 @@ export class Pty extends HeyApiClient {
parameters: {
ptyID: string
directory?: string
+ workspace?: string
title?: string
size?: {
rows: number
@@ -598,6 +645,7 @@ export class Pty extends HeyApiClient {
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "title" },
{ in: "body", key: "size" },
],
@@ -625,6 +673,7 @@ export class Pty extends HeyApiClient {
parameters: {
ptyID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -635,6 +684,7 @@ export class Pty extends HeyApiClient {
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -656,10 +706,21 @@ export class Config2 extends HeyApiClient {
public get(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/config",
...options,
@@ -675,6 +736,7 @@ export class Config2 extends HeyApiClient {
public update(
parameters?: {
directory?: string
+ workspace?: string
config?: Config3
},
options?: Options,
@@ -685,6 +747,7 @@ export class Config2 extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ key: "config", map: "body" },
],
},
@@ -710,10 +773,21 @@ export class Config2 extends HeyApiClient {
public providers(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/config/providers",
...options,
@@ -731,10 +805,21 @@ export class Tool extends HeyApiClient {
public ids(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/experimental/tool/ids",
...options,
@@ -750,6 +835,7 @@ export class Tool extends HeyApiClient {
public list(
parameters: {
directory?: string
+ workspace?: string
provider: string
model: string
},
@@ -761,6 +847,7 @@ export class Tool extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "provider" },
{ in: "query", key: "model" },
],
@@ -775,70 +862,50 @@ export class Tool extends HeyApiClient {
}
}
-export class Worktree extends HeyApiClient {
+export class Workspace extends HeyApiClient {
/**
- * Remove worktree
+ * List workspaces
*
- * Remove a git worktree and delete its branch.
- */
- public remove(
- parameters?: {
- directory?: string
- worktreeRemoveInput?: WorktreeRemoveInput
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { key: "worktreeRemoveInput", map: "body" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).delete({
- url: "/experimental/worktree",
- ...options,
- ...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
- })
- }
-
- /**
- * List worktrees
- *
- * List all sandbox worktrees for the current project.
+ * List all workspaces.
*/
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
- return (options?.client ?? this.client).get({
- url: "/experimental/worktree",
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/experimental/workspace",
...options,
...params,
})
}
/**
- * Create worktree
+ * Create workspace
*
- * Create a new git worktree for the current project and run any configured startup scripts.
+ * Create a workspace for the current project.
*/
public create(
parameters?: {
directory?: string
- worktreeCreateInput?: WorktreeCreateInput
+ workspace?: string
+ id?: string
+ type?: string
+ branch?: string | null
+ extra?: unknown | null
},
options?: Options,
) {
@@ -848,13 +915,21 @@ export class Worktree extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
- { key: "worktreeCreateInput", map: "body" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "id" },
+ { in: "body", key: "type" },
+ { in: "body", key: "branch" },
+ { in: "body", key: "extra" },
],
},
],
)
- return (options?.client ?? this.client).post({
- url: "/experimental/worktree",
+ return (options?.client ?? this.client).post<
+ ExperimentalWorkspaceCreateResponses,
+ ExperimentalWorkspaceCreateErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace",
...options,
...params,
headers: {
@@ -866,14 +941,15 @@ export class Worktree extends HeyApiClient {
}
/**
- * Reset worktree
+ * Remove workspace
*
- * Reset a worktree branch to the primary default branch.
+ * Remove an existing workspace.
*/
- public reset(
- parameters?: {
+ public remove(
+ parameters: {
+ id: string
directory?: string
- worktreeResetInput?: WorktreeResetInput
+ workspace?: string
},
options?: Options,
) {
@@ -882,21 +958,21 @@ export class Worktree extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "id" },
{ in: "query", key: "directory" },
- { key: "worktreeResetInput", map: "body" },
+ { in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).post({
- url: "/experimental/worktree/reset",
+ return (options?.client ?? this.client).delete<
+ ExperimentalWorkspaceRemoveResponses,
+ ExperimentalWorkspaceRemoveErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace/{id}",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
}
@@ -910,6 +986,7 @@ export class Session extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
roots?: boolean
start?: number
cursor?: number
@@ -925,6 +1002,7 @@ export class Session extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "cursor" },
@@ -952,10 +1030,21 @@ export class Resource extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/experimental/resource",
...options,
@@ -965,6 +1054,11 @@ export class Resource extends HeyApiClient {
}
export class Experimental extends HeyApiClient {
+ private _workspace?: Workspace
+ get workspace(): Workspace {
+ return (this._workspace ??= new Workspace({ client: this.client }))
+ }
+
private _session?: Session
get session(): Session {
return (this._session ??= new Session({ client: this.client }))
@@ -976,6 +1070,149 @@ export class Experimental extends HeyApiClient {
}
}
+export class Worktree extends HeyApiClient {
+ /**
+ * Remove worktree
+ *
+ * Remove a git worktree and delete its branch.
+ */
+ public remove(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ worktreeRemoveInput?: WorktreeRemoveInput
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { key: "worktreeRemoveInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).delete({
+ url: "/experimental/worktree",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * List worktrees
+ *
+ * List all sandbox worktrees for the current project.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/experimental/worktree",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Create worktree
+ *
+ * Create a new git worktree for the current project and run any configured startup scripts.
+ */
+ public create(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ worktreeCreateInput?: WorktreeCreateInput
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { key: "worktreeCreateInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/experimental/worktree",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Reset worktree
+ *
+ * Reset a worktree branch to the primary default branch.
+ */
+ public reset(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ worktreeResetInput?: WorktreeResetInput
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { key: "worktreeResetInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/experimental/worktree/reset",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Session2 extends HeyApiClient {
/**
* List sessions
@@ -985,6 +1222,7 @@ export class Session2 extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
roots?: boolean
start?: number
search?: string
@@ -998,6 +1236,7 @@ export class Session2 extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "search" },
@@ -1021,6 +1260,7 @@ export class Session2 extends HeyApiClient {
public create(
parameters?: {
directory?: string
+ workspace?: string
parentID?: string
title?: string
permission?: PermissionRuleset
@@ -1033,6 +1273,7 @@ export class Session2 extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "parentID" },
{ in: "body", key: "title" },
{ in: "body", key: "permission" },
@@ -1060,10 +1301,21 @@ export class Session2 extends HeyApiClient {
public status(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/session/status",
...options,
@@ -1080,6 +1332,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1090,6 +1343,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1110,6 +1364,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1120,6 +1375,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1140,6 +1396,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
title?: string
time?: {
archived?: number
@@ -1154,6 +1411,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "title" },
{ in: "body", key: "time" },
],
@@ -1181,6 +1439,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1191,6 +1450,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1211,6 +1471,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1221,6 +1482,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1241,6 +1503,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
modelID?: string
providerID?: string
messageID?: string
@@ -1254,6 +1517,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "modelID" },
{ in: "body", key: "providerID" },
{ in: "body", key: "messageID" },
@@ -1282,6 +1546,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
},
options?: Options,
@@ -1293,6 +1558,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
],
},
@@ -1319,6 +1585,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1329,6 +1596,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1349,6 +1617,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1359,6 +1628,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1379,6 +1649,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1389,6 +1660,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1409,6 +1681,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
},
options?: Options,
@@ -1420,6 +1693,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "messageID" },
],
},
@@ -1441,6 +1715,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
providerID?: string
modelID?: string
auto?: boolean
@@ -1454,6 +1729,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "providerID" },
{ in: "body", key: "modelID" },
{ in: "body", key: "auto" },
@@ -1482,6 +1758,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
limit?: number
},
options?: Options,
@@ -1493,6 +1770,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "limit" },
],
},
@@ -1514,6 +1792,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
model?: {
providerID: string
@@ -1538,6 +1817,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "model" },
{ in: "body", key: "agent" },
@@ -1573,6 +1853,7 @@ export class Session2 extends HeyApiClient {
sessionID: string
messageID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1584,6 +1865,7 @@ export class Session2 extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1609,6 +1891,7 @@ export class Session2 extends HeyApiClient {
sessionID: string
messageID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1620,6 +1903,7 @@ export class Session2 extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "path", key: "messageID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1640,6 +1924,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
model?: {
providerID: string
@@ -1664,6 +1949,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "model" },
{ in: "body", key: "agent" },
@@ -1698,6 +1984,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
agent?: string
model?: string
@@ -1722,6 +2009,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "agent" },
{ in: "body", key: "model" },
@@ -1754,6 +2042,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
agent?: string
model?: {
providerID: string
@@ -1770,6 +2059,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "agent" },
{ in: "body", key: "model" },
{ in: "body", key: "command" },
@@ -1798,6 +2088,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
messageID?: string
partID?: string
},
@@ -1810,6 +2101,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "messageID" },
{ in: "body", key: "partID" },
],
@@ -1837,6 +2129,7 @@ export class Session2 extends HeyApiClient {
parameters: {
sessionID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1847,6 +2140,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1869,6 +2163,7 @@ export class Part extends HeyApiClient {
messageID: string
partID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -1881,6 +2176,7 @@ export class Part extends HeyApiClient {
{ in: "path", key: "messageID" },
{ in: "path", key: "partID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -1901,6 +2197,7 @@ export class Part extends HeyApiClient {
messageID: string
partID: string
directory?: string
+ workspace?: string
part?: Part2
},
options?: Options,
@@ -1914,6 +2211,7 @@ export class Part extends HeyApiClient {
{ in: "path", key: "messageID" },
{ in: "path", key: "partID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ key: "part", map: "body" },
],
},
@@ -1945,6 +2243,7 @@ export class Permission extends HeyApiClient {
sessionID: string
permissionID: string
directory?: string
+ workspace?: string
response?: "once" | "always" | "reject"
},
options?: Options,
@@ -1957,6 +2256,7 @@ export class Permission extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "path", key: "permissionID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "response" },
],
},
@@ -1983,6 +2283,7 @@ export class Permission extends HeyApiClient {
parameters: {
requestID: string
directory?: string
+ workspace?: string
reply?: "once" | "always" | "reject"
message?: string
},
@@ -1995,6 +2296,7 @@ export class Permission extends HeyApiClient {
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "reply" },
{ in: "body", key: "message" },
],
@@ -2021,10 +2323,21 @@ export class Permission extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/permission",
...options,
@@ -2042,10 +2355,21 @@ export class Question extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/question",
...options,
@@ -2062,6 +2386,7 @@ export class Question extends HeyApiClient {
parameters: {
requestID: string
directory?: string
+ workspace?: string
answers?: Array
},
options?: Options,
@@ -2073,6 +2398,7 @@ export class Question extends HeyApiClient {
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "answers" },
],
},
@@ -2099,6 +2425,7 @@ export class Question extends HeyApiClient {
parameters: {
requestID: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -2109,6 +2436,7 @@ export class Question extends HeyApiClient {
args: [
{ in: "path", key: "requestID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -2131,6 +2459,7 @@ export class Oauth extends HeyApiClient {
parameters: {
providerID: string
directory?: string
+ workspace?: string
method?: number
},
options?: Options,
@@ -2142,6 +2471,7 @@ export class Oauth extends HeyApiClient {
args: [
{ in: "path", key: "providerID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "method" },
],
},
@@ -2172,6 +2502,7 @@ export class Oauth extends HeyApiClient {
parameters: {
providerID: string
directory?: string
+ workspace?: string
method?: number
code?: string
},
@@ -2184,6 +2515,7 @@ export class Oauth extends HeyApiClient {
args: [
{ in: "path", key: "providerID" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "method" },
{ in: "body", key: "code" },
],
@@ -2216,10 +2548,21 @@ export class Provider extends HeyApiClient {
public list(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/provider",
...options,
@@ -2235,10 +2578,21 @@ export class Provider extends HeyApiClient {
public auth(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/provider/auth",
...options,
@@ -2261,6 +2615,7 @@ export class Find extends HeyApiClient {
public text(
parameters: {
directory?: string
+ workspace?: string
pattern: string
},
options?: Options,
@@ -2271,6 +2626,7 @@ export class Find extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "pattern" },
],
},
@@ -2291,6 +2647,7 @@ export class Find extends HeyApiClient {
public files(
parameters: {
directory?: string
+ workspace?: string
query: string
dirs?: "true" | "false"
type?: "file" | "directory"
@@ -2304,6 +2661,7 @@ export class Find extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "query" },
{ in: "query", key: "dirs" },
{ in: "query", key: "type" },
@@ -2327,6 +2685,7 @@ export class Find extends HeyApiClient {
public symbols(
parameters: {
directory?: string
+ workspace?: string
query: string
},
options?: Options,
@@ -2337,6 +2696,7 @@ export class Find extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "query" },
],
},
@@ -2359,6 +2719,7 @@ export class File extends HeyApiClient {
public list(
parameters: {
directory?: string
+ workspace?: string
path: string
},
options?: Options,
@@ -2369,6 +2730,7 @@ export class File extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "path" },
],
},
@@ -2389,6 +2751,7 @@ export class File extends HeyApiClient {
public read(
parameters: {
directory?: string
+ workspace?: string
path: string
},
options?: Options,
@@ -2399,6 +2762,7 @@ export class File extends HeyApiClient {
{
args: [
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "query", key: "path" },
],
},
@@ -2419,10 +2783,21 @@ export class File extends HeyApiClient {
public status(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
return (options?.client ?? this.client).get({
url: "/file/status",
...options,
@@ -2441,6 +2816,7 @@ export class Auth2 extends HeyApiClient {
parameters: {
name: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -2451,6 +2827,7 @@ export class Auth2 extends HeyApiClient {
args: [
{ in: "path", key: "name" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -2471,6 +2848,7 @@ export class Auth2 extends HeyApiClient {
parameters: {
name: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -2481,6 +2859,7 @@ export class Auth2 extends HeyApiClient {
args: [
{ in: "path", key: "name" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -2501,6 +2880,7 @@ export class Auth2 extends HeyApiClient {
parameters: {
name: string
directory?: string
+ workspace?: string
code?: string
},
options?: Options,
@@ -2512,6 +2892,7 @@ export class Auth2 extends HeyApiClient {
args: [
{ in: "path", key: "name" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
{ in: "body", key: "code" },
],
},
@@ -2538,6 +2919,7 @@ export class Auth2 extends HeyApiClient {
parameters: {
name: string
directory?: string
+ workspace?: string
},
options?: Options,
) {
@@ -2548,6 +2930,7 @@ export class Auth2 extends HeyApiClient {
args: [
{ in: "path", key: "name" },
{ in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
],
},
],
@@ -2571,10 +2954,21 @@ export class Mcp extends HeyApiClient {
public status(
parameters?: {
directory?: string
+ workspace?: string
},
options?: Options