merge dev into implement-background-agents

This commit is contained in:
Shoubhit Dash
2026-03-05 21:37:14 +05:30
794 changed files with 36555 additions and 9363 deletions
@@ -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
);
@@ -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": []
}
@@ -0,0 +1,2 @@
ALTER TABLE `session` ADD `workspace_id` text;--> statement-breakpoint
CREATE INDEX `session_workspace_idx` ON `session` (`workspace_id`);
@@ -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": []
}
@@ -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`;
@@ -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": []
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.15",
"version": "1.2.17",
"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",
+6 -1
View File
@@ -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)
}
}
+84 -47
View File
@@ -20,10 +20,19 @@ type PluginAuth = NonNullable<Hooks["auth"]>
* 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<boolean> {
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
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,8 +41,8 @@ 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]
@@ -252,10 +261,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 +283,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 +300,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 +342,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
}
+8 -1
View File
@@ -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) =>
+39
View File
@@ -555,6 +555,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(
+4
View File
@@ -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()
},
@@ -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"
@@ -178,7 +180,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)
@@ -252,6 +254,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()
@@ -263,7 +267,6 @@ export function Session() {
const logo = UI.logo(" ").split(/\r?\n/)
return exit.message.set(
[
``,
`${logo[0] ?? ""}`,
`${logo[1] ?? ""}`,
`${logo[2] ?? ""}`,
@@ -338,19 +341,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(() => [
{
@@ -910,24 +934,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()
},
},
@@ -937,7 +950,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({
@@ -946,7 +960,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()
}),
},
])
@@ -997,9 +1035,6 @@ export function Session() {
}
})
const dialog = useDialog()
const renderer = useRenderer()
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
@@ -1340,6 +1375,8 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
const keybind = useKeybind()
return (
<>
<For each={props.parts}>
@@ -1357,6 +1394,14 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
</For>
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
{keybind.print("session_child_first")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</box>
</Show>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<box
border={["left"]}
@@ -1626,13 +1671,17 @@ function InlineTool(props: {
iconColor?: RGBA
complete: any
pending: string
spinner?: boolean
children: JSX.Element
part: ToolPart
onClick?: () => 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
@@ -1642,6 +1691,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
})
@@ -1659,6 +1709,12 @@ function InlineTool(props: {
<box
marginTop={margin()}
paddingLeft={3}
onMouseOver={() => 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
@@ -1682,11 +1738,18 @@ function InlineTool(props: {
}
}}
>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show>
</text>
<Switch>
<Match when={props.spinner}>
<Spinner color={fg()} children={props.children} />
</Match>
<Match when={true}>
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
</Show>
</text>
</Match>
</Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
@@ -1853,6 +1916,7 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) {
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 []
@@ -1862,7 +1926,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
})
return (
<>
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
<InlineTool
icon="→"
pending="Reading file..."
complete={props.input.filePath}
spinner={isRunning()}
part={props.part}
>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
@@ -1932,22 +2002,24 @@ function WebSearch(props: ToolProps<any>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const keybind = useKeybind()
const { navigate } = useRoute()
const sync = useSync()
const msgs = createMemo(() => {
const sessionID = props.metadata.sessionId
return sync.data.message[sessionID ?? ""] ?? []
onMount(() => {
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
sync.session.sync(props.metadata.sessionId)
})
const tools = createMemo(() =>
msgs().flatMap((msg) =>
const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? [])
const tools = createMemo(() => {
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 background = createMemo(() => props.metadata.background === true)
@@ -1966,8 +2038,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
})
const childRunning = createMemo(() => status()?.type === "busy" || status()?.type === "retry")
const latest = createMemo(() => {
const user = msgs().findLast((msg) => msg.role === "user")
const assistant = msgs().findLast((msg) => msg.role === "assistant")
const user = messages().findLast((msg) => msg.role === "user")
const assistant = messages().findLast((msg) => msg.role === "assistant")
return {
user,
assistant,
@@ -1990,55 +2062,51 @@ function Task(props: ToolProps<typeof TaskTool>) {
return "background task finished"
})
const isRunning = createMemo(() => props.part.state.status === "running" || childRunning())
const toolLabel = createMemo(() => `${childRunning() ? counts().done : counts().all} toolcalls`)
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 ""
const toolLabel = `${childRunning() ? counts().done : counts().all} toolcalls`
const content = [`Task ${props.input.description}`]
if (background()) content.push(`${statusLabel()}`)
if (isRunning() && tools().length > 0) {
const title = current() && (current()!.state as any).title
if (title) content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
else content.push(`${toolLabel}`)
}
if (props.part.state.status === "completed") {
content.push(`${toolLabel} · ${Locale.duration(duration())}`)
} else if (props.metadata.sessionId) {
content.push(`${keybind.print("session_child_cycle")} view subagents`)
}
return content.join("\n")
})
return (
<Switch>
<Match when={props.input.description || props.input.subagent_type}>
<BlockTool
title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
onClick={
props.metadata.sessionId
? () => navigate({ type: "session", sessionID: props.metadata.sessionId! })
: undefined
}
part={props.part}
spinner={isRunning()}
>
<box>
<text style={{ fg: theme.textMuted }}>
{props.input.description} ({toolLabel()})
</text>
<Show when={background()}>
<text style={{ fg: failed() ? theme.error : backgroundRunning() ? theme.warning : theme.textMuted }}>
{statusLabel()}
</text>
</Show>
<Show when={current()}>
{(item) => {
const title = item().state.status === "completed" ? (item().state as any).title : ""
return (
<text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(item().tool)} {title}
</text>
)
}}
</Show>
</box>
<Show when={props.metadata.sessionId}>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</Show>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
{props.input.subagent_type} Task {props.input.description}
</InlineTool>
</Match>
</Switch>
<InlineTool
icon="│"
spinner={isRunning()}
complete={props.input.description}
pending="Delegating..."
part={props.part}
onClick={() => {
if (props.metadata.sessionId) {
navigate({ type: "session", sessionID: props.metadata.sessionId })
}
}}
>
{content()}
</InlineTool>
)
}
@@ -2257,10 +2325,16 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
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<string, any>, omit?: string[]): string {
+88 -68
View File
@@ -5,8 +5,8 @@ import { type rpc } from "./worker"
import path from "path"
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 +45,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 Bun.stdin.text()
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 +111,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 +129,88 @@ export const TuiThreadCommand = cmd({
worker.onerror = (e) => {
Log.Default.error(e)
}
const client = Rpc.client<typeof rpc>(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<typeof rpc>(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?.()
}
+1 -6
View File
@@ -137,12 +137,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)
},
}
@@ -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()
},
})
+11 -9
View File
@@ -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("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
session_child_first: z.string().optional().default("<leader>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("<leader>h").describe("Toggle tips on home screen"),
@@ -0,0 +1,20 @@
import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
export function getAdaptor(type: string): Promise<Adaptor> {
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
}
@@ -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<typeof Config>
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)
},
}
@@ -0,0 +1,66 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
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()
}
}
@@ -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<typeof WorkspaceInfo>
export type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise<Response>
}
@@ -0,0 +1,23 @@
import { Context } from "../util/context"
interface Context {
workspaceID?: string
}
const context = Context.create<Context>("workspace")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: string; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, async () => {
return input.fn()
})
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (e) {
return undefined
}
},
}
@@ -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()
}
@@ -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<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
})
})
})
}
@@ -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,
})
}
}
@@ -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" }),
})
@@ -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<typeof Info>
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()
},
}
}
}
+6 -1
View File
@@ -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"]
+1
View File
@@ -11,6 +11,7 @@ export namespace Identifier {
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function schema(prefix: keyof typeof prefixes) {
+6
View File
@@ -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,
+37
View File
@@ -160,6 +160,28 @@ export namespace MCP {
return typeof entry === "object" && entry !== null && "type" in entry
}
async function descendants(pid: number): Promise<number[]> {
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) => {
+14 -1
View File
@@ -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*<!doctype|^\s*<html/i.test(e.responseBody)) {
if (e.statusCode === 401) {
return "Unauthorized: request was blocked by a gateway or proxy. Your authentication token may be missing or expired — try running `opencode auth login <your provider URL>` 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,
+22 -1
View File
@@ -555,7 +555,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 {
+29 -5
View File
@@ -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<string, any> =>
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
}
@@ -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({
@@ -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))
},
),
)
+23 -5
View File
@@ -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())
+90 -23
View File
@@ -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,
})
},
)
+10 -1
View File
@@ -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),
}),
)
})
+30 -14
View File
@@ -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<typeof WithParts>
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<string>()
// 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
+28 -20
View File
@@ -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)
+2
View File
@@ -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
+6 -1
View File
@@ -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(
+1
View File
@@ -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"
+1 -1
View File
@@ -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)
+18 -6
View File
@@ -331,7 +331,7 @@ export namespace Worktree {
}, 0)
}
export const create = fn(CreateInput.optional(), async (input) => {
export async function makeWorktreeInfo(name?: string): Promise<Info> {
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
})
+58
View File
@@ -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()
})
@@ -1535,6 +1535,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")
@@ -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([])
// })
})
@@ -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<Uint8Array>({
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,
},
},
])
})
})
@@ -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<void>((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()
}
})
})
@@ -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<Uint8Array>({
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<void>((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()
})
})
+11
View File
@@ -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)
}
@@ -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",
})
},
})
})
@@ -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",
@@ -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 },
)
})