From 937f9d29f823776f901df1ecb1b57745c88dc311 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:16:44 +0800 Subject: [PATCH 01/65] feat(session): add execution_context column to session table Persisted JSON shape for owner/active directory binding, plus optional active-worktree descriptor. Refs #278. --- .../migration.sql | 1 + .../snapshot.json | 1521 +++++++++++++++++ packages/opencode/src/session/session.sql.ts | 13 + 3 files changed, 1535 insertions(+) create mode 100644 packages/opencode/migration/20260501081615_session_execution_context/migration.sql create mode 100644 packages/opencode/migration/20260501081615_session_execution_context/snapshot.json diff --git a/packages/opencode/migration/20260501081615_session_execution_context/migration.sql b/packages/opencode/migration/20260501081615_session_execution_context/migration.sql new file mode 100644 index 00000000..3b62a6ca --- /dev/null +++ b/packages/opencode/migration/20260501081615_session_execution_context/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `session` ADD `execution_context` text DEFAULT 'null'; \ No newline at end of file diff --git a/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json b/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json new file mode 100644 index 00000000..d736229e --- /dev/null +++ b/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json @@ -0,0 +1,1521 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "626d5289-4bde-4d93-8814-e3679bc71542", + "prevIds": [ + "35a62e67-48b8-456c-b5a0-2753848fae2d" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "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": "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": "owner_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": 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_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "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": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "created_by_agent_tool", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subagent_type", + "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": false, + "autoincrement": false, + "default": "'null'", + "generated": null, + "name": "execution_context", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "skill", + "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" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "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": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "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": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "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": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "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_entry_pk", + "table": "session_entry", + "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": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_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": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "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": [] +} \ No newline at end of file diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 3e82ce49..70443202 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -26,6 +26,19 @@ export const SessionTable = sqliteTable( subagent_type: text(), slug: text().notNull(), directory: text().notNull(), + execution_context: text({ mode: "json" }) + .$type<{ + ownerDirectory: string + activeDirectory: string + activeWorktree?: { + directory: string + name: string + branch?: string + source: "created" | "existing" + } + lastChangedAt: number + } | null>() + .default(null), title: text().notNull(), skill: text(), version: text().notNull(), From b18e96cd58415e79e973e3d49c596d3bd746e144 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:20:42 +0800 Subject: [PATCH 02/65] feat(session): synthesize executionContext for new and legacy sessions - Required executionContext field on Session.Info; populated at create time from the user-opened directory captured in session.directory. - fromRow falls back to a synthesized root context when the column is null, so legacy rows surface as if backfilled even before the one-shot UPDATE runs. - toRow persists the field, so any subsequent write durably backfills. - backfillExecutionContext exported for explicit one-shot UPDATE; safe to call repeatedly. Refs #278. --- .../opencode/src/session/execution-context.ts | 29 ++++++++++++++++ packages/opencode/src/session/session.ts | 33 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 packages/opencode/src/session/execution-context.ts diff --git a/packages/opencode/src/session/execution-context.ts b/packages/opencode/src/session/execution-context.ts new file mode 100644 index 00000000..47ae9965 --- /dev/null +++ b/packages/opencode/src/session/execution-context.ts @@ -0,0 +1,29 @@ +import z from "zod" + +export const ActiveWorktree = z.object({ + directory: z.string(), + name: z.string(), + branch: z.string().optional(), + source: z.enum(["created", "existing"]), +}) +export type ActiveWorktree = z.infer + +export const SessionExecutionContext = z.object({ + ownerDirectory: z.string(), + activeDirectory: z.string(), + activeWorktree: ActiveWorktree.optional(), + lastChangedAt: z.number(), +}) +export type SessionExecutionContext = z.infer + +export function rootContext(ownerDirectory: string): SessionExecutionContext { + return { + ownerDirectory, + activeDirectory: ownerDirectory, + lastChangedAt: Date.now(), + } +} + +export function isAtRoot(ctx: SessionExecutionContext): boolean { + return ctx.activeDirectory === ctx.ownerDirectory && ctx.activeWorktree === undefined +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c926b4af..5fd3d650 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -36,6 +36,7 @@ import { SubagentRunGuardViolation, lifecycleFieldsChanged, } from "./subagent-run-context" +import { SessionExecutionContext, rootContext } from "./execution-context" const log = Log.create({ service: "session" }) @@ -82,6 +83,9 @@ export function fromRow(row: SessionRow): Info { share, revert, permission: row.permission ?? undefined, + // Backfill at boot guarantees this is non-null on every load (see backfillExecutionContext). + // Defensive fallback for the brief window during boot before backfill runs. + executionContext: row.execution_context ?? rootContext(row.directory), time: { created: row.time_created, updated: row.time_updated, @@ -101,6 +105,7 @@ export function toRow(info: Info) { subagent_type: info.subagentType, slug: info.slug, directory: info.directory, + execution_context: info.executionContext, title: info.title, skill: info.skill, version: info.version, @@ -169,6 +174,7 @@ export const Info = z diff: z.string().optional(), }) .optional(), + executionContext: SessionExecutionContext, }) .meta({ ref: "Session", @@ -443,6 +449,11 @@ export const layer: Layer.Layer = title: input.title ?? createDefaultTitle(!!input.parentID), skill: input.skill, permission: input.permission, + // ownerDirectory is the directory the user opened to create the session. + // session.directory is what the Instance was scoped to at session creation, + // which is the user-opened path (Project.worktree happens to coincide for + // git project roots; non-git paths and git subdirectories use this). + executionContext: rootContext(input.directory), time: { created: Date.now(), updated: Date.now(), @@ -770,6 +781,28 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Storage.defaultLayer), ) +/** + * One-shot backfill that fills `execution_context` for every legacy row whose value is NULL. + * Idempotent (subsequent runs find no NULL rows) and safe to call any number of times. Sources + * `ownerDirectory` from the existing `directory` column, which captures the directory the user + * opened at session creation time. Defensive fallback: rows somehow missing both still pass through + * `fromRow` synthesis on read. + */ +export const backfillExecutionContext = Effect.sync(() => { + Database.use((d) => { + const rows = d + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(isNull(SessionTable.execution_context)) + .all() + for (const row of rows) { + const ctx = rootContext(row.directory) + d.update(SessionTable).set({ execution_context: ctx }).where(eq(SessionTable.id, row.id)).run() + } + return rows.length + }) +}) + const { runPromise } = makeRuntime(Service, defaultLayer) export const create = fn(CreateInput, (input) => runPromise((svc) => svc.create(input))) From ebda2c8a95a6eeec5a04b363cafa6a25986282c8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:21:09 +0800 Subject: [PATCH 03/65] feat(session): persist execution_context through projectors Insert path already includes the field via toRow; this commit adds the partial-update branch so executionContext mutations flow through. Refs #278. --- packages/opencode/src/session/projectors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 0a546353..f2058d59 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -51,6 +51,7 @@ export function toPartialRow(info: DeepPartial) { summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), revert: grab(info, "revert"), permission: grab(info, "permission"), + execution_context: grab(info, "executionContext"), time_created: grab(info, "time", (v) => grab(v, "created")), time_updated: grab(info, "time", (v) => grab(v, "updated")), time_compacting: grab(info, "time", (v) => grab(v, "compacting")), From 5155b3b8dfa7cf0fa71f9754d6280e903dc78b6c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:21:53 +0800 Subject: [PATCH 04/65] feat(session): add Session.updateExecutionContext helper Single mutator for activeDirectory + activeWorktree; ownerDirectory is set at session creation and immutable. activeWorktree=null clears the field for Exit semantics. Refs #278. --- packages/opencode/src/session/session.ts | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5fd3d650..1be0d712 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -384,6 +384,11 @@ export interface Interface { }) => Effect.Effect readonly clearRevert: (sessionID: SessionID) => Effect.Effect readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect + readonly updateExecutionContext: (input: { + sessionID: SessionID + activeDirectory?: string + activeWorktree?: SessionExecutionContext["activeWorktree"] | null + }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect @@ -688,6 +693,27 @@ export const layer: Layer.Layer = yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) }) + const updateExecutionContext = Effect.fn("Session.updateExecutionContext")(function* (input: { + sessionID: SessionID + activeDirectory?: string + activeWorktree?: SessionExecutionContext["activeWorktree"] | null + }) { + const current = yield* get(input.sessionID) + // ownerDirectory is set at session creation and never moves; never taken from patch. + // Drop activeWorktree when caller passes null (Exit semantics) or omits it explicitly. + const next: SessionExecutionContext = { + ownerDirectory: current.executionContext.ownerDirectory, + activeDirectory: input.activeDirectory ?? current.executionContext.activeDirectory, + activeWorktree: + "activeWorktree" in input + ? input.activeWorktree ?? undefined + : current.executionContext.activeWorktree, + lastChangedAt: Date.now(), + } + yield* patch(input.sessionID, { time: { updated: Date.now() }, executionContext: next }) + return { ...current, executionContext: next } + }) + const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { return yield* storage .read(["session_diff", sessionID]) @@ -761,6 +787,7 @@ export const layer: Layer.Layer = setRevert, clearRevert, setSummary, + updateExecutionContext, diff, messages, children, From 1bc52b31975efc4d78dda8ed4e882fb135e28b00 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:22:20 +0800 Subject: [PATCH 05/65] feat(session): accept legacy string path on inbound messages Pre-design messages serialised path as a single absolute string; union now accepts both shapes and lifts the string form to {cwd, root} where cwd === root. Writers still emit only the modern object shape. Refs #278. --- packages/opencode/src/session/message-v2.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index b3245be4..aa3f2dfc 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -484,10 +484,12 @@ export const Assistant = Base.extend({ */ mode: z.string(), agent: z.string(), - path: z.object({ - cwd: z.string(), - root: z.string(), - }), + // Pre-design messages serialised path as a single absolute string. Readers lift it to + // {cwd, root} where cwd === root; writers still emit only the modern object shape. + path: z.union([ + z.object({ cwd: z.string(), root: z.string() }), + z.string().transform((s) => ({ cwd: s, root: s })), + ]), summary: z.boolean().optional(), cost: z.number(), tokens: z.object({ From 32ccf95d9bbe5e79e4edb4b505b01aa57fc0d512 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:23:48 +0800 Subject: [PATCH 06/65] feat(session): route message path through executionContext Per-dispatch lookup of activeDirectory and ownerDirectory ensures EnterWorktree and ExitWorktree take effect on the very next tool call without restructuring the prompt loop. Refs #278. --- packages/opencode/src/session/prompt.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f0076269..9e89a6fd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -705,6 +705,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the const promptOps = yield* ops() const { agent: agentTool } = yield* registry.named() const taskModel = subtask.model ? yield* getModel(subtask.model.providerID, subtask.model.modelID, sessionID) : model + // Re-read live to pick up Enter/Exit transitions made earlier in the same turn. + const execLive = (yield* sessions.get(sessionID)).executionContext const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "assistant", @@ -713,7 +715,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: subtask.agent, agent: subtask.agent, variant: lastUser.model.variant, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: execLive.activeDirectory, root: execLive.ownerDirectory }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: taskModel.id, @@ -930,7 +932,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: input.agent, agent: input.agent, cost: 0, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: session.executionContext.activeDirectory, root: session.executionContext.ownerDirectory }, time: { created: Date.now() }, role: "assistant", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -1679,6 +1681,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the discard: true, }) + const execLive = (yield* sessions.get(sessionID)).executionContext const msg: MessageV2.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, @@ -1686,7 +1689,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: agent.name, agent: agent.name, variant: lastUser.model.variant, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: execLive.activeDirectory, root: execLive.ownerDirectory }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: model.id, From 78ad32f6b988a23b3a0bdcc65d9818c30f9742fe Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:27:33 +0800 Subject: [PATCH 07/65] feat(instance): add Instance.activate for session-scoped binding Promise-shaped wrapper around Instance.provide that always supplies both directory (= activeDirectory) and worktree (= ownerDirectory) to preserve the naming-bridge invariant. Refs #278. Wiring into prompt.ts/processor.ts dispatch loop is deferred: the existing call sites set Instance ALS once at request boundary; mid- turn EnterWorktree updates Session.executionContext but does not yet shift Instance.directory for subsequent tool reads. EnterWorktree itself will rebind via Instance.activate before mutating tool state (see Task 14). Full middleware refactor for cross-request rebinding is a follow-up. --- packages/opencode/src/project/instance.ts | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 61ffed01..1147e129 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -54,7 +54,13 @@ function track(directory: string, next: Promise) { } export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + async provide(input: { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info + fn: () => R + }): Promise { const directory = Filesystem.resolve(input.directory) let existing = cache.get(directory) if (!existing) { @@ -64,6 +70,8 @@ export const Instance = { boot({ directory, init: input.init, + worktree: input.worktree, + project: input.project, }), ) } @@ -72,6 +80,29 @@ export const Instance = { return input.fn() }) }, + /** + * Scope a function under a session's executionContext: directory = activeDirectory, + * worktree = ownerDirectory. Reuses the per-directory instance cache so entering the + * same worktree twice reuses the cached entry. + * + * The plan's naming-bridge invariant (Instance.worktree === executionContext.ownerDirectory) + * requires both `directory` AND `worktree` to be passed to provide; otherwise Project.fromDirectory + * would resolve a fresh worktree from the .worktrees/pawwork/ path, breaking permission + * scope and any code comparing Instance.worktree to the project root. + */ + async activate(input: { + activeDirectory: string + ownerDirectory: string + project: Project.Info + fn: () => R + }): Promise { + return Instance.provide({ + directory: input.activeDirectory, + worktree: input.ownerDirectory, + project: input.project, + fn: input.fn, + }) + }, get current() { return context.use() }, From 557c9a999a4ef9dbb73d8204ee18dc7cd4590d0a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:28:35 +0800 Subject: [PATCH 08/65] feat(session): add state-machine guard helpers hasInFlightToolCallsExcept queries running/pending tool parts via Session.messages. hasRunningSubagents is currently a stub (returns false) because SubagentRun.activeCounts is private; the parent's running-tool guard already covers the common case since subagents are dispatched through a parent agent tool call. Refs #278. --- .../src/session/state-machine-guard.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/opencode/src/session/state-machine-guard.ts diff --git a/packages/opencode/src/session/state-machine-guard.ts b/packages/opencode/src/session/state-machine-guard.ts new file mode 100644 index 00000000..0c620bbe --- /dev/null +++ b/packages/opencode/src/session/state-machine-guard.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect" +import * as Session from "./session" +import type { SessionID } from "./schema" + +/** + * Returns true when this session has a tool part in pending or running state whose callID differs + * from `exceptCallID`. Used by EnterWorktree / ExitWorktree to refuse a transition while another + * tool call is unresolved (the calling tool's own callID is excluded so the tool can introspect + * itself). + */ +export const hasInFlightToolCallsExcept = (sessionID: SessionID, exceptCallID: string) => + Effect.gen(function* () { + const svc = yield* Session.Service + const messages = yield* svc.messages({ sessionID }) + for (const m of messages) { + for (const part of m.parts) { + if (part.type !== "tool") continue + if (part.callID === exceptCallID) continue + if (part.state.status === "running" || part.state.status === "pending") return true + } + } + return false + }) + +/** + * Returns true when this session has at least one active subagent run. + * + * Note: SubagentRun.activeCounts is private to the layer; a public count helper is a follow-up. + * For now this returns false unconditionally; the parent's running-tool guard above already covers + * the common case (subagents are always invoked through a parent tool call which shows as running). + */ +export const hasRunningSubagents = (_sessionID: SessionID) => Effect.succeed(false) From 4bed20a01f65d637080e75118efc78cbfa7315d9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:34:36 +0800 Subject: [PATCH 09/65] feat(tool): add EnterWorktree and ExitWorktree agent tools EnterWorktree binds the session to a git worktree. Three modes: - no-arg: auto-generated slug, new managed worktree - name=: reuse if exists, else create a managed worktree under Global.Path.data/worktree// - path=: take over an external worktree of the same git common-dir State-machine guard refuses transitions while another tool call is running (subagent guard is a stub pending SubagentRun count exposure). A->B switches require ExitWorktree first; A->A is a no-op success. ExitWorktree clears activeWorktree and rebinds activeDirectory = ownerDirectory. From-root is idempotent. Both tools registered in the main toolset. Subagent exclusion is a follow-up (today the registry filter is enabled/disabled only). Refs #278. --- .../src/session/state-machine-guard.ts | 11 +- packages/opencode/src/tool/enter-worktree.ts | 205 ++++++++++++++++++ packages/opencode/src/tool/enter-worktree.txt | 17 ++ packages/opencode/src/tool/exit-worktree.ts | 60 +++++ packages/opencode/src/tool/exit-worktree.txt | 5 + packages/opencode/src/tool/registry.ts | 8 + 6 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/tool/enter-worktree.ts create mode 100644 packages/opencode/src/tool/enter-worktree.txt create mode 100644 packages/opencode/src/tool/exit-worktree.ts create mode 100644 packages/opencode/src/tool/exit-worktree.txt diff --git a/packages/opencode/src/session/state-machine-guard.ts b/packages/opencode/src/session/state-machine-guard.ts index 0c620bbe..3bca070e 100644 --- a/packages/opencode/src/session/state-machine-guard.ts +++ b/packages/opencode/src/session/state-machine-guard.ts @@ -8,10 +8,13 @@ import type { SessionID } from "./schema" * tool call is unresolved (the calling tool's own callID is excluded so the tool can introspect * itself). */ -export const hasInFlightToolCallsExcept = (sessionID: SessionID, exceptCallID: string) => +export const hasInFlightToolCallsExcept = ( + sessions: Session.Service["Service"], + sessionID: SessionID, + exceptCallID: string, +) => Effect.gen(function* () { - const svc = yield* Session.Service - const messages = yield* svc.messages({ sessionID }) + const messages = yield* sessions.messages({ sessionID }) for (const m of messages) { for (const part of m.parts) { if (part.type !== "tool") continue @@ -27,6 +30,6 @@ export const hasInFlightToolCallsExcept = (sessionID: SessionID, exceptCallID: s * * Note: SubagentRun.activeCounts is private to the layer; a public count helper is a follow-up. * For now this returns false unconditionally; the parent's running-tool guard above already covers - * the common case (subagents are always invoked through a parent tool call which shows as running). + * the common case since subagents are dispatched through a parent agent tool call. */ export const hasRunningSubagents = (_sessionID: SessionID) => Effect.succeed(false) diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts new file mode 100644 index 00000000..d76285e0 --- /dev/null +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -0,0 +1,205 @@ +import { Effect, Schema } from "effect" +import * as path from "path" +import { promises as fs } from "fs" +import * as Tool from "./tool" +import DESCRIPTION from "./enter-worktree.txt" +import * as Session from "../session/session" +import { Worktree } from "../worktree" +import { Instance } from "../project/instance" +import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" +import type { SessionID } from "../session/schema" + +export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ +const MAX_SLUG_LEN = 40 + +export const Parameters = Schema.Struct({ + name: Schema.optional(Schema.String).annotate({ + description: + "Slug for a managed worktree (kebab-case, [a-z0-9-]+, max 40). If omitted, a slug is auto-generated. Mutually exclusive with `path`.", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Absolute path to an existing same-repo worktree to take over. Mutually exclusive with `name`.", + }), +}) + +// Resolve the canonical git common-dir for a path. Returns undefined for non-git paths or any error. +async function gitCommonDir(cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "rev-parse", "--git-common-dir"], { cwd, stdout: "pipe", stderr: "pipe" }) + const exit = await proc.exited + if (exit !== 0) return undefined + const out = (await new Response(proc.stdout).text()).trim() + if (!out) return undefined + return path.resolve(cwd, out) + } catch { + return undefined + } +} + +async function currentBranch(cwd: string): Promise { + try { + const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" }) + const exit = await proc.exited + if (exit !== 0) return "" + return (await new Response(proc.stdout).text()).trim() + } catch { + return "" + } +} + +export const EnterWorktreeTool = Tool.define( + "enter-worktree", + Effect.gen(function* () { + const sessions = yield* Session.Service + + const guard = (sessionID: SessionID, callID: string | undefined) => + Effect.gen(function* () { + if (callID) { + const inFlight = yield* hasInFlightToolCallsExcept(sessions, sessionID, callID) + if (inFlight) { + return yield* Effect.fail( + new Error("Cannot enter a worktree while another tool call is running in this session."), + ) + } + } + const subs = yield* hasRunningSubagents(sessionID) + if (subs) { + return yield* Effect.fail( + new Error("Cannot enter a worktree while a subagent is running in this session."), + ) + } + }) + + const applyEnter = (sessionID: SessionID, info: Worktree.Info, source: "created" | "existing") => + sessions.updateExecutionContext({ + sessionID, + activeDirectory: info.directory, + activeWorktree: { + directory: info.directory, + name: info.name, + branch: info.branch, + source, + }, + }) + + const successResult = (input: { + activeDirectory: string + slug: string + branch: string + state: "created" | "reused" + }): Tool.ExecuteResult => ({ + title: `Entered worktree ${input.slug}`, + output: `Now active in ${input.activeDirectory} (branch ${input.branch}, slug ${input.slug}). Subsequent paths resolve from this directory.`, + metadata: { + activeDirectory: input.activeDirectory, + slug: input.slug, + branch: input.branch, + state: input.state, + }, + }) + + const run = (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (params.name && params.path) { + return yield* Effect.fail(new Error("name and path are mutually exclusive")) + } + if (params.name && !SLUG_RE.test(params.name)) { + return yield* Effect.fail( + new Error("name must be kebab-case, [a-z0-9-]+, no leading/trailing or double hyphens"), + ) + } + if (params.name && params.name.length > MAX_SLUG_LEN) { + return yield* Effect.fail(new Error(`name max ${MAX_SLUG_LEN} chars`)) + } + + yield* guard(ctx.sessionID, ctx.callID) + + const project = Instance.project + if (project.vcs !== "git") { + return yield* Effect.fail(new Error("This project is not a git repository.")) + } + + const session = yield* sessions.get(ctx.sessionID) + const exec = session.executionContext + + if (params.path) { + const canonical = yield* Effect.promise(() => + fs.realpath(params.path!).catch(() => path.resolve(params.path!)), + ) + if (exec.activeDirectory === canonical) { + const slug = exec.activeWorktree?.name ?? path.basename(canonical) + return successResult({ + activeDirectory: canonical, + slug, + branch: exec.activeWorktree?.branch ?? "", + state: "reused", + }) + } + if (exec.activeDirectory !== exec.ownerDirectory) { + return yield* Effect.fail( + new Error("This session is already inside another worktree. Call ExitWorktree first."), + ) + } + const ownerCommon = yield* Effect.promise(() => gitCommonDir(exec.ownerDirectory)) + const targetCommon = yield* Effect.promise(() => gitCommonDir(canonical)) + if (!ownerCommon || !targetCommon || ownerCommon !== targetCommon) { + return yield* Effect.fail( + new Error(`Path ${canonical} is not part of the same git repository as the project.`), + ) + } + const branch = yield* Effect.promise(() => currentBranch(canonical)) + const info: Worktree.Info = { + name: path.basename(canonical), + directory: canonical, + branch, + } + yield* applyEnter(ctx.sessionID, info, "existing") + return successResult({ + activeDirectory: canonical, + slug: info.name, + branch, + state: "reused", + }) + } + + // name= or no-arg branch + const planned = yield* Effect.promise(() => Worktree.makeWorktreeInfo(params.name)) + if (exec.activeDirectory === planned.directory) { + return successResult({ + activeDirectory: planned.directory, + slug: planned.name, + branch: planned.branch, + state: "reused", + }) + } + if (exec.activeDirectory !== exec.ownerDirectory) { + return yield* Effect.fail( + new Error("This session is already inside another worktree. Call ExitWorktree first."), + ) + } + const exists = yield* Effect.promise(() => + fs + .stat(planned.directory) + .then(() => true) + .catch(() => false), + ) + if (!exists) { + yield* Effect.promise(() => Worktree.createFromInfo(planned)) + } + yield* applyEnter(ctx.sessionID, planned, "created") + return successResult({ + activeDirectory: planned.directory, + slug: planned.name, + branch: planned.branch, + state: exists ? "reused" : "created", + }) + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/enter-worktree.txt b/packages/opencode/src/tool/enter-worktree.txt new file mode 100644 index 00000000..00e193d9 --- /dev/null +++ b/packages/opencode/src/tool/enter-worktree.txt @@ -0,0 +1,17 @@ +Bind this session to a git worktree. Once entered, every subsequent tool call resolves relative paths from inside the worktree until ExitWorktree is called. + +Use only when the user explicitly asks for worktree usage, or project instructions require it. + +Modes: +- EnterWorktree() with no arguments creates a new worktree on a fresh branch from project HEAD; the slug is auto-generated. +- EnterWorktree(name="my-feature") creates or reuses a managed worktree of that name on branch opencode/my-feature. +- EnterWorktree(path="/abs/path/to/existing/worktree") enters a worktree elsewhere on disk that is part of the same git repository as the project. + +After entering, run further tools normally; their working directory is now the worktree. Call ExitWorktree to return to the project root. + +Constraints: +- name and path are mutually exclusive. +- name slug rules: kebab-case, [a-z0-9-]+, max 40 chars, no leading/trailing hyphen. +- path is canonicalised; same-repo validation is via git common-dir. +- The transition is rejected if any other tool call is currently running in this session. +- A→B direct switches are not allowed: call ExitWorktree first. diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts new file mode 100644 index 00000000..8cde1ea8 --- /dev/null +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -0,0 +1,60 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./exit-worktree.txt" +import * as Session from "../session/session" +import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" + +export const Parameters = Schema.Struct({}) + +export const ExitWorktreeTool = Tool.define( + "exit-worktree", + Effect.gen(function* () { + const sessions = yield* Session.Service + + const run = (_params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + if (ctx.callID) { + const inFlight = yield* hasInFlightToolCallsExcept(sessions, ctx.sessionID, ctx.callID) + if (inFlight) { + return yield* Effect.fail( + new Error("Cannot exit a worktree while another tool call is running in this session."), + ) + } + } + const subs = yield* hasRunningSubagents(ctx.sessionID) + if (subs) { + return yield* Effect.fail( + new Error("Cannot exit a worktree while a subagent is running in this session."), + ) + } + + const session = yield* sessions.get(ctx.sessionID) + const exec = session.executionContext + if (exec.activeDirectory === exec.ownerDirectory && exec.activeWorktree === undefined) { + return { + title: "Already at project root", + output: `Returned to project root ${exec.ownerDirectory}. Subsequent paths resolve from this directory.`, + metadata: { activeDirectory: exec.ownerDirectory }, + } + } + + yield* sessions.updateExecutionContext({ + sessionID: ctx.sessionID, + activeDirectory: exec.ownerDirectory, + activeWorktree: null, + }) + return { + title: "Exited worktree", + output: `Returned to project root ${exec.ownerDirectory}. Subsequent paths resolve from this directory.`, + metadata: { activeDirectory: exec.ownerDirectory }, + } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + run(params, ctx).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/exit-worktree.txt b/packages/opencode/src/tool/exit-worktree.txt new file mode 100644 index 00000000..7897e2b9 --- /dev/null +++ b/packages/opencode/src/tool/exit-worktree.txt @@ -0,0 +1,5 @@ +Return this session to the project root. After exiting, every subsequent tool call resolves relative paths from inside the project root. + +ExitWorktree does not delete, reset, merge, or open pull requests. It only releases the binding. + +Use only when the user explicitly asks for worktree usage and is now done with it, or project instructions require it. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c06a2689..9a1ecb01 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -30,6 +30,8 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncate" import { WebSearchAuth } from "./websearch-auth" import { ApplyPatchTool } from "./apply_patch" +import { EnterWorktreeTool } from "./enter-worktree" +import { ExitWorktreeTool } from "./exit-worktree" import { Permission } from "../permission" import { Glob } from "../util/glob" import path from "path" @@ -138,6 +140,8 @@ export namespace ToolRegistry { const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const enterWorktree = yield* EnterWorktreeTool + const exitWorktree = yield* ExitWorktreeTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -263,6 +267,8 @@ export namespace ToolRegistry { question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + enterWorktree: Tool.init(enterWorktree), + exitWorktree: Tool.init(exitWorktree), }) return { @@ -287,6 +293,8 @@ export namespace ToolRegistry { tool.patch, ...(lspEnabled ? [tool.lsp] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), + tool.enterWorktree, + tool.exitWorktree, ], agent: tool.agent, read: tool.read, From 4d5b59c76ff8acaac67a3464d88ab64a36942933 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:35:21 +0800 Subject: [PATCH 10/65] feat(subagent): inherit parent activeDirectory at dispatch When AgentTool spawns a subagent session, it now snapshots the parent's executionContext and applies it to the child immediately after creation. Subagents see the parent's activeDirectory at the moment of dispatch; the parent can mutate later but the child keeps the snapshot. Refs #278. --- packages/opencode/src/tool/agent.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/opencode/src/tool/agent.ts b/packages/opencode/src/tool/agent.ts index 2f871609..6d8d4286 100644 --- a/packages/opencode/src/tool/agent.ts +++ b/packages/opencode/src/tool/agent.ts @@ -288,6 +288,12 @@ export const AgentTool = Tool.define( model, }) + // Snapshot the parent's executionContext at dispatch so the subagent inherits the + // currently bound worktree (per spec: subagents see the parent's activeDirectory at + // the moment of dispatch). Refs #278. + const parent = yield* sessions.get(ctx.sessionID) + const parentExec = parent.executionContext + const nextSession = session ?? (yield* sessions.create({ @@ -320,6 +326,19 @@ export const AgentTool = Tool.define( ], })) + // Inherit parent's activeWorktree if any (no-op when parent is at root and child was + // freshly created at the project root). + if ( + parentExec.activeDirectory !== nextSession.executionContext.activeDirectory || + parentExec.activeWorktree !== nextSession.executionContext.activeWorktree + ) { + yield* sessions.updateExecutionContext({ + sessionID: nextSession.id, + activeDirectory: parentExec.activeDirectory, + activeWorktree: parentExec.activeWorktree ?? null, + }) + } + yield* subagentRun.patchSession(ctx.callID!, nextSession.id) yield* ctx.metadata({ From fabf4186ec0f7bd7f7b6bc6e868f0d1ac92e748e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:52:52 +0800 Subject: [PATCH 11/65] feat(sdk): expose Session.executionContext to clients Adds the new required executionContext field to the Session schema in openapi.json and both v1 and v2 generated TypeScript types. Targeted edit (no regen) per repo convention. Refs #278. --- packages/sdk/js/src/gen/types.gen.ts | 11 +++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 11 +++++++++++ packages/sdk/openapi.json | 22 +++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 19b01140..57a3e6c1 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -557,6 +557,17 @@ export type Session = { snapshot?: string diff?: string } + executionContext: { + ownerDirectory: string + activeDirectory: string + activeWorktree?: { + directory: string + name: string + branch?: string + source: "created" | "existing" + } + lastChangedAt: number + } } export type EventSessionCreated = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3f49ca62..d5509e22 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -906,6 +906,17 @@ export type Session = { snapshot?: string diff?: string } + executionContext: { + ownerDirectory: string + activeDirectory: string + activeWorktree?: { + directory: string + name: string + branch?: string + source: "created" | "existing" + } + lastChangedAt: number + } } export type EventSessionCreated = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 510b494f..458d902d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9897,6 +9897,25 @@ "required": [ "messageID" ] + }, + "executionContext": { + "type": "object", + "properties": { + "ownerDirectory": { "type": "string" }, + "activeDirectory": { "type": "string" }, + "activeWorktree": { + "type": "object", + "properties": { + "directory": { "type": "string" }, + "name": { "type": "string" }, + "branch": { "type": "string" }, + "source": { "type": "string", "enum": ["created", "existing"] } + }, + "required": ["directory", "name", "source"] + }, + "lastChangedAt": { "type": "number" } + }, + "required": ["ownerDirectory", "activeDirectory", "lastChangedAt"] } }, "required": [ @@ -9906,7 +9925,8 @@ "directory", "title", "version", - "time" + "time", + "executionContext" ] }, "Event.session.created": { From 62dc4838e4423835c41cd188754e40ce543f09f6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 16:53:00 +0800 Subject: [PATCH 12/65] =?UTF-8?q?feat(app):=20titlebar=20worktree=20badge?= =?UTF-8?q?=20+=20Settings=20=E2=86=92=20Worktrees=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PawworkWorktreeBadge: portals into the titlebar center slot whenever the active session's executionContext.activeDirectory differs from ownerDirectory and an activeWorktree is bound; shows worktree icon + slug + branch. - Settings → Worktrees tab: lists every PawWork-tracked worktree for the current project (via client.worktree.list()), with a per-row two-step delete. Delete is disabled while any open session in this app instance has the worktree as its activeDirectory. - worktree icon registered in @opencode-ai/ui (placeholder reusing the branch glyph; will be replaced with a dedicated icon). - en + zh i18n strings; zh self-reference uses 爪印. Refs #278. --- packages/app/src/components/settings-page.tsx | 21 ++- .../app/src/components/settings-worktrees.tsx | 160 ++++++++++++++++++ packages/app/src/i18n/en.ts | 14 ++ packages/app/src/i18n/zh.ts | 14 ++ packages/app/src/pages/layout.tsx | 2 + .../pages/layout/pawwork-worktree-badge.tsx | 51 ++++++ 6 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/components/settings-worktrees.tsx create mode 100644 packages/app/src/pages/layout/pawwork-worktree-badge.tsx diff --git a/packages/app/src/components/settings-page.tsx b/packages/app/src/components/settings-page.tsx index bd5095be..976c796b 100644 --- a/packages/app/src/components/settings-page.tsx +++ b/packages/app/src/components/settings-page.tsx @@ -17,8 +17,9 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsModels } from "./settings-models" import { SettingsProviders } from "./settings-providers" +import { SettingsWorktrees } from "./settings-worktrees" -export type SettingsPageTab = "general" | "shortcuts" | "providers" | "models" +export type SettingsPageTab = "general" | "shortcuts" | "providers" | "models" | "worktrees" export const SettingsPage: Component<{ active: SettingsPageTab @@ -86,7 +87,14 @@ export const SettingsPage: Component<{ variant="settings" value={props.active} onChange={(value) => { - if (value !== "general" && value !== "shortcuts" && value !== "providers" && value !== "models") return + if ( + value !== "general" && + value !== "shortcuts" && + value !== "providers" && + value !== "models" && + value !== "worktrees" + ) + return props.onSelect(value) }} class="h-full w-full" @@ -134,6 +142,10 @@ export const SettingsPage: Component<{ {language.t("settings.models.title")} + + + {language.t("settings.tab.worktrees")} + @@ -165,6 +177,11 @@ export const SettingsPage: Component<{ + +
+ +
+
) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx new file mode 100644 index 00000000..2c7d9cde --- /dev/null +++ b/packages/app/src/components/settings-worktrees.tsx @@ -0,0 +1,160 @@ +import { type Component, createResource, createSignal, For, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { SettingsList } from "./settings-list" + +function basename(p: string): string { + const trimmed = p.replace(/[/\\]+$/, "") + const last = trimmed.split(/[/\\]/).pop() + return last || p +} + +/** + * Settings → Worktrees panel. + * + * Lists every PawWork-tracked worktree directory for the current project (via + * `client.worktree.list()`); offers a per-row delete with two-step confirm. Delete is disabled + * when an open session in this app instance has the worktree as its activeDirectory; the user + * must call ExitWorktree from that session first. + */ +export const SettingsWorktrees: Component = () => { + const language = useLanguage() + const sdk = useSDK() + const sync = useSync() + + const [data, { refetch }] = createResource(async () => { + const res = await sdk.client.worktree.list() + return (res.data ?? []) as string[] + }) + + // Sessions whose activeDirectory points at a worktree path block its deletion. + const boundDirectories = (): Set => { + const set = new Set() + const sessions = sync.data.session ?? [] + for (const s of sessions) { + const exec = s.executionContext + if (!exec) continue + if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) { + set.add(exec.activeDirectory) + } + } + return set + } + + const [confirming, setConfirming] = createSignal(undefined) + const [deleting, setDeleting] = createSignal(undefined) + + const handleDelete = async (directory: string) => { + setDeleting(directory) + try { + const res = await sdk.client.worktree.remove({ worktreeRemoveInput: { directory } }) + if (res.error) throw new Error(JSON.stringify(res.error)) + setConfirming(undefined) + void refetch() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + showToast({ + title: language.t("settings.worktrees.deleteFailed", { message }), + }) + } finally { + setDeleting(undefined) + } + } + + return ( + +
+
+

{language.t("settings.worktrees.title")}

+

{language.t("settings.worktrees.description")}

+
+ + {language.t("common.loading")}
+ } + > + 0} + fallback={ +
+ {language.t("settings.worktrees.empty")} +
+ } + > +
    + + {(directory) => { + const name = basename(directory) + const blocked = () => boundDirectories().has(directory) + const isConfirming = () => confirming() === directory + const isDeleting = () => deleting() === directory + + return ( +
  • + +
    + + {name} + + + {directory} + +
    + setConfirming(directory)} + > + {language.t("settings.worktrees.delete")} + + } + > +
    + + {language.t("settings.worktrees.confirmDelete.body", { name })} + + + +
    +
    +
  • + ) + }} +
    +
+
+ + +
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 12ad376d..8a79a30c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -809,6 +809,20 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", + "settings.tab.worktrees": "Worktrees", + "settings.worktrees.title": "Worktrees", + "settings.worktrees.description": "Worktrees that PawWork has created or registered. Each session may bind to a worktree via the EnterWorktree tool; ExitWorktree releases the binding without deleting anything.", + "settings.worktrees.empty": "No worktrees yet.", + "settings.worktrees.column.name": "Name", + "settings.worktrees.column.branch": "Branch", + "settings.worktrees.column.path": "Path", + "settings.worktrees.delete": "Delete", + "settings.worktrees.deleteDisabled.tooltip": "In use by an open session. Use ExitWorktree from that session first.", + "settings.worktrees.confirmDelete.title": "Delete worktree?", + "settings.worktrees.confirmDelete.body": "Delete \"{name}\" and all of its files? This runs git worktree remove and cannot be undone.", + "settings.worktrees.confirmDelete.confirmLabel": "Delete", + "settings.worktrees.confirmDelete.cancelLabel": "Cancel", + "settings.worktrees.deleteFailed": "Failed to delete worktree: {message}", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.description": "Run the PawWork server inside WSL on Windows.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c7c4399f..b75ed5a2 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -700,6 +700,20 @@ export const dict = { "settings.tab.general": "通用", "settings.tab.shortcuts": "快捷键", + "settings.tab.worktrees": "工作树", + "settings.worktrees.title": "工作树", + "settings.worktrees.description": "爪印创建或登记过的工作树。每个会话可以通过 EnterWorktree 工具绑定到工作树;ExitWorktree 解除绑定但不会删除任何东西。", + "settings.worktrees.empty": "暂无工作树。", + "settings.worktrees.column.name": "名称", + "settings.worktrees.column.branch": "分支", + "settings.worktrees.column.path": "路径", + "settings.worktrees.delete": "删除", + "settings.worktrees.deleteDisabled.tooltip": "正在被一个会话使用,先在该会话里调用 ExitWorktree。", + "settings.worktrees.confirmDelete.title": "删除该工作树?", + "settings.worktrees.confirmDelete.body": "确定删除「{name}」及其所有文件?这将执行 git worktree remove,操作不可撤销。", + "settings.worktrees.confirmDelete.confirmLabel": "删除", + "settings.worktrees.confirmDelete.cancelLabel": "取消", + "settings.worktrees.deleteFailed": "删除工作树失败:{message}", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL 集成", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 49043045..11d18c59 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -86,6 +86,7 @@ import { import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" import { PawworkTitlebar } from "./layout/pawwork-titlebar" +import { PawworkWorktreeBadge } from "./layout/pawwork-worktree-badge" import { SettingsPage, type SettingsPageTab } from "@/components/settings-page" import { DialogDeleteSession } from "@/components/dialog-delete-session" @@ -2061,6 +2062,7 @@ export default function Layout(props: ParentProps) { > language.t("sidebar.settings")} /> +
diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx new file mode 100644 index 00000000..dff44806 --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -0,0 +1,51 @@ +import { createMemo, createSignal, onMount, Show } from "solid-js" +import { Portal } from "solid-js/web" +import { useParams } from "@solidjs/router" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" + +/** + * Renders an inline worktree indicator (slug · branch) in the titlebar center slot whenever the + * active session is bound to a worktree (executionContext.activeDirectory != ownerDirectory). + * Hidden when the session is at project root, when no session is open, or when there is no + * activeWorktree on the executionContext. + */ +export function PawworkWorktreeBadge() { + const params = useParams() + const sync = useSync() + const [centerMount, setCenterMount] = createSignal() + + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center") ?? undefined) + }) + + const session = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const exec = createMemo(() => session()?.executionContext) + const wt = createMemo(() => exec()?.activeWorktree) + const visible = createMemo(() => { + const e = exec() + if (!e) return false + return e.activeDirectory !== e.ownerDirectory && wt() !== undefined + }) + + return ( + + {(mount) => ( + + + + )} + + ) +} From fd58cf96953e2cf490fe82cb308a1210a188cc79 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:17:39 +0800 Subject: [PATCH 13/65] fix(worktree): use PawWork managed worktree convention --- packages/opencode/src/worktree/index.ts | 9 +++++---- .../opencode/test/project/worktree.test.ts | 20 +++++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index db786fcd..f2dd8206 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,6 +1,5 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" -import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" @@ -45,6 +44,7 @@ export namespace Worktree { name: z.string(), branch: z.string(), directory: z.string(), + source: z.enum(["created", "existing"]).default("created"), }) .meta({ ref: "Worktree", @@ -201,11 +201,12 @@ export namespace Worktree { ) const MAX_NAME_ATTEMPTS = 26 + const BRANCH_PREFIX = "pawwork/" const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { const ctx = yield* InstanceState.context for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create() - const branch = `opencode/${name}` + const branch = `${BRANCH_PREFIX}${name}` const directory = pathSvc.join(root, name) if (yield* fs.exists(directory).pipe(Effect.orDie)) continue @@ -214,7 +215,7 @@ export namespace Worktree { const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) if (branchCheck.code === 0) continue - return Info.parse({ name, branch, directory }) + return Info.parse({ name, branch, directory, source: "created" }) } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) @@ -225,7 +226,7 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id) + const root = pathSvc.join(ctx.worktree, ".worktrees", "pawwork") yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie) const base = name ? slugify(name) : "" diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index dd91c772..d31985ef 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -47,8 +47,9 @@ describe("Worktree", () => { expect(info.name).toBeDefined() expect(typeof info.name).toBe("string") - expect(info.branch).toBe(`opencode/${info.name}`) - expect(info.directory).toContain(info.name) + expect(info.branch).toBe(`pawwork/${info.name}`) + expect(info.directory).toBe(path.join(tmp.path, ".worktrees", "pawwork", info.name)) + expect(info.source).toBe("created") }) test("uses provided name as base", async () => { @@ -57,7 +58,9 @@ describe("Worktree", () => { const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature")) expect(info.name).toBe("my-feature") - expect(info.branch).toBe("opencode/my-feature") + expect(info.branch).toBe("pawwork/my-feature") + expect(info.directory).toBe(path.join(tmp.path, ".worktrees", "pawwork", "my-feature")) + expect(info.source).toBe("created") }) test("slugifies the provided name", async () => { @@ -82,8 +85,9 @@ describe("Worktree", () => { const info = await withInstance(tmp.path, () => Worktree.create()) expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch).toStartWith("pawwork/") expect(info.directory).toBeDefined() + expect(info.source).toBe("created") // Wait for bootstrap to complete await Bun.sleep(1000) @@ -100,7 +104,9 @@ describe("Worktree", () => { // create returns before bootstrap completes, but the worktree already exists expect(info.name).toBeDefined() - expect(info.branch).toStartWith("opencode/") + expect(info.branch).toStartWith("pawwork/") + expect(info.directory).toBe(path.join(tmp.path, ".worktrees", "pawwork", info.name)) + expect(info.source).toBe("created") const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() const dir = await fs.realpath(info.directory).catch(() => info.directory) @@ -124,7 +130,9 @@ describe("Worktree", () => { const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" })) expect(info.name).toBe("test-workspace") - expect(info.branch).toBe("opencode/test-workspace") + expect(info.branch).toBe("pawwork/test-workspace") + expect(info.directory).toBe(path.join(tmp.path, ".worktrees", "pawwork", "test-workspace")) + expect(info.source).toBe("created") // Cleanup await ready From 6691c9880d1ffc486988729d0a3a129dcbc97bf2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:23:04 +0800 Subject: [PATCH 14/65] fix(worktree): preserve registry source semantics --- .../src/control-plane/adaptors/worktree.ts | 1 + packages/opencode/src/project/project.sql.ts | 4 +- packages/opencode/src/project/project.ts | 37 +++-- .../src/server/instance/experimental.ts | 9 +- packages/opencode/src/tool/enter-worktree.ts | 14 +- packages/opencode/src/tool/enter-worktree.txt | 4 +- packages/opencode/src/worktree/index.ts | 140 +++++++++++++++++- .../opencode/test/project/worktree.test.ts | 28 ++++ 8 files changed, 205 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 9fb6c747..dbcf5a5f 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -26,6 +26,7 @@ export const WorktreeAdaptor: Adaptor = { name: config.name, directory: config.directory, branch: config.branch, + source: "created", }) }, async remove(info) { diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index efbc400b..355814b8 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -11,6 +11,8 @@ export const ProjectTable = sqliteTable("project", { icon_color: text(), ...Timestamps, time_initialized: integer(), - sandboxes: text({ mode: "json" }).notNull().$type(), + sandboxes: text({ mode: "json" }) + .notNull() + .$type>(), commands: text({ mode: "json" }).$type<{ start?: string }>(), }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1a2c17f9..1555c7be 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -48,6 +48,12 @@ export namespace Project { }) export type Info = z.infer + function sandboxDirectory( + entry: string | { directory: string; name?: string; branch?: string; source?: "created" | "existing" }, + ) { + return typeof entry === "string" ? entry : entry.directory + } + export const Event = { Updated: BusEvent.define("project.updated", Info), } @@ -70,7 +76,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(sandboxDirectory), commands: row.commands ?? undefined, } } @@ -248,6 +254,7 @@ export namespace Project { // Phase 2: upsert const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) + const existingSandboxes = row ? [...row.sandboxes] : [] const existing = row ? fromRow(row) : { @@ -267,17 +274,21 @@ export namespace Project { vcs: data.vcs, time: { ...existing.time, updated: Date.now() }, } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = yield* Effect.forEach( - result.sandboxes, - (s) => - fs.exists(s).pipe( + if ( + data.sandbox !== result.worktree && + !existingSandboxes.some((entry) => sandboxDirectory(entry) === data.sandbox) + ) + existingSandboxes.push(data.sandbox) + const activeSandboxes = yield* Effect.forEach( + existingSandboxes, + (entry) => + fs.exists(sandboxDirectory(entry)).pipe( Effect.orDie, - Effect.map((exists) => (exists ? s : undefined)), + Effect.map((exists) => (exists ? entry : undefined)), ), { concurrency: "unbounded" }, - ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + ).pipe(Effect.map((arr) => arr.filter((x): x is (typeof existingSandboxes)[number] => x !== undefined))) + result.sandboxes = activeSandboxes.map(sandboxDirectory) yield* db((d) => d @@ -292,7 +303,7 @@ export namespace Project { time_created: result.time.created, time_updated: result.time.updated, time_initialized: result.time.initialized, - sandboxes: result.sandboxes, + sandboxes: activeSandboxes, commands: result.commands, }) .onConflictDoUpdate({ @@ -305,7 +316,7 @@ export namespace Project { icon_color: result.icon?.color, time_updated: result.time.updated, time_initialized: result.time.initialized, - sandboxes: result.sandboxes, + sandboxes: activeSandboxes, commands: result.commands, }, }) @@ -414,7 +425,7 @@ export namespace Project { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) const sboxes = [...row.sandboxes] - if (!sboxes.includes(directory)) sboxes.push(directory) + if (!sboxes.some((entry) => sandboxDirectory(entry) === directory)) sboxes.push(directory) const result = yield* db((d) => d .update(ProjectTable) @@ -430,7 +441,7 @@ export namespace Project { const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - const sboxes = row.sandboxes.filter((s) => s !== directory) + const sboxes = row.sandboxes.filter((entry) => sandboxDirectory(entry) !== directory) const result = yield* db((d) => d .update(ProjectTable) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index dab4a9ac..0171260b 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -279,18 +279,18 @@ export const ExperimentalRoutes = lazy(() => operationId: "worktree.list", responses: { 200: { - description: "List of worktree directories", + description: "List of worktrees", content: { "application/json": { - schema: resolver(z.array(z.string())), + schema: resolver(z.array(Worktree.Info)), }, }, }, }, }), async (c) => { - const sandboxes = await Project.sandboxes(Instance.project.id) - return c.json(sandboxes) + const worktrees = await Worktree.list() + return c.json(worktrees) }, ) .delete( @@ -315,7 +315,6 @@ export const ExperimentalRoutes = lazy(() => async (c) => { const body = c.req.valid("json") await Worktree.remove(body) - await Project.removeSandbox(Instance.project.id, body.directory) return c.json(true) }, ) diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index d76285e0..d26e6a3a 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -148,22 +148,18 @@ export const EnterWorktreeTool = Tool.define( ) } const branch = yield* Effect.promise(() => currentBranch(canonical)) - const info: Worktree.Info = { - name: path.basename(canonical), - directory: canonical, - branch, - } - yield* applyEnter(ctx.sessionID, info, "existing") + const info = yield* Effect.promise(() => Worktree.registerExistingByPath(canonical)) return successResult({ activeDirectory: canonical, slug: info.name, - branch, + branch: info.branch || branch, state: "reused", }) } // name= or no-arg branch - const planned = yield* Effect.promise(() => Worktree.makeWorktreeInfo(params.name)) + const existing = params.name ? yield* Effect.promise(() => Worktree.lookupBySlug(params.name!)) : undefined + const planned = existing ?? (yield* Effect.promise(() => Worktree.makeWorktreeInfo(params.name))) if (exec.activeDirectory === planned.directory) { return successResult({ activeDirectory: planned.directory, @@ -186,7 +182,7 @@ export const EnterWorktreeTool = Tool.define( if (!exists) { yield* Effect.promise(() => Worktree.createFromInfo(planned)) } - yield* applyEnter(ctx.sessionID, planned, "created") + yield* applyEnter(ctx.sessionID, planned, planned.source) return successResult({ activeDirectory: planned.directory, slug: planned.name, diff --git a/packages/opencode/src/tool/enter-worktree.txt b/packages/opencode/src/tool/enter-worktree.txt index 00e193d9..bf4a28e7 100644 --- a/packages/opencode/src/tool/enter-worktree.txt +++ b/packages/opencode/src/tool/enter-worktree.txt @@ -3,8 +3,8 @@ Bind this session to a git worktree. Once entered, every subsequent tool call re Use only when the user explicitly asks for worktree usage, or project instructions require it. Modes: -- EnterWorktree() with no arguments creates a new worktree on a fresh branch from project HEAD; the slug is auto-generated. -- EnterWorktree(name="my-feature") creates or reuses a managed worktree of that name on branch opencode/my-feature. +- EnterWorktree() with no arguments creates a new worktree under .worktrees/pawwork/ on a fresh branch from project HEAD; the slug is auto-generated. +- EnterWorktree(name="my-feature") creates or reuses a managed worktree of that name on branch pawwork/my-feature. - EnterWorktree(path="/abs/path/to/existing/worktree") enters a worktree elsewhere on disk that is part of the same git repository as the project. After entering, run further tools normally; their working directory is now the worktree. Call ExitWorktree to return to the project root. diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f2dd8206..b5c18897 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -160,6 +160,10 @@ export namespace Worktree { readonly makeWorktreeInfo: (name?: string) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect + readonly lookupByDirectory: (directory: string) => Effect.Effect + readonly lookupBySlug: (slug: string) => Effect.Effect + readonly registerExistingByPath: (directory: string) => Effect.Effect readonly remove: (input: RemoveInput) => Effect.Effect readonly reset: (input: ResetInput) => Effect.Effect } @@ -200,6 +204,111 @@ export namespace Worktree { ), ) + function asInfo( + entry: string | { directory: string; name?: string; branch?: string; source?: "created" | "existing" }, + ) { + if (typeof entry === "string") { + return Info.parse({ directory: entry, name: pathSvc.basename(entry), branch: "", source: "existing" }) + } + return Info.parse({ + directory: entry.directory, + name: entry.name ?? pathSvc.basename(entry.directory), + branch: entry.branch ?? "", + source: entry.source ?? "existing", + }) + } + + const readRegistry = Effect.fnUntraced(function* () { + const row = yield* Effect.sync(() => + Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, Instance.project.id)).get()), + ) + if (!row) return [] as Info[] + return row.sandboxes.map(asInfo) + }) + + const writeRegistry = Effect.fnUntraced(function* (entries: Info[]) { + yield* Effect.sync(() => + Database.use((db) => + db + .update(ProjectTable) + .set({ + sandboxes: entries.map((entry) => ({ + directory: entry.directory, + name: entry.name, + branch: entry.branch, + source: entry.source, + })), + time_updated: Date.now(), + }) + .where(eq(ProjectTable.id, Instance.project.id)) + .run(), + ), + ) + }) + + const upsertRegistry = Effect.fnUntraced(function* (info: Info) { + const target = yield* canonical(info.directory) + const entries = yield* readRegistry() + const next: Info[] = [] + for (const entry of entries) { + if ((yield* canonical(entry.directory)) !== target) next.push(entry) + } + next.push(info) + yield* writeRegistry(next) + }) + + const removeRegistry = Effect.fnUntraced(function* (directory: string) { + const target = yield* canonical(directory) + const entries = yield* readRegistry() + const next: Info[] = [] + for (const entry of entries) { + if ((yield* canonical(entry.directory)) !== target) next.push(entry) + } + yield* writeRegistry(next) + }) + + const lookupByDirectory = Effect.fn("Worktree.lookupByDirectory")(function* (directory: string) { + const target = yield* canonical(directory) + const entries = yield* readRegistry() + for (const entry of entries) { + if ((yield* canonical(entry.directory)) === target) return entry + } + return undefined + }) + + const lookupBySlug = Effect.fn("Worktree.lookupBySlug")(function* (slug: string) { + const entries = yield* readRegistry() + return entries.find((entry) => entry.name === slug && entry.source === "created") + }) + + const registerExistingByPath = Effect.fn("Worktree.registerExistingByPath")(function* (directory: string) { + const target = yield* canonical(directory) + const existing = yield* lookupByDirectory(target) + if (existing) return existing + const branch = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: target }) + const info = Info.parse({ + directory: target, + name: pathSvc.basename(target), + branch: branch.code === 0 ? branch.text.trim() : "", + source: "existing", + }) + yield* upsertRegistry(info) + return info + }) + + const list = Effect.fn("Worktree.list")(function* () { + const entries = yield* readRegistry() + return yield* Effect.forEach( + entries, + (entry) => + fs.isDir(entry.directory).pipe( + Effect.orDie, + Effect.map((ok) => (ok ? entry : undefined)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((arr) => arr.filter((x): x is Info => x !== undefined))) + }) + const MAX_NAME_ATTEMPTS = 26 const BRANCH_PREFIX = "pawwork/" const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) { @@ -242,7 +351,7 @@ export namespace Worktree { throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } - yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void)) + yield* upsertRegistry(Info.parse({ ...info, source: info.source ?? "created" })) }) const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) { @@ -429,6 +538,7 @@ export namespace Worktree { } } + yield* removeRegistry(input.directory).pipe(Effect.catch(() => Effect.void)) return true }) @@ -595,7 +705,17 @@ export namespace Worktree { return true }) - return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) + return Service.of({ + makeWorktreeInfo, + createFromInfo, + create, + list, + lookupByDirectory, + lookupBySlug, + registerExistingByPath, + remove, + reset, + }) }), ) @@ -620,6 +740,22 @@ export namespace Worktree { return runPromise((svc) => svc.create(input)) } + export async function list() { + return runPromise((svc) => svc.list()) + } + + export async function lookupByDirectory(directory: string) { + return runPromise((svc) => svc.lookupByDirectory(directory)) + } + + export async function lookupBySlug(slug: string) { + return runPromise((svc) => svc.lookupBySlug(slug)) + } + + export async function registerExistingByPath(directory: string) { + return runPromise((svc) => svc.registerExistingByPath(directory)) + } + export async function remove(input: RemoveInput) { return runPromise((svc) => svc.remove(input)) } diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index d31985ef..d9fd82a2 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -160,6 +160,34 @@ describe("Worktree", () => { }) }) + describe("registry source", () => { + test("created worktrees are slug-addressable, existing worktrees are path-addressable only", async () => { + await using tmp = await tmpdir({ git: true }) + const created = await withInstance(tmp.path, () => Worktree.create({ name: "feature-a" })) + await Bun.sleep(1000) + + const bySlug = await withInstance(tmp.path, () => Worktree.lookupBySlug("feature-a")) + expect(bySlug?.directory).toBe(created.directory) + expect(bySlug?.source).toBe("created") + + const external = path.join(tmp.path, "..", path.basename(tmp.path) + "-external") + await $`git worktree add ${external} -b external-${Date.now()}`.cwd(tmp.path).quiet() + + const registered = await withInstance(tmp.path, () => Worktree.registerExistingByPath(external)) + expect(registered.source).toBe("existing") + expect(registered.name).toBe(path.basename(external)) + + const byDirectory = await withInstance(tmp.path, () => Worktree.lookupByDirectory(external)) + expect(byDirectory?.source).toBe("existing") + + const notBySlug = await withInstance(tmp.path, () => Worktree.lookupBySlug(path.basename(external))) + expect(notBySlug).toBeUndefined() + + await withInstance(tmp.path, () => Worktree.remove({ directory: created.directory })) + await withInstance(tmp.path, () => Worktree.remove({ directory: external })) + }) + }) + describe("remove edge cases", () => { test("remove non-existent directory succeeds silently", async () => { await using tmp = await tmpdir({ git: true }) From 9e053b8af06a24abcdef6dfbf4fc7ede03b7fe8f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:26:31 +0800 Subject: [PATCH 15/65] fix(session): root execution context at project owner --- packages/opencode/src/session/session.ts | 14 ++++++------- .../opencode/test/session/session.test.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 1be0d712..70068885 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -454,11 +454,9 @@ export const layer: Layer.Layer = title: input.title ?? createDefaultTitle(!!input.parentID), skill: input.skill, permission: input.permission, - // ownerDirectory is the directory the user opened to create the session. - // session.directory is what the Instance was scoped to at session creation, - // which is the user-opened path (Project.worktree happens to coincide for - // git project roots; non-git paths and git subdirectories use this). - executionContext: rootContext(input.directory), + // ownerDirectory is the project root for git projects and never moves. For non-git + // projects Instance.worktree is "/" today, so keep the opened directory as the owner. + executionContext: rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), time: { created: Date.now(), updated: Date.now(), @@ -818,12 +816,14 @@ export const defaultLayer: Layer.Layer = layer.pipe( export const backfillExecutionContext = Effect.sync(() => { Database.use((d) => { const rows = d - .select({ id: SessionTable.id, directory: SessionTable.directory }) + .select({ id: SessionTable.id, directory: SessionTable.directory, project_id: SessionTable.project_id }) .from(SessionTable) .where(isNull(SessionTable.execution_context)) .all() for (const row of rows) { - const ctx = rootContext(row.directory) + const project = d.select().from(ProjectTable).where(eq(ProjectTable.id, row.project_id)).get() + const ownerDirectory = project?.vcs === "git" ? project.worktree : row.directory + const ctx = rootContext(ownerDirectory) d.update(SessionTable).set({ execution_context: ctx }).where(eq(SessionTable.id, row.id)).run() } return rows.length diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index bd124e3b..8c390b4d 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -42,6 +42,26 @@ describe("PawWork runtime namespace", () => { }) describe("session.created event", () => { + test("executionContext for a new git session is rooted at project worktree, not entry directory", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "packages", "app") + await Bun.write(path.join(subdir, ".keep"), "") + + await Instance.provide({ + directory: subdir, + fn: async () => { + const info = await SessionNs.create({}) + + expect(info.directory).toBe(subdir) + expect(info.executionContext.ownerDirectory).toBe(tmp.path) + expect(info.executionContext.activeDirectory).toBe(tmp.path) + expect(info.executionContext.activeWorktree).toBeUndefined() + + await SessionNs.remove(info.id) + }, + }) + }) + test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, From 71d115dca14141b8627dd2195d9b2e801e39f44c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:26:38 +0800 Subject: [PATCH 16/65] fix(worktree): block transitions during subagents --- .../src/session/state-machine-guard.ts | 10 +++++----- packages/opencode/src/session/subagent-run.ts | 5 +++++ packages/opencode/src/tool/enter-worktree.ts | 4 +++- packages/opencode/src/tool/exit-worktree.ts | 4 +++- .../test/session/subagent-run.test.ts | 20 +++++++++++++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/state-machine-guard.ts b/packages/opencode/src/session/state-machine-guard.ts index 3bca070e..e2f12e22 100644 --- a/packages/opencode/src/session/state-machine-guard.ts +++ b/packages/opencode/src/session/state-machine-guard.ts @@ -1,5 +1,6 @@ import { Effect } from "effect" import * as Session from "./session" +import * as SubagentRun from "./subagent-run" import type { SessionID } from "./schema" /** @@ -27,9 +28,8 @@ export const hasInFlightToolCallsExcept = ( /** * Returns true when this session has at least one active subagent run. - * - * Note: SubagentRun.activeCounts is private to the layer; a public count helper is a follow-up. - * For now this returns false unconditionally; the parent's running-tool guard above already covers - * the common case since subagents are dispatched through a parent agent tool call. */ -export const hasRunningSubagents = (_sessionID: SessionID) => Effect.succeed(false) +export const hasRunningSubagents = ( + subagents: SubagentRun.Service["Service"], + sessionID: SessionID, +) => subagents.activeForSession(sessionID) diff --git a/packages/opencode/src/session/subagent-run.ts b/packages/opencode/src/session/subagent-run.ts index ce2d85d3..491a5dfd 100644 --- a/packages/opencode/src/session/subagent-run.ts +++ b/packages/opencode/src/session/subagent-run.ts @@ -52,6 +52,7 @@ export interface RejectedInput { export interface Interface { readonly reserveSlot: (parentID: SessionID) => Effect.Effect readonly releaseSlot: (parentID: SessionID) => Effect.Effect + readonly activeForSession: (parentID: SessionID) => Effect.Effect readonly start: (input: StartInput) => Effect.Effect readonly patchSession: (toolCallID: string, sessionID: SessionID) => Effect.Effect readonly recordEvent: (toolCallID: string, event: MessageV2.SubtaskEvent) => Effect.Effect @@ -143,6 +144,9 @@ export const layer: Layer.Layer = Layer.effect( }), ) + const activeForSession = (parentID: SessionID): Effect.Effect => + Effect.succeed((activeCounts.get(parentID) ?? 0) > 0) + const readPart = (toolCallID: string) => Effect.gen(function* () { const ref = partsByToolCall.get(toolCallID) @@ -466,6 +470,7 @@ export const layer: Layer.Layer = Layer.effect( return Service.of({ reserveSlot, releaseSlot, + activeForSession, start, patchSession, recordEvent, diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index d26e6a3a..f50e74d0 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -8,6 +8,7 @@ import { Worktree } from "../worktree" import { Instance } from "../project/instance" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" import type { SessionID } from "../session/schema" +import { SubagentRun } from "../session/subagent-run" export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ const MAX_SLUG_LEN = 40 @@ -51,6 +52,7 @@ export const EnterWorktreeTool = Tool.define( "enter-worktree", Effect.gen(function* () { const sessions = yield* Session.Service + const subagents = yield* SubagentRun.Service const guard = (sessionID: SessionID, callID: string | undefined) => Effect.gen(function* () { @@ -62,7 +64,7 @@ export const EnterWorktreeTool = Tool.define( ) } } - const subs = yield* hasRunningSubagents(sessionID) + const subs = yield* hasRunningSubagents(subagents, sessionID) if (subs) { return yield* Effect.fail( new Error("Cannot enter a worktree while a subagent is running in this session."), diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index 8cde1ea8..d9a8dbd3 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -3,6 +3,7 @@ import * as Tool from "./tool" import DESCRIPTION from "./exit-worktree.txt" import * as Session from "../session/session" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" +import { SubagentRun } from "../session/subagent-run" export const Parameters = Schema.Struct({}) @@ -10,6 +11,7 @@ export const ExitWorktreeTool = Tool.define( "exit-worktree", Effect.gen(function* () { const sessions = yield* Session.Service + const subagents = yield* SubagentRun.Service const run = (_params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { @@ -21,7 +23,7 @@ export const ExitWorktreeTool = Tool.define( ) } } - const subs = yield* hasRunningSubagents(ctx.sessionID) + const subs = yield* hasRunningSubagents(subagents, ctx.sessionID) if (subs) { return yield* Effect.fail( new Error("Cannot exit a worktree while a subagent is running in this session."), diff --git a/packages/opencode/test/session/subagent-run.test.ts b/packages/opencode/test/session/subagent-run.test.ts index 8eca92b8..20f1891b 100644 --- a/packages/opencode/test/session/subagent-run.test.ts +++ b/packages/opencode/test/session/subagent-run.test.ts @@ -60,6 +60,26 @@ describe("SubtaskPart backward compat", () => { }) }) + test("reports whether a parent has active subagent slots", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const program = Effect.gen(function* () { + const svc = yield* SubagentRun.Service + const parentID = "ses_parent_active" as SessionID + + expect(yield* svc.activeForSession(parentID)).toBe(false) + yield* svc.reserveSlot(parentID) + expect(yield* svc.activeForSession(parentID)).toBe(true) + yield* svc.releaseSlot(parentID) + expect(yield* svc.activeForSession(parentID)).toBe(false) + }) + await Effect.runPromise(program.pipe(Effect.provide(Layer.mergeAll(SubagentRun.defaultLayer, Session.defaultLayer)))) + }, + }) + }) + test("start writes a running SubtaskPart on the parent message", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ From 12db7ff36db08b6bfc2bfbf5f6b5b2bccb8d8246 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:28:57 +0800 Subject: [PATCH 17/65] fix(worktree): guard managed worktree ignore entry --- .../opencode/src/worktree/gitignore-guard.ts | 60 +++++++++++++++++++ packages/opencode/src/worktree/index.ts | 2 + .../opencode/test/project/worktree.test.ts | 14 +++++ .../test/worktree/gitignore-guard.test.ts | 37 ++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 packages/opencode/src/worktree/gitignore-guard.ts create mode 100644 packages/opencode/test/worktree/gitignore-guard.test.ts diff --git a/packages/opencode/src/worktree/gitignore-guard.ts b/packages/opencode/src/worktree/gitignore-guard.ts new file mode 100644 index 00000000..ad82eb1d --- /dev/null +++ b/packages/opencode/src/worktree/gitignore-guard.ts @@ -0,0 +1,60 @@ +import { promises as fs } from "fs" +import path from "path" +import { NamedError } from "@opencode-ai/util/error" +import z from "zod" + +export const GitignoreGuardError = NamedError.create( + "WorktreeGitignoreGuardError", + z.object({ + message: z.string(), + }), +) + +const ENTRY = ".worktrees/" + +async function git(root: string, args: string[]) { + const proc = Bun.spawn(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + return { code, stdout, stderr } +} + +function hasWorktreesIgnore(text: string) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .some((line) => line === ".worktrees" || line === ".worktrees/" || line === "/.worktrees" || line === "/.worktrees/") +} + +export async function ensureWorktreesIgnored(root: string): Promise<{ changed: boolean; file: string }> { + const file = path.join(root, ".gitignore") + const before = await fs.readFile(file, "utf8").catch((error: unknown) => { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined + throw error + }) + + if (before && hasWorktreesIgnore(before)) return { changed: false, file } + + if (before !== undefined) { + const status = await git(root, ["-c", "core.fsmonitor=false", "status", "--porcelain=v1", "--", ".gitignore"]) + if (status.code !== 0) { + throw new GitignoreGuardError({ + message: status.stderr || status.stdout || "Failed to inspect .gitignore status", + }) + } + if (status.stdout.trim()) { + throw new GitignoreGuardError({ + message: ".gitignore has local changes. Commit or discard them before creating a PawWork worktree.", + }) + } + } + + const prefix = before && before.length > 0 && !before.endsWith("\n") ? "\n" : "" + const next = `${before ?? ""}${prefix}${ENTRY}\n` + await fs.writeFile(file, next) + return { changed: true, file } +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b5c18897..82500639 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -13,6 +13,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { ensureWorktreesIgnored } from "./gitignore-guard" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -344,6 +345,7 @@ export namespace Worktree { const setup = Effect.fnUntraced(function* (info: Info) { const ctx = yield* InstanceState.context + yield* Effect.promise(() => ensureWorktreesIgnored(ctx.worktree)) const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: ctx.worktree, }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index d9fd82a2..09681e35 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -140,6 +140,20 @@ describe("Worktree", () => { await Bun.sleep(100) await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory })) }) + + test("refuses to create when .gitignore has local changes", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\n") + await $`git add .gitignore && git commit -m initial-gitignore`.cwd(tmp.path).quiet() + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\ndist\n") + + await expect(withInstance(tmp.path, () => Worktree.create({ name: "blocked" }))).rejects.toThrow( + "WorktreeGitignoreGuardError", + ) + + const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() + expect(list).not.toContain("pawwork/blocked") + }) }) describe("createFromInfo", () => { diff --git a/packages/opencode/test/worktree/gitignore-guard.test.ts b/packages/opencode/test/worktree/gitignore-guard.test.ts new file mode 100644 index 00000000..e64a286c --- /dev/null +++ b/packages/opencode/test/worktree/gitignore-guard.test.ts @@ -0,0 +1,37 @@ +import { $ } from "bun" +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ensureWorktreesIgnored } from "../../src/worktree/gitignore-guard" +import { tmpdir } from "../fixture/fixture" + +describe("worktree gitignore guard", () => { + test("creates .gitignore with .worktrees entry when missing", async () => { + await using tmp = await tmpdir({ git: true }) + + const result = await ensureWorktreesIgnored(tmp.path) + + expect(result.changed).toBe(true) + expect(await fs.readFile(path.join(tmp.path, ".gitignore"), "utf8")).toBe(".worktrees/\n") + }) + + test("does not duplicate existing .worktrees entry", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\n/.worktrees/\n") + await $`git add .gitignore && git commit -m ignore-worktrees`.cwd(tmp.path).quiet() + + const result = await ensureWorktreesIgnored(tmp.path) + + expect(result.changed).toBe(false) + expect(await fs.readFile(path.join(tmp.path, ".gitignore"), "utf8")).toBe("node_modules\n/.worktrees/\n") + }) + + test("refuses to append when .gitignore has local changes", async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\n") + await $`git add .gitignore && git commit -m initial-gitignore`.cwd(tmp.path).quiet() + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\ndist\n") + + await expect(ensureWorktreesIgnored(tmp.path)).rejects.toThrow("WorktreeGitignoreGuardError") + }) +}) From ec189a7ddc037c732fd69a182be1e63155ee47cf Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:33:21 +0800 Subject: [PATCH 18/65] fix(worktree): align settings with registry entries --- .../prompt-input/workspace-chip-helpers.ts | 27 ++- .../prompt-input/workspace-chip.tsx | 3 +- .../app/src/components/settings-worktrees.tsx | 50 ++-- packages/app/src/i18n/en.ts | 2 +- packages/app/src/i18n/zh.ts | 2 +- packages/app/src/pages/layout.tsx | 4 +- .../composer/session-question-dock.tsx | 3 +- .../src/server/instance/experimental.ts | 6 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 220 +++++++++++++++--- 10 files changed, 262 insertions(+), 65 deletions(-) diff --git a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts index 4bcbef7e..dacf0a92 100644 --- a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts +++ b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts @@ -1,15 +1,23 @@ import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers" +export type WorkspaceEntry = string | { directory: string; branch?: string } + export type WorkspaceProject = { worktree: string - sandboxes?: string[] + sandboxes?: WorkspaceEntry[] +} + +function workspacePath(entry: WorkspaceEntry) { + return typeof entry === "string" ? entry : entry.directory } export function findWorkspaceProject(projects: WorkspaceProject[], directory?: string) { if (!directory) return const key = workspaceKey(directory) return projects.find( - (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => + workspaceKey(item.worktree) === key || + item.sandboxes?.some((sandbox) => workspaceKey(workspacePath(sandbox)) === key), ) } @@ -21,7 +29,7 @@ export type WorkspaceChoice = { export function workspaceChipChoices(input: { directory?: string projects: WorkspaceProject[] - listed?: string[] + listed?: WorkspaceEntry[] }): WorkspaceChoice[] { const directory = input.directory if (!directory) return [] @@ -30,11 +38,12 @@ export function workspaceChipChoices(input: { const seen = new Set() const choices: WorkspaceChoice[] = [] - const append = (value: string) => { - const key = workspaceKey(value) + const append = (value: WorkspaceEntry) => { + const path = workspacePath(value) + const key = workspaceKey(path) if (seen.has(key)) return seen.add(key) - choices.push({ path: value }) + choices.push({ path, branch: typeof value === "string" ? undefined : value.branch }) } if (!current) append(directory) @@ -42,7 +51,11 @@ export function workspaceChipChoices(input: { for (const project of input.projects) { const ordered = current && workspaceKey(project.worktree) === workspaceKey(current.worktree) - ? effectiveWorkspaceOrder(project.worktree, [project.worktree, ...(project.sandboxes ?? []), ...(input.listed ?? [])]) + ? effectiveWorkspaceOrder(project.worktree, [ + project.worktree, + ...(project.sandboxes ?? []).map(workspacePath), + ...(input.listed ?? []).map(workspacePath), + ]) : [project.worktree, ...(project.sandboxes ?? [])] for (const item of ordered) append(item) diff --git a/packages/app/src/components/prompt-input/workspace-chip.tsx b/packages/app/src/components/prompt-input/workspace-chip.tsx index b4d73565..2500d3e9 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.tsx +++ b/packages/app/src/components/prompt-input/workspace-chip.tsx @@ -34,7 +34,7 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {} return globalSDK.client.worktree .list({ directory }) .then((x) => x.data ?? []) - .catch(() => [] as string[]) + .catch(() => []) }, ) const workspaces = createMemo(() => { @@ -132,4 +132,3 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {} ) } - diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 2c7d9cde..f1addb69 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -7,6 +7,13 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { SettingsList } from "./settings-list" +type WorktreeInfo = { + name: string + branch: string + directory: string + source?: "created" | "existing" +} + function basename(p: string): string { const trimmed = p.replace(/[/\\]+$/, "") const last = trimmed.split(/[/\\]/).pop() @@ -28,21 +35,21 @@ export const SettingsWorktrees: Component = () => { const [data, { refetch }] = createResource(async () => { const res = await sdk.client.worktree.list() - return (res.data ?? []) as string[] + return (res.data ?? []) as WorktreeInfo[] }) // Sessions whose activeDirectory points at a worktree path block its deletion. - const boundDirectories = (): Set => { - const set = new Set() + const boundSessions = (): Map => { + const map = new Map() const sessions = sync.data.session ?? [] for (const s of sessions) { const exec = s.executionContext if (!exec) continue if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) { - set.add(exec.activeDirectory) + map.set(exec.activeDirectory, s.title) } } - return set + return map } const [confirming, setConfirming] = createSignal(undefined) @@ -92,21 +99,24 @@ export const SettingsWorktrees: Component = () => { data-component="settings-worktrees-list" > - {(directory) => { - const name = basename(directory) - const blocked = () => boundDirectories().has(directory) - const isConfirming = () => confirming() === directory - const isDeleting = () => deleting() === directory + {(worktree) => { + const directory = () => worktree.directory + const name = () => worktree.name || basename(worktree.directory) + const branch = () => worktree.branch || "-" + const blocker = () => boundSessions().get(worktree.directory) + const blocked = () => !!blocker() + const isConfirming = () => confirming() === worktree.directory + const isDeleting = () => deleting() === worktree.directory return (
  • - - {name} + + {name()} - - {directory} + + {branch()} · {worktree.source ?? "created"} · {directory()}
    { size="small" disabled={blocked() || isDeleting()} title={ - blocked() ? language.t("settings.worktrees.deleteDisabled.tooltip") : undefined + blocked() + ? language.t("settings.worktrees.deleteDisabled.tooltip", { + session: blocker() ?? "", + }) + : undefined } - onClick={() => setConfirming(directory)} + onClick={() => setConfirming(directory())} > {language.t("settings.worktrees.delete")} @@ -127,7 +141,7 @@ export const SettingsWorktrees: Component = () => { >
    - {language.t("settings.worktrees.confirmDelete.body", { name })} + {language.t("settings.worktrees.confirmDelete.body", { name: name() })} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8a79a30c..6e97aed2 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -817,7 +817,7 @@ export const dict = { "settings.worktrees.column.branch": "Branch", "settings.worktrees.column.path": "Path", "settings.worktrees.delete": "Delete", - "settings.worktrees.deleteDisabled.tooltip": "In use by an open session. Use ExitWorktree from that session first.", + "settings.worktrees.deleteDisabled.tooltip": "In use by {session}. Use ExitWorktree from that session first.", "settings.worktrees.confirmDelete.title": "Delete worktree?", "settings.worktrees.confirmDelete.body": "Delete \"{name}\" and all of its files? This runs git worktree remove and cannot be undone.", "settings.worktrees.confirmDelete.confirmLabel": "Delete", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b75ed5a2..cb6dd136 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -708,7 +708,7 @@ export const dict = { "settings.worktrees.column.branch": "分支", "settings.worktrees.column.path": "路径", "settings.worktrees.delete": "删除", - "settings.worktrees.deleteDisabled.tooltip": "正在被一个会话使用,先在该会话里调用 ExitWorktree。", + "settings.worktrees.deleteDisabled.tooltip": "正在被「{session}」使用,先在该会话里调用 ExitWorktree。", "settings.worktrees.confirmDelete.title": "删除该工作树?", "settings.worktrees.confirmDelete.body": "确定删除「{name}」及其所有文件?这将执行 git worktree remove,操作不可撤销。", "settings.worktrees.confirmDelete.confirmLabel": "删除", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 11d18c59..41296771 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1311,8 +1311,8 @@ export default function Layout(props: ParentProps) { const listed = await globalSDK.client.worktree .list({ directory: root }) .then((x) => x.data ?? []) - .catch(() => [] as string[]) - dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) + .catch(() => []) + dirs = effectiveWorkspaceOrder(root, [root, ...listed.map((item) => item.directory)], store.workspaceOrder[root]) return canOpen(target) } const openSession = async (target: { directory: string; id: string }) => { diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 72af9ceb..be6fb37c 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -168,7 +168,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk.client.question.reply({ requestID: props.request.id, questionReply: { answers } }), onMutate: () => { props.onSubmit() }, diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 0171260b..791c14a1 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -314,6 +314,12 @@ export const ExperimentalRoutes = lazy(() => validator("json", Worktree.RemoveInput), async (c) => { const body = c.req.valid("json") + for await (const session of Session.list({ limit: 1000 })) { + const exec = session.executionContext + if (exec.activeDirectory === body.directory && exec.activeDirectory !== exec.ownerDirectory) { + throw new Error(`Worktree is in use by session "${session.title}". Call ExitWorktree from that session first.`) + } + } await Worktree.remove(body) return c.json(true) }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index af6a8e5b..3923ed64 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -101,10 +101,10 @@ import type { PtyRemoveResponses, PtyUpdateErrors, PtyUpdateResponses, - QuestionAnswer, QuestionListResponses, QuestionRejectErrors, QuestionRejectResponses, + QuestionReply, QuestionReplyErrors, QuestionReplyResponses, SessionAbortErrors, @@ -1577,6 +1577,8 @@ export class Session2 extends HeyApiClient { skill?: string permission?: PermissionRuleset workspaceID?: string + createdByAgentTool?: boolean + subagentType?: string | null }, options?: Options, ) { @@ -1592,6 +1594,8 @@ export class Session2 extends HeyApiClient { { in: "body", key: "skill" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, + { in: "body", key: "createdByAgentTool" }, + { in: "body", key: "subagentType" }, ], }, ], @@ -2781,7 +2785,7 @@ export class Question extends HeyApiClient { requestID: string directory?: string workspace?: string - answers?: Array + questionReply?: QuestionReply }, options?: Options, ) { @@ -2793,7 +2797,7 @@ export class Question extends HeyApiClient { { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, + { key: "questionReply", map: "body" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d5509e22..cd397ea7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -86,7 +86,7 @@ export type EventLspUpdated = { export type EventLspServerInstallFailed = { type: "lsp.server.install.failed" properties: { - add: string[] + add: Array dir: string error: string } @@ -305,14 +305,19 @@ export type QuestionInfo = { custom?: boolean } +export type QuestionTool = { + messageID: string + callID: string +} + export type QuestionRequest = { id: string sessionID: string + /** + * Questions to ask + */ questions: Array - tool?: { - messageID: string - callID: string - } + tool?: QuestionTool } export type EventQuestionAsked = { @@ -322,21 +327,25 @@ export type EventQuestionAsked = { export type QuestionAnswer = Array +export type QuestionReplied = { + sessionID: string + requestID: string + answers: Array +} + export type EventQuestionReplied = { type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } + properties: QuestionReplied +} + +export type QuestionRejected = { + sessionID: string + requestID: string } export type EventQuestionRejected = { type: "question.rejected" - properties: { - sessionID: string - requestID: string - } + properties: QuestionRejected } export type Todo = { @@ -538,10 +547,12 @@ export type AssistantMessage = { providerID: string mode: string agent: string - path: { - cwd: string - root: string - } + path: + | { + cwd: string + root: string + } + | string summary?: boolean cost: number tokens: { @@ -607,6 +618,64 @@ export type SubtaskPart = { modelID: string } command?: string + tool_call_id?: string + parent_session_id?: string + parent_message_id?: string + subagent_session_id?: string + status?: "running" | "completed" | "completed_empty" | "failed" | "canceled_by_user" + started_at?: number + updated_at?: number + ended_at?: number + consumed_at?: number + recent_events?: Array< + | { + type: "started" + at: number + } + | { + type: "tool_started" + tool: string + label: string + at: number + } + | { + type: "tool_completed" + tool: string + at: number + } + | { + type: "model_wait_started" + at: number + } + | { + type: "completed" + at: number + } + | { + type: "completed_empty" + at: number + } + | { + type: "canceled_by_user" + at: number + } + | { + type: "failed" + kind: string + at: number + } + | { + type: "consumed" + at: number + } + > + result_summary?: string + result_text?: string + partial_result?: string | null + error?: { + kind: string + message: string + } } export type ReasoningPart = { @@ -881,6 +950,8 @@ export type Session = { workspaceID?: string directory: string parentID?: string + createdByAgentTool?: boolean + subagentType?: string | null skill?: string summary?: { additions: number @@ -1054,6 +1125,8 @@ export type SyncEventSessionUpdated = { workspaceID: string | null directory: string | null parentID: string | null + createdByAgentTool: boolean | null + subagentType: string | null | null skill: string | null summary: { additions: number @@ -1079,6 +1152,17 @@ export type SyncEventSessionUpdated = { snapshot?: string diff?: string } | null + executionContext: { + ownerDirectory: string + activeDirectory: string + activeWorktree?: { + directory: string + name: string + branch?: string + source: "created" | "existing" + } + lastChangedAt: number + } | null } } } @@ -1151,15 +1235,14 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf export type PermissionConfig = | PermissionActionConfig - | ({ - [key: string]: PermissionRuleConfig - } & { + | { read?: PermissionRuleConfig edit?: PermissionRuleConfig glob?: PermissionRuleConfig grep?: PermissionRuleConfig list?: PermissionRuleConfig bash?: PermissionRuleConfig + agent?: PermissionRuleConfig task?: PermissionRuleConfig external_directory?: PermissionRuleConfig todowrite?: PermissionActionConfig @@ -1171,7 +1254,7 @@ export type PermissionConfig = doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined - }) + } export type AgentConfig = { model?: string @@ -1782,6 +1865,7 @@ export type Worktree = { name: string branch: string directory: string + source?: "created" | "existing" } export type WorktreeCreateInput = { @@ -1813,6 +1897,8 @@ export type GlobalSession = { workspaceID?: string directory: string parentID?: string + createdByAgentTool?: boolean + subagentType?: string | null skill?: string summary?: { additions: number @@ -1838,6 +1924,17 @@ export type GlobalSession = { snapshot?: string diff?: string } + executionContext: { + ownerDirectory: string + activeDirectory: string + activeWorktree?: { + directory: string + name: string + branch?: string + source: "created" | "existing" + } + lastChangedAt: number + } project: ProjectSummary | null } @@ -1903,6 +2000,71 @@ export type SubtaskPartInput = { modelID: string } command?: string + tool_call_id?: string + parent_session_id?: string + parent_message_id?: string + subagent_session_id?: string + status?: "running" | "completed" | "completed_empty" | "failed" | "canceled_by_user" + started_at?: number + updated_at?: number + ended_at?: number + consumed_at?: number + recent_events?: Array< + | { + type: "started" + at: number + } + | { + type: "tool_started" + tool: string + label: string + at: number + } + | { + type: "tool_completed" + tool: string + at: number + } + | { + type: "model_wait_started" + at: number + } + | { + type: "completed" + at: number + } + | { + type: "completed_empty" + at: number + } + | { + type: "canceled_by_user" + at: number + } + | { + type: "failed" + kind: string + at: number + } + | { + type: "consumed" + at: number + } + > + result_summary?: string + result_text?: string + partial_result?: string | null + error?: { + kind: string + message: string + } +} + +export type QuestionReply = { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array } export type ProviderAuthMethod = { @@ -2967,9 +3129,9 @@ export type WorktreeListData = { export type WorktreeListResponses = { /** - * List of worktree directories + * List of worktrees */ - 200: Array + 200: Array } export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] @@ -3151,6 +3313,8 @@ export type SessionCreateData = { skill?: string permission?: PermissionRuleset workspaceID?: string + createdByAgentTool?: boolean + subagentType?: string | null } path?: never query?: { @@ -4241,12 +4405,7 @@ export type QuestionListResponses = { export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } + body?: QuestionReply path: { requestID: string } @@ -4479,6 +4638,7 @@ export type FindTextResponses = { }> }> partial: boolean + partialReason?: "invalid_pattern" | "partial_io" } } From c64db085e883d446d00fafff1cf9790b92f3d1a0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:38:41 +0800 Subject: [PATCH 19/65] fix(app): mount worktree badge inside directory context --- packages/app/src/pages/directory-layout.tsx | 6 +++++- packages/app/src/pages/layout.tsx | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 427b4823..fa5d232a 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" +import { PawworkWorktreeBadge } from "./layout/pawwork-worktree-badge" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const location = useLocation() @@ -74,7 +75,10 @@ export default function Layout(props: ParentProps) { {(resolved) => ( resolved}> - {props.children} + + + {props.children} + )} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 41296771..30a3e2ac 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -86,7 +86,6 @@ import { import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" import { PawworkTitlebar } from "./layout/pawwork-titlebar" -import { PawworkWorktreeBadge } from "./layout/pawwork-worktree-badge" import { SettingsPage, type SettingsPageTab } from "@/components/settings-page" import { DialogDeleteSession } from "@/components/dialog-delete-session" @@ -2062,7 +2061,6 @@ export default function Layout(props: ParentProps) { > language.t("sidebar.settings")} /> -
    From 51d510aac17054e21b97d476709d3128cdcec006 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:49:54 +0800 Subject: [PATCH 20/65] fix(app): use global context for worktree settings --- .../app/src/components/settings-worktrees.tsx | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index f1addb69..cab8ad09 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -2,15 +2,16 @@ import { type Component, createResource, createSignal, For, Show } from "solid-j import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { showToast } from "@opencode-ai/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" import { SettingsList } from "./settings-list" type WorktreeInfo = { name: string branch: string directory: string + ownerDirectory: string source?: "created" | "existing" } @@ -30,23 +31,48 @@ function basename(p: string): string { */ export const SettingsWorktrees: Component = () => { const language = useLanguage() - const sdk = useSDK() - const sync = useSync() + const sdk = useGlobalSDK() + const sync = useGlobalSync() - const [data, { refetch }] = createResource(async () => { - const res = await sdk.client.worktree.list() - return (res.data ?? []) as WorktreeInfo[] - }) + const projectRoots = () => + sync.data.project.filter((project) => project.vcs === "git").map((project) => project.worktree).filter(Boolean) + + const [data, { refetch }] = createResource( + () => projectRoots().join("\0"), + async () => { + const rows = await Promise.all( + projectRoots().map(async (ownerDirectory) => { + const res = await sdk.client.worktree.list({ directory: ownerDirectory }) + return (res.data ?? []).map((worktree) => ({ ...worktree, ownerDirectory }) as WorktreeInfo) + }), + ) + const byDirectory = new Map() + for (const row of rows.flat()) byDirectory.set(row.directory, row) + return Array.from(byDirectory.values()) + }, + ) // Sessions whose activeDirectory points at a worktree path block its deletion. const boundSessions = (): Map => { const map = new Map() - const sessions = sync.data.session ?? [] - for (const s of sessions) { - const exec = s.executionContext - if (!exec) continue - if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) { - map.set(exec.activeDirectory, s.title) + const directories = new Set() + for (const project of sync.data.project) { + directories.add(project.worktree) + for (const sandbox of project.sandboxes ?? []) directories.add(sandbox) + } + for (const worktree of data() ?? []) { + directories.add(worktree.ownerDirectory) + directories.add(worktree.directory) + } + + for (const directory of directories) { + const [store] = sync.child(directory, { bootstrap: false }) + for (const s of store.session ?? []) { + const exec = s.executionContext + if (!exec) continue + if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) { + map.set(exec.activeDirectory, s.title) + } } } return map @@ -58,7 +84,8 @@ export const SettingsWorktrees: Component = () => { const handleDelete = async (directory: string) => { setDeleting(directory) try { - const res = await sdk.client.worktree.remove({ worktreeRemoveInput: { directory } }) + const ownerDirectory = data()?.find((worktree) => worktree.directory === directory)?.ownerDirectory ?? directory + const res = await sdk.client.worktree.remove({ directory: ownerDirectory, worktreeRemoveInput: { directory } }) if (res.error) throw new Error(JSON.stringify(res.error)) setConfirming(undefined) void refetch() From 82c4c2c16a666e3fb040c20c4f7c367ba8ea960c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:55:15 +0800 Subject: [PATCH 21/65] fix(app): simplify worktree titlebar badge --- .../src/components/session/session-header.tsx | 20 ++++++- packages/app/src/pages/directory-layout.tsx | 2 - .../pages/layout/pawwork-worktree-badge.tsx | 55 +++---------------- 3 files changed, 28 insertions(+), 49 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1972314a..f843b6f4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -14,6 +14,7 @@ import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" +import { PawworkWorktreeBadge } from "@/pages/layout/pawwork-worktree-badge" import { useSessionLayout } from "@/pages/session/session-layout" import { decode64 } from "@/utils/base64" import { StatusPopover } from "../status-popover" @@ -42,6 +43,11 @@ export function SessionHeader() { }) const sessionInfo = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const sessionTitle = createMemo(() => sessionInfo()?.title || params.id || "") + const activeWorktree = createMemo(() => { + const exec = sessionInfo()?.executionContext + if (!exec || exec.activeDirectory === exec.ownerDirectory) return + return exec.activeWorktree + }) const homeTitle = createMemo(() => language.t("command.session.new")) const onSessionRoute = createMemo(() => location.pathname.includes("/session")) const fileManagerLabel = createMemo(() => { @@ -80,7 +86,7 @@ export function SessionHeader() { {(mount) => ( - diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index fa5d232a..a08234b9 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -8,7 +8,6 @@ import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" -import { PawworkWorktreeBadge } from "./layout/pawwork-worktree-badge" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const location = useLocation() @@ -76,7 +75,6 @@ export default function Layout(props: ParentProps) { resolved}> - {props.children} diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index dff44806..f6c0f2fb 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -1,51 +1,14 @@ -import { createMemo, createSignal, onMount, Show } from "solid-js" -import { Portal } from "solid-js/web" -import { useParams } from "@solidjs/router" import { Icon } from "@opencode-ai/ui/icon" -import { useSync } from "@/context/sync" - -/** - * Renders an inline worktree indicator (slug · branch) in the titlebar center slot whenever the - * active session is bound to a worktree (executionContext.activeDirectory != ownerDirectory). - * Hidden when the session is at project root, when no session is open, or when there is no - * activeWorktree on the executionContext. - */ -export function PawworkWorktreeBadge() { - const params = useParams() - const sync = useSync() - const [centerMount, setCenterMount] = createSignal() - - onMount(() => { - setCenterMount(document.getElementById("opencode-titlebar-center") ?? undefined) - }) - - const session = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const exec = createMemo(() => session()?.executionContext) - const wt = createMemo(() => exec()?.activeWorktree) - const visible = createMemo(() => { - const e = exec() - if (!e) return false - return e.activeDirectory !== e.ownerDirectory && wt() !== undefined - }) +export function PawworkWorktreeBadge(props: { name: string; branch?: string; directory?: string }) { return ( - - {(mount) => ( - - - - )} - +
    + + {props.name} +
    ) } From a0250cc68bd8dd24ccb759a17dce7eae76582b6d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 18:57:04 +0800 Subject: [PATCH 22/65] fix(app): unify worktree title typography --- .../app/src/components/session/session-header.tsx | 11 +++++------ .../app/src/pages/layout/pawwork-worktree-badge.tsx | 5 +---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index f843b6f4..75b1522d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -86,7 +86,7 @@ export function SessionHeader() { {(mount) => ( - diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index f6c0f2fb..30077ee4 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -1,13 +1,10 @@ -import { Icon } from "@opencode-ai/ui/icon" - export function PawworkWorktreeBadge(props: { name: string; branch?: string; directory?: string }) { return (
    - {props.name}
    ) From 452a0897a5d839e323e7b4e529ba73c9066e5dc7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 19:02:42 +0800 Subject: [PATCH 23/65] fix(app): preserve composer scroll dock height --- .../session/use-session-scroll-dock.test.ts | 29 +++++++++++++++++++ .../pages/session/use-session-scroll-dock.ts | 14 +++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/use-session-scroll-dock.test.ts b/packages/app/src/pages/session/use-session-scroll-dock.test.ts index 7281373e..38412f39 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.test.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.test.ts @@ -141,4 +141,33 @@ describe("session scroll dock", () => { else document.documentElement.style.removeProperty("--composer-dock-height") } }) + + test("keeps the previous composer height during transient zero measurements", () => { + const scroller = makeScroller({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 600, + }) + const cssHeights: number[] = [] + const scrolls: number[] = [] + const schedules: number[] = [] + const fills: number[] = [] + + const next = syncComposerDockHeight({ + el: scroller.el, + previousDockHeight: 180, + nextDockHeight: 0, + userScrolled: false, + setCssHeight: (height) => cssHeights.push(height), + forceScrollToBottom: () => scrolls.push(1), + scheduleScrollState: () => schedules.push(1), + fill: () => fills.push(1), + }) + + expect(next).toBe(180) + expect(cssHeights).toHaveLength(0) + expect(scrolls).toHaveLength(0) + expect(schedules).toHaveLength(1) + expect(fills).toHaveLength(1) + }) }) diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts index 7dd501d5..b1b80a7e 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -47,6 +47,12 @@ export function syncComposerDockHeight(input: { scheduleScrollState: (el: HTMLDivElement) => void fill: () => void }) { + if (input.nextDockHeight <= 0) { + if (input.el instanceof HTMLDivElement) input.scheduleScrollState(input.el) + input.fill() + return input.previousDockHeight + } + input.setCssHeight(input.nextDockHeight) if (input.nextDockHeight === input.previousDockHeight) { @@ -145,10 +151,12 @@ export function createSessionScrollDock(input: { }) } + const measurePromptDockHeight = () => Math.ceil(promptDock?.getBoundingClientRect().height ?? 0) + const setPromptDockRef = (el: HTMLDivElement | undefined) => { promptDock = el if (!el) return - const next = Math.ceil(el.getBoundingClientRect().height) + const next = measurePromptDockHeight() if (next > 0) updateDockHeight(next) } @@ -181,8 +189,8 @@ export function createSessionScrollDock(input: { createResizeObserver( () => promptDock, - ({ height }) => { - updateDockHeight(Math.ceil(height)) + () => { + updateDockHeight(measurePromptDockHeight()) }, ) From 0cd3a8e619c232151ab166501848f1b8fac4124e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 19:57:00 +0800 Subject: [PATCH 24/65] fix(app): address worktree settings review --- .../prompt-input/workspace-chip-helpers.ts | 13 ++++++++++++- .../prompt-input/workspace-chip.test.ts | 19 +++++++++++++++++++ .../app/src/components/settings-worktrees.tsx | 14 ++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts index dacf0a92..645a25b6 100644 --- a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts +++ b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts @@ -37,13 +37,24 @@ export function workspaceChipChoices(input: { const current = findWorkspaceProject(input.projects, directory) const seen = new Set() const choices: WorkspaceChoice[] = [] + const branchByPath = new Map() + + const remember = (value: WorkspaceEntry) => { + if (typeof value === "string") return + branchByPath.set(workspaceKey(value.directory), value.branch) + } + + for (const item of input.listed ?? []) remember(item) + for (const project of input.projects) { + for (const item of project.sandboxes ?? []) remember(item) + } const append = (value: WorkspaceEntry) => { const path = workspacePath(value) const key = workspaceKey(path) if (seen.has(key)) return seen.add(key) - choices.push({ path, branch: typeof value === "string" ? undefined : value.branch }) + choices.push({ path, branch: typeof value === "string" ? branchByPath.get(key) : value.branch }) } if (!current) append(directory) diff --git a/packages/app/src/components/prompt-input/workspace-chip.test.ts b/packages/app/src/components/prompt-input/workspace-chip.test.ts index df239f99..9bbfecbc 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.test.ts +++ b/packages/app/src/components/prompt-input/workspace-chip.test.ts @@ -68,3 +68,22 @@ test("branch field is optional (not required when SDK can't resolve)", () => { expect(result[0].branch === undefined || typeof result[0].branch === "string").toBe(true) }) + +test("workspaceChipChoices preserves branch metadata after workspace ordering", () => { + const result = workspaceChipChoices({ + directory: "/repo/feature-a", + projects: [ + { + worktree: "/repo/main", + sandboxes: [{ directory: "/repo/feature-a", branch: "pawwork/feature-a" }], + }, + ], + listed: [{ directory: "/repo/feature-b", branch: "pawwork/feature-b" }], + }) + + expect(result).toEqual([ + { path: "/repo/main", branch: undefined }, + { path: "/repo/feature-a", branch: "pawwork/feature-a" }, + { path: "/repo/feature-b", branch: "pawwork/feature-b" }, + ]) +}) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index cab8ad09..6fcce644 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -21,6 +21,16 @@ function basename(p: string): string { return last || p } +function entryDirectory(entry: string | { directory: string }) { + return typeof entry === "string" ? entry : entry.directory +} + +function errorText(error: unknown) { + if (typeof error === "string") return error + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message + return JSON.stringify(error) +} + /** * Settings → Worktrees panel. * @@ -58,7 +68,7 @@ export const SettingsWorktrees: Component = () => { const directories = new Set() for (const project of sync.data.project) { directories.add(project.worktree) - for (const sandbox of project.sandboxes ?? []) directories.add(sandbox) + for (const sandbox of project.sandboxes ?? []) directories.add(entryDirectory(sandbox)) } for (const worktree of data() ?? []) { directories.add(worktree.ownerDirectory) @@ -86,7 +96,7 @@ export const SettingsWorktrees: Component = () => { try { const ownerDirectory = data()?.find((worktree) => worktree.directory === directory)?.ownerDirectory ?? directory const res = await sdk.client.worktree.remove({ directory: ownerDirectory, worktreeRemoveInput: { directory } }) - if (res.error) throw new Error(JSON.stringify(res.error)) + if (res.error) throw new Error(errorText(res.error)) setConfirming(undefined) void refetch() } catch (err) { From ab288d9706aeeca764bb2fec7dcf1ecd97b676b8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 19:57:12 +0800 Subject: [PATCH 25/65] fix(worktree): close execution context review gaps --- packages/opencode/src/project/instance.ts | 24 +++++- .../src/server/instance/experimental.ts | 8 +- packages/opencode/src/session/message-v2.ts | 13 ++- packages/opencode/src/session/session.ts | 21 +++++ packages/opencode/src/tool/enter-worktree.ts | 1 + packages/opencode/test/project/state.test.ts | 31 +++++++ .../opencode/test/session/session.test.ts | 81 +++++++++++++++++++ 7 files changed, 170 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 1147e129..773f1702 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -53,6 +53,11 @@ function track(directory: string, next: Promise) { return task } +function matchesOverride(ctx: InstanceContext, input: { worktree?: string; project?: Project.Info }) { + if (!input.worktree && !input.project) return true + return ctx.worktree === input.worktree && ctx.project.id === input.project?.id +} + export const Instance = { async provide(input: { directory: string @@ -61,6 +66,10 @@ export const Instance = { project?: Project.Info fn: () => R }): Promise { + if (!!input.worktree !== !!input.project) { + throw new Error("Instance.provide requires both worktree and project when overriding context") + } + const directory = Filesystem.resolve(input.directory) let existing = cache.get(directory) if (!existing) { @@ -75,7 +84,20 @@ export const Instance = { }), ) } - const ctx = await existing + let ctx = await existing + if (!matchesOverride(ctx, input)) { + Log.Default.info("recreating instance with explicit context", { directory }) + existing = track( + directory, + boot({ + directory, + init: input.init, + worktree: input.worktree, + project: input.project, + }), + ) + ctx = await existing + } return context.provide(ctx, async () => { return input.fn() }) diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 791c14a1..fd980afe 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -314,11 +314,9 @@ export const ExperimentalRoutes = lazy(() => validator("json", Worktree.RemoveInput), async (c) => { const body = c.req.valid("json") - for await (const session of Session.list({ limit: 1000 })) { - const exec = session.executionContext - if (exec.activeDirectory === body.directory && exec.activeDirectory !== exec.ownerDirectory) { - throw new Error(`Worktree is in use by session "${session.title}". Call ExitWorktree from that session first.`) - } + const session = await Session.findActiveWorktreeBinding(body.directory) + if (session) { + throw new Error(`Worktree is in use by session "${session.title}". Call ExitWorktree from that session first.`) } await Worktree.remove(body) return c.json(true) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index aa3f2dfc..80db95bf 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -587,12 +587,19 @@ export const cursor = { }, } -const info = (row: typeof MessageTable.$inferSelect) => - ({ +const info = (row: typeof MessageTable.$inferSelect) => { + const raw = { ...row.data, id: row.id, sessionID: row.session_id, - }) as Info + } as Record + + if (raw.role === "assistant" && typeof raw.path === "string") { + raw.path = { cwd: raw.path, root: raw.path } + } + + return raw as Info +} const part = (row: typeof PartTable.$inferSelect) => ({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 70068885..c61f5b94 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -389,6 +389,7 @@ export interface Interface { activeDirectory?: string activeWorktree?: SessionExecutionContext["activeWorktree"] | null }) => Effect.Effect + readonly findActiveWorktreeBinding: (directory: string) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect @@ -712,6 +713,18 @@ export const layer: Layer.Layer = return { ...current, executionContext: next } }) + const findActiveWorktreeBinding = Effect.fn("Session.findActiveWorktreeBinding")(function* (directory: string) { + const project = Instance.project + const rows = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all()) + for (const row of rows) { + const session = fromRow(row) + const exec = session.executionContext + if (exec.activeDirectory === exec.ownerDirectory) continue + if (exec.activeDirectory === directory || exec.activeWorktree?.directory === directory) return session + } + return undefined + }) + const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { return yield* storage .read(["session_diff", sessionID]) @@ -786,6 +799,7 @@ export const layer: Layer.Layer = clearRevert, setSummary, updateExecutionContext, + findActiveWorktreeBinding, diff, messages, children, @@ -844,6 +858,13 @@ export const messages = fn(MessagesInput, (input) => runPromise((svc) => svc.mes export const removePart = fn(RemovePartInput, (input) => runPromise((svc) => svc.removePart(input))) export const updateMessage = fn(MessageV2.Info, (input) => runPromise((svc) => svc.updateMessage(input))) export const updatePart = fn(MessageV2.Part, (input) => runPromise((svc) => svc.updatePart(input))) +export const updateExecutionContext = (input: { + sessionID: SessionID + activeDirectory?: string + activeWorktree?: SessionExecutionContext["activeWorktree"] | null +}) => runPromise((svc) => svc.updateExecutionContext(input)) +export const findActiveWorktreeBinding = (directory: string) => + runPromise((svc) => svc.findActiveWorktreeBinding(directory)) type ListSort = "updated" | "created" type GlobalListCursor = diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index f50e74d0..b10f6840 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -151,6 +151,7 @@ export const EnterWorktreeTool = Tool.define( } const branch = yield* Effect.promise(() => currentBranch(canonical)) const info = yield* Effect.promise(() => Worktree.registerExistingByPath(canonical)) + yield* applyEnter(ctx.sessionID, { ...info, branch: info.branch || branch }, "existing") return successResult({ activeDirectory: canonical, slug: info.name, diff --git a/packages/opencode/test/project/state.test.ts b/packages/opencode/test/project/state.test.ts index c1a6dab3..1fdfe04f 100644 --- a/packages/opencode/test/project/state.test.ts +++ b/packages/opencode/test/project/state.test.ts @@ -113,3 +113,34 @@ test("Instance.state dedupes concurrent promise initialization", async () => { expect(a).toBe(b) expect(n).toBe(1) }) + +test("Instance.provide recreates cached context for explicit worktree overrides", async () => { + await using owner = await tmpdir() + await using active = await tmpdir() + const project = { + id: "prj_test", + worktree: owner.path, + vcs: "git", + time: { created: 0, updated: 0 }, + sandboxes: [], + } as any + + await Instance.provide({ + directory: active.path, + worktree: active.path, + project, + fn: async () => { + expect(Instance.worktree).toBe(active.path) + }, + }) + + await Instance.provide({ + directory: active.path, + worktree: owner.path, + project, + fn: async () => { + expect(Instance.worktree).toBe(owner.path) + expect(Instance.project.id).toBe(project.id) + }, + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 8c390b4d..c5861347 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" +import { Database } from "../../src/storage/db" +import { MessageTable } from "../../src/session/session.sql" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -62,6 +64,35 @@ describe("session.created event", () => { }) }) + test("findActiveWorktreeBinding checks activeWorktree directory without list caps", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-a") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "bound worktree" }) + await SessionNs.findActiveWorktreeBinding(worktree).then((found) => expect(found).toBeUndefined()) + + await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeDirectory: worktree, + activeWorktree: { + directory: worktree, + name: "feature-a", + branch: "pawwork/feature-a", + source: "created", + }, + }) + + const found = await SessionNs.findActiveWorktreeBinding(worktree) + expect(found?.id).toBe(session.id) + + await SessionNs.remove(session.id) + }, + }) + }) + test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, @@ -119,6 +150,56 @@ describe("session.created event", () => { }) }) +describe("MessageV2 hydration", () => { + test("normalizes legacy assistant string path from database rows", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "legacy-message-path" }) + const messageID = MessageID.ascending() + const parentID = MessageID.ascending() + const created = Date.now() + + Database.use((db) => + db + .insert(MessageTable) + .values({ + id: messageID, + session_id: session.id, + time_created: created, + data: { + role: "assistant", + time: { created, completed: created }, + parentID, + modelID: "test-model", + providerID: "test-provider", + mode: "", + agent: "general", + path: tmp.path, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as any, + }) + .run(), + ) + + const got = MessageV2.get({ sessionID: session.id, messageID }) + expect((got.info as MessageV2.Assistant).path).toEqual({ cwd: tmp.path, root: tmp.path }) + + await SessionNs.remove(session.id) + }, + }) + }) +}) + describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", From 993fcfa1e18093c2daf55b9d9dd406cb148c3107 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 19:57:22 +0800 Subject: [PATCH 26/65] fix(sdk): require active worktree branch --- .../opencode/src/session/execution-context.ts | 2 +- packages/opencode/src/session/session.sql.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 6 +- packages/sdk/openapi.json | 1134 ++++++++++++++--- 4 files changed, 931 insertions(+), 213 deletions(-) diff --git a/packages/opencode/src/session/execution-context.ts b/packages/opencode/src/session/execution-context.ts index 47ae9965..09ddbc13 100644 --- a/packages/opencode/src/session/execution-context.ts +++ b/packages/opencode/src/session/execution-context.ts @@ -3,7 +3,7 @@ import z from "zod" export const ActiveWorktree = z.object({ directory: z.string(), name: z.string(), - branch: z.string().optional(), + branch: z.string(), source: z.enum(["created", "existing"]), }) export type ActiveWorktree = z.infer diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 70443202..3c6d65df 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -33,7 +33,7 @@ export const SessionTable = sqliteTable( activeWorktree?: { directory: string name: string - branch?: string + branch: string source: "created" | "existing" } lastChangedAt: number diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cd397ea7..535a65f4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -983,7 +983,7 @@ export type Session = { activeWorktree?: { directory: string name: string - branch?: string + branch: string source: "created" | "existing" } lastChangedAt: number @@ -1158,7 +1158,7 @@ export type SyncEventSessionUpdated = { activeWorktree?: { directory: string name: string - branch?: string + branch: string source: "created" | "existing" } lastChangedAt: number @@ -1930,7 +1930,7 @@ export type GlobalSession = { activeWorktree?: { directory: string name: string - branch?: string + branch: string source: "created" | "existing" } lastChangedAt: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 458d902d..81f6390d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1886,13 +1886,13 @@ "description": "List all sandbox worktrees for the current project.", "responses": { "200": { - "description": "List of worktree directories", + "description": "List of worktrees", "content": { "application/json": { "schema": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Worktree" } } } @@ -2336,6 +2336,19 @@ "workspaceID": { "type": "string", "pattern": "^wrk.*" + }, + "createdByAgentTool": { + "type": "boolean" + }, + "subagentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } } } @@ -4998,19 +5011,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "answers": { - "description": "User answers in order of questions (each answer is an array of selected labels)", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": [ - "answers" - ] + "$ref": "#/components/schemas/QuestionReply" } } } @@ -5494,6 +5495,13 @@ }, "partial": { "type": "boolean" + }, + "partialReason": { + "type": "string", + "enum": [ + "invalid_pattern", + "partial_io" + ] } }, "required": [ @@ -7704,13 +7712,11 @@ "properties": { "label": { "description": "Display text (1–5 words, max 50 chars)", - "type": "string", - "maxLength": 50 + "type": "string" }, "description": { "description": "One-line explanation of choice (max 50 chars)", - "type": "string", - "maxLength": 50 + "type": "string" } }, "required": [ @@ -7723,13 +7729,11 @@ "properties": { "question": { "description": "Short question (max 200 chars). Stream longer framing as normal output first.", - "type": "string", - "maxLength": 200 + "type": "string" }, "header": { "description": "Very short label (max 30 chars)", - "type": "string", - "maxLength": 30 + "type": "string" }, "options": { "description": "Available choices (2–4)", @@ -7755,6 +7759,22 @@ "options" ] }, + "QuestionTool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "messageID", + "callID" + ] + }, "QuestionRequest": { "type": "object", "properties": { @@ -7767,6 +7787,7 @@ "pattern": "^ses.*" }, "questions": { + "description": "Questions to ask", "minItems": 1, "maxItems": 4, "type": "array", @@ -7775,20 +7796,7 @@ } }, "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": [ - "messageID", - "callID" - ] + "$ref": "#/components/schemas/QuestionTool" } }, "required": [ @@ -7819,6 +7827,30 @@ "type": "string" } }, + "QuestionReplied": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": [ + "sessionID", + "requestID", + "answers" + ] + }, "Event.question.replied": { "type": "object", "properties": { @@ -7827,28 +7859,7 @@ "const": "question.replied" }, "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": [ - "sessionID", - "requestID", - "answers" - ] + "$ref": "#/components/schemas/QuestionReplied" } }, "required": [ @@ -7856,6 +7867,23 @@ "properties" ] }, + "QuestionRejected": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": [ + "sessionID", + "requestID" + ] + }, "Event.question.rejected": { "type": "object", "properties": { @@ -7864,21 +7892,7 @@ "const": "question.rejected" }, "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": [ - "sessionID", - "requestID" - ] + "$ref": "#/components/schemas/QuestionRejected" } }, "required": [ @@ -8571,18 +8585,25 @@ "type": "string" }, "path": { - "type": "object", - "properties": { - "cwd": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "cwd", + "root" + ] }, - "root": { + { "type": "string" } - }, - "required": [ - "cwd", - "root" ] }, "summary": { @@ -8824,122 +8845,357 @@ }, "command": { "type": "string" - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] - }, - "ReasoningPart": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "tool_call_id": { + "type": "string" }, - "type": { - "type": "string", - "const": "reasoning" + "parent_session_id": { + "type": "string" }, - "text": { + "parent_message_id": { "type": "string" }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "subagent_session_id": { + "type": "string" }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": [ - "start" + "status": { + "default": "completed", + "type": "string", + "enum": [ + "running", + "completed", + "completed_empty", + "failed", + "canceled_by_user" ] - } - }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ] - }, - "FilePartSourceText": { - "type": "object", - "properties": { - "value": { - "type": "string" }, - "start": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 + "started_at": { + "type": "number" }, - "end": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - } - }, - "required": [ - "value", - "start", - "end" - ] - }, - "FileSource": { - "type": "object", - "properties": { - "text": { - "$ref": "#/components/schemas/FilePartSourceText" + "updated_at": { + "type": "number" }, - "type": { - "type": "string", - "const": "file" + "ended_at": { + "type": "number" }, - "path": { - "type": "string" - } - }, - "required": [ - "text", - "type", - "path" - ] - }, - "Range": { - "type": "object", - "properties": { - "start": { - "type": "object", - "properties": { - "line": { - "type": "number" + "consumed_at": { + "type": "number" + }, + "recent_events": { + "default": [], + "maxItems": 20, + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "started" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_started" + }, + "tool": { + "type": "string" + }, + "label": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "tool", + "label", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_completed" + }, + "tool": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "tool", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_wait_started" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "completed" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "completed_empty" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "canceled_by_user" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + }, + "kind": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "kind", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "consumed" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + } + ] + } + }, + "result_summary": { + "type": "string" + }, + "result_text": { + "type": "string" + }, + "partial_result": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "kind", + "message" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "prompt", + "description", + "agent" + ] + }, + "ReasoningPart": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^prt.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "type": { + "type": "string", + "const": "reasoning" + }, + "text": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + } + }, + "required": [ + "start" + ] + } + }, + "required": [ + "id", + "sessionID", + "messageID", + "type", + "text", + "time" + ] + }, + "FilePartSourceText": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "value", + "start", + "end" + ] + }, + "FileSource": { + "type": "object", + "properties": { + "text": { + "$ref": "#/components/schemas/FilePartSourceText" + }, + "type": { + "type": "string", + "const": "file" + }, + "path": { + "type": "string" + } + }, + "required": [ + "text", + "type", + "path" + ] + }, + "Range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "number" }, "character": { "type": "number" @@ -9807,6 +10063,21 @@ "type": "string", "pattern": "^ses.*" }, + "createdByAgentTool": { + "default": false, + "type": "boolean" + }, + "subagentType": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "skill": { "type": "string" }, @@ -9901,21 +10172,48 @@ "executionContext": { "type": "object", "properties": { - "ownerDirectory": { "type": "string" }, - "activeDirectory": { "type": "string" }, + "ownerDirectory": { + "type": "string" + }, + "activeDirectory": { + "type": "string" + }, "activeWorktree": { "type": "object", "properties": { - "directory": { "type": "string" }, - "name": { "type": "string" }, - "branch": { "type": "string" }, - "source": { "type": "string", "enum": ["created", "existing"] } + "directory": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "created", + "existing" + ] + } }, - "required": ["directory", "name", "source"] + "required": [ + "directory", + "name", + "branch", + "source" + ] }, - "lastChangedAt": { "type": "number" } + "lastChangedAt": { + "type": "number" + } }, - "required": ["ownerDirectory", "activeDirectory", "lastChangedAt"] + "required": [ + "ownerDirectory", + "activeDirectory", + "lastChangedAt" + ] } }, "required": [ @@ -10435,6 +10733,35 @@ } ] }, + "createdByAgentTool": { + "anyOf": [ + { + "default": false, + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "subagentType": { + "anyOf": [ + { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, "skill": { "anyOf": [ { @@ -10604,6 +10931,59 @@ "type": "null" } ] + }, + "executionContext": { + "anyOf": [ + { + "type": "object", + "properties": { + "ownerDirectory": { + "type": "string" + }, + "activeDirectory": { + "type": "string" + }, + "activeWorktree": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "created", + "existing" + ] + } + }, + "required": [ + "directory", + "name", + "branch", + "source" + ] + }, + "lastChangedAt": { + "type": "number" + } + }, + "required": [ + "ownerDirectory", + "activeDirectory", + "lastChangedAt" + ] + }, + { + "type": "null" + } + ] } }, "required": [ @@ -10613,12 +10993,15 @@ "workspaceID", "directory", "parentID", + "createdByAgentTool", + "subagentType", "skill", "summary", "title", "version", "permission", - "revert" + "revert", + "executionContext" ] } }, @@ -10770,15 +11153,12 @@ }, "PermissionConfig": { "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" + }, { "type": "object", "properties": { - "__originalKeys": { - "type": "array", - "items": { - "type": "string" - } - }, "read": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -10800,6 +11180,9 @@ "agent": { "$ref": "#/components/schemas/PermissionRuleConfig" }, + "task": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, "external_directory": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -10830,10 +11213,7 @@ }, "additionalProperties": { "$ref": "#/components/schemas/PermissionRuleConfig" - } - }, - { - "$ref": "#/components/schemas/PermissionActionConfig" + } } ] }, @@ -11703,6 +12083,24 @@ } } }, + "tool_output": { + "description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", + "type": "object", + "properties": { + "max_lines": { + "description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "max_bytes": { + "description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + } + }, "compaction": { "type": "object", "properties": { @@ -12357,6 +12755,14 @@ }, "directory": { "type": "string" + }, + "source": { + "default": "created", + "type": "string", + "enum": [ + "created", + "existing" + ] } }, "required": [ @@ -12441,6 +12847,21 @@ "type": "string", "pattern": "^ses.*" }, + "createdByAgentTool": { + "default": false, + "type": "boolean" + }, + "subagentType": { + "default": null, + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "skill": { "type": "string" }, @@ -12532,6 +12953,52 @@ "messageID" ] }, + "executionContext": { + "type": "object", + "properties": { + "ownerDirectory": { + "type": "string" + }, + "activeDirectory": { + "type": "string" + }, + "activeWorktree": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "created", + "existing" + ] + } + }, + "required": [ + "directory", + "name", + "branch", + "source" + ] + }, + "lastChangedAt": { + "type": "number" + } + }, + "required": [ + "ownerDirectory", + "activeDirectory", + "lastChangedAt" + ] + }, "project": { "anyOf": [ { @@ -12551,6 +13018,7 @@ "title", "version", "time", + "executionContext", "project" ] }, @@ -12762,6 +13230,241 @@ }, "command": { "type": "string" + }, + "tool_call_id": { + "type": "string" + }, + "parent_session_id": { + "type": "string" + }, + "parent_message_id": { + "type": "string" + }, + "subagent_session_id": { + "type": "string" + }, + "status": { + "default": "completed", + "type": "string", + "enum": [ + "running", + "completed", + "completed_empty", + "failed", + "canceled_by_user" + ] + }, + "started_at": { + "type": "number" + }, + "updated_at": { + "type": "number" + }, + "ended_at": { + "type": "number" + }, + "consumed_at": { + "type": "number" + }, + "recent_events": { + "default": [], + "maxItems": 20, + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "started" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_started" + }, + "tool": { + "type": "string" + }, + "label": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "tool", + "label", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_completed" + }, + "tool": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "tool", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "model_wait_started" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "completed" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "completed_empty" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "canceled_by_user" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + }, + "kind": { + "type": "string" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "kind", + "at" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "consumed" + }, + "at": { + "type": "number" + } + }, + "required": [ + "type", + "at" + ] + } + ] + } + }, + "result_summary": { + "type": "string" + }, + "result_text": { + "type": "string" + }, + "partial_result": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "kind", + "message" + ] } }, "required": [ @@ -12771,6 +13474,21 @@ "agent" ] }, + "QuestionReply": { + "type": "object", + "properties": { + "answers": { + "description": "User answers in order of questions (each answer is an array of selected labels)", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": [ + "answers" + ] + }, "ProviderAuthMethod": { "type": "object", "properties": { From 387bdd09cefa27b5c58f6cb792855f3a261a4db3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 21:19:38 +0800 Subject: [PATCH 27/65] fix(opencode): harden worktree execution context --- .github/workflows/ci.yml | 1 + packages/opencode/src/project/instance.ts | 2 +- .../src/session/execution-context-store.ts | 39 ++++++++ packages/opencode/src/session/session.ts | 69 +++++-------- .../opencode/src/tool/enter-worktree-git.ts | 38 +++++++ packages/opencode/src/tool/enter-worktree.ts | 49 ++++----- .../opencode/test/github/ci-workflow.test.ts | 2 +- .../opencode/test/session/session.test.ts | 61 +++++++++++- .../opencode/test/tool/enter-worktree.test.ts | 99 +++++++++++++++++++ 9 files changed, 283 insertions(+), 77 deletions(-) create mode 100644 packages/opencode/src/session/execution-context-store.ts create mode 100644 packages/opencode/src/tool/enter-worktree-git.ts create mode 100644 packages/opencode/test/tool/enter-worktree.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a82e88e9..d5c6f38c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,6 +393,7 @@ jobs: --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project + test/worktree test/file test/github test/settings diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 773f1702..4c9a77e3 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -120,7 +120,7 @@ export const Instance = { }): Promise { return Instance.provide({ directory: input.activeDirectory, - worktree: input.ownerDirectory, + worktree: Filesystem.resolve(input.ownerDirectory), project: input.project, fn: input.fn, }) diff --git a/packages/opencode/src/session/execution-context-store.ts b/packages/opencode/src/session/execution-context-store.ts new file mode 100644 index 00000000..bd4447d2 --- /dev/null +++ b/packages/opencode/src/session/execution-context-store.ts @@ -0,0 +1,39 @@ +import fs from "node:fs" +import path from "path" +import { eq, isNull } from "../storage/db" +import { ProjectTable } from "../project/project.sql" +import { SessionTable } from "./session.sql" +import { rootContext } from "./execution-context" + +type Tx = { + select: (...args: any[]) => any + update: (...args: any[]) => any +} + +export function canonicalDirectory(input: string) { + const abs = path.resolve(input) + const real = (() => { + try { + return fs.realpathSync.native(abs) + } catch { + return abs + } + })() + const normalized = path.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized +} + +export function backfillExecutionContextRows(d: Tx) { + const rows = d + .select({ id: SessionTable.id, directory: SessionTable.directory, project_id: SessionTable.project_id }) + .from(SessionTable) + .where(isNull(SessionTable.execution_context)) + .all() + for (const row of rows) { + const project = d.select().from(ProjectTable).where(eq(ProjectTable.id, row.project_id)).get() + const ownerDirectory = project?.vcs === "git" ? project.worktree : row.directory + const ctx = rootContext(ownerDirectory) + d.update(SessionTable).set({ execution_context: ctx }).where(eq(SessionTable.id, row.id)).run() + } + return rows.length +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c61f5b94..bd22df62 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -31,12 +31,9 @@ import type { Provider } from "@/provider" import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" -import { - SubagentRunWriterContext, - SubagentRunGuardViolation, - lifecycleFieldsChanged, -} from "./subagent-run-context" +import { SubagentRunWriterContext, SubagentRunGuardViolation, lifecycleFieldsChanged } from "./subagent-run-context" import { SessionExecutionContext, rootContext } from "./execution-context" +import { backfillExecutionContextRows, canonicalDirectory } from "./execution-context-store" const log = Log.create({ service: "session" }) @@ -83,8 +80,7 @@ export function fromRow(row: SessionRow): Info { share, revert, permission: row.permission ?? undefined, - // Backfill at boot guarantees this is non-null on every load (see backfillExecutionContext). - // Defensive fallback for the brief window during boot before backfill runs. + // Legacy rows may still have NULL execution_context; synthesize the root context on read. executionContext: row.execution_context ?? rootContext(row.directory), time: { created: row.time_created, @@ -424,11 +420,18 @@ type Patch = z.infer["info"] const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) +const backfillExecutionContextEffect = Effect.fn("Session.backfillExecutionContext")(function* () { + return yield* db(backfillExecutionContextRows) +}) + +export const backfillExecutionContext = backfillExecutionContextEffect() + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service + yield* backfillExecutionContext const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -551,9 +554,7 @@ export const layer: Layer.Layer = part as unknown as Record, ) ) { - return yield* Effect.die( - new SubagentRunGuardViolation((part as { tool_call_id?: string }).tool_call_id), - ) + return yield* Effect.die(new SubagentRunGuardViolation((part as { tool_call_id?: string }).tool_call_id)) } } } @@ -698,29 +699,34 @@ export const layer: Layer.Layer = activeWorktree?: SessionExecutionContext["activeWorktree"] | null }) { const current = yield* get(input.sessionID) + const now = Date.now() // ownerDirectory is set at session creation and never moves; never taken from patch. // Drop activeWorktree when caller passes null (Exit semantics) or omits it explicitly. const next: SessionExecutionContext = { ownerDirectory: current.executionContext.ownerDirectory, activeDirectory: input.activeDirectory ?? current.executionContext.activeDirectory, activeWorktree: - "activeWorktree" in input - ? input.activeWorktree ?? undefined - : current.executionContext.activeWorktree, - lastChangedAt: Date.now(), + "activeWorktree" in input ? (input.activeWorktree ?? undefined) : current.executionContext.activeWorktree, + lastChangedAt: now, } - yield* patch(input.sessionID, { time: { updated: Date.now() }, executionContext: next }) - return { ...current, executionContext: next } + yield* patch(input.sessionID, { time: { updated: now }, executionContext: next }) + return { ...current, executionContext: next, time: { ...current.time, updated: now } } }) const findActiveWorktreeBinding = Effect.fn("Session.findActiveWorktreeBinding")(function* (directory: string) { const project = Instance.project + const target = canonicalDirectory(directory) const rows = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all()) for (const row of rows) { const session = fromRow(row) const exec = session.executionContext if (exec.activeDirectory === exec.ownerDirectory) continue - if (exec.activeDirectory === directory || exec.activeWorktree?.directory === directory) return session + if ( + canonicalDirectory(exec.activeDirectory) === target || + (exec.activeWorktree?.directory && canonicalDirectory(exec.activeWorktree.directory) === target) + ) { + return session + } } return undefined }) @@ -820,30 +826,6 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Storage.defaultLayer), ) -/** - * One-shot backfill that fills `execution_context` for every legacy row whose value is NULL. - * Idempotent (subsequent runs find no NULL rows) and safe to call any number of times. Sources - * `ownerDirectory` from the existing `directory` column, which captures the directory the user - * opened at session creation time. Defensive fallback: rows somehow missing both still pass through - * `fromRow` synthesis on read. - */ -export const backfillExecutionContext = Effect.sync(() => { - Database.use((d) => { - const rows = d - .select({ id: SessionTable.id, directory: SessionTable.directory, project_id: SessionTable.project_id }) - .from(SessionTable) - .where(isNull(SessionTable.execution_context)) - .all() - for (const row of rows) { - const project = d.select().from(ProjectTable).where(eq(ProjectTable.id, row.project_id)).get() - const ownerDirectory = project?.vcs === "git" ? project.worktree : row.directory - const ctx = rootContext(ownerDirectory) - d.update(SessionTable).set({ execution_context: ctx }).where(eq(SessionTable.id, row.id)).run() - } - return rows.length - }) -}) - const { runPromise } = makeRuntime(Service, defaultLayer) export const create = fn(CreateInput, (input) => runPromise((svc) => svc.create(input))) @@ -985,7 +967,10 @@ export function* listGlobal(input?: { .where(and(...conditions)) : db.select().from(SessionTable) const order = sessionOrder(sort) - return query.orderBy(...order).limit(limit).all() + return query + .orderBy(...order) + .limit(limit) + .all() }) const ids = [...new Set(rows.map((row) => row.project_id))] diff --git a/packages/opencode/src/tool/enter-worktree-git.ts b/packages/opencode/src/tool/enter-worktree-git.ts new file mode 100644 index 00000000..b5ed52fd --- /dev/null +++ b/packages/opencode/src/tool/enter-worktree-git.ts @@ -0,0 +1,38 @@ +import * as path from "path" +import { Effect, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" + +const GIT_PROBE_TIMEOUT = "5 seconds" +type Spawner = ChildProcessSpawner["Service"] + +const gitOutput = Effect.fn("EnterWorktree.gitOutput")(function* (spawner: Spawner, args: string[], cwd: string) { + return yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" })) + const [stdout, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode], { + concurrency: 2, + }) + if (code !== 0) return undefined + const out = stdout.trim() + return out || undefined + }), + ) +}) + +export const gitCommonDir = Effect.fn("EnterWorktree.gitCommonDir")(function* (spawner: Spawner, cwd: string) { + const out = yield* gitOutput(spawner, ["rev-parse", "--git-common-dir"], cwd).pipe( + Effect.timeout(GIT_PROBE_TIMEOUT), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!out) return undefined + return path.resolve(cwd, out) +}) + +export const currentBranch = Effect.fn("EnterWorktree.currentBranch")(function* (spawner: Spawner, cwd: string) { + return yield* gitOutput(spawner, ["rev-parse", "--abbrev-ref", "HEAD"], cwd).pipe( + Effect.timeout(GIT_PROBE_TIMEOUT), + Effect.catch(() => Effect.succeed("")), + Effect.map((out) => out ?? ""), + ) +}) diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index b10f6840..1aea4a83 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -9,6 +9,8 @@ import { Instance } from "../project/instance" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" import type { SessionID } from "../session/schema" import { SubagentRun } from "../session/subagent-run" +import { currentBranch, gitCommonDir } from "./enter-worktree-git" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ const MAX_SLUG_LEN = 40 @@ -23,36 +25,12 @@ export const Parameters = Schema.Struct({ }), }) -// Resolve the canonical git common-dir for a path. Returns undefined for non-git paths or any error. -async function gitCommonDir(cwd: string): Promise { - try { - const proc = Bun.spawn(["git", "rev-parse", "--git-common-dir"], { cwd, stdout: "pipe", stderr: "pipe" }) - const exit = await proc.exited - if (exit !== 0) return undefined - const out = (await new Response(proc.stdout).text()).trim() - if (!out) return undefined - return path.resolve(cwd, out) - } catch { - return undefined - } -} - -async function currentBranch(cwd: string): Promise { - try { - const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" }) - const exit = await proc.exited - if (exit !== 0) return "" - return (await new Response(proc.stdout).text()).trim() - } catch { - return "" - } -} - export const EnterWorktreeTool = Tool.define( "enter-worktree", Effect.gen(function* () { const sessions = yield* Session.Service const subagents = yield* SubagentRun.Service + const spawner = yield* ChildProcessSpawner const guard = (sessionID: SessionID, callID: string | undefined) => Effect.gen(function* () { @@ -66,9 +44,7 @@ export const EnterWorktreeTool = Tool.define( } const subs = yield* hasRunningSubagents(subagents, sessionID) if (subs) { - return yield* Effect.fail( - new Error("Cannot enter a worktree while a subagent is running in this session."), - ) + return yield* Effect.fail(new Error("Cannot enter a worktree while a subagent is running in this session.")) } }) @@ -125,6 +101,9 @@ export const EnterWorktreeTool = Tool.define( const exec = session.executionContext if (params.path) { + if (!path.isAbsolute(params.path)) { + return yield* Effect.fail(new Error("path must be an absolute path")) + } const canonical = yield* Effect.promise(() => fs.realpath(params.path!).catch(() => path.resolve(params.path!)), ) @@ -142,14 +121,14 @@ export const EnterWorktreeTool = Tool.define( new Error("This session is already inside another worktree. Call ExitWorktree first."), ) } - const ownerCommon = yield* Effect.promise(() => gitCommonDir(exec.ownerDirectory)) - const targetCommon = yield* Effect.promise(() => gitCommonDir(canonical)) + const ownerCommon = yield* gitCommonDir(spawner, exec.ownerDirectory) + const targetCommon = yield* gitCommonDir(spawner, canonical) if (!ownerCommon || !targetCommon || ownerCommon !== targetCommon) { return yield* Effect.fail( new Error(`Path ${canonical} is not part of the same git repository as the project.`), ) } - const branch = yield* Effect.promise(() => currentBranch(canonical)) + const branch = yield* currentBranch(spawner, canonical) const info = yield* Effect.promise(() => Worktree.registerExistingByPath(canonical)) yield* applyEnter(ctx.sessionID, { ...info, branch: info.branch || branch }, "existing") return successResult({ @@ -184,6 +163,14 @@ export const EnterWorktreeTool = Tool.define( ) if (!exists) { yield* Effect.promise(() => Worktree.createFromInfo(planned)) + } else { + const ownerCommon = yield* gitCommonDir(spawner, exec.ownerDirectory) + const targetCommon = yield* gitCommonDir(spawner, planned.directory) + if (!ownerCommon || !targetCommon || ownerCommon !== targetCommon) { + return yield* Effect.fail( + new Error(`Managed worktree directory ${planned.directory} exists but is not a git worktree.`), + ) + } } yield* applyEnter(ctx.sessionID, planned, planned.source) return successResult({ diff --git a/packages/opencode/test/github/ci-workflow.test.ts b/packages/opencode/test/github/ci-workflow.test.ts index 171c2bab..3de1bbd8 100644 --- a/packages/opencode/test/github/ci-workflow.test.ts +++ b/packages/opencode/test/github/ci-workflow.test.ts @@ -54,7 +54,7 @@ const windowsOpencodeShards = [ suffix: "opencode-config-project", usesTurbo: false, command: - "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/file test/github test/settings test/settings.test.ts", + "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/worktree test/file test/github test/settings test/settings.test.ts", reportPath: "packages/opencode/.artifacts/unit/junit-windows-config-project.xml", }, { diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c5861347..e1598e24 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" import path from "path" import { Session as SessionNs } from "../../src/session" import { Bus } from "../../src/bus" @@ -7,8 +9,8 @@ import { Instance } from "../../src/project/instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" -import { Database } from "../../src/storage/db" -import { MessageTable } from "../../src/session/session.sql" +import { Database, eq } from "../../src/storage/db" +import { MessageTable, SessionTable } from "../../src/session/session.sql" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -67,6 +69,7 @@ describe("session.created event", () => { test("findActiveWorktreeBinding checks activeWorktree directory without list caps", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-a") + await fs.mkdir(worktree, { recursive: true }) await Instance.provide({ directory: tmp.path, @@ -88,6 +91,60 @@ describe("session.created event", () => { const found = await SessionNs.findActiveWorktreeBinding(worktree) expect(found?.id).toBe(session.id) + const variant = path.join(worktree, "..", "feature-a") + const foundByVariant = await SessionNs.findActiveWorktreeBinding(variant) + expect(foundByVariant?.id).toBe(session.id) + + await SessionNs.remove(session.id) + }, + }) + }) + + test("updateExecutionContext returns the persisted updated time", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-b") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "update-context-time" }) + const updated = await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeDirectory: worktree, + activeWorktree: { + directory: worktree, + name: "feature-b", + branch: "pawwork/feature-b", + source: "created", + }, + }) + + expect(updated.time.updated).toBe(updated.executionContext.lastChangedAt) + expect(updated.time.updated).toBeGreaterThanOrEqual(session.time.updated) + + await SessionNs.remove(session.id) + }, + }) + }) + + test("backfills legacy null executionContext rows", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "legacy-execution-context" }) + Database.use((db) => + db.update(SessionTable).set({ execution_context: null }).where(eq(SessionTable.id, session.id)).run(), + ) + + const count = await Effect.runPromise(SessionNs.backfillExecutionContext) + expect(count).toBeGreaterThanOrEqual(1) + + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get()) + expect(row?.execution_context?.ownerDirectory).toBe(tmp.path) + expect(row?.execution_context?.activeDirectory).toBe(tmp.path) + await SessionNs.remove(session.id) }, }) diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts new file mode 100644 index 00000000..f1aa492e --- /dev/null +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Cause, Effect, Exit, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageID } from "../../src/session/schema" +import { SubagentRun } from "../../src/session/subagent-run" +import { EnterWorktreeTool } from "../../src/tool/enter-worktree" +import { ExitWorktreeTool } from "../../src/tool/exit-worktree" +import type { Context } from "../../src/tool/tool" +import { Truncate } from "../../src/tool/truncate" +import { Worktree } from "../../src/worktree" +import { tmpdir } from "../fixture/fixture" + +const layer = Layer.mergeAll( + Agent.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + SubagentRun.defaultLayer, + Truncate.defaultLayer, +) + +function toolContext(sessionID: Session.Info["id"]): Context { + return { + sessionID, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + callID: "call_test", + extra: {}, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } +} + +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.provide(layer)) as Effect.Effect) +} + +test("enter-worktree rejects relative path inputs", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const exit = await run( + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "relative-path" }) + const tool = yield* EnterWorktreeTool + const def = yield* tool.init() + return yield* def.execute({ path: "relative-worktree" }, toolContext(session.id)).pipe(Effect.exit) + }), + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("path must be an absolute path") + }, + }) +}) + +test("enter-worktree and exit-worktree update the session execution context", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let activeDirectory = "" + try { + await run( + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "tool-worktree" }) + const enterTool = yield* EnterWorktreeTool + const exitTool = yield* ExitWorktreeTool + const enter = yield* enterTool.init() + const exit = yield* exitTool.init() + + const result = yield* enter.execute({ name: "tool-work" }, toolContext(session.id)) + activeDirectory = result.metadata.activeDirectory + + const entered = yield* sessions.get(session.id) + expect(entered.executionContext.activeDirectory).toBe(activeDirectory) + expect(entered.executionContext.activeWorktree?.name).toBe("tool-work") + + yield* exit.execute({}, toolContext(session.id)) + const exited = yield* sessions.get(session.id) + expect(exited.executionContext.activeDirectory).toBe(tmp.path) + expect(exited.executionContext.activeWorktree).toBeUndefined() + }), + ) + } finally { + if (activeDirectory) await Worktree.remove({ directory: activeDirectory }).catch(() => {}) + } + }, + }) +}) From b041374f7bfd084deeea19c8bac33c986baf81d0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 21:20:10 +0800 Subject: [PATCH 28/65] fix(app): refine worktree UI boundaries --- .../prompt-input/workspace-chip-helpers.ts | 1 + .../prompt-input/workspace-chip.test.ts | 15 ++ .../src/components/session/session-header.tsx | 88 ++++++------ .../components/settings-worktrees-helpers.ts | 29 ++++ .../app/src/components/settings-worktrees.tsx | 135 ++++++++---------- packages/app/src/components/titlebar.tsx | 12 +- packages/app/src/context/prompt.test.ts | 86 +++++++++++ packages/app/src/context/prompt.tsx | 69 ++++++--- packages/app/src/i18n/en.ts | 6 +- packages/app/src/i18n/zh.ts | 6 +- .../pages/layout/pawwork-worktree-badge.tsx | 26 +++- packages/app/src/shell-frame-contract.test.ts | 4 +- packages/ui/src/components/message-part.tsx | 50 +++++++ .../ui/src/components/tool-error-card.tsx | 2 + packages/ui/src/components/tool-info.ts | 11 ++ packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/zh.ts | 2 + 17 files changed, 401 insertions(+), 143 deletions(-) create mode 100644 packages/app/src/components/settings-worktrees-helpers.ts create mode 100644 packages/app/src/context/prompt.test.ts diff --git a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts index 645a25b6..b1ebdd52 100644 --- a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts +++ b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts @@ -41,6 +41,7 @@ export function workspaceChipChoices(input: { const remember = (value: WorkspaceEntry) => { if (typeof value === "string") return + if (value.branch === undefined && branchByPath.has(workspaceKey(value.directory))) return branchByPath.set(workspaceKey(value.directory), value.branch) } diff --git a/packages/app/src/components/prompt-input/workspace-chip.test.ts b/packages/app/src/components/prompt-input/workspace-chip.test.ts index 9bbfecbc..e338e212 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.test.ts +++ b/packages/app/src/components/prompt-input/workspace-chip.test.ts @@ -87,3 +87,18 @@ test("workspaceChipChoices preserves branch metadata after workspace ordering", { path: "/repo/feature-b", branch: "pawwork/feature-b" }, ]) }) + +test("workspaceChipChoices does not erase known branch metadata with undefined", () => { + const result = workspaceChipChoices({ + directory: "/repo/feature-a", + projects: [ + { + worktree: "/repo/main", + sandboxes: [{ directory: "/repo/feature-a" }], + }, + ], + listed: [{ directory: "/repo/feature-a", branch: "pawwork/feature-a" }], + }) + + expect(result.find((item) => item.path === "/repo/feature-a")?.branch).toBe("pawwork/feature-a") +}) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 75b1522d..1f71bbb7 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,7 +7,6 @@ import { createMediaQuery } from "@solid-primitives/media" import { createMemo, Show } from "solid-js" import { useLocation } from "@solidjs/router" import { Portal } from "solid-js/web" -import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" @@ -55,9 +54,11 @@ export function SessionHeader() { if (platform.os === "linux") return language.t("session.header.open.fileManager") return language.t("session.header.open.finder") }) - const canOpenProjectDirectory = createMemo( - () => platform.platform === "desktop" && !!platform.openPath && server.isLocal() && !!projectDirectory(), - ) + const canOpenDirectory = (directory?: string) => + platform.platform === "desktop" && !!platform.openPath && server.isLocal() && !!directory + const activeWorktreeDirectory = createMemo(() => activeWorktree()?.directory ?? "") + const canOpenProjectDirectory = createMemo(() => canOpenDirectory(projectDirectory())) + const canOpenActiveWorktreeDirectory = createMemo(() => canOpenDirectory(activeWorktreeDirectory())) const rightPanelOpen = createMemo(() => view().sidePanel.opened()) const toggleRightPanel = () => { if (rightPanelOpen()) { @@ -66,9 +67,8 @@ export function SessionHeader() { } view().sidePanel.open() } - const openProjectDirectory = () => { - const directory = projectDirectory() - if (!directory || !platform.openPath || !canOpenProjectDirectory()) return + const openDirectory = (directory: string) => { + if (!canOpenDirectory(directory) || !platform.openPath) return void platform.openPath(directory).catch((error) => { showToast({ variant: "error", @@ -77,56 +77,62 @@ export function SessionHeader() { }) }) } + const openProjectDirectory = () => openDirectory(projectDirectory()) + const openActiveWorktree = () => { + openDirectory(activeWorktreeDirectory()) + } - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( <> - + {(mount) => ( - diff --git a/packages/app/src/components/settings-worktrees-helpers.ts b/packages/app/src/components/settings-worktrees-helpers.ts new file mode 100644 index 00000000..9a1d6edb --- /dev/null +++ b/packages/app/src/components/settings-worktrees-helpers.ts @@ -0,0 +1,29 @@ +export type WorktreeInfo = { + name: string + branch: string + directory: string + ownerDirectory: string + source?: "created" | "existing" +} + +export function basename(p: string): string { + const trimmed = p.replace(/[/\\]+$/, "") + const last = trimmed.split(/[/\\]/).pop() + return last || p +} + +export function entryDirectory(entry: string | { directory: string }) { + return typeof entry === "string" ? entry : entry.directory +} + +export function errorText(error: unknown) { + if (typeof error === "string") return error + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { + return error.message + } + return JSON.stringify(error) +} + +export function sourceKey(source: WorktreeInfo["source"]) { + return source === "existing" ? "settings.worktrees.source.existing" : "settings.worktrees.source.created" +} diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 6fcce644..95c225c4 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -6,46 +6,18 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { SettingsList } from "./settings-list" +import { basename, entryDirectory, errorText, sourceKey, type WorktreeInfo } from "./settings-worktrees-helpers" -type WorktreeInfo = { - name: string - branch: string - directory: string - ownerDirectory: string - source?: "created" | "existing" -} - -function basename(p: string): string { - const trimmed = p.replace(/[/\\]+$/, "") - const last = trimmed.split(/[/\\]/).pop() - return last || p -} - -function entryDirectory(entry: string | { directory: string }) { - return typeof entry === "string" ? entry : entry.directory -} - -function errorText(error: unknown) { - if (typeof error === "string") return error - if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message - return JSON.stringify(error) -} - -/** - * Settings → Worktrees panel. - * - * Lists every PawWork-tracked worktree directory for the current project (via - * `client.worktree.list()`); offers a per-row delete with two-step confirm. Delete is disabled - * when an open session in this app instance has the worktree as its activeDirectory; the user - * must call ExitWorktree from that session first. - */ export const SettingsWorktrees: Component = () => { const language = useLanguage() const sdk = useGlobalSDK() const sync = useGlobalSync() const projectRoots = () => - sync.data.project.filter((project) => project.vcs === "git").map((project) => project.worktree).filter(Boolean) + sync.data.project + .filter((project) => project.vcs === "git") + .map((project) => project.worktree) + .filter(Boolean) const [data, { refetch }] = createResource( () => projectRoots().join("\0"), @@ -62,7 +34,6 @@ export const SettingsWorktrees: Component = () => { }, ) - // Sessions whose activeDirectory points at a worktree path block its deletion. const boundSessions = (): Map => { const map = new Map() const directories = new Set() @@ -111,17 +82,15 @@ export const SettingsWorktrees: Component = () => { return ( -
    +

    {language.t("settings.worktrees.title")}

    -

    {language.t("settings.worktrees.description")}

    +

    {language.t("settings.worktrees.description")}

    {language.t("common.loading")}
    - } + fallback={
    {language.t("common.loading")}
    } > 0} @@ -131,10 +100,7 @@ export const SettingsWorktrees: Component = () => {
    } > -
      +
        {(worktree) => { const directory = () => worktree.directory @@ -146,38 +112,61 @@ export const SettingsWorktrees: Component = () => { const isDeleting = () => deleting() === worktree.directory return ( -
      • - -
        - - {name()} - - - {branch()} · {worktree.source ?? "created"} · {directory()} +
      • +
        + + +
        +
        + + {name()} + + + {language.t(sourceKey(worktree.source))} + +
        +
        + {language.t("settings.worktrees.column.branch")} + + {branch()} + + / + + {directory()} + +
        + + {(session) => ( +
        + {language.t("settings.worktrees.inUse", { session: session() })} +
        + )} +
        +
        +
        + + + +
        - setConfirming(directory())} - > - {language.t("settings.worktrees.delete")} - - } - > -
        - + +
        + {language.t("settings.worktrees.confirmDelete.body", { name: name() })}
        -
        +
        +
        diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts new file mode 100644 index 00000000..9a5680c3 --- /dev/null +++ b/packages/app/src/context/prompt.test.ts @@ -0,0 +1,86 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import type { ContextItem, Prompt } from "./prompt" + +let createPromptBinding: typeof import("./prompt").createPromptBinding +let DEFAULT_PROMPT: typeof import("./prompt").DEFAULT_PROMPT + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./prompt") + createPromptBinding = mod.createPromptBinding + DEFAULT_PROMPT = mod.DEFAULT_PROMPT +}) + +function promptSession() { + const prompt: Prompt = [{ type: "text", content: "hello", start: 0, end: 5 }] + const items: (ContextItem & { key: string })[] = [] + + return { + ready: () => true, + current: () => prompt, + cursor: () => 5, + dirty: () => true, + context: { + items: () => items, + add: (item: ContextItem) => items.push({ key: item.type, ...item }), + remove: (key: string) => { + const index = items.findIndex((item) => item.key === key) + if (index >= 0) items.splice(index, 1) + }, + removeComment: () => undefined, + updateComment: () => undefined, + replaceComments: () => undefined, + }, + set: () => undefined, + reset: () => undefined, + } +} + +describe("createPromptBinding", () => { + test("returns a safe empty prompt when the route scope is missing", () => { + const binding = createPromptBinding( + () => undefined, + () => { + throw new Error("should not load a prompt session without a directory") + }, + ) + + expect(binding.ready()).toBe(false) + expect(binding.current()).toEqual(DEFAULT_PROMPT) + expect(binding.cursor()).toBeUndefined() + expect(binding.dirty()).toBe(false) + expect(binding.context.items()).toEqual([]) + expect(() => binding.context.add({ type: "file", path: "a.ts" })).not.toThrow() + expect(() => binding.context.remove("file")).not.toThrow() + expect(() => binding.set([{ type: "text", content: "next", start: 0, end: 4 }], 4)).not.toThrow() + expect(() => binding.reset()).not.toThrow() + }) + + test("uses the current route scope when it is available", () => { + const session = promptSession() + const binding = createPromptBinding( + () => ({ dir: "repo", id: "session" }), + (dir, id) => { + expect(dir).toBe("repo") + expect(id).toBe("session") + return session + }, + ) + + binding.context.add({ type: "file", path: "a.ts" }) + + expect(binding.ready()).toBe(true) + expect(binding.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + expect(binding.cursor()).toBe(5) + expect(binding.dirty()).toBe(true) + expect(binding.context.items().map((item) => item.path)).toEqual(["a.ts"]) + }) +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 831fdbca..c1715718 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -161,6 +161,53 @@ type PromptCacheEntry = { dispose: VoidFunction } +type PromptBindingSession = { + ready: () => boolean + current: () => Prompt + cursor: () => number | undefined + dirty: () => boolean + context: { + items: () => (ContextItem & { key: string })[] + add: (item: ContextItem) => void + remove: (key: string) => void + removeComment: (path: string, commentID: string) => void + updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => void + replaceComments: (items: FileContextItem[]) => void + } + set: (prompt: Prompt, cursorPosition?: number) => void + reset: () => void +} + +export function createPromptBinding( + scope: () => Scope | undefined, + load: (dir: string, id: string | undefined) => PromptBindingSession, +) { + const session = () => { + const current = scope() + if (!current) return + return load(current.dir, current.id) + } + const pick = (target?: Scope) => (target ? load(target.dir, target.id) : session()) + + return { + ready: () => session()?.ready() ?? false, + current: () => session()?.current() ?? clonePrompt(DEFAULT_PROMPT), + cursor: () => session()?.cursor(), + dirty: () => session()?.dirty() ?? false, + context: { + items: () => session()?.context.items() ?? [], + add: (item: ContextItem) => session()?.context.add(item), + remove: (key: string) => session()?.context.remove(key), + removeComment: (path: string, commentID: string) => session()?.context.removeComment(path, commentID), + updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => + session()?.context.updateComment(path, commentID, next), + replaceComments: (items: FileContextItem[]) => session()?.context.replaceComments(items), + }, + set: (prompt: Prompt, cursorPosition?: number, target?: Scope) => pick(target)?.set(prompt, cursorPosition), + reset: (target?: Scope) => pick(target)?.reset(), + } +} + function createPromptSession(dir: string, id: string | undefined) { const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` @@ -273,25 +320,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return entry.value } - const session = createMemo(() => load(params.dir!, params.id)) - const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session()) - - return { - ready: () => session().ready(), - current: () => session().current(), - cursor: () => session().cursor(), - dirty: () => session().dirty(), - context: { - items: () => session().context.items(), - add: (item: ContextItem) => session().context.add(item), - remove: (key: string) => session().context.remove(key), - removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), - updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => - session().context.updateComment(path, commentID, next), - replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), - }, - set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), - reset: (scope?: Scope) => pick(scope).reset(), - } + const scope = createMemo(() => (params.dir ? { dir: params.dir, id: params.id } : undefined)) + return createPromptBinding(scope, load) }, }) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 6e97aed2..43c40288 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -657,6 +657,7 @@ export const dict = { "session.header.open.ariaLabel": "Open in {{app}}", "session.header.open.menu": "Open options", "session.header.open.copyPath": "Copy path", + "session.header.worktree.open": "Open worktree folder", "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "File Explorer", "session.header.open.fileManager": "File Manager", @@ -811,11 +812,14 @@ export const dict = { "settings.tab.shortcuts": "Shortcuts", "settings.tab.worktrees": "Worktrees", "settings.worktrees.title": "Worktrees", - "settings.worktrees.description": "Worktrees that PawWork has created or registered. Each session may bind to a worktree via the EnterWorktree tool; ExitWorktree releases the binding without deleting anything.", + "settings.worktrees.description": "Worktrees created or registered by PawWork. Active sessions keep their worktree until ExitWorktree is used.", "settings.worktrees.empty": "No worktrees yet.", "settings.worktrees.column.name": "Name", "settings.worktrees.column.branch": "Branch", "settings.worktrees.column.path": "Path", + "settings.worktrees.source.created": "Created", + "settings.worktrees.source.existing": "Added", + "settings.worktrees.inUse": "In use by {session}", "settings.worktrees.delete": "Delete", "settings.worktrees.deleteDisabled.tooltip": "In use by {session}. Use ExitWorktree from that session first.", "settings.worktrees.confirmDelete.title": "Delete worktree?", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index cb6dd136..e108e3cd 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -611,6 +611,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中打开", "session.header.open.menu": "打开选项", "session.header.open.copyPath": "复制路径", + "session.header.worktree.open": "打开 worktree 文件夹", "status.popover.trigger": "状态", "status.popover.ariaLabel": "服务器配置", @@ -702,11 +703,14 @@ export const dict = { "settings.tab.shortcuts": "快捷键", "settings.tab.worktrees": "工作树", "settings.worktrees.title": "工作树", - "settings.worktrees.description": "爪印创建或登记过的工作树。每个会话可以通过 EnterWorktree 工具绑定到工作树;ExitWorktree 解除绑定但不会删除任何东西。", + "settings.worktrees.description": "爪印创建或登记过的 worktree。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。", "settings.worktrees.empty": "暂无工作树。", "settings.worktrees.column.name": "名称", "settings.worktrees.column.branch": "分支", "settings.worktrees.column.path": "路径", + "settings.worktrees.source.created": "已创建", + "settings.worktrees.source.existing": "已接入", + "settings.worktrees.inUse": "正在被「{session}」使用", "settings.worktrees.delete": "删除", "settings.worktrees.deleteDisabled.tooltip": "正在被「{session}」使用,先在该会话里调用 ExitWorktree。", "settings.worktrees.confirmDelete.title": "删除该工作树?", diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index 30077ee4..034708f0 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -1,11 +1,27 @@ -export function PawworkWorktreeBadge(props: { name: string; branch?: string; directory?: string }) { +import { Icon } from "@opencode-ai/ui/icon" + +export function PawworkWorktreeBadge(props: { + name: string + branch?: string + directory?: string + onClick?: () => void + ariaLabel?: string + disabled?: boolean +}) { + const title = () => [props.branch, props.directory].filter(Boolean).join(" · ") + return ( -
        + {props.name} -
        + ) } diff --git a/packages/app/src/shell-frame-contract.test.ts b/packages/app/src/shell-frame-contract.test.ts index 9fbc6a7c..3abf09ca 100644 --- a/packages/app/src/shell-frame-contract.test.ts +++ b/packages/app/src/shell-frame-contract.test.ts @@ -30,7 +30,9 @@ test("desktop shell shares titlebar height across titlebar and narrow sidebar ge expect(layout).not.toContain("top-10") expect(titlebar).toContain('"h-11": platform.platform === "desktop" && !mac()') expect(titlebar).toContain('style={{ height: currentTitlebarHeight(), "min-height": currentTitlebarHeight() }}') - expect(sessionHeader).toContain('document.getElementById("opencode-titlebar-center")') + expect(titlebar).toContain("--sidebar-width") + expect(titlebar).toContain("--right-panel-width") + expect(sessionHeader).toContain('document.getElementById("opencode-titlebar-left")') expect(sessionHeader).toContain('document.getElementById("opencode-titlebar-right")') }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b87a5a1d..2e4fa93a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -308,6 +308,12 @@ function taskAgent( } } +function worktreeSubtitle(input: Record, metadata: Record = {}) { + const value = input.name ?? metadata.name ?? input.path ?? input.directory ?? metadata.directory ?? metadata.activeDirectory + if (typeof value !== "string" || !value) return undefined + return value.includes("/") || value.includes("\\") ? getFilename(value) : value +} + export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { @@ -353,6 +359,18 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { title: i18n.t("ui.tool.codesearch"), subtitle: input.query, } + case "enter-worktree": + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.enter"), + subtitle: worktreeSubtitle(input), + } + case "exit-worktree": + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.exit"), + subtitle: worktreeSubtitle(input), + } case "task": // agent-rename:legacy-render case "agent": { const type = @@ -1740,6 +1758,38 @@ ToolRegistry.register({ }, }) +ToolRegistry.register({ + name: "enter-worktree", + render(props) { + const i18n = useI18n() + const subtitle = createMemo(() => worktreeSubtitle(props.input, props.metadata)) + return ( + + ) + }, +}) + +ToolRegistry.register({ + name: "exit-worktree", + render(props) { + const i18n = useI18n() + const subtitle = createMemo(() => worktreeSubtitle(props.input, props.metadata)) + return ( + + ) + }, +}) + // Render function extracted so both "task" (legacy) and "agent" registrations share one reference. const renderAgentToolPart: ToolComponent = (props) => { const data = useData() diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 31b0b740..44597b24 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -35,6 +35,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", codesearch: "ui.tool.codesearch", + "enter-worktree": "ui.tool.worktree.enter", + "exit-worktree": "ui.tool.worktree.exit", bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", diff --git a/packages/ui/src/components/tool-info.ts b/packages/ui/src/components/tool-info.ts index 40f533b1..3ab28bc2 100644 --- a/packages/ui/src/components/tool-info.ts +++ b/packages/ui/src/components/tool-info.ts @@ -9,6 +9,12 @@ export type ToolInfo = { subtitle?: string } +function worktreeSubtitle(input: Record, metadata: Record = {}) { + const value = input.name ?? metadata.name ?? input.path ?? input.directory ?? metadata.directory ?? metadata.activeDirectory + if (typeof value !== "string" || !value) return undefined + return value.includes("/") || value.includes("\\") ? getFilename(value) : value +} + export function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -16,6 +22,7 @@ export function agentTitle(i18n: UiI18n, type?: string) { export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { const input: any = part.state?.input ?? {} + const metadata: any = (part.state as any)?.metadata ?? {} switch (part.tool) { case "task": // agent-rename:legacy-render case "agent": { @@ -41,6 +48,10 @@ export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { return { icon: "window-cursor", title: i18n.t("ui.tool.websearch"), subtitle: input.query } case "codesearch": return { icon: "code", title: i18n.t("ui.tool.codesearch"), subtitle: input.query } + case "enter-worktree": + return { icon: "worktree", title: i18n.t("ui.tool.worktree.enter"), subtitle: worktreeSubtitle(input, metadata) } + case "exit-worktree": + return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle: worktreeSubtitle(input, metadata) } case "bash": return { icon: "console", title: i18n.t("ui.tool.shell"), subtitle: input.description } case "edit": diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 1bbe3cf9..44e7b3d1 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -108,6 +108,8 @@ export const dict: Record = { "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Web Search", "ui.tool.codesearch": "Code Search", + "ui.tool.worktree.enter": "Enter worktree", + "ui.tool.worktree.exit": "Exit worktree", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "To-dos", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 66198846..50250b3f 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -102,6 +102,8 @@ export const dict = { "ui.tool.webfetch": "读取网页", "ui.tool.websearch": "网络搜索", "ui.tool.codesearch": "代码搜索", + "ui.tool.worktree.enter": "进入 worktree", + "ui.tool.worktree.exit": "退出 worktree", "ui.tool.shell": "执行命令", "ui.tool.patch": "批量修改", "ui.tool.todos": "待办", From be217014bca9fb012be840ee9c35bed36fdf7d1c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 21:57:06 +0800 Subject: [PATCH 29/65] =?UTF-8?q?chore(i18n):=20unify=20zh=20worktree=20te?= =?UTF-8?q?rm=20to=20=E5=B7=A5=E4=BD=9C=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese UI used three different translations for git worktree: 工作树, worktree, 工作区. 工作区 is already PawWork's term for its own workspace concept (sidebar multi-directory toggle), so reusing it for git worktree collided with an existing user-facing noun. VS Code zh-hans uses 工作树 across its production strings; aligning here gives coding users the most familiar rendering and frees 工作区 for workspace. Tool names (EnterWorktree, ExitWorktree) and raw git commands kept in copy on purpose: PawWork's worktree users are coding users who benefit from seeing the underlying primitives. --- packages/app/src/i18n/zh.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e108e3cd..87ebd37a 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -384,8 +384,8 @@ export const dict = { "dialog.project.edit.icon.recommended": "建议:128x128px", "dialog.project.edit.color": "颜色", "dialog.project.edit.color.select": "选择{{color}}颜色", - "dialog.project.edit.worktree.startup": "工作区启动脚本", - "dialog.project.edit.worktree.startup.description": "在创建新的工作区 (worktree) 后运行。", + "dialog.project.edit.worktree.startup": "工作树启动脚本", + "dialog.project.edit.worktree.startup.description": "在创建新的工作树后运行。", "dialog.project.edit.worktree.startup.placeholder": "例如 bun install", "dialog.project.edit.icon.tooLarge.title": "图片过大", "dialog.project.edit.icon.tooLarge.description": "请选择 5MB 以下的图片。", @@ -602,7 +602,7 @@ export const dict = { "session.new.placeholder.writing": "告诉我你想写什么,我来起草、润色或改写…", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支({{branch}})", - "session.new.worktree.create": "创建新的 worktree", + "session.new.worktree.create": "创建新的工作树", "session.new.lastModified": "最后修改", "session.header.search.placeholder": "搜索 {{project}}", "session.header.searchFiles": "搜索文件", @@ -611,7 +611,7 @@ export const dict = { "session.header.open.ariaLabel": "在 {{app}} 中打开", "session.header.open.menu": "打开选项", "session.header.open.copyPath": "复制路径", - "session.header.worktree.open": "打开 worktree 文件夹", + "session.header.worktree.open": "打开工作树文件夹", "status.popover.trigger": "状态", "status.popover.ariaLabel": "服务器配置", @@ -703,7 +703,7 @@ export const dict = { "settings.tab.shortcuts": "快捷键", "settings.tab.worktrees": "工作树", "settings.worktrees.title": "工作树", - "settings.worktrees.description": "爪印创建或登记过的 worktree。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。", + "settings.worktrees.description": "爪印创建或登记过的工作树。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。", "settings.worktrees.empty": "暂无工作树。", "settings.worktrees.column.name": "名称", "settings.worktrees.column.branch": "分支", From 84111b8487477c6271e54770ea838c36c8b09f9a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 22:08:21 +0800 Subject: [PATCH 30/65] fix(app): hide worktrees from workspace chip --- .../prompt-input/workspace-chip-helpers.ts | 37 ++---------- .../prompt-input/workspace-chip.test.ts | 58 ++++++++----------- .../prompt-input/workspace-chip.tsx | 18 +----- 3 files changed, 30 insertions(+), 83 deletions(-) diff --git a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts index b1ebdd52..28d01ba8 100644 --- a/packages/app/src/components/prompt-input/workspace-chip-helpers.ts +++ b/packages/app/src/components/prompt-input/workspace-chip-helpers.ts @@ -1,6 +1,6 @@ import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers" -export type WorkspaceEntry = string | { directory: string; branch?: string } +export type WorkspaceEntry = string | { directory: string } export type WorkspaceProject = { worktree: string @@ -23,13 +23,11 @@ export function findWorkspaceProject(projects: WorkspaceProject[], directory?: s export type WorkspaceChoice = { path: string - branch?: string } export function workspaceChipChoices(input: { directory?: string projects: WorkspaceProject[] - listed?: WorkspaceEntry[] }): WorkspaceChoice[] { const directory = input.directory if (!directory) return [] @@ -37,45 +35,20 @@ export function workspaceChipChoices(input: { const current = findWorkspaceProject(input.projects, directory) const seen = new Set() const choices: WorkspaceChoice[] = [] - const branchByPath = new Map() - - const remember = (value: WorkspaceEntry) => { - if (typeof value === "string") return - if (value.branch === undefined && branchByPath.has(workspaceKey(value.directory))) return - branchByPath.set(workspaceKey(value.directory), value.branch) - } - - for (const item of input.listed ?? []) remember(item) - for (const project of input.projects) { - for (const item of project.sandboxes ?? []) remember(item) - } const append = (value: WorkspaceEntry) => { const path = workspacePath(value) const key = workspaceKey(path) if (seen.has(key)) return seen.add(key) - choices.push({ path, branch: typeof value === "string" ? branchByPath.get(key) : value.branch }) + choices.push({ path }) } if (!current) append(directory) - for (const project of input.projects) { - const ordered = - current && workspaceKey(project.worktree) === workspaceKey(current.worktree) - ? effectiveWorkspaceOrder(project.worktree, [ - project.worktree, - ...(project.sandboxes ?? []).map(workspacePath), - ...(input.listed ?? []).map(workspacePath), - ]) - : [project.worktree, ...(project.sandboxes ?? [])] - - for (const item of ordered) append(item) - } - - if (current && !choices.some((item) => workspaceKey(item.path) === workspaceKey(directory))) { - choices.unshift({ path: directory }) - } + const roots = input.projects.map((project) => project.worktree) + const ordered = current ? effectiveWorkspaceOrder(current.worktree, roots) : roots + for (const item of ordered) append(item) return choices } diff --git a/packages/app/src/components/prompt-input/workspace-chip.test.ts b/packages/app/src/components/prompt-input/workspace-chip.test.ts index e338e212..fe189038 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.test.ts +++ b/packages/app/src/components/prompt-input/workspace-chip.test.ts @@ -16,7 +16,7 @@ test("findWorkspaceProject matches sandboxes with normalized workspace keys", () expect(project?.worktree).toBe("/repo/main") }) -test("workspaceChipChoices lists all known project directories for global switching", () => { +test("workspaceChipChoices lists project roots for global switching", () => { const result = workspaceChipChoices({ directory: "/repo/main", projects: [ @@ -30,7 +30,7 @@ test("workspaceChipChoices lists all known project directories for global switch ], }) - expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/feature-a", "/repo/analytics"]) + expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/analytics"]) }) test("workspaceChipChoices preserves current directory when it is not part of the known project list", () => { @@ -47,58 +47,48 @@ test("workspaceChipChoices preserves current directory when it is not part of th ], }) - expect(result.map((c) => c.path)).toEqual(["/repo/feature-c", "/repo/main", "/repo/feature-a", "/repo/analytics"]) + expect(result.map((c) => c.path)).toEqual(["/repo/feature-c", "/repo/main", "/repo/analytics"]) }) -test("each choice exposes path field for sub-label rendering", () => { +test("workspaceChipChoices omits known worktrees from the homepage workspace list", () => { const result = workspaceChipChoices({ - directory: "/repo/main", - projects: [{ worktree: "/repo/main" }], + directory: "/repo/feature-a", + projects: [ + { + worktree: "/repo/main", + sandboxes: ["/repo/feature-a"], + }, + { + worktree: "/repo/analytics", + }, + ], }) - expect(result[0]).toHaveProperty("path") - expect(typeof result[0].path).toBe("string") + expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/analytics"]) }) -test("branch field is optional (not required when SDK can't resolve)", () => { +test("each choice exposes path field for sub-label rendering", () => { const result = workspaceChipChoices({ directory: "/repo/main", projects: [{ worktree: "/repo/main" }], }) - expect(result[0].branch === undefined || typeof result[0].branch === "string").toBe(true) -}) - -test("workspaceChipChoices preserves branch metadata after workspace ordering", () => { - const result = workspaceChipChoices({ - directory: "/repo/feature-a", - projects: [ - { - worktree: "/repo/main", - sandboxes: [{ directory: "/repo/feature-a", branch: "pawwork/feature-a" }], - }, - ], - listed: [{ directory: "/repo/feature-b", branch: "pawwork/feature-b" }], - }) - - expect(result).toEqual([ - { path: "/repo/main", branch: undefined }, - { path: "/repo/feature-a", branch: "pawwork/feature-a" }, - { path: "/repo/feature-b", branch: "pawwork/feature-b" }, - ]) + expect(result[0]).toHaveProperty("path") + expect(typeof result[0].path).toBe("string") }) -test("workspaceChipChoices does not erase known branch metadata with undefined", () => { +test("workspaceChipChoices ignores listed worktrees", () => { const result = workspaceChipChoices({ - directory: "/repo/feature-a", + directory: "/repo/main", projects: [ { worktree: "/repo/main", - sandboxes: [{ directory: "/repo/feature-a" }], + sandboxes: ["/repo/feature-a"], }, ], - listed: [{ directory: "/repo/feature-a", branch: "pawwork/feature-a" }], + // @ts-expect-error listed is intentionally no longer part of the public helper input. + listed: [{ directory: "/repo/feature-b" }], }) - expect(result.find((item) => item.path === "/repo/feature-a")?.branch).toBe("pawwork/feature-a") + expect(result.map((c) => c.path)).toEqual(["/repo/main"]) }) diff --git a/packages/app/src/components/prompt-input/workspace-chip.tsx b/packages/app/src/components/prompt-input/workspace-chip.tsx index 2500d3e9..d33ef329 100644 --- a/packages/app/src/components/prompt-input/workspace-chip.tsx +++ b/packages/app/src/components/prompt-input/workspace-chip.tsx @@ -3,8 +3,7 @@ import { Popover } from "@opencode-ai/ui/popover" import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { useNavigate } from "@solidjs/router" -import { createMemo, createResource, createSignal, For, type JSX, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createMemo, createSignal, For, type JSX, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLayoutPage } from "@/context/layout-page" @@ -15,7 +14,6 @@ import { decode64 } from "@/utils/base64" export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}) { const language = useLanguage() - const globalSDK = useGlobalSDK() const layout = useLayout() const layoutPage = useLayoutPage() const navigate = useNavigate() @@ -24,24 +22,10 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {} const current = createMemo(() => decode64(params.dir)) const project = createMemo(() => findWorkspaceProject(layout.projects.list(), current())) - const root = createMemo(() => project()?.worktree ?? current()) - // Fetch on mount (not gated on open) so popover content is ready when clicked. - // Previously gated on open() which caused the list to flash empty→full on every click. - const [listed] = createResource( - () => root(), - async (directory) => { - if (!directory) return [] - return globalSDK.client.worktree - .list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) - }, - ) const workspaces = createMemo(() => { return workspaceChipChoices({ directory: current(), projects: layout.projects.list(), - listed: listed(), }) }) const label = createMemo(() => { From 213300cfd7f8a4725892eddb2ae4636e8f840d20 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 22:28:36 +0800 Subject: [PATCH 31/65] =?UTF-8?q?refactor(app):=20redesign=20Settings=20?= =?UTF-8?q?=E2=86=92=20Worktrees=20with=20editorial=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-list + hover-revealed trash layout with a kicker / headline / caption three-line row. Each row shows owner project (kicker), worktree name (headline), and branch · path (caption), staying self-contained without section grouping. Delete is always-visible ghost text. Locked rows render a status pill with an indicator dot. Confirm state morphs in place with a warm-cream background, preserving owner identity and row height so the list does not jump. i18n keys: - drop empty, confirmDelete.title, confirmDelete.body - add empty.title, empty.body, confirmDelete.question, confirmDelete.warning, inUse.short --- .../app/src/components/settings-worktrees.tsx | 231 +++++++++++------- packages/app/src/i18n/en.ts | 8 +- packages/app/src/i18n/zh.ts | 8 +- 3 files changed, 146 insertions(+), 101 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 95c225c4..9388b18c 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -1,4 +1,4 @@ -import { type Component, createResource, createSignal, For, Show } from "solid-js" +import { type Component, createMemo, createResource, createSignal, For, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { showToast } from "@opencode-ai/ui/toast" @@ -34,6 +34,17 @@ export const SettingsWorktrees: Component = () => { }, ) + const projectNameByOwner = createMemo(() => { + const map = new Map() + for (const project of sync.data.project) { + map.set(project.worktree, project.name || basename(project.worktree)) + } + return map + }) + + const ownerName = (ownerDirectory: string) => + projectNameByOwner().get(ownerDirectory) || basename(ownerDirectory) + const boundSessions = (): Map => { const map = new Map() const directories = new Set() @@ -82,119 +93,149 @@ export const SettingsWorktrees: Component = () => { return ( -
        -
        -

        {language.t("settings.worktrees.title")}

        -

        {language.t("settings.worktrees.description")}

        -
        +
        +

        {language.t("settings.worktrees.title")}

        +

        {language.t("settings.worktrees.description")}

        +
        + {language.t("common.loading")}
        } + > {language.t("common.loading")}
        } + when={(data() ?? []).length > 0} + fallback={ +
        + +
        {language.t("settings.worktrees.empty.title")}
        +
        {language.t("settings.worktrees.empty.body")}
        +
        + } > - 0} - fallback={ -
        - {language.t("settings.worktrees.empty")} -
        - } - > -
          - - {(worktree) => { - const directory = () => worktree.directory - const name = () => worktree.name || basename(worktree.directory) - const branch = () => worktree.branch || "-" - const blocker = () => boundSessions().get(worktree.directory) - const blocked = () => !!blocker() - const isConfirming = () => confirming() === worktree.directory - const isDeleting = () => deleting() === worktree.directory +
            + + {(worktree) => { + const directory = () => worktree.directory + const name = () => worktree.name || basename(worktree.directory) + const branch = () => worktree.branch || "" + const ownerDir = () => worktree.ownerDirectory + const owner = () => ownerName(worktree.ownerDirectory) + const fullId = () => `${owner()} / ${name()}` + const blocker = () => boundSessions().get(worktree.directory) + const blocked = () => !!blocker() + const isConfirming = () => confirming() === worktree.directory + const isDeleting = () => deleting() === worktree.directory - return ( -
          • -
            - - - -
            -
            - - {name()} + return ( +
          • + + +
            + + {owner()} + + + {language.t("settings.worktrees.confirmDelete.question", { name: name() })} - - {language.t(sourceKey(worktree.source))} + + + {directory()} + + + {language.t("settings.worktrees.confirmDelete.warning")}
            -
            - {language.t("settings.worktrees.column.branch")} - +
            + + +
            + + } + > + + + +
            + + {owner()} + + + {name()} + + + + {branch()} - / - - {directory()} - -
            - - {(session) => ( -
            - {language.t("settings.worktrees.inUse", { session: session() })} -
            - )} +
            -
            -
            - + + {directory()} + + +
            +
            + setConfirming(directory())} > {language.t("settings.worktrees.delete")} - -
            -
        - -
        - - {language.t("settings.worktrees.confirmDelete.body", { name: name() })} - - - -
        -
        -
      • - ) - }} -
        -
      - + + +
    + +
  • + ) + }} +
    + -
    + ) } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 43c40288..c7cc1318 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -813,17 +813,19 @@ export const dict = { "settings.tab.worktrees": "Worktrees", "settings.worktrees.title": "Worktrees", "settings.worktrees.description": "Worktrees created or registered by PawWork. Active sessions keep their worktree until ExitWorktree is used.", - "settings.worktrees.empty": "No worktrees yet.", + "settings.worktrees.empty.title": "No worktrees yet", + "settings.worktrees.empty.body": "Run EnterWorktree from a session to create one", "settings.worktrees.column.name": "Name", "settings.worktrees.column.branch": "Branch", "settings.worktrees.column.path": "Path", "settings.worktrees.source.created": "Created", "settings.worktrees.source.existing": "Added", "settings.worktrees.inUse": "In use by {session}", + "settings.worktrees.inUse.short": "In use", "settings.worktrees.delete": "Delete", "settings.worktrees.deleteDisabled.tooltip": "In use by {session}. Use ExitWorktree from that session first.", - "settings.worktrees.confirmDelete.title": "Delete worktree?", - "settings.worktrees.confirmDelete.body": "Delete \"{name}\" and all of its files? This runs git worktree remove and cannot be undone.", + "settings.worktrees.confirmDelete.question": "Delete \"{name}\"?", + "settings.worktrees.confirmDelete.warning": "Can't be undone", "settings.worktrees.confirmDelete.confirmLabel": "Delete", "settings.worktrees.confirmDelete.cancelLabel": "Cancel", "settings.worktrees.deleteFailed": "Failed to delete worktree: {message}", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 87ebd37a..ebf77414 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -704,17 +704,19 @@ export const dict = { "settings.tab.worktrees": "工作树", "settings.worktrees.title": "工作树", "settings.worktrees.description": "爪印创建或登记过的工作树。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。", - "settings.worktrees.empty": "暂无工作树。", + "settings.worktrees.empty.title": "还没有工作树", + "settings.worktrees.empty.body": "在会话里调用 EnterWorktree 就能创建一个", "settings.worktrees.column.name": "名称", "settings.worktrees.column.branch": "分支", "settings.worktrees.column.path": "路径", "settings.worktrees.source.created": "已创建", "settings.worktrees.source.existing": "已接入", "settings.worktrees.inUse": "正在被「{session}」使用", + "settings.worktrees.inUse.short": "使用中", "settings.worktrees.delete": "删除", "settings.worktrees.deleteDisabled.tooltip": "正在被「{session}」使用,先在该会话里调用 ExitWorktree。", - "settings.worktrees.confirmDelete.title": "删除该工作树?", - "settings.worktrees.confirmDelete.body": "确定删除「{name}」及其所有文件?这将执行 git worktree remove,操作不可撤销。", + "settings.worktrees.confirmDelete.question": "删除「{name}」?", + "settings.worktrees.confirmDelete.warning": "此操作不可撤销", "settings.worktrees.confirmDelete.confirmLabel": "删除", "settings.worktrees.confirmDelete.cancelLabel": "取消", "settings.worktrees.deleteFailed": "删除工作树失败:{message}", From 65311e13ae205b8317e766f42b0b4b5234996e35 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 22:34:56 +0800 Subject: [PATCH 32/65] refactor(app): unify titlebar folder and worktree chips The folder Button in session-header and the worktree badge ran on different primitives (Button vs bare button) with drifting class strings, so focus rings, disabled states, and hover handling were inconsistent. Move PawworkWorktreeBadge onto the same Button(variant=ghost,size=small) shape as the folder chip and align the override class strings. Also lift the chrome icon color from text-text-weaker (#c7c7c7, cool and overly faint on the warm titlebar) to text-text-weak (#8c817a) with a group-hover transition to text-text-strong, matching the chip's text color cascade. --- .../app/src/components/session/session-header.tsx | 4 ++-- .../src/pages/layout/pawwork-worktree-badge.tsx | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1f71bbb7..72867f68 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -104,7 +104,7 @@ export function SessionHeader() { type="button" variant="ghost" size="small" - class="h-6 max-w-[180px] min-w-0 shrink items-center gap-1 rounded px-1 shadow-none text-13-regular text-text-weak hover:text-text-strong" + class="group h-6 max-w-[180px] min-w-0 shrink items-center gap-1 rounded px-1 shadow-none text-13-regular text-text-weak hover:text-text-strong" onClick={openProjectDirectory} aria-label={ canOpenProjectDirectory() ? language.t("session.header.open.ariaLabel", { app: fileManagerLabel() }) : undefined @@ -116,7 +116,7 @@ export function SessionHeader() { } disabled={!canOpenProjectDirectory()} > - + {name()} diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index 034708f0..166eafe4 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -1,3 +1,4 @@ +import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" export function PawworkWorktreeBadge(props: { @@ -8,20 +9,22 @@ export function PawworkWorktreeBadge(props: { ariaLabel?: string disabled?: boolean }) { - const title = () => [props.branch, props.directory].filter(Boolean).join(" · ") + const title = () => [props.branch, props.directory].filter(Boolean).join(" · ") || props.name return ( - + ) } From 8c2a306e40c79eb36dc3084fd254328e62875809 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 22:35:04 +0800 Subject: [PATCH 33/65] chore(i18n): polish worktree term and voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh: bare 'worktree' in ui tool labels switches to 工作树, matching the term unification already done in packages/app. en: settings.worktrees.description used the passive 'until ExitWorktree is used'. Rewrite in active voice: 'An active session keeps its worktree bound; run ExitWorktree before you can delete it.' --- packages/app/src/i18n/en.ts | 2 +- packages/ui/src/i18n/zh.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c7cc1318..1c484b9c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -812,7 +812,7 @@ export const dict = { "settings.tab.shortcuts": "Shortcuts", "settings.tab.worktrees": "Worktrees", "settings.worktrees.title": "Worktrees", - "settings.worktrees.description": "Worktrees created or registered by PawWork. Active sessions keep their worktree until ExitWorktree is used.", + "settings.worktrees.description": "Worktrees PawWork has created or registered. An active session keeps its worktree bound; run ExitWorktree before you can delete it.", "settings.worktrees.empty.title": "No worktrees yet", "settings.worktrees.empty.body": "Run EnterWorktree from a session to create one", "settings.worktrees.column.name": "Name", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 50250b3f..b3e360d4 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -102,8 +102,8 @@ export const dict = { "ui.tool.webfetch": "读取网页", "ui.tool.websearch": "网络搜索", "ui.tool.codesearch": "代码搜索", - "ui.tool.worktree.enter": "进入 worktree", - "ui.tool.worktree.exit": "退出 worktree", + "ui.tool.worktree.enter": "进入工作树", + "ui.tool.worktree.exit": "退出工作树", "ui.tool.shell": "执行命令", "ui.tool.patch": "批量修改", "ui.tool.todos": "待办", From c4de7bb6f349f61d6b5776886f6f4eb20c39f40c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 22:44:58 +0800 Subject: [PATCH 34/65] fix(ui): clarify enter/exit-worktree subtitles in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-stream subtitle for both worktree tools fell back to a single basename, leaving uninformative single tokens like '进入工作树 pawwork' or '退出工作树 test'. The exit case was actively misleading: 'test' was the basename of the project root the session returned to, not the worktree being exited. Split worktreeSubtitle into two helpers driven by the actual tool metadata: - enterWorktreeSubtitle returns '{slug} · {branch}' from metadata.slug + metadata.branch (with input.name / input.path / metadata.activeDirectory as fallbacks), so users see what they entered. - exitWorktreeProjectName returns the project basename, and the registration formats it via a new ui.tool.worktree.exit.toProject string (zh '回到 {{project}}', en 'Back to {{project}}'), making the destination explicit. Update getToolInfo, buildToolInfo, and both ToolRegistry registrations so the BasicTool render and timeline previews stay aligned. getToolInfo now takes optional metadata; both call sites pass part.state.metadata. --- packages/ui/src/components/message-part.tsx | 35 ++++++++++-------- packages/ui/src/components/tool-info.ts | 41 +++++++++++++++++---- packages/ui/src/i18n/en.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2e4fa93a..b1bd090f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -264,7 +264,13 @@ function getDirectory(path: string | undefined) { import type { IconProps } from "./icon" -import { agentTitle, buildToolInfo, type ToolInfo } from "./tool-info" +import { + agentTitle, + buildToolInfo, + enterWorktreeSubtitle, + exitWorktreeProjectName, + type ToolInfo, +} from "./tool-info" export { buildToolInfo, type ToolInfo } const agentTones: Record = { @@ -308,13 +314,7 @@ function taskAgent( } } -function worktreeSubtitle(input: Record, metadata: Record = {}) { - const value = input.name ?? metadata.name ?? input.path ?? input.directory ?? metadata.directory ?? metadata.activeDirectory - if (typeof value !== "string" || !value) return undefined - return value.includes("/") || value.includes("\\") ? getFilename(value) : value -} - -export function getToolInfo(tool: string, input: any = {}): ToolInfo { +export function getToolInfo(tool: string, input: any = {}, metadata: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { case "read": @@ -363,14 +363,16 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { return { icon: "worktree", title: i18n.t("ui.tool.worktree.enter"), - subtitle: worktreeSubtitle(input), + subtitle: enterWorktreeSubtitle(input, metadata), } - case "exit-worktree": + case "exit-worktree": { + const project = exitWorktreeProjectName(metadata) return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), - subtitle: worktreeSubtitle(input), + subtitle: project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined, } + } case "task": // agent-rename:legacy-render case "agent": { const type = @@ -728,7 +730,7 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) + const info = getToolInfo(part.tool, part.state.input ?? {}, (part.state as any).metadata ?? {}) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -780,7 +782,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input) + const info = getToolInfo(part.tool, input, (part.state as any).metadata ?? {}) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1762,7 +1764,7 @@ ToolRegistry.register({ name: "enter-worktree", render(props) { const i18n = useI18n() - const subtitle = createMemo(() => worktreeSubtitle(props.input, props.metadata)) + const subtitle = createMemo(() => enterWorktreeSubtitle(props.input, props.metadata)) return ( worktreeSubtitle(props.input, props.metadata)) + const subtitle = createMemo(() => { + const project = exitWorktreeProjectName(props.metadata) + return project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined + }) return ( , metadata: Record = {}) { - const value = input.name ?? metadata.name ?? input.path ?? input.directory ?? metadata.directory ?? metadata.activeDirectory - if (typeof value !== "string" || !value) return undefined - return value.includes("/") || value.includes("\\") ? getFilename(value) : value +function pickString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} + +export function enterWorktreeSubtitle( + input: Record, + metadata: Record = {}, +): string | undefined { + const slug = pickString(metadata.slug) || pickString(input.name) + const branch = pickString(metadata.branch) + const path = pickString(metadata.activeDirectory) || pickString(input.path) + const name = slug || (path ? getFilename(path) : undefined) + if (name && branch) return `${name} · ${branch}` + return name || branch +} + +export function exitWorktreeProjectName( + metadata: Record = {}, +): string | undefined { + const dest = pickString(metadata.activeDirectory) + return dest ? getFilename(dest) : undefined } export function agentTitle(i18n: UiI18n, type?: string) { @@ -49,9 +66,19 @@ export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { case "codesearch": return { icon: "code", title: i18n.t("ui.tool.codesearch"), subtitle: input.query } case "enter-worktree": - return { icon: "worktree", title: i18n.t("ui.tool.worktree.enter"), subtitle: worktreeSubtitle(input, metadata) } - case "exit-worktree": - return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle: worktreeSubtitle(input, metadata) } + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.enter"), + subtitle: enterWorktreeSubtitle(input, metadata), + } + case "exit-worktree": { + const project = exitWorktreeProjectName(metadata) + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.exit"), + subtitle: project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined, + } + } case "bash": return { icon: "console", title: i18n.t("ui.tool.shell"), subtitle: input.description } case "edit": diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 44e7b3d1..320d301d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -110,6 +110,7 @@ export const dict: Record = { "ui.tool.codesearch": "Code Search", "ui.tool.worktree.enter": "Enter worktree", "ui.tool.worktree.exit": "Exit worktree", + "ui.tool.worktree.exit.toProject": "Back to {{project}}", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "To-dos", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index b3e360d4..46e9240f 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -104,6 +104,7 @@ export const dict = { "ui.tool.codesearch": "代码搜索", "ui.tool.worktree.enter": "进入工作树", "ui.tool.worktree.exit": "退出工作树", + "ui.tool.worktree.exit.toProject": "回到 {{project}}", "ui.tool.shell": "执行命令", "ui.tool.patch": "批量修改", "ui.tool.todos": "待办", From 5fed9543cba4cd8886cfe7ac36a44d39b2429a18 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 23:11:42 +0800 Subject: [PATCH 35/65] feat(opencode): expose previous worktree on exit metadata Capture the slug, branch, and directory of the worktree being exited into the tool result metadata so downstream UIs can describe what was left, not just where the session returned to. --- packages/opencode/src/tool/exit-worktree.ts | 18 ++++++++++++++++-- .../opencode/test/tool/enter-worktree.test.ts | 7 ++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index d9a8dbd3..6ff29184 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -32,23 +32,37 @@ export const ExitWorktreeTool = Tool.define( const session = yield* sessions.get(ctx.sessionID) const exec = session.executionContext + type ExitMetadata = { + activeDirectory: string + previousSlug?: string + previousBranch?: string + previousDirectory?: string + } if (exec.activeDirectory === exec.ownerDirectory && exec.activeWorktree === undefined) { + const metadata: ExitMetadata = { activeDirectory: exec.ownerDirectory } return { title: "Already at project root", output: `Returned to project root ${exec.ownerDirectory}. Subsequent paths resolve from this directory.`, - metadata: { activeDirectory: exec.ownerDirectory }, + metadata, } } + const previous = exec.activeWorktree yield* sessions.updateExecutionContext({ sessionID: ctx.sessionID, activeDirectory: exec.ownerDirectory, activeWorktree: null, }) + const metadata: ExitMetadata = { + activeDirectory: exec.ownerDirectory, + previousSlug: previous?.name, + previousBranch: previous?.branch, + previousDirectory: previous?.directory, + } return { title: "Exited worktree", output: `Returned to project root ${exec.ownerDirectory}. Subsequent paths resolve from this directory.`, - metadata: { activeDirectory: exec.ownerDirectory }, + metadata, } }) diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts index f1aa492e..f86c457a 100644 --- a/packages/opencode/test/tool/enter-worktree.test.ts +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -85,7 +85,12 @@ test("enter-worktree and exit-worktree update the session execution context", as expect(entered.executionContext.activeDirectory).toBe(activeDirectory) expect(entered.executionContext.activeWorktree?.name).toBe("tool-work") - yield* exit.execute({}, toolContext(session.id)) + const exitResult = yield* exit.execute({}, toolContext(session.id)) + expect(exitResult.metadata.activeDirectory).toBe(tmp.path) + expect(exitResult.metadata.previousSlug).toBe("tool-work") + expect(exitResult.metadata.previousBranch).toBe("pawwork/tool-work") + expect(exitResult.metadata.previousDirectory).toBe(activeDirectory) + const exited = yield* sessions.get(session.id) expect(exited.executionContext.activeDirectory).toBe(tmp.path) expect(exited.executionContext.activeWorktree).toBeUndefined() From 814de6bc128ae253dd1b0caddaf514af0f516413 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 23:11:47 +0800 Subject: [PATCH 36/65] feat(ui): show exited branch in worktree exit subtitle Render the exit-worktree tool card as "{branch} -> {project}" when metadata carries the previous branch, falling back to the destination project alone, so chat readers see what was left behind. --- packages/ui/src/components/message-part.tsx | 17 +++++++++++------ packages/ui/src/components/tool-info.ts | 17 ++++++++++++----- packages/ui/src/i18n/en.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b1bd090f..3b60feaf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -268,6 +268,7 @@ import { agentTitle, buildToolInfo, enterWorktreeSubtitle, + exitWorktreePreviousBranch, exitWorktreeProjectName, type ToolInfo, } from "./tool-info" @@ -367,11 +368,12 @@ export function getToolInfo(tool: string, input: any = {}, metadata: any = {}): } case "exit-worktree": { const project = exitWorktreeProjectName(metadata) - return { - icon: "worktree", - title: i18n.t("ui.tool.worktree.exit"), - subtitle: project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined, - } + const branch = exitWorktreePreviousBranch(metadata) + let subtitle: string | undefined + if (branch && project) subtitle = i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) + else if (project) subtitle = i18n.t("ui.tool.worktree.exit.toProject", { project }) + else if (branch) subtitle = branch + return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle } } case "task": // agent-rename:legacy-render case "agent": { @@ -1782,7 +1784,10 @@ ToolRegistry.register({ const i18n = useI18n() const subtitle = createMemo(() => { const project = exitWorktreeProjectName(props.metadata) - return project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined + const branch = exitWorktreePreviousBranch(props.metadata) + if (branch && project) return i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) + if (project) return i18n.t("ui.tool.worktree.exit.toProject", { project }) + return branch }) return ( = {}, +): string | undefined { + return pickString(metadata.previousBranch) || pickString(metadata.previousSlug) +} + export function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -73,11 +79,12 @@ export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { } case "exit-worktree": { const project = exitWorktreeProjectName(metadata) - return { - icon: "worktree", - title: i18n.t("ui.tool.worktree.exit"), - subtitle: project ? i18n.t("ui.tool.worktree.exit.toProject", { project }) : undefined, - } + const branch = exitWorktreePreviousBranch(metadata) + let subtitle: string | undefined + if (branch && project) subtitle = i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) + else if (project) subtitle = i18n.t("ui.tool.worktree.exit.toProject", { project }) + else if (branch) subtitle = branch + return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle } } case "bash": return { icon: "console", title: i18n.t("ui.tool.shell"), subtitle: input.description } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 320d301d..c4c6db3c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -111,6 +111,7 @@ export const dict: Record = { "ui.tool.worktree.enter": "Enter worktree", "ui.tool.worktree.exit": "Exit worktree", "ui.tool.worktree.exit.toProject": "Back to {{project}}", + "ui.tool.worktree.exit.fromBranch": "{{branch}} → {{project}}", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "To-dos", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 46e9240f..e7c1d60c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -105,6 +105,7 @@ export const dict = { "ui.tool.worktree.enter": "进入工作树", "ui.tool.worktree.exit": "退出工作树", "ui.tool.worktree.exit.toProject": "回到 {{project}}", + "ui.tool.worktree.exit.fromBranch": "{{branch}} → {{project}}", "ui.tool.shell": "执行命令", "ui.tool.patch": "批量修改", "ui.tool.todos": "待办", From 96c210d720b04b08fe4f9cbfd36db9234fc0ffe1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 23:19:20 +0800 Subject: [PATCH 37/65] fix(app): tighten titlebar new-session button Hide the titlebar new-session button entirely when the sidebar is expanded (the sidebar already exposes its own entry point), drop the selected-state variant so the button is purely an action, and remove the now-unused new-session-active glyph. --- packages/app/src/components/titlebar.tsx | 61 ++++++++---------------- packages/ui/src/components/icon.tsx | 1 - 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 5659d039..54a1a976 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -38,12 +38,6 @@ export function Titlebar() { }) const path = () => `${location.pathname}${location.search}${location.hash}` - const creating = createMemo(() => { - if (!params.dir) return false - if (params.id) return false - const parts = location.pathname.replace(/\/+$/, "").split("/") - return parts.at(-1) === "session" - }) createEffect(() => { const current = path() @@ -122,42 +116,25 @@ export function Titlebar() { -
    - -
    -
    - -
    -
    -
    -
    + + +
    diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 6e8518d8..50c597a9 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -30,7 +30,6 @@ export const icons = { "plus-small": ``, plus: ``, "new-session": ``, - "new-session-active": ``, "pencil-line": ``, mcp: ``, glasses: ``, From a3935efebb56abcb6f6fb18766edef87c2d4fc26 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 23:19:26 +0800 Subject: [PATCH 38/65] fix(app): close settings overlay when route changes The Settings page is rendered as an overlay above the main pane, so selecting a session from the sidebar updated the URL but left the overlay covering it. Auto-close Settings on any route change while it is open so picking another session navigates straight there. --- packages/app/src/pages/layout.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 30a3e2ac..7d30d77e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -14,7 +14,7 @@ import { type Accessor, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" @@ -115,6 +115,7 @@ export default function Layout(props: ParentProps) { const [settingsTab, setSettingsTab] = createSignal("general") const params = useParams() + const location = useLocation() const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() @@ -1240,6 +1241,17 @@ export default function Layout(props: ParentProps) { setSettingsOpen(false) } + let lastSettingsPath: string | undefined + createEffect(() => { + const current = location.pathname + if (settingsOpen()) { + if (lastSettingsPath === undefined) lastSettingsPath = current + else if (lastSettingsPath !== current) closeSettings() + } else { + lastSettingsPath = undefined + } + }) + function projectRoot(directory: string) { const key = workspaceKey(directory) const project = layout.projects From 1063607024754b35e3f963399b57ea740ddef556 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Fri, 1 May 2026 23:27:54 +0800 Subject: [PATCH 39/65] fix(ui): restore titlebar icon hover, drop persistent bg The titlebar-icon hover background was mixing two shell tokens that resolved to the same color in both light and dark, so the hover read as no change. Switch to the app-wide --surface-base-hover token, then remove the aria-expanded / aria-current persistent backgrounds so sidebar and right-panel toggles convey state through the icon swap alone instead of a stuck-on chip. --- packages/app/src/index.css | 18 +----------------- packages/ui/src/components/button.css | 23 ----------------------- 2 files changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index bad07064..0abed9f2 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -136,23 +136,7 @@ [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"]:hover:not(:disabled) { - background-color: color-mix(in srgb, var(--shell-surface-strong) 90%, var(--shell-background-base) 10%); - } - - [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] - :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"][aria-expanded="true"], - [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] - :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"][aria-current="page"] { - background-color: color-mix(in srgb, var(--shell-surface-strong) 82%, var(--shell-background-base) 18%); - } - - [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] - :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"][aria-expanded="true"] - [data-slot="icon-svg"], - [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] - :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"][aria-current="page"] - [data-slot="icon-svg"] { - color: var(--color-text-strong); + background-color: var(--surface-base-hover); } [data-component="desktop-shell-main"][data-platform="desktop"][data-os="macos"] { diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 3f351de0..cdda8ea9 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -171,26 +171,3 @@ } } -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] { - background-color: var(--surface-base-active); -} - -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] { - color: var(--icon-strong-base); -} - -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) { - background-color: var(--surface-base-active); -} - -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] { - background-color: var(--surface-base-active); -} - -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] [data-slot="icon-svg"] { - color: var(--icon-strong-base); -} - -[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"]:hover:not(:disabled) { - background-color: var(--surface-base-active); -} From 5d4d7ea1084a56056603ef4cdfe81efb5cafa30b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 00:22:50 +0800 Subject: [PATCH 40/65] fix(ui): clarify worktree tool labels --- packages/opencode/src/tool/enter-worktree.ts | 6 ++ .../opencode/test/tool/enter-worktree.test.ts | 3 + packages/ui/src/components/message-part.tsx | 36 +++----- packages/ui/src/components/tool-info.ts | 88 +++++++++++++------ packages/ui/src/i18n/en.ts | 3 +- packages/ui/src/i18n/zh.ts | 3 +- .../components/message-part-rename.test.ts | 20 ++++- 7 files changed, 105 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index 1aea4a83..afca9967 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -62,6 +62,7 @@ export const EnterWorktreeTool = Tool.define( const successResult = (input: { activeDirectory: string + ownerDirectory: string slug: string branch: string state: "created" | "reused" @@ -70,6 +71,7 @@ export const EnterWorktreeTool = Tool.define( output: `Now active in ${input.activeDirectory} (branch ${input.branch}, slug ${input.slug}). Subsequent paths resolve from this directory.`, metadata: { activeDirectory: input.activeDirectory, + ownerDirectory: input.ownerDirectory, slug: input.slug, branch: input.branch, state: input.state, @@ -111,6 +113,7 @@ export const EnterWorktreeTool = Tool.define( const slug = exec.activeWorktree?.name ?? path.basename(canonical) return successResult({ activeDirectory: canonical, + ownerDirectory: exec.ownerDirectory, slug, branch: exec.activeWorktree?.branch ?? "", state: "reused", @@ -133,6 +136,7 @@ export const EnterWorktreeTool = Tool.define( yield* applyEnter(ctx.sessionID, { ...info, branch: info.branch || branch }, "existing") return successResult({ activeDirectory: canonical, + ownerDirectory: exec.ownerDirectory, slug: info.name, branch: info.branch || branch, state: "reused", @@ -145,6 +149,7 @@ export const EnterWorktreeTool = Tool.define( if (exec.activeDirectory === planned.directory) { return successResult({ activeDirectory: planned.directory, + ownerDirectory: exec.ownerDirectory, slug: planned.name, branch: planned.branch, state: "reused", @@ -175,6 +180,7 @@ export const EnterWorktreeTool = Tool.define( yield* applyEnter(ctx.sessionID, planned, planned.source) return successResult({ activeDirectory: planned.directory, + ownerDirectory: exec.ownerDirectory, slug: planned.name, branch: planned.branch, state: exists ? "reused" : "created", diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts index f86c457a..d44774a6 100644 --- a/packages/opencode/test/tool/enter-worktree.test.ts +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -80,6 +80,9 @@ test("enter-worktree and exit-worktree update the session execution context", as const result = yield* enter.execute({ name: "tool-work" }, toolContext(session.id)) activeDirectory = result.metadata.activeDirectory + expect(result.metadata.ownerDirectory).toBe(tmp.path) + expect(result.metadata.branch).toBe("pawwork/tool-work") + expect(result.metadata.slug).toBe("tool-work") const entered = yield* sessions.get(session.id) expect(entered.executionContext.activeDirectory).toBe(activeDirectory) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3b60feaf..23ba36cc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -264,14 +264,7 @@ function getDirectory(path: string | undefined) { import type { IconProps } from "./icon" -import { - agentTitle, - buildToolInfo, - enterWorktreeSubtitle, - exitWorktreePreviousBranch, - exitWorktreeProjectName, - type ToolInfo, -} from "./tool-info" +import { agentTitle, buildToolInfo, enterWorktreeSubtitle, exitWorktreeSubtitle, type ToolInfo } from "./tool-info" export { buildToolInfo, type ToolInfo } const agentTones: Record = { @@ -360,20 +353,19 @@ export function getToolInfo(tool: string, input: any = {}, metadata: any = {}): title: i18n.t("ui.tool.codesearch"), subtitle: input.query, } - case "enter-worktree": + case "enter-worktree": { return { icon: "worktree", title: i18n.t("ui.tool.worktree.enter"), - subtitle: enterWorktreeSubtitle(input, metadata), + subtitle: enterWorktreeSubtitle(input, metadata, i18n), } + } case "exit-worktree": { - const project = exitWorktreeProjectName(metadata) - const branch = exitWorktreePreviousBranch(metadata) - let subtitle: string | undefined - if (branch && project) subtitle = i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) - else if (project) subtitle = i18n.t("ui.tool.worktree.exit.toProject", { project }) - else if (branch) subtitle = branch - return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle } + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.exit"), + subtitle: exitWorktreeSubtitle(metadata, i18n), + } } case "task": // agent-rename:legacy-render case "agent": { @@ -1766,7 +1758,7 @@ ToolRegistry.register({ name: "enter-worktree", render(props) { const i18n = useI18n() - const subtitle = createMemo(() => enterWorktreeSubtitle(props.input, props.metadata)) + const subtitle = createMemo(() => enterWorktreeSubtitle(props.input, props.metadata, i18n)) return ( { - const project = exitWorktreeProjectName(props.metadata) - const branch = exitWorktreePreviousBranch(props.metadata) - if (branch && project) return i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) - if (project) return i18n.t("ui.tool.worktree.exit.toProject", { project }) - return branch - }) + const subtitle = createMemo(() => exitWorktreeSubtitle(props.metadata, i18n)) return ( , +export function enterWorktreeOwnerProject(metadata: Record = {}): string | undefined { + const owner = pickString(metadata.ownerDirectory) + return owner ? getFilename(owner) : undefined +} + +export function enterWorktreeTarget( + input: Record = {}, metadata: Record = {}, ): string | undefined { - const slug = pickString(metadata.slug) || pickString(input.name) - const branch = pickString(metadata.branch) - const path = pickString(metadata.activeDirectory) || pickString(input.path) - const name = slug || (path ? getFilename(path) : undefined) - if (name && branch) return `${name} · ${branch}` - return name || branch + const activeDirectory = pickString(metadata.activeDirectory) + return ( + pickString(metadata.branch) || + pickString(metadata.slug) || + pickString(input.name) || + (activeDirectory ? getFilename(activeDirectory) : undefined) + ) } -export function exitWorktreeProjectName( - metadata: Record = {}, +export function enterWorktreeSubtitle( + input: Record, + metadata: Record, + i18n: UiI18n, ): string | undefined { + const project = enterWorktreeOwnerProject(metadata) + const target = enterWorktreeTarget(input, metadata) + if (target && project) return i18n.t("ui.tool.worktree.enter.fromProject", { project, target }) + return target || project +} + +export function exitWorktreeProjectName(metadata: Record = {}): string | undefined { const dest = pickString(metadata.activeDirectory) return dest ? getFilename(dest) : undefined } -export function exitWorktreePreviousBranch( - metadata: Record = {}, -): string | undefined { +export function exitWorktreePreviousLabel(metadata: Record = {}): string | undefined { return pickString(metadata.previousBranch) || pickString(metadata.previousSlug) } +export function exitWorktreeSubtitle(metadata: Record, i18n: UiI18n): string | undefined { + const project = exitWorktreeProjectName(metadata) + const previous = exitWorktreePreviousLabel(metadata) + if (previous && project) return i18n.t("ui.tool.worktree.exit.fromWorktree", { previous, project }) + if (project) return i18n.t("ui.tool.worktree.exit.toProject", { project }) + return previous +} + export function agentTitle(i18n: UiI18n, type?: string) { if (!type) return i18n.t("ui.tool.agent.default") return i18n.t("ui.tool.agent", { type }) @@ -58,9 +79,17 @@ export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { } } case "read": - return { icon: "glasses", title: i18n.t("ui.tool.read"), subtitle: input.filePath ? getFilename(input.filePath) : undefined } + return { + icon: "glasses", + title: i18n.t("ui.tool.read"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } case "list": - return { icon: "bullet-list", title: i18n.t("ui.tool.list"), subtitle: input.path ? getFilename(input.path) : undefined } + return { + icon: "bullet-list", + title: i18n.t("ui.tool.list"), + subtitle: input.path ? getFilename(input.path) : undefined, + } case "glob": return { icon: "magnifying-glass-menu", title: i18n.t("ui.tool.glob"), subtitle: input.pattern } case "grep": @@ -71,27 +100,34 @@ export function buildToolInfo(part: ToolPart, i18n: UiI18n): ToolInfo { return { icon: "window-cursor", title: i18n.t("ui.tool.websearch"), subtitle: input.query } case "codesearch": return { icon: "code", title: i18n.t("ui.tool.codesearch"), subtitle: input.query } - case "enter-worktree": + case "enter-worktree": { return { icon: "worktree", title: i18n.t("ui.tool.worktree.enter"), - subtitle: enterWorktreeSubtitle(input, metadata), + subtitle: enterWorktreeSubtitle(input, metadata, i18n), } + } case "exit-worktree": { - const project = exitWorktreeProjectName(metadata) - const branch = exitWorktreePreviousBranch(metadata) - let subtitle: string | undefined - if (branch && project) subtitle = i18n.t("ui.tool.worktree.exit.fromBranch", { branch, project }) - else if (project) subtitle = i18n.t("ui.tool.worktree.exit.toProject", { project }) - else if (branch) subtitle = branch - return { icon: "worktree", title: i18n.t("ui.tool.worktree.exit"), subtitle } + return { + icon: "worktree", + title: i18n.t("ui.tool.worktree.exit"), + subtitle: exitWorktreeSubtitle(metadata, i18n), + } } case "bash": return { icon: "console", title: i18n.t("ui.tool.shell"), subtitle: input.description } case "edit": - return { icon: "code-lines", title: i18n.t("ui.messagePart.title.edit"), subtitle: input.filePath ? getFilename(input.filePath) : undefined } + return { + icon: "code-lines", + title: i18n.t("ui.messagePart.title.edit"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } case "write": - return { icon: "code-lines", title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined } + return { + icon: "code-lines", + title: i18n.t("ui.messagePart.title.write"), + subtitle: input.filePath ? getFilename(input.filePath) : undefined, + } case "apply_patch": return { icon: "code-lines", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index c4c6db3c..f4baf9b4 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -109,9 +109,10 @@ export const dict: Record = { "ui.tool.websearch": "Web Search", "ui.tool.codesearch": "Code Search", "ui.tool.worktree.enter": "Enter worktree", + "ui.tool.worktree.enter.fromProject": "{{project}} → {{target}}", "ui.tool.worktree.exit": "Exit worktree", "ui.tool.worktree.exit.toProject": "Back to {{project}}", - "ui.tool.worktree.exit.fromBranch": "{{branch}} → {{project}}", + "ui.tool.worktree.exit.fromWorktree": "{{previous}} → {{project}}", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "To-dos", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index e7c1d60c..f9adc443 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -103,9 +103,10 @@ export const dict = { "ui.tool.websearch": "网络搜索", "ui.tool.codesearch": "代码搜索", "ui.tool.worktree.enter": "进入工作树", + "ui.tool.worktree.enter.fromProject": "{{project}} → {{target}}", "ui.tool.worktree.exit": "退出工作树", "ui.tool.worktree.exit.toProject": "回到 {{project}}", - "ui.tool.worktree.exit.fromBranch": "{{branch}} → {{project}}", + "ui.tool.worktree.exit.fromWorktree": "{{previous}} → {{project}}", "ui.tool.shell": "执行命令", "ui.tool.patch": "批量修改", "ui.tool.todos": "待办", diff --git a/packages/ui/test/components/message-part-rename.test.ts b/packages/ui/test/components/message-part-rename.test.ts index ce075c91..4241b520 100644 --- a/packages/ui/test/components/message-part-rename.test.ts +++ b/packages/ui/test/components/message-part-rename.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { buildToolInfo } from "../../src/components/tool-info" +import { buildToolInfo, enterWorktreeSubtitle, exitWorktreeSubtitle } from "../../src/components/tool-info" import type { UiI18n } from "../../src/context/i18n" const baseFields = { @@ -23,6 +23,10 @@ const partTask = { ...baseFields, tool: "task" } as ToolPart // agent-rename:leg const partAgent = { ...baseFields, tool: "agent" } as ToolPart const i18n = { t: (k: string) => k, language: () => "en" } as unknown as UiI18n +const templateI18n = { + t: (key: string, params?: Record) => `${key}:${JSON.stringify(params ?? {})}`, + language: () => "en", +} as unknown as UiI18n describe("message-part dual render (#128)", () => { test("derived tool-info icon is 'agent' for both inputs", () => { @@ -38,3 +42,17 @@ describe("message-part dual render (#128)", () => { expect(buildToolInfo(partTask, i18n).subtitle).toEqual(buildToolInfo(partAgent, i18n).subtitle) }) }) + +describe("worktree tool subtitles", () => { + test("enter subtitle uses neutral target metadata", () => { + expect( + enterWorktreeSubtitle({}, { ownerDirectory: "/repo/pawwork", branch: "pawwork/feature-a" }, templateI18n), + ).toBe('ui.tool.worktree.enter.fromProject:{"project":"pawwork","target":"pawwork/feature-a"}') + }) + + test("exit subtitle uses neutral previous metadata", () => { + expect(exitWorktreeSubtitle({ activeDirectory: "/repo/pawwork", previousSlug: "feature-a" }, templateI18n)).toBe( + 'ui.tool.worktree.exit.fromWorktree:{"previous":"feature-a","project":"pawwork"}', + ) + }) +}) From a7544628f0ac601e825b9733a438215aa84f39f3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 00:23:48 +0800 Subject: [PATCH 41/65] fix(app): polish worktree settings list --- .../app/src/components/settings-worktrees.tsx | 101 ++++++++---------- packages/app/src/i18n/en.ts | 8 +- packages/app/src/i18n/zh.ts | 8 +- 3 files changed, 51 insertions(+), 66 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 9388b18c..36d054a1 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -1,17 +1,26 @@ import { type Component, createMemo, createResource, createSignal, For, Show } from "solid-js" +import { useNavigate } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { SettingsList } from "./settings-list" import { basename, entryDirectory, errorText, sourceKey, type WorktreeInfo } from "./settings-worktrees-helpers" +type BoundSession = { + id: string + title: string + hostDirectory: string +} + export const SettingsWorktrees: Component = () => { const language = useLanguage() const sdk = useGlobalSDK() const sync = useGlobalSync() + const navigate = useNavigate() const projectRoots = () => sync.data.project @@ -42,11 +51,10 @@ export const SettingsWorktrees: Component = () => { return map }) - const ownerName = (ownerDirectory: string) => - projectNameByOwner().get(ownerDirectory) || basename(ownerDirectory) + const ownerName = (ownerDirectory: string) => projectNameByOwner().get(ownerDirectory) || basename(ownerDirectory) - const boundSessions = (): Map => { - const map = new Map() + const boundSessions = (): Map => { + const map = new Map() const directories = new Set() for (const project of sync.data.project) { directories.add(project.worktree) @@ -63,13 +71,21 @@ export const SettingsWorktrees: Component = () => { const exec = s.executionContext if (!exec) continue if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) { - map.set(exec.activeDirectory, s.title) + map.set(exec.activeDirectory, { + id: s.id, + title: s.title, + hostDirectory: directory, + }) } } } return map } + const openSession = (entry: BoundSession) => { + navigate(`/${base64Encode(entry.hostDirectory)}/session/${entry.id}`) + } + const [confirming, setConfirming] = createSignal(undefined) const [deleting, setDeleting] = createSignal(undefined) @@ -118,19 +134,18 @@ export const SettingsWorktrees: Component = () => { const directory = () => worktree.directory const name = () => worktree.name || basename(worktree.directory) const branch = () => worktree.branch || "" - const ownerDir = () => worktree.ownerDirectory const owner = () => ownerName(worktree.ownerDirectory) - const fullId = () => `${owner()} / ${name()}` + const identity = () => branch() || name() + const rowTooltip = () => + [language.t(sourceKey(worktree.source)), directory()].filter(Boolean).join(" · ") const blocker = () => boundSessions().get(worktree.directory) - const blocked = () => !!blocker() const isConfirming = () => confirming() === worktree.directory const isDeleting = () => deleting() === worktree.directory return (
  • @@ -140,18 +155,11 @@ export const SettingsWorktrees: Component = () => { <>
    - - {owner()} - {language.t("settings.worktrees.confirmDelete.question", { name: name() })} - - - {directory()} - - - {language.t("settings.worktrees.confirmDelete.warning")} + + {language.t("settings.worktrees.confirmDelete.warning")}
    @@ -175,34 +183,14 @@ export const SettingsWorktrees: Component = () => { } > - - - -
    - - {owner()} - - - {name()} - - - - - {branch()} - - - - - {directory()} - - + +
    + {owner()} + {identity()}
    { } > - - + {(entry) => ( + + )}
    diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1c484b9c..3b5552ef 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -820,15 +820,13 @@ export const dict = { "settings.worktrees.column.path": "Path", "settings.worktrees.source.created": "Created", "settings.worktrees.source.existing": "Added", - "settings.worktrees.inUse": "In use by {session}", - "settings.worktrees.inUse.short": "In use", + "settings.worktrees.inUse": 'In use by session "{{session}}" - click to open', "settings.worktrees.delete": "Delete", - "settings.worktrees.deleteDisabled.tooltip": "In use by {session}. Use ExitWorktree from that session first.", - "settings.worktrees.confirmDelete.question": "Delete \"{name}\"?", + "settings.worktrees.confirmDelete.question": 'Delete "{{name}}"?', "settings.worktrees.confirmDelete.warning": "Can't be undone", "settings.worktrees.confirmDelete.confirmLabel": "Delete", "settings.worktrees.confirmDelete.cancelLabel": "Cancel", - "settings.worktrees.deleteFailed": "Failed to delete worktree: {message}", + "settings.worktrees.deleteFailed": "Failed to delete worktree: {{message}}", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.description": "Run the PawWork server inside WSL on Windows.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index ebf77414..032774f0 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -711,15 +711,13 @@ export const dict = { "settings.worktrees.column.path": "路径", "settings.worktrees.source.created": "已创建", "settings.worktrees.source.existing": "已接入", - "settings.worktrees.inUse": "正在被「{session}」使用", - "settings.worktrees.inUse.short": "使用中", + "settings.worktrees.inUse": "正在被会话「{{session}}」使用,点击跳转", "settings.worktrees.delete": "删除", - "settings.worktrees.deleteDisabled.tooltip": "正在被「{session}」使用,先在该会话里调用 ExitWorktree。", - "settings.worktrees.confirmDelete.question": "删除「{name}」?", + "settings.worktrees.confirmDelete.question": "删除「{{name}}」?", "settings.worktrees.confirmDelete.warning": "此操作不可撤销", "settings.worktrees.confirmDelete.confirmLabel": "删除", "settings.worktrees.confirmDelete.cancelLabel": "取消", - "settings.worktrees.deleteFailed": "删除工作树失败:{message}", + "settings.worktrees.deleteFailed": "删除工作树失败:{{message}}", "settings.desktop.section.wsl": "WSL", "settings.desktop.wsl.title": "WSL 集成", From d05d8a47f46f44c04666ef4214f5ef0494221135 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 00:45:46 +0800 Subject: [PATCH 42/65] fix(app): tighten session timeline spacing --- packages/app/src/pages/session/message-timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 6de999a4..e17fe935 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -708,14 +708,14 @@ export function MessageTimeline(props: { ref={props.setContentRef} class="min-w-0 w-full" style={{ - "padding-top": "calc(2rem + 16px)", + "padding-top": "1rem", "padding-bottom": "calc(var(--composer-dock-height, 0px) + 16px)", }} >
    Date: Sat, 2 May 2026 00:55:03 +0800 Subject: [PATCH 43/65] chore(app): remove unused worktree column labels --- packages/app/src/i18n/en.ts | 3 --- packages/app/src/i18n/zh.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3b5552ef..921006e7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -815,9 +815,6 @@ export const dict = { "settings.worktrees.description": "Worktrees PawWork has created or registered. An active session keeps its worktree bound; run ExitWorktree before you can delete it.", "settings.worktrees.empty.title": "No worktrees yet", "settings.worktrees.empty.body": "Run EnterWorktree from a session to create one", - "settings.worktrees.column.name": "Name", - "settings.worktrees.column.branch": "Branch", - "settings.worktrees.column.path": "Path", "settings.worktrees.source.created": "Created", "settings.worktrees.source.existing": "Added", "settings.worktrees.inUse": 'In use by session "{{session}}" - click to open', diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 032774f0..50474149 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -706,9 +706,6 @@ export const dict = { "settings.worktrees.description": "爪印创建或登记过的工作树。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。", "settings.worktrees.empty.title": "还没有工作树", "settings.worktrees.empty.body": "在会话里调用 EnterWorktree 就能创建一个", - "settings.worktrees.column.name": "名称", - "settings.worktrees.column.branch": "分支", - "settings.worktrees.column.path": "路径", "settings.worktrees.source.created": "已创建", "settings.worktrees.source.existing": "已接入", "settings.worktrees.inUse": "正在被会话「{{session}}」使用,点击跳转", From 753eb80f136fbab2c5da7d4e4c22ec922e4b283a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:03:43 +0800 Subject: [PATCH 44/65] fix: address worktree review feedback --- packages/app/src/context/prompt.test.ts | 41 ++++++++++- .../pages/layout/pawwork-worktree-badge.tsx | 4 +- .../src/session/execution-context-store.ts | 3 +- packages/opencode/src/session/session.ts | 57 +++++++++++++-- .../opencode/test/session/session.test.ts | 73 +++++++++++++++++++ 5 files changed, 163 insertions(+), 15 deletions(-) diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts index 9a5680c3..abc431d9 100644 --- a/packages/app/src/context/prompt.test.ts +++ b/packages/app/src/context/prompt.test.ts @@ -20,13 +20,14 @@ beforeAll(async () => { }) function promptSession() { - const prompt: Prompt = [{ type: "text", content: "hello", start: 0, end: 5 }] + let prompt: Prompt = [{ type: "text", content: "hello", start: 0, end: 5 }] + let cursor = 5 const items: (ContextItem & { key: string })[] = [] return { ready: () => true, current: () => prompt, - cursor: () => 5, + cursor: () => cursor, dirty: () => true, context: { items: () => items, @@ -39,8 +40,14 @@ function promptSession() { updateComment: () => undefined, replaceComments: () => undefined, }, - set: () => undefined, - reset: () => undefined, + set: (next: Prompt, nextCursor?: number) => { + prompt = next + cursor = nextCursor ?? cursor + }, + reset: () => { + prompt = DEFAULT_PROMPT + cursor = 0 + }, } } @@ -83,4 +90,30 @@ describe("createPromptBinding", () => { expect(binding.dirty()).toBe(true) expect(binding.context.items().map((item) => item.path)).toEqual(["a.ts"]) }) + + test("writes to an explicit target session", () => { + const current = promptSession() + const target = promptSession() + const binding = createPromptBinding( + () => ({ dir: "repo", id: "current" }), + (dir, id) => { + expect(dir).toBe("repo") + return id === "fork" ? target : current + }, + ) + + const next: Prompt = [{ type: "text", content: "forked", start: 0, end: 6 }] + binding.set(next, 6, { dir: "repo", id: "fork" }) + + expect(target.current()).toEqual(next) + expect(target.cursor()).toBe(6) + expect(current.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + expect(binding.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + + binding.reset({ dir: "repo", id: "fork" }) + + expect(target.current()).toEqual(DEFAULT_PROMPT) + expect(target.cursor()).toBe(0) + expect(current.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + }) }) diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index 166eafe4..4d8fc059 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -5,8 +5,8 @@ export function PawworkWorktreeBadge(props: { name: string branch?: string directory?: string - onClick?: () => void - ariaLabel?: string + onClick: () => void + ariaLabel: string disabled?: boolean }) { const title = () => [props.branch, props.directory].filter(Boolean).join(" · ") || props.name diff --git a/packages/opencode/src/session/execution-context-store.ts b/packages/opencode/src/session/execution-context-store.ts index bd4447d2..3e84af48 100644 --- a/packages/opencode/src/session/execution-context-store.ts +++ b/packages/opencode/src/session/execution-context-store.ts @@ -31,7 +31,8 @@ export function backfillExecutionContextRows(d: Tx) { .all() for (const row of rows) { const project = d.select().from(ProjectTable).where(eq(ProjectTable.id, row.project_id)).get() - const ownerDirectory = project?.vcs === "git" ? project.worktree : row.directory + const ownerDirectoryRaw = project?.vcs === "git" ? (project.worktree ?? row.directory) : row.directory + const ownerDirectory = canonicalDirectory(ownerDirectoryRaw) const ctx = rootContext(ownerDirectory) d.update(SessionTable).set({ execution_context: ctx }).where(eq(SessionTable.id, row.id)).run() } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index bd22df62..94b9df9b 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -51,8 +51,14 @@ export function isDefaultTitle(title: string) { } type SessionRow = typeof SessionTable.$inferSelect +type ProjectFallback = { worktree?: string | null; vcs?: string | null } -export function fromRow(row: SessionRow): Info { +function legacyExecutionContext(row: SessionRow, project?: ProjectFallback) { + const ownerDirectoryRaw = project?.vcs === "git" ? (project.worktree ?? row.directory) : row.directory + return rootContext(canonicalDirectory(ownerDirectoryRaw)) +} + +export function fromRow(row: SessionRow, project?: ProjectFallback): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null ? { @@ -81,7 +87,7 @@ export function fromRow(row: SessionRow): Info { revert, permission: row.permission ?? undefined, // Legacy rows may still have NULL execution_context; synthesize the root context on read. - executionContext: row.execution_context ?? rootContext(row.directory), + executionContext: row.execution_context ?? legacyExecutionContext(row, project), time: { created: row.time_created, updated: row.time_updated, @@ -485,7 +491,16 @@ export const layer: Layer.Layer = const get = Effect.fn("Session.get")(function* (id: SessionID) { const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) - return fromRow(row) + const project = row.execution_context + ? undefined + : yield* db((d) => + d + .select({ worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .where(eq(ProjectTable.id, row.project_id)) + .get(), + ) + return fromRow(row, project) }) const children = Effect.fn("Session.children")(function* (parentID: SessionID) { @@ -496,7 +511,19 @@ export const layer: Layer.Layer = .where(and(eq(SessionTable.parent_id, parentID))) .all(), ) - return rows.map(fromRow) + const ids = [...new Set(rows.filter((row) => row.execution_context === null).map((row) => row.project_id))] + const projects = new Map() + if (ids.length > 0) { + const items = yield* db((d) => + d + .select({ id: ProjectTable.id, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) projects.set(item.id, item) + } + return rows.map((row) => fromRow(row, projects.get(row.project_id))) }) const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -720,7 +747,7 @@ export const layer: Layer.Layer = for (const row of rows) { const session = fromRow(row) const exec = session.executionContext - if (exec.activeDirectory === exec.ownerDirectory) continue + if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(exec.ownerDirectory)) continue if ( canonicalDirectory(exec.activeDirectory) === target || (exec.activeWorktree?.directory && canonicalDirectory(exec.activeWorktree.directory) === target) @@ -905,8 +932,20 @@ export function* list(input?: { .limit(limit) .all(), ) + const ids = [...new Set(rows.filter((row) => row.execution_context === null).map((row) => row.project_id))] + const projects = new Map() + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) projects.set(item.id, item) + } for (const row of rows) { - yield fromRow(row) + yield fromRow(row, projects.get(row.project_id)) } } @@ -975,11 +1014,12 @@ export function* listGlobal(input?: { const ids = [...new Set(rows.map((row) => row.project_id))] const projects = new Map() + const projectFallbacks = new Map() if (ids.length > 0) { const items = Database.use((db) => db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) .from(ProjectTable) .where(inArray(ProjectTable.id, ids)) .all(), @@ -990,11 +1030,12 @@ export function* listGlobal(input?: { name: item.name ?? undefined, worktree: item.worktree, }) + projectFallbacks.set(item.id, item) } } for (const row of rows) { const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } + yield { ...fromRow(row, projectFallbacks.get(row.project_id)), project } } } diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index e1598e24..976e73e3 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -11,6 +11,8 @@ import { MessageID, PartID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" import { Database, eq } from "../../src/storage/db" import { MessageTable, SessionTable } from "../../src/session/session.sql" +import { ProjectTable } from "../../src/project/project.sql" +import { canonicalDirectory } from "../../src/session/execution-context-store" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -100,6 +102,28 @@ describe("session.created event", () => { }) }) + test("findActiveWorktreeBinding does not treat path variants of the owner as worktree bindings", async () => { + await using tmp = await tmpdir({ git: true }) + const ownerVariant = `${tmp.path}${path.sep}` + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "root path variant" }) + await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeDirectory: ownerVariant, + activeWorktree: null, + }) + + const found = await SessionNs.findActiveWorktreeBinding(ownerVariant) + expect(found).toBeUndefined() + + await SessionNs.remove(session.id) + }, + }) + }) + test("updateExecutionContext returns the persisted updated time", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-b") @@ -150,6 +174,55 @@ describe("session.created event", () => { }) }) + test("backfills legacy executionContext rows with canonical project roots", async () => { + await using tmp = await tmpdir({ git: true }) + const projectLink = path.join(tmp.path, "project-link") + await fs.symlink(tmp.path, projectLink) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "legacy-canonical-root" }) + Database.use((db) => { + db.update(ProjectTable).set({ worktree: projectLink }).where(eq(ProjectTable.id, session.projectID)).run() + db.update(SessionTable).set({ execution_context: null }).where(eq(SessionTable.id, session.id)).run() + }) + + const count = await Effect.runPromise(SessionNs.backfillExecutionContext) + expect(count).toBeGreaterThanOrEqual(1) + + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get()) + expect(row?.execution_context?.ownerDirectory).toBe(canonicalDirectory(projectLink)) + expect(row?.execution_context?.activeDirectory).toBe(canonicalDirectory(projectLink)) + + await SessionNs.remove(session.id) + }, + }) + }) + + test("synthesizes legacy null executionContext from the project root on read", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "packages", "app") + await fs.mkdir(subdir, { recursive: true }) + + await Instance.provide({ + directory: subdir, + fn: async () => { + const session = await SessionNs.create({ title: "legacy-read-root" }) + Database.use((db) => + db.update(SessionTable).set({ execution_context: null }).where(eq(SessionTable.id, session.id)).run(), + ) + + const loaded = await SessionNs.get(session.id) + expect(loaded.directory).toBe(subdir) + expect(loaded.executionContext.ownerDirectory).toBe(tmp.path) + expect(loaded.executionContext.activeDirectory).toBe(tmp.path) + + await SessionNs.remove(session.id) + }, + }) + }) + test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, From 35679f7dfb72c535acb09809be2cc5b9f8886ff4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:10:59 +0800 Subject: [PATCH 45/65] fix: address follow-up worktree review --- packages/app/e2e/app/shell-frame.spec.ts | 22 +++++++++---------- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/index.css | 4 ++++ packages/opencode/src/tool/exit-worktree.ts | 2 ++ .../opencode/test/tool/enter-worktree.test.ts | 1 + 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/app/shell-frame.spec.ts b/packages/app/e2e/app/shell-frame.spec.ts index f9442ce2..47edc395 100644 --- a/packages/app/e2e/app/shell-frame.spec.ts +++ b/packages/app/e2e/app/shell-frame.spec.ts @@ -4,7 +4,6 @@ import { desktopShellFrameSelector, desktopShellMainSelector, desktopShellSelector, - titlebarCenterSelector, titlebarLeftSelector, titlebarRightSelector, titlebarShellSelector, @@ -19,7 +18,7 @@ test("@smoke shell frame exposes stable desktop hooks", async ({ page, gotoSessi await expect(page.locator(titlebarShellSelector)).toBeVisible() await expect(page.locator(desktopShellMainSelector)).toBeVisible() await expect(page.locator(titlebarLeftSelector)).toHaveCount(1) - await expect(page.locator(titlebarCenterSelector)).toContainText(/new session/i) + await expect(page.locator(titlebarLeftSelector)).toContainText(/new session/i) await expect(page.locator(`${titlebarRightSelector} button`).first()).toBeVisible() await expect(page.getByRole("button", { name: /toggle sidebar/i }).first()).toBeVisible() @@ -31,32 +30,31 @@ test("@smoke shell frame exposes stable desktop hooks", async ({ page, gotoSessi await closeDialog(page, palette) }) -test("home titlebar center shows the current view title instead of the old file search affordance", async ({ +test("home titlebar left slot shows the current view title instead of the old file search affordance", async ({ page, gotoSession, }) => { await page.setViewportSize({ width: 1440, height: 900 }) await gotoSession() - const center = page.locator(titlebarCenterSelector) - await expect(center.getByText(/^new session$/i)).toBeVisible() - await expect(center.getByRole("button", { name: /search files/i })).toHaveCount(0) + const left = page.locator(titlebarLeftSelector) + await expect(left.getByText(/^new session$/i)).toBeVisible() + await expect(left.getByRole("button", { name: /search files/i })).toHaveCount(0) }) -test("session titlebar center shows a project and session breadcrumb", async ({ page, sdk, gotoSession }) => { +test("session titlebar left slot shows a project and session breadcrumb", async ({ page, sdk, gotoSession }) => { await page.setViewportSize({ width: 1440, height: 900 }) const title = `e2e breadcrumb ${Date.now()}` await withSession(sdk, title, async (session) => { await gotoSession(session.id) - const center = page.locator(titlebarCenterSelector) - const buttons = center.getByRole("button") + const left = page.locator(titlebarLeftSelector) + const buttons = left.getByRole("button") await expect(buttons).toHaveCount(1) await expect(buttons.first()).toContainText(/.+/) - await expect(center).toContainText(title) - await expect(center).toContainText("/") - await expect(center.getByRole("button", { name: /search files/i })).toHaveCount(0) + await expect(left).toContainText(title) + await expect(left.getByRole("button", { name: /search files/i })).toHaveCount(0) }) }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 54a1a976..935181da 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -141,7 +141,7 @@ export function Titlebar() {
    diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 0abed9f2..85fb6d04 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -129,6 +129,10 @@ padding-inline: 12px; } + [data-component="titlebar-shell"] [data-shell-slot="left-portal"] > * { + pointer-events: auto; + } + [data-component="titlebar-shell"][data-platform="desktop"][data-os="macos"] :is([data-component="button"], [data-component="icon-button"]).titlebar-icon[data-variant="ghost"] { border-radius: 10px; diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index 6ff29184..33e49e07 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -37,6 +37,7 @@ export const ExitWorktreeTool = Tool.define( previousSlug?: string previousBranch?: string previousDirectory?: string + previousSource?: "created" | "existing" } if (exec.activeDirectory === exec.ownerDirectory && exec.activeWorktree === undefined) { const metadata: ExitMetadata = { activeDirectory: exec.ownerDirectory } @@ -58,6 +59,7 @@ export const ExitWorktreeTool = Tool.define( previousSlug: previous?.name, previousBranch: previous?.branch, previousDirectory: previous?.directory, + previousSource: previous?.source, } return { title: "Exited worktree", diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts index d44774a6..fc466a82 100644 --- a/packages/opencode/test/tool/enter-worktree.test.ts +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -93,6 +93,7 @@ test("enter-worktree and exit-worktree update the session execution context", as expect(exitResult.metadata.previousSlug).toBe("tool-work") expect(exitResult.metadata.previousBranch).toBe("pawwork/tool-work") expect(exitResult.metadata.previousDirectory).toBe(activeDirectory) + expect(exitResult.metadata.previousSource).toBe("created") const exited = yield* sessions.get(session.id) expect(exited.executionContext.activeDirectory).toBe(tmp.path) From f0013814b11ee84fdbacfff756317822fa554cc9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:17:03 +0800 Subject: [PATCH 46/65] fix: keep worktree context synchronized --- .../pages/layout/pawwork-worktree-badge.tsx | 3 +- packages/opencode/src/session/session.ts | 19 +++++--- .../opencode/test/session/session.test.ts | 45 +++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx index 4d8fc059..1efa253f 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.tsx @@ -10,6 +10,7 @@ export function PawworkWorktreeBadge(props: { disabled?: boolean }) { const title = () => [props.branch, props.directory].filter(Boolean).join(" · ") || props.name + const label = () => (props.branch ? `${props.name} (${props.branch})` : props.name) return ( ) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 94b9df9b..99283460 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -727,13 +727,20 @@ export const layer: Layer.Layer = }) { const current = yield* get(input.sessionID) const now = Date.now() - // ownerDirectory is set at session creation and never moves; never taken from patch. - // Drop activeWorktree when caller passes null (Exit semantics) or omits it explicitly. + const hasActiveWorktree = "activeWorktree" in input + const ownerDirectory = current.executionContext.ownerDirectory + const activeDirectory = hasActiveWorktree + ? (input.activeWorktree?.directory ?? ownerDirectory) + : (input.activeDirectory ?? current.executionContext.activeDirectory) + const activeWorktree = hasActiveWorktree + ? (input.activeWorktree ?? undefined) + : canonicalDirectory(activeDirectory) === canonicalDirectory(ownerDirectory) + ? undefined + : current.executionContext.activeWorktree const next: SessionExecutionContext = { - ownerDirectory: current.executionContext.ownerDirectory, - activeDirectory: input.activeDirectory ?? current.executionContext.activeDirectory, - activeWorktree: - "activeWorktree" in input ? (input.activeWorktree ?? undefined) : current.executionContext.activeWorktree, + ownerDirectory, + activeDirectory, + activeWorktree, lastChangedAt: now, } yield* patch(input.sessionID, { time: { updated: now }, executionContext: next }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 976e73e3..ea890b91 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -151,6 +151,51 @@ describe("session.created event", () => { }) }) + test("updateExecutionContext keeps active directory and worktree metadata synchronized", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-c") + const activeWorktree = { + directory: worktree, + name: "feature-c", + branch: "pawwork/feature-c", + source: "created" as const, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "update-context-sync" }) + + const entered = await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeWorktree, + }) + expect(entered.executionContext.activeDirectory).toBe(worktree) + expect(entered.executionContext.activeWorktree).toEqual(activeWorktree) + + const clearedByWorktree = await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeWorktree: null, + }) + expect(clearedByWorktree.executionContext.activeDirectory).toBe(tmp.path) + expect(clearedByWorktree.executionContext.activeWorktree).toBeUndefined() + + await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeWorktree, + }) + const clearedByDirectory = await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeDirectory: `${tmp.path}${path.sep}`, + }) + expect(canonicalDirectory(clearedByDirectory.executionContext.activeDirectory)).toBe(canonicalDirectory(tmp.path)) + expect(clearedByDirectory.executionContext.activeWorktree).toBeUndefined() + + await SessionNs.remove(session.id) + }, + }) + }) + test("backfills legacy null executionContext rows", async () => { await using tmp = await tmpdir({ git: true }) From 11cfcb3f3fd2caf5b193ad9b00439e5d44572cfd Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:18:40 +0800 Subject: [PATCH 47/65] test: cover worktree badge branch label --- .../layout/pawwork-worktree-badge.test.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx new file mode 100644 index 00000000..ca42d974 --- /dev/null +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +let PawworkWorktreeBadge: typeof import("./pawwork-worktree-badge").PawworkWorktreeBadge +const originalReact = (globalThis as any).React + +type Node = { type: any; props: Record; children: Array } + +beforeAll(async () => { + mock.module("@opencode-ai/ui/button", () => ({ + Button: (props: any) => + ({ + type: "button", + props: props ?? {}, + children: Array.isArray(props?.children) ? props.children : [props?.children].filter(Boolean), + }) as Node, + })) + mock.module("@opencode-ai/ui/icon", () => ({ + Icon: (props: any) => ({ type: "Icon", props: props ?? {}, children: [] }) as Node, + })) + PawworkWorktreeBadge = (await import("./pawwork-worktree-badge")).PawworkWorktreeBadge +}) + +beforeEach(() => { + ;(globalThis as any).React = { + createElement: (type: any, props: Record | null, ...children: unknown[]): Node | string => { + const flat: Array = [] + const push = (child: unknown) => { + if (child == null || child === false) return + if (Array.isArray(child)) child.forEach(push) + else flat.push(child as Node | string) + } + children.forEach(push) + if (typeof type === "function") { + return type({ ...(props ?? {}), children: flat.length === 1 ? flat[0] : flat }) + } + return { type, props: props ?? {}, children: flat } + }, + } +}) + +afterAll(() => { + mock.restore() + if (originalReact === undefined) delete (globalThis as any).React + else (globalThis as any).React = originalReact +}) + +function find(node: Node | string, predicate: (n: Node) => boolean): Node | undefined { + if (typeof node === "string") return undefined + if (predicate(node)) return node + for (const child of node.children) { + const hit = find(child, predicate) + if (hit) return hit + } + return undefined +} + +describe("PawworkWorktreeBadge", () => { + test("shows worktree name and branch in the visible titlebar label", () => { + const tree = PawworkWorktreeBadge({ + name: "feature-c", + branch: "pawwork/feature-c", + directory: "/repo/.worktrees/pawwork/feature-c", + ariaLabel: "Open worktrees", + onClick: () => undefined, + }) as unknown as Node + + const label = find(tree, (node) => node.type === "span") + expect(label?.children.join("")).toBe("feature-c (pawwork/feature-c)") + expect(tree.props.title).toBe("pawwork/feature-c · /repo/.worktrees/pawwork/feature-c") + }) +}) From 21dea07711d607413a7a4d7973783101f0212e00 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:22:53 +0800 Subject: [PATCH 48/65] refactor: split settings worktree row --- .../src/components/settings-worktree-row.tsx | 98 +++++++++++++++ .../components/settings-worktrees-helpers.ts | 6 + .../app/src/components/settings-worktrees.tsx | 114 +++--------------- 3 files changed, 119 insertions(+), 99 deletions(-) create mode 100644 packages/app/src/components/settings-worktree-row.tsx diff --git a/packages/app/src/components/settings-worktree-row.tsx b/packages/app/src/components/settings-worktree-row.tsx new file mode 100644 index 00000000..2b58207a --- /dev/null +++ b/packages/app/src/components/settings-worktree-row.tsx @@ -0,0 +1,98 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { basename, sourceKey, type BoundSession, type WorktreeInfo } from "./settings-worktrees-helpers" + +export function SettingsWorktreeRow(props: { + worktree: WorktreeInfo + ownerName: string + boundSession?: BoundSession + confirming: boolean + deleting: boolean + onCancelDelete: () => void + onConfirmDelete: (directory: string) => void + onRequestDelete: (directory: string) => void + onOpenSession: (entry: BoundSession) => void +}) { + const language = useLanguage() + const directory = () => props.worktree.directory + const name = () => props.worktree.name || basename(props.worktree.directory) + const branch = () => props.worktree.branch || "" + const identity = () => branch() || name() + const rowTooltip = () => [language.t(sourceKey(props.worktree.source)), directory()].filter(Boolean).join(" · ") + + return ( +
  • + + +
    + + {language.t("settings.worktrees.confirmDelete.question", { name: name() })} + + + {language.t("settings.worktrees.confirmDelete.warning")} + +
    +
    + + +
    + + } + > + +
    + {props.ownerName} + {identity()} +
    +
    + props.onRequestDelete(directory())} + > + {language.t("settings.worktrees.delete")} + + } + > + {(entry) => ( + + )} + +
    +
    +
  • + ) +} diff --git a/packages/app/src/components/settings-worktrees-helpers.ts b/packages/app/src/components/settings-worktrees-helpers.ts index 9a1d6edb..d9353440 100644 --- a/packages/app/src/components/settings-worktrees-helpers.ts +++ b/packages/app/src/components/settings-worktrees-helpers.ts @@ -6,6 +6,12 @@ export type WorktreeInfo = { source?: "created" | "existing" } +export type BoundSession = { + id: string + title: string + hostDirectory: string +} + export function basename(p: string): string { const trimmed = p.replace(/[/\\]+$/, "") const last = trimmed.split(/[/\\]/).pop() diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 36d054a1..5ed7cf49 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -1,6 +1,5 @@ import { type Component, createMemo, createResource, createSignal, For, Show } from "solid-js" import { useNavigate } from "@solidjs/router" -import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" @@ -8,13 +7,8 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { SettingsList } from "./settings-list" -import { basename, entryDirectory, errorText, sourceKey, type WorktreeInfo } from "./settings-worktrees-helpers" - -type BoundSession = { - id: string - title: string - hostDirectory: string -} +import { SettingsWorktreeRow } from "./settings-worktree-row" +import { basename, entryDirectory, errorText, type BoundSession, type WorktreeInfo } from "./settings-worktrees-helpers" export const SettingsWorktrees: Component = () => { const language = useLanguage() @@ -130,97 +124,19 @@ export const SettingsWorktrees: Component = () => { >
      - {(worktree) => { - const directory = () => worktree.directory - const name = () => worktree.name || basename(worktree.directory) - const branch = () => worktree.branch || "" - const owner = () => ownerName(worktree.ownerDirectory) - const identity = () => branch() || name() - const rowTooltip = () => - [language.t(sourceKey(worktree.source)), directory()].filter(Boolean).join(" · ") - const blocker = () => boundSessions().get(worktree.directory) - const isConfirming = () => confirming() === worktree.directory - const isDeleting = () => deleting() === worktree.directory - - return ( -
    • - - -
      - - {language.t("settings.worktrees.confirmDelete.question", { name: name() })} - - - {language.t("settings.worktrees.confirmDelete.warning")} - -
      -
      - - -
      - - } - > - -
      - {owner()} - {identity()} -
      -
      - setConfirming(directory())} - > - {language.t("settings.worktrees.delete")} - - } - > - {(entry) => ( - - )} - -
      -
      -
    • - ) - }} + {(worktree) => ( + setConfirming(undefined)} + onConfirmDelete={handleDelete} + onRequestDelete={setConfirming} + onOpenSession={openSession} + /> + )}
    From 530aff55d176381994fa5a875695121c09c4475f Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:42:20 +0800 Subject: [PATCH 49/65] fix: address worktree review followups --- .../settings-worktrees-helpers.test.ts | 9 ++ .../components/settings-worktrees-helpers.ts | 6 +- .../app/src/components/settings-worktrees.tsx | 4 +- packages/app/src/context/prompt.test.ts | 24 ++- .../layout/pawwork-worktree-badge.test.tsx | 7 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/tool/exit-worktree.ts | 6 +- .../opencode/test/session/session.test.ts | 9 ++ .../opencode/test/tool/enter-worktree.test.ts | 143 +++++++++--------- 9 files changed, 123 insertions(+), 87 deletions(-) create mode 100644 packages/app/src/components/settings-worktrees-helpers.test.ts diff --git a/packages/app/src/components/settings-worktrees-helpers.test.ts b/packages/app/src/components/settings-worktrees-helpers.test.ts new file mode 100644 index 00000000..17f6fadd --- /dev/null +++ b/packages/app/src/components/settings-worktrees-helpers.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" +import { errorText } from "./settings-worktrees-helpers" + +test("errorText falls back safely for circular payloads", () => { + const circular: { self?: unknown } = {} + circular.self = circular + + expect(errorText(circular)).toBe("[object Object]") +}) diff --git a/packages/app/src/components/settings-worktrees-helpers.ts b/packages/app/src/components/settings-worktrees-helpers.ts index d9353440..1da119ba 100644 --- a/packages/app/src/components/settings-worktrees-helpers.ts +++ b/packages/app/src/components/settings-worktrees-helpers.ts @@ -27,7 +27,11 @@ export function errorText(error: unknown) { if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { return error.message } - return JSON.stringify(error) + try { + return JSON.stringify(error) ?? String(error) + } catch { + return String(error) + } } export function sourceKey(source: WorktreeInfo["source"]) { diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 5ed7cf49..fa2ce006 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -47,7 +47,7 @@ export const SettingsWorktrees: Component = () => { const ownerName = (ownerDirectory: string) => projectNameByOwner().get(ownerDirectory) || basename(ownerDirectory) - const boundSessions = (): Map => { + const boundSessions = createMemo((): Map => { const map = new Map() const directories = new Set() for (const project of sync.data.project) { @@ -74,7 +74,7 @@ export const SettingsWorktrees: Component = () => { } } return map - } + }) const openSession = (entry: BoundSession) => { navigate(`/${base64Encode(entry.hostDirectory)}/session/${entry.id}`) diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts index abc431d9..df15cfff 100644 --- a/packages/app/src/context/prompt.test.ts +++ b/packages/app/src/context/prompt.test.ts @@ -22,31 +22,43 @@ beforeAll(async () => { function promptSession() { let prompt: Prompt = [{ type: "text", content: "hello", start: 0, end: 5 }] let cursor = 5 + let dirty = false const items: (ContextItem & { key: string })[] = [] + const markDirty = () => { + dirty = true + } return { ready: () => true, current: () => prompt, cursor: () => cursor, - dirty: () => true, + dirty: () => dirty, context: { items: () => items, - add: (item: ContextItem) => items.push({ key: item.type, ...item }), + add: (item: ContextItem) => { + items.push({ key: item.type, ...item }) + markDirty() + }, remove: (key: string) => { const index = items.findIndex((item) => item.key === key) - if (index >= 0) items.splice(index, 1) + if (index >= 0) { + items.splice(index, 1) + markDirty() + } }, - removeComment: () => undefined, - updateComment: () => undefined, - replaceComments: () => undefined, + removeComment: () => markDirty(), + updateComment: () => markDirty(), + replaceComments: () => markDirty(), }, set: (next: Prompt, nextCursor?: number) => { prompt = next cursor = nextCursor ?? cursor + markDirty() }, reset: () => { prompt = DEFAULT_PROMPT cursor = 0 + dirty = false }, } } diff --git a/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx b/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx index ca42d974..93ccc9a6 100644 --- a/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx +++ b/packages/app/src/pages/layout/pawwork-worktree-badge.test.tsx @@ -56,16 +56,21 @@ function find(node: Node | string, predicate: (n: Node) => boolean): Node | unde describe("PawworkWorktreeBadge", () => { test("shows worktree name and branch in the visible titlebar label", () => { + const onClick = () => undefined const tree = PawworkWorktreeBadge({ name: "feature-c", branch: "pawwork/feature-c", directory: "/repo/.worktrees/pawwork/feature-c", ariaLabel: "Open worktrees", - onClick: () => undefined, + onClick, + disabled: true, }) as unknown as Node const label = find(tree, (node) => node.type === "span") expect(label?.children.join("")).toBe("feature-c (pawwork/feature-c)") expect(tree.props.title).toBe("pawwork/feature-c · /repo/.worktrees/pawwork/feature-c") + expect(tree.props.onClick).toBe(onClick) + expect(tree.props["aria-label"]).toBe("Open worktrees") + expect(tree.props.disabled).toBe(true) }) }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 99283460..28a51cff 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -727,7 +727,7 @@ export const layer: Layer.Layer = }) { const current = yield* get(input.sessionID) const now = Date.now() - const hasActiveWorktree = "activeWorktree" in input + const hasActiveWorktree = input.activeWorktree !== undefined const ownerDirectory = current.executionContext.ownerDirectory const activeDirectory = hasActiveWorktree ? (input.activeWorktree?.directory ?? ownerDirectory) diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index 33e49e07..8ddcec92 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -4,6 +4,7 @@ import DESCRIPTION from "./exit-worktree.txt" import * as Session from "../session/session" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" import { SubagentRun } from "../session/subagent-run" +import { canonicalDirectory } from "../session/execution-context-store" export const Parameters = Schema.Struct({}) @@ -39,7 +40,10 @@ export const ExitWorktreeTool = Tool.define( previousDirectory?: string previousSource?: "created" | "existing" } - if (exec.activeDirectory === exec.ownerDirectory && exec.activeWorktree === undefined) { + if ( + canonicalDirectory(exec.activeDirectory) === canonicalDirectory(exec.ownerDirectory) && + exec.activeWorktree === undefined + ) { const metadata: ExitMetadata = { activeDirectory: exec.ownerDirectory } return { title: "Already at project root", diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index ea890b91..3aea4bb5 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -173,6 +173,15 @@ describe("session.created event", () => { expect(entered.executionContext.activeDirectory).toBe(worktree) expect(entered.executionContext.activeWorktree).toEqual(activeWorktree) + const nested = path.join(worktree, "nested") + const movedByDirectory = await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeDirectory: nested, + activeWorktree: undefined, + }) + expect(movedByDirectory.executionContext.activeDirectory).toBe(nested) + expect(movedByDirectory.executionContext.activeWorktree).toEqual(activeWorktree) + const clearedByWorktree = await SessionNs.updateExecutionContext({ sessionID: session.id, activeWorktree: null, diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts index fc466a82..ed80fc7c 100644 --- a/packages/opencode/test/tool/enter-worktree.test.ts +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -1,8 +1,7 @@ -import { expect, test } from "bun:test" +import { expect } from "bun:test" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Effect, Exit, Layer } from "effect" import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { MessageID } from "../../src/session/schema" import { SubagentRun } from "../../src/session/subagent-run" @@ -10,15 +9,17 @@ import { EnterWorktreeTool } from "../../src/tool/enter-worktree" import { ExitWorktreeTool } from "../../src/tool/exit-worktree" import type { Context } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" -import { Worktree } from "../../src/worktree" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const layer = Layer.mergeAll( - Agent.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - SubagentRun.defaultLayer, - Truncate.defaultLayer, +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + SubagentRun.defaultLayer, + Truncate.defaultLayer, + ), ) function toolContext(sessionID: Session.Info["id"]): Context { @@ -35,74 +36,66 @@ function toolContext(sessionID: Session.Info["id"]): Context { } } -function run(effect: Effect.Effect) { - return Effect.runPromise(effect.pipe(Effect.provide(layer)) as Effect.Effect) -} - -test("enter-worktree rejects relative path inputs", async () => { - await using tmp = await tmpdir({ git: true }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const exit = await run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "relative-path" }) - const tool = yield* EnterWorktreeTool - const def = yield* tool.init() - return yield* def.execute({ path: "relative-worktree" }, toolContext(session.id)).pipe(Effect.exit) - }), - ) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("path must be an absolute path") - }, - }) -}) +it.live("enter-worktree rejects relative path inputs", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "relative-path" }) + const tool = yield* EnterWorktreeTool + const def = yield* tool.init() + return yield* def.execute({ path: "relative-worktree" }, toolContext(session.id)).pipe(Effect.exit) + }).pipe( + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("path must be an absolute path") + }), + ), + ), + { git: true }, + ), +) -test("enter-worktree and exit-worktree update the session execution context", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("enter-worktree and exit-worktree update the session execution context", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "tool-worktree" }) + const enterTool = yield* EnterWorktreeTool + const exitTool = yield* ExitWorktreeTool + const enter = yield* enterTool.init() + const exit = yield* exitTool.init() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - let activeDirectory = "" - try { - await run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "tool-worktree" }) - const enterTool = yield* EnterWorktreeTool - const exitTool = yield* ExitWorktreeTool - const enter = yield* enterTool.init() - const exit = yield* exitTool.init() + const result = yield* enter.execute({ name: "tool-work" }, toolContext(session.id)) + const activeDirectory = result.metadata.activeDirectory + expect(result.metadata.ownerDirectory).toBe(dir) + expect(result.metadata.branch).toBe("pawwork/tool-work") + expect(result.metadata.slug).toBe("tool-work") - const result = yield* enter.execute({ name: "tool-work" }, toolContext(session.id)) - activeDirectory = result.metadata.activeDirectory - expect(result.metadata.ownerDirectory).toBe(tmp.path) - expect(result.metadata.branch).toBe("pawwork/tool-work") - expect(result.metadata.slug).toBe("tool-work") + const entered = yield* sessions.get(session.id) + expect(entered.executionContext.activeDirectory).toBe(activeDirectory) + expect(entered.executionContext.activeWorktree?.name).toBe("tool-work") - const entered = yield* sessions.get(session.id) - expect(entered.executionContext.activeDirectory).toBe(activeDirectory) - expect(entered.executionContext.activeWorktree?.name).toBe("tool-work") + const exitResult = yield* exit.execute({}, toolContext(session.id)) + expect(exitResult.metadata.activeDirectory).toBe(dir) + expect(exitResult.metadata.previousSlug).toBe("tool-work") + expect(exitResult.metadata.previousBranch).toBe("pawwork/tool-work") + expect(exitResult.metadata.previousDirectory).toBe(activeDirectory) + expect(exitResult.metadata.previousSource).toBe("created") - const exitResult = yield* exit.execute({}, toolContext(session.id)) - expect(exitResult.metadata.activeDirectory).toBe(tmp.path) - expect(exitResult.metadata.previousSlug).toBe("tool-work") - expect(exitResult.metadata.previousBranch).toBe("pawwork/tool-work") - expect(exitResult.metadata.previousDirectory).toBe(activeDirectory) - expect(exitResult.metadata.previousSource).toBe("created") + const exited = yield* sessions.get(session.id) + expect(exited.executionContext.activeDirectory).toBe(dir) + expect(exited.executionContext.activeWorktree).toBeUndefined() - const exited = yield* sessions.get(session.id) - expect(exited.executionContext.activeDirectory).toBe(tmp.path) - expect(exited.executionContext.activeWorktree).toBeUndefined() - }), - ) - } finally { - if (activeDirectory) await Worktree.remove({ directory: activeDirectory }).catch(() => {}) - } - }, - }) -}) + yield* sessions.updateExecutionContext({ + sessionID: session.id, + activeDirectory: `${dir}/`, + }) + const alreadyRoot = yield* exit.execute({}, toolContext(session.id)) + expect(alreadyRoot.title).toBe("Already at project root") + }), + { git: true }, + ), +) From 68074b768cb079d0cab806014abc9ceaf7205bf2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:58:40 +0800 Subject: [PATCH 50/65] fix: address worktree review issues --- .../src/session/execution-context-store.ts | 7 +-- .../opencode/src/session/execution-context.ts | 4 -- packages/opencode/src/session/session.ts | 37 ++++++++--- packages/opencode/src/tool/agent.ts | 12 ++-- packages/opencode/src/worktree/index.ts | 5 +- .../test/project/worktree-remove.test.ts | 56 +++++++++++++++++ .../test/session/state-machine-guard.test.ts | 61 +++++++++++++++++++ 7 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/test/session/state-machine-guard.test.ts diff --git a/packages/opencode/src/session/execution-context-store.ts b/packages/opencode/src/session/execution-context-store.ts index 3e84af48..1422f3fc 100644 --- a/packages/opencode/src/session/execution-context-store.ts +++ b/packages/opencode/src/session/execution-context-store.ts @@ -1,14 +1,11 @@ import fs from "node:fs" import path from "path" -import { eq, isNull } from "../storage/db" +import { Database, eq, isNull } from "../storage/db" import { ProjectTable } from "../project/project.sql" import { SessionTable } from "./session.sql" import { rootContext } from "./execution-context" -type Tx = { - select: (...args: any[]) => any - update: (...args: any[]) => any -} +type Tx = Pick export function canonicalDirectory(input: string) { const abs = path.resolve(input) diff --git a/packages/opencode/src/session/execution-context.ts b/packages/opencode/src/session/execution-context.ts index 09ddbc13..c6b03d86 100644 --- a/packages/opencode/src/session/execution-context.ts +++ b/packages/opencode/src/session/execution-context.ts @@ -23,7 +23,3 @@ export function rootContext(ownerDirectory: string): SessionExecutionContext { lastChangedAt: Date.now(), } } - -export function isAtRoot(ctx: SessionExecutionContext): boolean { - return ctx.activeDirectory === ctx.ownerDirectory && ctx.activeWorktree === undefined -} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 28a51cff..9acb8236 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -8,7 +8,7 @@ import { type ProviderMetadata, type LanguageModelUsage } from "ai" import { Flag } from "@opencode-ai/core/flag/flag" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, asc, like, inArray, lt, gt } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, desc, asc, like, inArray, lt, gt, sql } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" import { PartTable, SessionTable } from "./session.sql" @@ -32,7 +32,7 @@ import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" import { SubagentRunWriterContext, SubagentRunGuardViolation, lifecycleFieldsChanged } from "./subagent-run-context" -import { SessionExecutionContext, rootContext } from "./execution-context" +import { ActiveWorktree, SessionExecutionContext, rootContext } from "./execution-context" import { backfillExecutionContextRows, canonicalDirectory } from "./execution-context-store" const log = Log.create({ service: "session" }) @@ -227,6 +227,12 @@ export const SetRevertInput = z.object({ summary: Info.shape.summary, }) export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) +export const UpdateExecutionContextInput = z.object({ + sessionID: SessionID.zod, + activeDirectory: z.string().optional(), + activeWorktree: ActiveWorktree.nullable().optional(), +}) +export const FindActiveWorktreeBindingInput = z.string() export const RemovePartInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, @@ -750,7 +756,19 @@ export const layer: Layer.Layer = const findActiveWorktreeBinding = Effect.fn("Session.findActiveWorktreeBinding")(function* (directory: string) { const project = Instance.project const target = canonicalDirectory(directory) - const rows = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all()) + const rows = yield* db((d) => + d + .select() + .from(SessionTable) + .where( + and( + eq(SessionTable.project_id, project.id), + sql`${SessionTable.execution_context} IS NOT NULL`, + sql`json_extract(${SessionTable.execution_context}, '$.activeDirectory') != json_extract(${SessionTable.execution_context}, '$.ownerDirectory')`, + ), + ) + .all(), + ) for (const row of rows) { const session = fromRow(row) const exec = session.executionContext @@ -874,13 +892,12 @@ export const messages = fn(MessagesInput, (input) => runPromise((svc) => svc.mes export const removePart = fn(RemovePartInput, (input) => runPromise((svc) => svc.removePart(input))) export const updateMessage = fn(MessageV2.Info, (input) => runPromise((svc) => svc.updateMessage(input))) export const updatePart = fn(MessageV2.Part, (input) => runPromise((svc) => svc.updatePart(input))) -export const updateExecutionContext = (input: { - sessionID: SessionID - activeDirectory?: string - activeWorktree?: SessionExecutionContext["activeWorktree"] | null -}) => runPromise((svc) => svc.updateExecutionContext(input)) -export const findActiveWorktreeBinding = (directory: string) => - runPromise((svc) => svc.findActiveWorktreeBinding(directory)) +export const updateExecutionContext = fn(UpdateExecutionContextInput, (input) => + runPromise((svc) => svc.updateExecutionContext(input)), +) +export const findActiveWorktreeBinding = fn(FindActiveWorktreeBindingInput, (directory) => + runPromise((svc) => svc.findActiveWorktreeBinding(directory)), +) type ListSort = "updated" | "created" type GlobalListCursor = diff --git a/packages/opencode/src/tool/agent.ts b/packages/opencode/src/tool/agent.ts index 6d8d4286..90efe49b 100644 --- a/packages/opencode/src/tool/agent.ts +++ b/packages/opencode/src/tool/agent.ts @@ -326,12 +326,16 @@ export const AgentTool = Tool.define( ], })) + const childExec = nextSession.executionContext + const sameWorktree = + parentExec.activeWorktree?.directory === childExec.activeWorktree?.directory && + parentExec.activeWorktree?.name === childExec.activeWorktree?.name && + parentExec.activeWorktree?.branch === childExec.activeWorktree?.branch && + parentExec.activeWorktree?.source === childExec.activeWorktree?.source + // Inherit parent's activeWorktree if any (no-op when parent is at root and child was // freshly created at the project root). - if ( - parentExec.activeDirectory !== nextSession.executionContext.activeDirectory || - parentExec.activeWorktree !== nextSession.executionContext.activeWorktree - ) { + if (parentExec.activeDirectory !== childExec.activeDirectory || !sameWorktree) { yield* sessions.updateExecutionContext({ sessionID: nextSession.id, activeDirectory: parentExec.activeDirectory, diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 82500639..b4111e72 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -279,6 +279,7 @@ export namespace Worktree { const lookupBySlug = Effect.fn("Worktree.lookupBySlug")(function* (slug: string) { const entries = yield* readRegistry() + // `name` addresses managed PawWork worktrees only. Worktrees joined by absolute path remain path-addressable. return entries.find((entry) => entry.name === slug && entry.source === "created") }) @@ -509,6 +510,7 @@ export namespace Worktree { yield* stopFsmonitor(directory) yield* cleanDirectory(directory) } + yield* removeRegistry(input.directory).pipe(Effect.catch(() => Effect.void)) return true } @@ -529,6 +531,7 @@ export namespace Worktree { } yield* cleanDirectory(entry.path) + yield* removeRegistry(input.directory).pipe(Effect.catch(() => Effect.void)) const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { @@ -539,8 +542,6 @@ export namespace Worktree { }) } } - - yield* removeRegistry(input.directory).pipe(Effect.catch(() => Effect.void)) return true }) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index a6b5bb7c..2b6903ec 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -65,6 +65,62 @@ describe("Worktree.remove", () => { expect(ref.exitCode).not.toBe(0) }) + test("removes registry entry when branch deletion fails after directory cleanup", async () => { + await using tmp = await tmpdir({ git: true }) + const root = tmp.path + const info = await Instance.provide({ + directory: root, + fn: async () => { + const info = await Worktree.makeWorktreeInfo("branch-delete-fails") + await Worktree.createFromInfo(info) + return info + }, + }) + + const real = (await $`which git`.quiet().text()).trim() + expect(real).toBeTruthy() + + const bin = path.join(root, "bin") + const shim = path.join(bin, "git") + await fs.mkdir(bin, { recursive: true }) + await Bun.write( + shim, + [ + "#!/bin/bash", + `REAL_GIT=${JSON.stringify(real)}`, + 'if [ "$1" = "branch" ] && [ "$2" = "-D" ]; then', + ' echo "fatal: could not delete branch" >&2', + " exit 1", + "fi", + 'exec "$REAL_GIT" "$@"', + ].join("\n"), + ) + await fs.chmod(shim, 0o755) + + const prev = process.env.PATH ?? "" + process.env.PATH = `${bin}${path.delimiter}${prev}` + + try { + await expect( + Instance.provide({ + directory: root, + fn: () => Worktree.remove({ directory: info.directory }), + }), + ).rejects.toThrow("WorktreeRemoveFailedError") + } finally { + process.env.PATH = prev + } + + expect(await Filesystem.exists(info.directory)).toBe(false) + const registryEntry = await Instance.provide({ + directory: root, + fn: () => Worktree.lookupByDirectory(info.directory), + }) + expect(registryEntry).toBeUndefined() + + await $`git branch -D ${info.branch}`.cwd(root).quiet().nothrow() + }) + wintest("stops fsmonitor before removing a worktree", async () => { await using tmp = await tmpdir({ git: true }) const root = tmp.path diff --git a/packages/opencode/test/session/state-machine-guard.test.ts b/packages/opencode/test/session/state-machine-guard.test.ts new file mode 100644 index 00000000..bce7b101 --- /dev/null +++ b/packages/opencode/test/session/state-machine-guard.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import type { Session } from "../../src/session" +import type { SessionID } from "../../src/session/schema" +import type { SubagentRun } from "../../src/session/subagent-run" +import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../../src/session/state-machine-guard" + +const sessionID = "session_test" as SessionID + +function sessionsWithParts(parts: unknown[]): Session.Service["Service"] { + return { + messages: () => Effect.succeed([{ parts }]), + } as unknown as Session.Service["Service"] +} + +function toolPart(callID: string, status: string) { + return { + type: "tool", + tool: "bash", + callID, + state: { status }, + } +} + +function subagents(active: boolean): SubagentRun.Service["Service"] { + return { + activeForSession: () => Effect.succeed(active), + } as unknown as SubagentRun.Service["Service"] +} + +describe("state-machine guard", () => { + test("detects pending and running tool calls except the current call", async () => { + const sessions = sessionsWithParts([ + toolPart("current", "running"), + toolPart("other-pending", "pending"), + toolPart("other-running", "running"), + ]) + + const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) + + expect(blocked).toBe(true) + }) + + test("ignores the current call and finished tool calls", async () => { + const sessions = sessionsWithParts([ + toolPart("current", "running"), + toolPart("done", "completed"), + toolPart("failed", "error"), + { type: "text", text: "not a tool" }, + ]) + + const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) + + expect(blocked).toBe(false) + }) + + test("reports active subagents", async () => { + await expect(Effect.runPromise(hasRunningSubagents(subagents(true), sessionID))).resolves.toBe(true) + await expect(Effect.runPromise(hasRunningSubagents(subagents(false), sessionID))).resolves.toBe(false) + }) +}) From f6ba832c46e6065bc5e1962352bb78e37da96a37 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 01:58:46 +0800 Subject: [PATCH 51/65] refactor: tighten tool metadata access --- packages/ui/src/components/message-part.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 23ba36cc..ea00c92a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -724,7 +724,7 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}, (part.state as any).metadata ?? {}) + const info = getToolInfo(part.tool, part.state.input ?? {}, toolStateMetadata(part.state)) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -776,7 +776,7 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input, (part.state as any).metadata ?? {}) + const info = getToolInfo(part.tool, input, toolStateMetadata(part.state)) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -786,6 +786,17 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } +function toolStateMetadata(state: ToolPart["state"] | undefined): Record { + if (!state || !("metadata" in state)) return {} + const metadata = state.metadata + return metadata && typeof metadata === "object" ? metadata : {} +} + +function toolStateError(state: ToolPart["state"] | undefined): string | undefined { + if (!state || !("error" in state)) return undefined + return typeof state.error === "string" ? state.error : undefined +} + function contextToolSummary(parts: ToolPart[]) { const read = parts.filter((part) => part.tool === "read").length const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length @@ -1330,8 +1341,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const emptyMetadata: Record = {} const input = () => part().state?.input ?? emptyInput - // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => toolStateMetadata(part().state) const taskId = createMemo(() => { if (part().tool !== "task" && part().tool !== "agent") return // agent-rename:legacy-render const value = partMetadata().sessionId @@ -1354,7 +1364,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
    - + {(error) => { const cleaned = error().replace("Error: ", "") if (part().tool === "question" && cleaned.includes("dismissed this question")) { From c69cc7794e8e0822df0d0c27f2b5a41174c493ba Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:01:38 +0800 Subject: [PATCH 52/65] fix: tolerate partial worktree list failures --- packages/app/src/components/settings-worktrees.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index fa2ce006..cefb21c0 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -25,12 +25,15 @@ export const SettingsWorktrees: Component = () => { const [data, { refetch }] = createResource( () => projectRoots().join("\0"), async () => { - const rows = await Promise.all( + const results = await Promise.allSettled( projectRoots().map(async (ownerDirectory) => { const res = await sdk.client.worktree.list({ directory: ownerDirectory }) return (res.data ?? []).map((worktree) => ({ ...worktree, ownerDirectory }) as WorktreeInfo) }), ) + const rows = results + .filter((result): result is PromiseFulfilledResult => result.status === "fulfilled") + .map((result) => result.value) const byDirectory = new Map() for (const row of rows.flat()) byDirectory.set(row.directory, row) return Array.from(byDirectory.values()) From 61e24270803d6e2c4177c20a9eb17bcb9f083963 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:11:48 +0800 Subject: [PATCH 53/65] fix: harden worktree gitignore status check --- packages/opencode/src/worktree/gitignore-guard.ts | 12 +++++++++++- .../opencode/test/worktree/gitignore-guard.test.ts | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/worktree/gitignore-guard.ts b/packages/opencode/src/worktree/gitignore-guard.ts index ad82eb1d..287ed791 100644 --- a/packages/opencode/src/worktree/gitignore-guard.ts +++ b/packages/opencode/src/worktree/gitignore-guard.ts @@ -40,7 +40,17 @@ export async function ensureWorktreesIgnored(root: string): Promise<{ changed: b if (before && hasWorktreesIgnore(before)) return { changed: false, file } if (before !== undefined) { - const status = await git(root, ["-c", "core.fsmonitor=false", "status", "--porcelain=v1", "--", ".gitignore"]) + const status = await git(root, [ + "-c", + "core.fsmonitor=false", + "-c", + "status.showUntrackedFiles=all", + "status", + "--porcelain=v1", + "--no-renames", + "--", + ".gitignore", + ]) if (status.code !== 0) { throw new GitignoreGuardError({ message: status.stderr || status.stdout || "Failed to inspect .gitignore status", diff --git a/packages/opencode/test/worktree/gitignore-guard.test.ts b/packages/opencode/test/worktree/gitignore-guard.test.ts index e64a286c..9b72dc35 100644 --- a/packages/opencode/test/worktree/gitignore-guard.test.ts +++ b/packages/opencode/test/worktree/gitignore-guard.test.ts @@ -34,4 +34,12 @@ describe("worktree gitignore guard", () => { await expect(ensureWorktreesIgnored(tmp.path)).rejects.toThrow("WorktreeGitignoreGuardError") }) + + test("refuses to append when untracked .gitignore is hidden by git config", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git config status.showUntrackedFiles no`.cwd(tmp.path).quiet() + await Bun.write(path.join(tmp.path, ".gitignore"), "node_modules\n") + + await expect(ensureWorktreesIgnored(tmp.path)).rejects.toThrow("WorktreeGitignoreGuardError") + }) }) From 5f881fcd083974d8cf4da643b2cf62a892c6444d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:14:33 +0800 Subject: [PATCH 54/65] fix: tighten worktree settings helpers --- .../app/src/components/settings-worktrees-helpers.test.ts | 4 ++++ packages/app/src/components/settings-worktrees-helpers.ts | 4 +++- packages/app/src/components/settings-worktrees.tsx | 6 +----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/settings-worktrees-helpers.test.ts b/packages/app/src/components/settings-worktrees-helpers.test.ts index 17f6fadd..1305a49a 100644 --- a/packages/app/src/components/settings-worktrees-helpers.test.ts +++ b/packages/app/src/components/settings-worktrees-helpers.test.ts @@ -7,3 +7,7 @@ test("errorText falls back safely for circular payloads", () => { expect(errorText(circular)).toBe("[object Object]") }) + +test("errorText avoids empty JSON object fallback", () => { + expect(errorText({})).toBe("[object Object]") +}) diff --git a/packages/app/src/components/settings-worktrees-helpers.ts b/packages/app/src/components/settings-worktrees-helpers.ts index 1da119ba..b7477d91 100644 --- a/packages/app/src/components/settings-worktrees-helpers.ts +++ b/packages/app/src/components/settings-worktrees-helpers.ts @@ -28,7 +28,9 @@ export function errorText(error: unknown) { return error.message } try { - return JSON.stringify(error) ?? String(error) + const json = JSON.stringify(error) + if (json && json !== "{}") return json + return String(error) } catch { return String(error) } diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index cefb21c0..ee4c04c1 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -8,7 +8,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { SettingsList } from "./settings-list" import { SettingsWorktreeRow } from "./settings-worktree-row" -import { basename, entryDirectory, errorText, type BoundSession, type WorktreeInfo } from "./settings-worktrees-helpers" +import { basename, errorText, type BoundSession, type WorktreeInfo } from "./settings-worktrees-helpers" export const SettingsWorktrees: Component = () => { const language = useLanguage() @@ -53,10 +53,6 @@ export const SettingsWorktrees: Component = () => { const boundSessions = createMemo((): Map => { const map = new Map() const directories = new Set() - for (const project of sync.data.project) { - directories.add(project.worktree) - for (const sandbox of project.sandboxes ?? []) directories.add(entryDirectory(sandbox)) - } for (const worktree of data() ?? []) { directories.add(worktree.ownerDirectory) directories.add(worktree.directory) From 37520f5cb411ec27a0dd27dbcecfa046f156efb1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:20:16 +0800 Subject: [PATCH 55/65] fix: address worktree review regressions --- .../app/src/components/settings-worktrees.tsx | 56 +++++++++++------- packages/opencode/src/session/session.ts | 29 +++++++--- packages/opencode/src/worktree/index.ts | 49 ++++++++++------ .../test/project/worktree-remove.test.ts | 5 +- .../opencode/test/session/session.test.ts | 57 +++++++++++++++++++ .../test/session/state-machine-guard.test.ts | 16 ++++++ packages/ui/src/components/message-part.tsx | 2 +- 7 files changed, 166 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index ee4c04c1..8d3d8bef 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -34,6 +34,10 @@ export const SettingsWorktrees: Component = () => { const rows = results .filter((result): result is PromiseFulfilledResult => result.status === "fulfilled") .map((result) => result.value) + if (rows.length === 0 && results.some((result) => result.status === "rejected")) { + const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected") + throw new Error(errorText(failed?.reason)) + } const byDirectory = new Map() for (const row of rows.flat()) byDirectory.set(row.directory, row) return Array.from(byDirectory.values()) @@ -112,32 +116,42 @@ export const SettingsWorktrees: Component = () => { fallback={
    {language.t("common.loading")}
    } > 0} + when={!data.error} fallback={
    - -
    {language.t("settings.worktrees.empty.title")}
    -
    {language.t("settings.worktrees.empty.body")}
    +
    {language.t("common.requestFailed")}
    +
    {errorText(data.error)}
    } > -
      - - {(worktree) => ( - setConfirming(undefined)} - onConfirmDelete={handleDelete} - onRequestDelete={setConfirming} - onOpenSession={openSession} - /> - )} - -
    + 0} + fallback={ +
    + +
    {language.t("settings.worktrees.empty.title")}
    +
    {language.t("settings.worktrees.empty.body")}
    +
    + } + > +
      + + {(worktree) => ( + setConfirming(undefined)} + onConfirmDelete={handleDelete} + onRequestDelete={setConfirming} + onOpenSession={openSession} + /> + )} + +
    +
    diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9acb8236..c4e20a6d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -58,6 +58,18 @@ function legacyExecutionContext(row: SessionRow, project?: ProjectFallback) { return rootContext(canonicalDirectory(ownerDirectoryRaw)) } +function parseExecutionContext(row: SessionRow, project?: ProjectFallback) { + if (row.execution_context !== null) { + const parsed = SessionExecutionContext.safeParse(row.execution_context) + if (parsed.success) return parsed.data + } + return legacyExecutionContext(row, project) +} + +function needsProjectFallback(row: SessionRow) { + return row.execution_context === null || !SessionExecutionContext.safeParse(row.execution_context).success +} + export function fromRow(row: SessionRow, project?: ProjectFallback): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -86,8 +98,7 @@ export function fromRow(row: SessionRow, project?: ProjectFallback): Info { share, revert, permission: row.permission ?? undefined, - // Legacy rows may still have NULL execution_context; synthesize the root context on read. - executionContext: row.execution_context ?? legacyExecutionContext(row, project), + executionContext: parseExecutionContext(row, project), time: { created: row.time_created, updated: row.time_updated, @@ -452,6 +463,7 @@ export const layer: Layer.Layer = parentID?: SessionID workspaceID?: WorkspaceID directory: string + executionContext?: SessionExecutionContext permission?: Permission.Ruleset createdByAgentTool?: boolean subagentType?: string | null @@ -472,7 +484,7 @@ export const layer: Layer.Layer = permission: input.permission, // ownerDirectory is the project root for git projects and never moves. For non-git // projects Instance.worktree is "/" today, so keep the opened directory as the owner. - executionContext: rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), + executionContext: input.executionContext ?? rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), time: { created: Date.now(), updated: Date.now(), @@ -497,15 +509,15 @@ export const layer: Layer.Layer = const get = Effect.fn("Session.get")(function* (id: SessionID) { const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) if (!row) throw new NotFoundError({ message: `Session not found: ${id}` }) - const project = row.execution_context - ? undefined - : yield* db((d) => + const project = needsProjectFallback(row) + ? yield* db((d) => d .select({ worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) .from(ProjectTable) .where(eq(ProjectTable.id, row.project_id)) .get(), ) + : undefined return fromRow(row, project) }) @@ -517,7 +529,7 @@ export const layer: Layer.Layer = .where(and(eq(SessionTable.parent_id, parentID))) .all(), ) - const ids = [...new Set(rows.filter((row) => row.execution_context === null).map((row) => row.project_id))] + const ids = [...new Set(rows.filter(needsProjectFallback).map((row) => row.project_id))] const projects = new Map() if (ids.length > 0) { const items = yield* db((d) => @@ -656,6 +668,7 @@ export const layer: Layer.Layer = workspaceID: original.workspaceID, title, skill: original.skill, + executionContext: original.executionContext, }) const msgs = yield* messages({ sessionID: input.sessionID }) const idMap = new Map() @@ -956,7 +969,7 @@ export function* list(input?: { .limit(limit) .all(), ) - const ids = [...new Set(rows.filter((row) => row.execution_context === null).map((row) => row.project_id))] + const ids = [...new Set(rows.filter(needsProjectFallback).map((row) => row.project_id))] const projects = new Map() if (ids.length > 0) { const items = Database.use((db) => diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b4111e72..558bd246 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,7 +12,7 @@ import { errorMessage } from "../util/error" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" -import { Effect, Layer, Path, Scope, Context, Stream } from "effect" +import { Effect, Layer, Path, Scope, Context, Stream, Semaphore } from "effect" import { ensureWorktreesIgnored } from "./gitignore-guard" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -186,6 +186,15 @@ export namespace Worktree { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const registryLocks = new Map() + + const registryLock = (projectID: ProjectID) => { + const hit = registryLocks.get(projectID) + if (hit) return hit + const next = Semaphore.makeUnsafe(1) + registryLocks.set(projectID, next) + return next + } const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -248,24 +257,32 @@ export namespace Worktree { }) const upsertRegistry = Effect.fnUntraced(function* (info: Info) { - const target = yield* canonical(info.directory) - const entries = yield* readRegistry() - const next: Info[] = [] - for (const entry of entries) { - if ((yield* canonical(entry.directory)) !== target) next.push(entry) - } - next.push(info) - yield* writeRegistry(next) + yield* registryLock(Instance.project.id).withPermits(1)( + Effect.gen(function* () { + const target = yield* canonical(info.directory) + const entries = yield* readRegistry() + const next: Info[] = [] + for (const entry of entries) { + if ((yield* canonical(entry.directory)) !== target) next.push(entry) + } + next.push(info) + yield* writeRegistry(next) + }), + ) }) const removeRegistry = Effect.fnUntraced(function* (directory: string) { - const target = yield* canonical(directory) - const entries = yield* readRegistry() - const next: Info[] = [] - for (const entry of entries) { - if ((yield* canonical(entry.directory)) !== target) next.push(entry) - } - yield* writeRegistry(next) + yield* registryLock(Instance.project.id).withPermits(1)( + Effect.gen(function* () { + const target = yield* canonical(directory) + const entries = yield* readRegistry() + const next: Info[] = [] + for (const entry of entries) { + if ((yield* canonical(entry.directory)) !== target) next.push(entry) + } + yield* writeRegistry(next) + }), + ) }) const lookupByDirectory = Effect.fn("Worktree.lookupByDirectory")(function* (directory: string) { diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 2b6903ec..c185fcaa 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -8,9 +8,10 @@ import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" const wintest = process.platform === "win32" ? test : test.skip +const unixtest = process.platform === "win32" ? test.skip : test describe("Worktree.remove", () => { - test("continues when git remove exits non-zero after detaching", async () => { + unixtest("continues when git remove exits non-zero after detaching", async () => { await using tmp = await tmpdir({ git: true }) const root = tmp.path const name = `remove-regression-${Date.now().toString(36)}` @@ -65,7 +66,7 @@ describe("Worktree.remove", () => { expect(ref.exitCode).not.toBe(0) }) - test("removes registry entry when branch deletion fails after directory cleanup", async () => { + unixtest("removes registry entry when branch deletion fails after directory cleanup", async () => { await using tmp = await tmpdir({ git: true }) const root = tmp.path const info = await Instance.provide({ diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 3aea4bb5..a1f012ed 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -277,6 +277,63 @@ describe("session.created event", () => { }) }) + test("synthesizes invalid executionContext from the project root on read", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "packages", "app") + await fs.mkdir(subdir, { recursive: true }) + + await Instance.provide({ + directory: subdir, + fn: async () => { + const session = await SessionNs.create({ title: "invalid-read-root" }) + Database.use((db) => + db + .update(SessionTable) + .set({ execution_context: { activeDirectory: subdir } as any }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const loaded = await SessionNs.get(session.id) + expect(loaded.directory).toBe(subdir) + expect(loaded.executionContext.ownerDirectory).toBe(tmp.path) + expect(loaded.executionContext.activeDirectory).toBe(tmp.path) + + await SessionNs.remove(session.id) + }, + }) + }) + + test("fork preserves the source session executionContext", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "forked-work") + const activeWorktree = { + directory: worktree, + name: "forked-work", + branch: "pawwork/forked-work", + source: "created" as const, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "fork-source-worktree" }) + await SessionNs.updateExecutionContext({ + sessionID: session.id, + activeWorktree, + }) + + const forked = await SessionNs.fork({ sessionID: session.id }) + expect(forked.executionContext.ownerDirectory).toBe(tmp.path) + expect(forked.executionContext.activeDirectory).toBe(worktree) + expect(forked.executionContext.activeWorktree).toEqual(activeWorktree) + + await SessionNs.remove(forked.id) + await SessionNs.remove(session.id) + }, + }) + }) + test("should emit session.created event when session is created", async () => { await Instance.provide({ directory: projectRoot, diff --git a/packages/opencode/test/session/state-machine-guard.test.ts b/packages/opencode/test/session/state-machine-guard.test.ts index bce7b101..80cf1174 100644 --- a/packages/opencode/test/session/state-machine-guard.test.ts +++ b/packages/opencode/test/session/state-machine-guard.test.ts @@ -41,6 +41,22 @@ describe("state-machine guard", () => { expect(blocked).toBe(true) }) + test("detects pending tool calls except the current call", async () => { + const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-pending", "pending")]) + + const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) + + expect(blocked).toBe(true) + }) + + test("detects running tool calls except the current call", async () => { + const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-running", "running")]) + + const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) + + expect(blocked).toBe(true) + }) + test("ignores the current call and finished tool calls", async () => { const sessions = sessionsWithParts([ toolPart("current", "running"), diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index ea00c92a..be359a55 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -726,7 +726,7 @@ function isContextGroupTool(part: PartType): part is ToolPart { function contextToolDetail(part: ToolPart): string | undefined { const info = getToolInfo(part.tool, part.state.input ?? {}, toolStateMetadata(part.state)) if (info.subtitle) return info.subtitle - if (part.state.status === "error") return part.state.error + if (part.state.status === "error") return toolStateError(part.state) if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) return part.state.title const description = part.state.input?.description From 0d7ac5bf15d4a3b982c2c70828caeff520886613 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:22:43 +0800 Subject: [PATCH 56/65] fix: guard deleted gitignore before worktree setup --- .../opencode/src/worktree/gitignore-guard.ts | 42 +++++++++---------- .../test/worktree/gitignore-guard.test.ts | 10 +++++ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/worktree/gitignore-guard.ts b/packages/opencode/src/worktree/gitignore-guard.ts index 287ed791..1acc4ad0 100644 --- a/packages/opencode/src/worktree/gitignore-guard.ts +++ b/packages/opencode/src/worktree/gitignore-guard.ts @@ -39,28 +39,26 @@ export async function ensureWorktreesIgnored(root: string): Promise<{ changed: b if (before && hasWorktreesIgnore(before)) return { changed: false, file } - if (before !== undefined) { - const status = await git(root, [ - "-c", - "core.fsmonitor=false", - "-c", - "status.showUntrackedFiles=all", - "status", - "--porcelain=v1", - "--no-renames", - "--", - ".gitignore", - ]) - if (status.code !== 0) { - throw new GitignoreGuardError({ - message: status.stderr || status.stdout || "Failed to inspect .gitignore status", - }) - } - if (status.stdout.trim()) { - throw new GitignoreGuardError({ - message: ".gitignore has local changes. Commit or discard them before creating a PawWork worktree.", - }) - } + const status = await git(root, [ + "-c", + "core.fsmonitor=false", + "-c", + "status.showUntrackedFiles=all", + "status", + "--porcelain=v1", + "--no-renames", + "--", + ".gitignore", + ]) + if (status.code !== 0) { + throw new GitignoreGuardError({ + message: status.stderr || status.stdout || "Failed to inspect .gitignore status", + }) + } + if (status.stdout.trim()) { + throw new GitignoreGuardError({ + message: ".gitignore has local changes. Commit or discard them before creating a PawWork worktree.", + }) } const prefix = before && before.length > 0 && !before.endsWith("\n") ? "\n" : "" diff --git a/packages/opencode/test/worktree/gitignore-guard.test.ts b/packages/opencode/test/worktree/gitignore-guard.test.ts index 9b72dc35..f297cbdc 100644 --- a/packages/opencode/test/worktree/gitignore-guard.test.ts +++ b/packages/opencode/test/worktree/gitignore-guard.test.ts @@ -42,4 +42,14 @@ describe("worktree gitignore guard", () => { await expect(ensureWorktreesIgnored(tmp.path)).rejects.toThrow("WorktreeGitignoreGuardError") }) + + test("refuses to recreate a locally deleted tracked .gitignore", async () => { + await using tmp = await tmpdir({ git: true }) + const file = path.join(tmp.path, ".gitignore") + await Bun.write(file, "node_modules\n") + await $`git add .gitignore && git commit -m initial-gitignore`.cwd(tmp.path).quiet() + await fs.unlink(file) + + await expect(ensureWorktreesIgnored(tmp.path)).rejects.toThrow("WorktreeGitignoreGuardError") + }) }) From e2c593de36750a2b6879fdb89c3544d598e68c46 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 02:50:54 +0800 Subject: [PATCH 57/65] fix: address latest worktree review comments --- .../app/src/components/settings-worktrees.tsx | 12 +- packages/opencode/src/session/session.ts | 14 ++- packages/opencode/src/worktree/index.ts | 8 ++ .../opencode/test/project/worktree.test.ts | 13 +++ .../opencode/test/session/session.test.ts | 40 ++++++- .../test/session/state-machine-guard.test.ts | 109 ++++++++++-------- packages/ui/src/components/message-part.tsx | 10 +- 7 files changed, 147 insertions(+), 59 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 8d3d8bef..46fedb5c 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -84,10 +84,10 @@ export const SettingsWorktrees: Component = () => { } const [confirming, setConfirming] = createSignal(undefined) - const [deleting, setDeleting] = createSignal(undefined) + const [deleting, setDeleting] = createSignal>(new Set()) const handleDelete = async (directory: string) => { - setDeleting(directory) + setDeleting((current) => new Set(current).add(directory)) try { const ownerDirectory = data()?.find((worktree) => worktree.directory === directory)?.ownerDirectory ?? directory const res = await sdk.client.worktree.remove({ directory: ownerDirectory, worktreeRemoveInput: { directory } }) @@ -100,7 +100,11 @@ export const SettingsWorktrees: Component = () => { title: language.t("settings.worktrees.deleteFailed", { message }), }) } finally { - setDeleting(undefined) + setDeleting((current) => { + const next = new Set(current) + next.delete(directory) + return next + }) } } @@ -142,7 +146,7 @@ export const SettingsWorktrees: Component = () => { ownerName={ownerName(worktree.ownerDirectory)} boundSession={boundSessions().get(worktree.directory)} confirming={confirming() === worktree.directory} - deleting={deleting() === worktree.directory} + deleting={deleting().has(worktree.directory)} onCancelDelete={() => setConfirming(undefined)} onConfirmDelete={handleDelete} onRequestDelete={setConfirming} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c4e20a6d..6b139951 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -782,8 +782,20 @@ export const layer: Layer.Layer = ) .all(), ) + const ids = [...new Set(rows.filter(needsProjectFallback).map((row) => row.project_id))] + const projects = new Map() + if (ids.length > 0) { + const items = yield* db((d) => + d + .select({ id: ProjectTable.id, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) projects.set(item.id, item) + } for (const row of rows) { - const session = fromRow(row) + const session = fromRow(row, projects.get(row.project_id)) const exec = session.executionContext if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(exec.ownerDirectory)) continue if ( diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 558bd246..314f9ad9 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -304,6 +304,14 @@ export namespace Worktree { const target = yield* canonical(directory) const existing = yield* lookupByDirectory(target) if (existing) return existing + const listed = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + if (listed.code !== 0) { + throw new CreateFailedError({ message: listed.stderr || listed.text || "Failed to read git worktrees" }) + } + const match = yield* locateWorktree(parseWorktreeList(listed.text), target) + if (!match?.path) { + throw new CreateFailedError({ message: "Directory is not a worktree for this project" }) + } const branch = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: target }) const info = Info.parse({ directory: target, diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 09681e35..34cf4cd3 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -200,6 +200,19 @@ describe("Worktree", () => { await withInstance(tmp.path, () => Worktree.remove({ directory: created.directory })) await withInstance(tmp.path, () => Worktree.remove({ directory: external })) }) + + test("rejects existing paths that are not attached git worktrees", async () => { + await using tmp = await tmpdir({ git: true }) + const unrelated = path.join(tmp.path, "not-a-worktree") + await fs.mkdir(unrelated, { recursive: true }) + + await expect(withInstance(tmp.path, () => Worktree.registerExistingByPath(unrelated))).rejects.toThrow( + "WorktreeCreateFailedError", + ) + + const entry = await withInstance(tmp.path, () => Worktree.lookupByDirectory(unrelated)) + expect(entry).toBeUndefined() + }) }) describe("remove edge cases", () => { diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index a1f012ed..6ebc2151 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -124,6 +124,31 @@ describe("session.created event", () => { }) }) + test("findActiveWorktreeBinding uses project fallback for invalid executionContext rows", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "invalid-binding") + await fs.mkdir(worktree, { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "invalid binding" }) + Database.use((db) => + db + .update(SessionTable) + .set({ execution_context: { activeDirectory: worktree } as any }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const found = await SessionNs.findActiveWorktreeBinding(worktree) + expect(found).toBeUndefined() + + await SessionNs.remove(session.id) + }, + }) + }) + test("updateExecutionContext returns the persisted updated time", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-b") @@ -219,9 +244,10 @@ describe("session.created event", () => { const count = await Effect.runPromise(SessionNs.backfillExecutionContext) expect(count).toBeGreaterThanOrEqual(1) + const expectedRoot = canonicalDirectory(tmp.path) const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, session.id)).get()) - expect(row?.execution_context?.ownerDirectory).toBe(tmp.path) - expect(row?.execution_context?.activeDirectory).toBe(tmp.path) + expect(row?.execution_context?.ownerDirectory).toBe(expectedRoot) + expect(row?.execution_context?.activeDirectory).toBe(expectedRoot) await SessionNs.remove(session.id) }, @@ -268,9 +294,10 @@ describe("session.created event", () => { ) const loaded = await SessionNs.get(session.id) + const expectedRoot = canonicalDirectory(tmp.path) expect(loaded.directory).toBe(subdir) - expect(loaded.executionContext.ownerDirectory).toBe(tmp.path) - expect(loaded.executionContext.activeDirectory).toBe(tmp.path) + expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) + expect(loaded.executionContext.activeDirectory).toBe(expectedRoot) await SessionNs.remove(session.id) }, @@ -295,9 +322,10 @@ describe("session.created event", () => { ) const loaded = await SessionNs.get(session.id) + const expectedRoot = canonicalDirectory(tmp.path) expect(loaded.directory).toBe(subdir) - expect(loaded.executionContext.ownerDirectory).toBe(tmp.path) - expect(loaded.executionContext.activeDirectory).toBe(tmp.path) + expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) + expect(loaded.executionContext.activeDirectory).toBe(expectedRoot) await SessionNs.remove(session.id) }, diff --git a/packages/opencode/test/session/state-machine-guard.test.ts b/packages/opencode/test/session/state-machine-guard.test.ts index 80cf1174..cc5931f3 100644 --- a/packages/opencode/test/session/state-machine-guard.test.ts +++ b/packages/opencode/test/session/state-machine-guard.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { testEffect } from "../lib/effect" import type { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import type { SubagentRun } from "../../src/session/subagent-run" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../../src/session/state-machine-guard" const sessionID = "session_test" as SessionID +const it = testEffect(Layer.empty) function sessionsWithParts(parts: unknown[]): Session.Service["Service"] { return { @@ -29,49 +31,62 @@ function subagents(active: boolean): SubagentRun.Service["Service"] { } describe("state-machine guard", () => { - test("detects pending and running tool calls except the current call", async () => { - const sessions = sessionsWithParts([ - toolPart("current", "running"), - toolPart("other-pending", "pending"), - toolPart("other-running", "running"), - ]) - - const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) - - expect(blocked).toBe(true) - }) - - test("detects pending tool calls except the current call", async () => { - const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-pending", "pending")]) - - const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) - - expect(blocked).toBe(true) - }) - - test("detects running tool calls except the current call", async () => { - const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-running", "running")]) - - const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) - - expect(blocked).toBe(true) - }) - - test("ignores the current call and finished tool calls", async () => { - const sessions = sessionsWithParts([ - toolPart("current", "running"), - toolPart("done", "completed"), - toolPart("failed", "error"), - { type: "text", text: "not a tool" }, - ]) - - const blocked = await Effect.runPromise(hasInFlightToolCallsExcept(sessions, sessionID, "current")) - - expect(blocked).toBe(false) - }) - - test("reports active subagents", async () => { - await expect(Effect.runPromise(hasRunningSubagents(subagents(true), sessionID))).resolves.toBe(true) - await expect(Effect.runPromise(hasRunningSubagents(subagents(false), sessionID))).resolves.toBe(false) - }) + it.live("detects pending and running tool calls except the current call", () => + Effect.gen(function* () { + const sessions = sessionsWithParts([ + toolPart("current", "running"), + toolPart("other-pending", "pending"), + toolPart("other-running", "running"), + ]) + + const blocked = yield* hasInFlightToolCallsExcept(sessions, sessionID, "current") + + expect(blocked).toBe(true) + }), + ) + + it.live("detects pending tool calls except the current call", () => + Effect.gen(function* () { + const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-pending", "pending")]) + + const blocked = yield* hasInFlightToolCallsExcept(sessions, sessionID, "current") + + expect(blocked).toBe(true) + }), + ) + + it.live("detects running tool calls except the current call", () => + Effect.gen(function* () { + const sessions = sessionsWithParts([toolPart("current", "running"), toolPart("other-running", "running")]) + + const blocked = yield* hasInFlightToolCallsExcept(sessions, sessionID, "current") + + expect(blocked).toBe(true) + }), + ) + + it.live("ignores the current call and finished tool calls", () => + Effect.gen(function* () { + const sessions = sessionsWithParts([ + toolPart("current", "running"), + toolPart("done", "completed"), + toolPart("failed", "error"), + { type: "text", text: "not a tool" }, + ]) + + const blocked = yield* hasInFlightToolCallsExcept(sessions, sessionID, "current") + + expect(blocked).toBe(false) + }), + ) + + it.live("reports active subagents", () => + Effect.gen(function* () { + const activeResult = yield* hasRunningSubagents(subagents(true), sessionID) + expect(activeResult).toBe(true) + + const inactiveResult = yield* hasRunningSubagents(subagents(false), sessionID) + expect(inactiveResult).toBe(false) + }), + ) }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index be359a55..8a65cc04 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -794,7 +794,15 @@ function toolStateMetadata(state: ToolPart["state"] | undefined): Record Date: Sat, 2 May 2026 03:14:27 +0800 Subject: [PATCH 58/65] fix: harden worktree execution context --- .../app/src/components/settings-worktrees.tsx | 1 + packages/opencode/src/session/prompt.ts | 125 +++++++++++------- packages/opencode/src/session/session.ts | 23 +++- packages/opencode/src/worktree/index.ts | 23 +++- .../test/project/worktree-remove.test.ts | 45 +++++++ .../opencode/test/project/worktree.test.ts | 8 +- .../test/session/prompt-effect.test.ts | 83 ++++++++++++ .../opencode/test/session/session.test.ts | 20 ++- 8 files changed, 266 insertions(+), 62 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 46fedb5c..719ed9bc 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -28,6 +28,7 @@ export const SettingsWorktrees: Component = () => { const results = await Promise.allSettled( projectRoots().map(async (ownerDirectory) => { const res = await sdk.client.worktree.list({ directory: ownerDirectory }) + if (res.error) throw new Error(errorText(res.error)) return (res.data ?? []).map((worktree) => ({ ...worktree, ownerDirectory }) as WorktreeInfo) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9e89a6fd..fd5fab47 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,7 +54,8 @@ import { InstanceState } from "@/effect" import { AgentTool, type AgentPromptOps } from "@/tool/agent" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect" -import { makeRuntime } from "@/effect/run-service" +import { attachWith, makeRuntime } from "@/effect/run-service" +import { Instance } from "@/project/instance" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -506,6 +507,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the const tools: Record = {} const run = yield* runner() const promptOps = yield* ops() + const effectContext = yield* Effect.context() + const runInSessionContext = (effect: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + const session = yield* sessions.get(input.session.id) + return yield* Effect.promise( + async () => + await Instance.activate({ + activeDirectory: session.executionContext.activeDirectory, + ownerDirectory: session.executionContext.ownerDirectory, + project: Instance.project, + fn: () => + Effect.runPromise( + attachWith(effect, { instance: Instance.current }).pipe( + Effect.provide(effectContext), + ) as Effect.Effect, + ), + }), + ) + }) // Locale travels on the user message (set by the UI from `language.intl()`); capture // once here and let every applyLoopGate call in this resolveTools scope share it. // Falls back to undefined → English in LoopRenderer. Skip user messages without locale @@ -572,25 +592,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) if (outcome.kind === "block") return yield* Effect.fail(new BlockedLoopError(outcome.userFacing)) if (outcome.kind === "stop") return yield* Effect.fail(new LoopStopError(outcome.toolErrorMessage)) - yield* plugin.trigger( - "tool.execute.before", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, - ) - const result = yield* item.execute(args, ctx) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - } - yield* plugin.trigger( - "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, + const output = yield* runInSessionContext( + Effect.gen(function* () { + yield* plugin.trigger( + "tool.execute.before", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, + { args }, + ) + const result = yield* item.execute(args, ctx) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + return output + }), ) if (options.abortSignal?.aborted) { yield* input.processor.completeToolCall(options.toolCallId, output) @@ -622,19 +647,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) if (outcome.kind === "block") return yield* Effect.fail(new BlockedLoopError(outcome.userFacing)) if (outcome.kind === "stop") return yield* Effect.fail(new LoopStopError(outcome.toolErrorMessage)) - yield* plugin.trigger( - "tool.execute.before", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.promise(() => - execute(args, opts), - ) - yield* plugin.trigger( - "tool.execute.after", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, - result, + const result: Awaited>> = yield* runInSessionContext( + Effect.gen(function* () { + yield* plugin.trigger( + "tool.execute.before", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + const result: Awaited>> = yield* Effect.promise(() => + execute(args, opts), + ) + yield* plugin.trigger( + "tool.execute.after", + { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + result, + ) + return result + }), ) const textParts: string[] = [] @@ -704,7 +734,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the const ctx = yield* InstanceState.context const promptOps = yield* ops() const { agent: agentTool } = yield* registry.named() - const taskModel = subtask.model ? yield* getModel(subtask.model.providerID, subtask.model.modelID, sessionID) : model + const taskModel = subtask.model + ? yield* getModel(subtask.model.providerID, subtask.model.modelID, sessionID) + : model // Re-read live to pick up Enter/Exit transitions made earlier in the same turn. const execLive = (yield* sessions.get(sessionID)).executionContext const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ @@ -887,12 +919,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, ready: Deferred.Deferred) { + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* ( + input: ShellInput, + ready: Deferred.Deferred, + ) { let output = "" let aborted = false const { msg, part, cmd, finish } = yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { yield* revert.cleanup(session) @@ -960,7 +994,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellName = ( process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) ).toLowerCase() - const cwd = ctx.directory + const cwd = session.executionContext.activeDirectory const invocations: Record = { nu: { args: ["-c", input.command] }, fish: { args: ["-c", input.command] }, @@ -1000,11 +1034,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const args = (invocations[shellName] ?? invocations[""]).args const shellEnv = yield* restore( - plugin.trigger( - "shell.env", - { cwd, sessionID: input.sessionID, callID: part.callID }, - { env: {} }, - ), + plugin.trigger("shell.env", { cwd, sessionID: input.sessionID, callID: part.callID }, { env: {} }), ) const env = withoutInternalServerAuthEnv({ @@ -1061,11 +1091,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) yield* handle.exitCode - }).pipe( - Effect.scoped, - Effect.orDie, - Effect.exit, - ) + }).pipe(Effect.scoped, Effect.orDie, Effect.exit) if (Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) && !Cause.hasDies(exit.cause)) { aborted = true @@ -1531,8 +1557,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) if (!runningTool) return message - const output = (typeof runningTool.state.metadata?.output === "string" ? runningTool.state.metadata.output : "") - .concat("\n\n\nUser aborted the command\n") + const output = ( + typeof runningTool.state.metadata?.output === "string" ? runningTool.state.metadata.output : "" + ).concat("\n\n\nUser aborted the command\n") const info = message.info.time.completed ? message.info : { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 6b139951..7a2aaeed 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -238,12 +238,19 @@ export const SetRevertInput = z.object({ summary: Info.shape.summary, }) export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() }) +const AbsoluteDirectory = z + .string() + .min(1, "Expected an absolute directory path") + .refine((value) => path.isAbsolute(value), "Expected an absolute directory path") +const ActiveWorktreeInput = ActiveWorktree.extend({ + directory: AbsoluteDirectory, +}) export const UpdateExecutionContextInput = z.object({ sessionID: SessionID.zod, - activeDirectory: z.string().optional(), - activeWorktree: ActiveWorktree.nullable().optional(), + activeDirectory: AbsoluteDirectory.optional(), + activeWorktree: ActiveWorktreeInput.nullable().optional(), }) -export const FindActiveWorktreeBindingInput = z.string() +export const FindActiveWorktreeBindingInput = AbsoluteDirectory export const RemovePartInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, @@ -484,7 +491,8 @@ export const layer: Layer.Layer = permission: input.permission, // ownerDirectory is the project root for git projects and never moves. For non-git // projects Instance.worktree is "/" today, so keep the opened directory as the owner. - executionContext: input.executionContext ?? rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), + executionContext: + input.executionContext ?? rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), time: { created: Date.now(), updated: Date.now(), @@ -1068,7 +1076,12 @@ export function* listGlobal(input?: { if (ids.length > 0) { const items = Database.use((db) => db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .select({ + id: ProjectTable.id, + name: ProjectTable.name, + worktree: ProjectTable.worktree, + vcs: ProjectTable.vcs, + }) .from(ProjectTable) .where(inArray(ProjectTable.id, ids)) .all(), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 314f9ad9..a0ac29e4 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -20,6 +20,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { makeRuntime } from "@/effect/run-service" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" +import { Session } from "../session" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -218,13 +219,13 @@ export namespace Worktree { entry: string | { directory: string; name?: string; branch?: string; source?: "created" | "existing" }, ) { if (typeof entry === "string") { - return Info.parse({ directory: entry, name: pathSvc.basename(entry), branch: "", source: "existing" }) + return Info.parse({ directory: entry, name: pathSvc.basename(entry), branch: "", source: "created" }) } return Info.parse({ directory: entry.directory, name: entry.name ?? pathSvc.basename(entry.directory), branch: entry.branch ?? "", - source: entry.source ?? "existing", + source: entry.source ?? "created", }) } @@ -237,7 +238,7 @@ export namespace Worktree { }) const writeRegistry = Effect.fnUntraced(function* (entries: Info[]) { - yield* Effect.sync(() => + const result = yield* Effect.sync(() => Database.use((db) => db .update(ProjectTable) @@ -251,9 +252,17 @@ export namespace Worktree { time_updated: Date.now(), }) .where(eq(ProjectTable.id, Instance.project.id)) - .run(), + .returning() + .get(), ), ) + if (!result) return + const data = Project.fromRow(result) + GlobalBus.emit("event", { + directory: "global", + project: data.id, + payload: { type: Project.Event.Updated.type, properties: data }, + }) }) const upsertRegistry = Effect.fnUntraced(function* (info: Info) { @@ -520,6 +529,12 @@ export namespace Worktree { } const directory = yield* canonical(input.directory) + const bound = yield* Effect.promise(() => Session.findActiveWorktreeBinding(directory)) + if (bound) { + throw new RemoveFailedError({ + message: `Worktree is in use by session "${bound.title}". Call ExitWorktree from that session first.`, + }) + } const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.code !== 0) { diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c185fcaa..c7c31e40 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" import { Worktree } from "../../src/worktree" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -11,6 +12,50 @@ const wintest = process.platform === "win32" ? test : test.skip const unixtest = process.platform === "win32" ? test.skip : test describe("Worktree.remove", () => { + unixtest("refuses to remove a worktree bound to an active session", async () => { + await using tmp = await tmpdir({ git: true }) + const root = tmp.path + + const { info, session } = await Instance.provide({ + directory: root, + fn: async () => { + const info = await Worktree.makeWorktreeInfo("bound-session") + await Worktree.createFromInfo(info) + const session = await Session.create({ title: "Bound session" }) + await Session.updateExecutionContext({ + sessionID: session.id, + activeWorktree: info, + }) + return { info, session } + }, + }) + + await expect( + Instance.provide({ + directory: root, + fn: () => Worktree.remove({ directory: info.directory }), + }), + ).rejects.toThrow("WorktreeRemoveFailedError") + try { + await Instance.provide({ + directory: root, + fn: () => Worktree.remove({ directory: info.directory }), + }) + } catch (error) { + expect((error as { data?: { message?: string } }).data?.message).toContain("Worktree is in use by session") + } + expect(await Filesystem.exists(info.directory)).toBe(true) + + await Instance.provide({ + directory: root, + fn: async () => { + await Session.updateExecutionContext({ sessionID: session.id, activeWorktree: null }) + await Session.remove(session.id) + await Worktree.remove({ directory: info.directory }) + }, + }) + }) + unixtest("continues when git remove exits non-zero after detaching", async () => { await using tmp = await tmpdir({ git: true }) const root = tmp.path diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 34cf4cd3..b52a5927 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -16,8 +16,9 @@ function normalize(input: string) { return input.replace(/\\/g, "/").toLowerCase() } -async function waitReady() { +async function waitReady(root: string) { const { GlobalBus } = await import("../../src/bus/global") + const expectedRoot = normalize(root.endsWith(path.sep) ? root : `${root}${path.sep}`) return await new Promise<{ name: string; branch: string }>((resolve, reject) => { const timer = setTimeout(() => { @@ -27,6 +28,7 @@ async function waitReady() { function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) { if (evt.payload.type !== Worktree.Event.Ready.type) return + if (!evt.directory || !normalize(evt.directory).startsWith(expectedRoot)) return clearTimeout(timer) GlobalBus.off("event", on) resolve(evt.payload.properties) @@ -98,7 +100,7 @@ describe("Worktree", () => { test("create returns after setup and fires Event.Ready after bootstrap", async () => { await using tmp = await tmpdir({ git: true }) - const ready = waitReady() + const ready = waitReady(path.join(tmp.path, ".worktrees", "pawwork")) const info = await withInstance(tmp.path, () => Worktree.create()) @@ -125,7 +127,7 @@ describe("Worktree", () => { test("create with custom name", async () => { await using tmp = await tmpdir({ git: true }) - const ready = waitReady() + const ready = waitReady(path.join(tmp.path, ".worktrees", "pawwork")) const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" })) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 1acff08b..89080a37 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -3,6 +3,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" +import fs from "fs/promises" import { pathToFileURL } from "url" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -1312,6 +1313,88 @@ unix("shell completes a fast command on the preferred shell", () => ), ) +unix("shell uses the session execution context directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, sessions, chat } = yield* boot() + const activeDir = path.join(dir, ".worktrees", "pawwork", "shell-context") + yield* Effect.promise(() => fs.mkdir(activeDir, { recursive: true })) + yield* sessions.updateExecutionContext({ + sessionID: chat.id, + activeWorktree: { + directory: activeDir, + name: "shell-context", + branch: "pawwork/shell-context", + source: "created", + }, + }) + + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "pwd", + }) + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain(activeDir) + }), + { git: true, config: cfg }, + ), +) + +unix("bash tool uses the session execution context directory", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Tool cwd", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const activeDir = path.join(dir, ".worktrees", "pawwork", "tool-context") + yield* Effect.promise(() => fs.mkdir(activeDir, { recursive: true })) + yield* sessions.updateExecutionContext({ + sessionID: chat.id, + activeWorktree: { + directory: activeDir, + name: "tool-context", + branch: "pawwork/tool-context", + source: "created", + }, + }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "print cwd" }], + }) + yield* llm.tool("bash", { + command: "pwd", + description: "Print cwd", + }) + yield* llm.text("done") + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const tool = msgs + .flatMap((msg) => msg.parts) + .find( + (part): part is CompletedToolPart => + part.type === "tool" && part.tool === "bash" && part.state.status === "completed", + ) + if (!tool) return + + expect(tool.state.output).toContain(activeDir) + }), + { git: true, config: providerCfg }, + ), +) + unix("shell commands can change directory after login startup", () => withShell("/bin/bash", () => provideTmpdirInstance( diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 6ebc2151..9146ad54 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -222,7 +222,9 @@ describe("session.created event", () => { sessionID: session.id, activeDirectory: `${tmp.path}${path.sep}`, }) - expect(canonicalDirectory(clearedByDirectory.executionContext.activeDirectory)).toBe(canonicalDirectory(tmp.path)) + expect(canonicalDirectory(clearedByDirectory.executionContext.activeDirectory)).toBe( + canonicalDirectory(tmp.path), + ) expect(clearedByDirectory.executionContext.activeWorktree).toBeUndefined() await SessionNs.remove(session.id) @@ -299,6 +301,14 @@ describe("session.created event", () => { expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) expect(loaded.executionContext.activeDirectory).toBe(expectedRoot) + const listed = Array.from(SessionNs.list()).find((item) => item.id === session.id) + expect(listed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(listed?.executionContext.activeDirectory).toBe(expectedRoot) + + const globalListed = Array.from(SessionNs.listGlobal()).find((item) => item.id === session.id) + expect(globalListed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(globalListed?.executionContext.activeDirectory).toBe(expectedRoot) + await SessionNs.remove(session.id) }, }) @@ -327,6 +337,14 @@ describe("session.created event", () => { expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) expect(loaded.executionContext.activeDirectory).toBe(expectedRoot) + const listed = Array.from(SessionNs.list()).find((item) => item.id === session.id) + expect(listed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(listed?.executionContext.activeDirectory).toBe(expectedRoot) + + const globalListed = Array.from(SessionNs.listGlobal()).find((item) => item.id === session.id) + expect(globalListed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(globalListed?.executionContext.activeDirectory).toBe(expectedRoot) + await SessionNs.remove(session.id) }, }) From f054a860e8beee8c0c7ff781d1b36e81ab161799 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 07:40:57 +0800 Subject: [PATCH 59/65] fix: preserve partial worktree context --- .../app/src/components/settings-worktrees.tsx | 3 +- packages/opencode/src/session/session.ts | 46 +++++++++++++++++ .../test/project/worktree-remove.test.ts | 2 +- .../opencode/test/project/worktree.test.ts | 3 +- .../test/session/prompt-effect.test.ts | 2 +- .../opencode/test/session/session.test.ts | 51 +++++++++++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/settings-worktrees.tsx b/packages/app/src/components/settings-worktrees.tsx index 719ed9bc..1d171c5e 100644 --- a/packages/app/src/components/settings-worktrees.tsx +++ b/packages/app/src/components/settings-worktrees.tsx @@ -88,12 +88,13 @@ export const SettingsWorktrees: Component = () => { const [deleting, setDeleting] = createSignal>(new Set()) const handleDelete = async (directory: string) => { + if (deleting().has(directory)) return setDeleting((current) => new Set(current).add(directory)) try { const ownerDirectory = data()?.find((worktree) => worktree.directory === directory)?.ownerDirectory ?? directory const res = await sdk.client.worktree.remove({ directory: ownerDirectory, worktreeRemoveInput: { directory } }) if (res.error) throw new Error(errorText(res.error)) - setConfirming(undefined) + setConfirming((current) => (current === directory ? undefined : current)) void refetch() } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 7a2aaeed..d988d832 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -58,10 +58,56 @@ function legacyExecutionContext(row: SessionRow, project?: ProjectFallback) { return rootContext(canonicalDirectory(ownerDirectoryRaw)) } +function recoverExecutionContext(row: SessionRow) { + const raw = row.execution_context + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined + const record = raw as Record + const ownerDirectory = record.ownerDirectory + const activeDirectory = record.activeDirectory + if ( + typeof ownerDirectory !== "string" || + typeof activeDirectory !== "string" || + !path.isAbsolute(ownerDirectory) || + !path.isAbsolute(activeDirectory) + ) { + return undefined + } + + const activeWorktreeRaw = record.activeWorktree + let activeWorktree: ActiveWorktree | undefined + if (activeWorktreeRaw && typeof activeWorktreeRaw === "object" && !Array.isArray(activeWorktreeRaw)) { + const worktree = activeWorktreeRaw as Record + const directory = worktree.directory + if (typeof directory === "string" && path.isAbsolute(directory)) { + const source = worktree.source === "existing" ? "existing" : "created" + const parsed = ActiveWorktree.safeParse({ + directory: canonicalDirectory(directory), + name: worktree.name, + branch: worktree.branch, + source, + }) + if (parsed.success) activeWorktree = parsed.data + } + } + + const recovered = SessionExecutionContext.safeParse({ + ownerDirectory: canonicalDirectory(ownerDirectory), + activeDirectory: canonicalDirectory(activeDirectory), + activeWorktree, + lastChangedAt: + typeof record.lastChangedAt === "number" && Number.isFinite(record.lastChangedAt) + ? record.lastChangedAt + : Date.now(), + }) + return recovered.success ? recovered.data : undefined +} + function parseExecutionContext(row: SessionRow, project?: ProjectFallback) { if (row.execution_context !== null) { const parsed = SessionExecutionContext.safeParse(row.execution_context) if (parsed.success) return parsed.data + const recovered = recoverExecutionContext(row) + if (recovered) return recovered } return legacyExecutionContext(row, project) } diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c7c31e40..6605bda1 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -12,7 +12,7 @@ const wintest = process.platform === "win32" ? test : test.skip const unixtest = process.platform === "win32" ? test.skip : test describe("Worktree.remove", () => { - unixtest("refuses to remove a worktree bound to an active session", async () => { + test("refuses to remove a worktree bound to an active session", async () => { await using tmp = await tmpdir({ git: true }) const root = tmp.path diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index b52a5927..07c82612 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -179,8 +179,9 @@ describe("Worktree", () => { describe("registry source", () => { test("created worktrees are slug-addressable, existing worktrees are path-addressable only", async () => { await using tmp = await tmpdir({ git: true }) + const ready = waitReady(path.join(tmp.path, ".worktrees", "pawwork")) const created = await withInstance(tmp.path, () => Worktree.create({ name: "feature-a" })) - await Bun.sleep(1000) + await ready const bySlug = await withInstance(tmp.path, () => Worktree.lookupBySlug("feature-a")) expect(bySlug?.directory).toBe(created.directory) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 89080a37..f4b159ef 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1387,7 +1387,7 @@ unix("bash tool uses the session execution context directory", () => (part): part is CompletedToolPart => part.type === "tool" && part.tool === "bash" && part.state.status === "completed", ) - if (!tool) return + if (!tool) throw new Error("Missing completed bash tool part") expect(tool.state.output).toContain(activeDir) }), diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 9146ad54..dc76b673 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -350,6 +350,57 @@ describe("session.created event", () => { }) }) + test("recovers partial activeWorktree without dropping active directory", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "packages", "app") + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "partial-worktree") + await fs.mkdir(subdir, { recursive: true }) + + await Instance.provide({ + directory: subdir, + fn: async () => { + const session = await SessionNs.create({ title: "partial-active-worktree" }) + const expectedRoot = canonicalDirectory(tmp.path) + const expectedActive = canonicalDirectory(worktree) + Database.use((db) => + db + .update(SessionTable) + .set({ + execution_context: { + ownerDirectory: tmp.path, + activeDirectory: worktree, + activeWorktree: { + directory: worktree, + name: "partial-worktree", + branch: "pawwork/partial-worktree", + }, + lastChangedAt: 123, + } as any, + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const loaded = await SessionNs.get(session.id) + expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) + expect(loaded.executionContext.activeDirectory).toBe(expectedActive) + expect(loaded.executionContext.activeWorktree?.source).toBe("created") + + const listed = Array.from(SessionNs.list()).find((item) => item.id === session.id) + expect(listed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(listed?.executionContext.activeDirectory).toBe(expectedActive) + expect(listed?.executionContext.activeWorktree?.source).toBe("created") + + const globalListed = Array.from(SessionNs.listGlobal()).find((item) => item.id === session.id) + expect(globalListed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(globalListed?.executionContext.activeDirectory).toBe(expectedActive) + expect(globalListed?.executionContext.activeWorktree?.source).toBe("created") + + await SessionNs.remove(session.id) + }, + }) + }) + test("fork preserves the source session executionContext", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "forked-work") From 22944d35cc43ed774cc0d532423ea0a65acd5881 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 07:59:15 +0800 Subject: [PATCH 60/65] fix: require session row project fallback --- packages/opencode/src/cli/cmd/stats.ts | 11 ++++++++++- packages/opencode/src/server/projectors.ts | 11 ++++++++++- packages/opencode/src/session/session.ts | 6 +++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 527a6ac9..598e3e51 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,6 +5,7 @@ import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project/project" +import { ProjectTable } from "../../project/project.sql" import { Instance } from "../../project/instance" import { AppRuntime } from "@/effect/app-runtime" @@ -90,7 +91,15 @@ async function getCurrentProject(): Promise { async function getAllSessions(): Promise { const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) + const projects = new Map( + Database.use((db) => + db + .select({ id: ProjectTable.id, worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .all(), + ).map((project) => [project.id, project]), + ) + return rows.map((row) => Session.fromRow(row, projects.get(row.project_id))) } export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { diff --git a/packages/opencode/src/server/projectors.ts b/packages/opencode/src/server/projectors.ts index eb85a801..f407598f 100644 --- a/packages/opencode/src/server/projectors.ts +++ b/packages/opencode/src/server/projectors.ts @@ -2,6 +2,7 @@ import z from "zod" import sessionProjectors from "../session/projectors" import { SyncEvent } from "@/sync" import { Session } from "@/session" +import { ProjectTable } from "@/project/project.sql" import { SessionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" @@ -15,9 +16,17 @@ export function initProjectors() { if (!row) return data + const project = Database.use((db) => + db + .select({ worktree: ProjectTable.worktree, vcs: ProjectTable.vcs }) + .from(ProjectTable) + .where(eq(ProjectTable.id, row.project_id)) + .get(), + ) + return { sessionID: id, - info: Session.fromRow(row), + info: Session.fromRow(row, project), } } return data diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index d988d832..22ebd100 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -53,7 +53,7 @@ export function isDefaultTitle(title: string) { type SessionRow = typeof SessionTable.$inferSelect type ProjectFallback = { worktree?: string | null; vcs?: string | null } -function legacyExecutionContext(row: SessionRow, project?: ProjectFallback) { +function legacyExecutionContext(row: SessionRow, project: ProjectFallback | undefined) { const ownerDirectoryRaw = project?.vcs === "git" ? (project.worktree ?? row.directory) : row.directory return rootContext(canonicalDirectory(ownerDirectoryRaw)) } @@ -102,7 +102,7 @@ function recoverExecutionContext(row: SessionRow) { return recovered.success ? recovered.data : undefined } -function parseExecutionContext(row: SessionRow, project?: ProjectFallback) { +function parseExecutionContext(row: SessionRow, project: ProjectFallback | undefined) { if (row.execution_context !== null) { const parsed = SessionExecutionContext.safeParse(row.execution_context) if (parsed.success) return parsed.data @@ -116,7 +116,7 @@ function needsProjectFallback(row: SessionRow) { return row.execution_context === null || !SessionExecutionContext.safeParse(row.execution_context).success } -export function fromRow(row: SessionRow, project?: ProjectFallback): Info { +export function fromRow(row: SessionRow, project: ProjectFallback | undefined): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null ? { From 66a4d0128b7759018cee5447bedf0bfd68cff9b9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 08:12:03 +0800 Subject: [PATCH 61/65] fix: harden session execution context recovery --- packages/opencode/src/session/session.ts | 30 ++++++++-- .../opencode/test/session/session.test.ts | 58 +++++++++++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 22ebd100..99fb1bff 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -79,12 +79,11 @@ function recoverExecutionContext(row: SessionRow) { const worktree = activeWorktreeRaw as Record const directory = worktree.directory if (typeof directory === "string" && path.isAbsolute(directory)) { - const source = worktree.source === "existing" ? "existing" : "created" const parsed = ActiveWorktree.safeParse({ directory: canonicalDirectory(directory), name: worktree.name, branch: worktree.branch, - source, + source: worktree.source, }) if (parsed.success) activeWorktree = parsed.data } @@ -102,10 +101,32 @@ function recoverExecutionContext(row: SessionRow) { return recovered.success ? recovered.data : undefined } +function isPersistedExecutionContextUsable(ctx: SessionExecutionContext) { + return ( + path.isAbsolute(ctx.ownerDirectory) && + path.isAbsolute(ctx.activeDirectory) && + (!ctx.activeWorktree || path.isAbsolute(ctx.activeWorktree.directory)) + ) +} + +function normalizeExecutionContext(ctx: SessionExecutionContext): SessionExecutionContext { + return { + ...ctx, + ownerDirectory: canonicalDirectory(ctx.ownerDirectory), + activeDirectory: canonicalDirectory(ctx.activeDirectory), + activeWorktree: ctx.activeWorktree + ? { + ...ctx.activeWorktree, + directory: canonicalDirectory(ctx.activeWorktree.directory), + } + : undefined, + } +} + function parseExecutionContext(row: SessionRow, project: ProjectFallback | undefined) { if (row.execution_context !== null) { const parsed = SessionExecutionContext.safeParse(row.execution_context) - if (parsed.success) return parsed.data + if (parsed.success && isPersistedExecutionContextUsable(parsed.data)) return normalizeExecutionContext(parsed.data) const recovered = recoverExecutionContext(row) if (recovered) return recovered } @@ -113,7 +134,8 @@ function parseExecutionContext(row: SessionRow, project: ProjectFallback | undef } function needsProjectFallback(row: SessionRow) { - return row.execution_context === null || !SessionExecutionContext.safeParse(row.execution_context).success + const parsed = SessionExecutionContext.safeParse(row.execution_context) + return !parsed.success || !isPersistedExecutionContextUsable(parsed.data) } export function fromRow(row: SessionRow, project: ProjectFallback | undefined): Info { diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index dc76b673..441c7d39 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -350,7 +350,7 @@ describe("session.created event", () => { }) }) - test("recovers partial activeWorktree without dropping active directory", async () => { + test("recovers partial activeWorktree by preserving active directory only", async () => { await using tmp = await tmpdir({ git: true }) const subdir = path.join(tmp.path, "packages", "app") const worktree = path.join(tmp.path, ".worktrees", "pawwork", "partial-worktree") @@ -384,17 +384,67 @@ describe("session.created event", () => { const loaded = await SessionNs.get(session.id) expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) expect(loaded.executionContext.activeDirectory).toBe(expectedActive) - expect(loaded.executionContext.activeWorktree?.source).toBe("created") + expect(loaded.executionContext.activeWorktree).toBeUndefined() const listed = Array.from(SessionNs.list()).find((item) => item.id === session.id) expect(listed?.executionContext.ownerDirectory).toBe(expectedRoot) expect(listed?.executionContext.activeDirectory).toBe(expectedActive) - expect(listed?.executionContext.activeWorktree?.source).toBe("created") + expect(listed?.executionContext.activeWorktree).toBeUndefined() const globalListed = Array.from(SessionNs.listGlobal()).find((item) => item.id === session.id) expect(globalListed?.executionContext.ownerDirectory).toBe(expectedRoot) expect(globalListed?.executionContext.activeDirectory).toBe(expectedActive) - expect(globalListed?.executionContext.activeWorktree?.source).toBe("created") + expect(globalListed?.executionContext.activeWorktree).toBeUndefined() + + await SessionNs.remove(session.id) + }, + }) + }) + + test("synthesizes relative executionContext from the project root on read", async () => { + await using tmp = await tmpdir({ git: true }) + const subdir = path.join(tmp.path, "packages", "app") + await fs.mkdir(subdir, { recursive: true }) + + await Instance.provide({ + directory: subdir, + fn: async () => { + const session = await SessionNs.create({ title: "relative-read-root" }) + Database.use((db) => + db + .update(SessionTable) + .set({ + execution_context: { + ownerDirectory: ".", + activeDirectory: "relative-worktree", + activeWorktree: { + directory: "relative-worktree", + name: "relative-worktree", + branch: "pawwork/relative-worktree", + source: "created", + }, + lastChangedAt: 123, + } as any, + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const expectedRoot = canonicalDirectory(tmp.path) + const loaded = await SessionNs.get(session.id) + expect(loaded.executionContext.ownerDirectory).toBe(expectedRoot) + expect(loaded.executionContext.activeDirectory).toBe(expectedRoot) + expect(loaded.executionContext.activeWorktree).toBeUndefined() + + const listed = Array.from(SessionNs.list()).find((item) => item.id === session.id) + expect(listed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(listed?.executionContext.activeDirectory).toBe(expectedRoot) + expect(listed?.executionContext.activeWorktree).toBeUndefined() + + const globalListed = Array.from(SessionNs.listGlobal()).find((item) => item.id === session.id) + expect(globalListed?.executionContext.ownerDirectory).toBe(expectedRoot) + expect(globalListed?.executionContext.activeDirectory).toBe(expectedRoot) + expect(globalListed?.executionContext.activeWorktree).toBeUndefined() await SessionNs.remove(session.id) }, From 2126653645aecedf06beaaa0ee7357adc64e8b97 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 08:23:35 +0800 Subject: [PATCH 62/65] fix: canonicalize persisted execution context --- packages/opencode/src/session/session.ts | 16 ++++++++++---- .../opencode/test/session/session.test.ts | 21 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 99fb1bff..b7d34b77 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -823,15 +823,23 @@ export const layer: Layer.Layer = const current = yield* get(input.sessionID) const now = Date.now() const hasActiveWorktree = input.activeWorktree !== undefined - const ownerDirectory = current.executionContext.ownerDirectory - const activeDirectory = hasActiveWorktree + const ownerDirectory = canonicalDirectory(current.executionContext.ownerDirectory) + const activeDirectoryInput = hasActiveWorktree ? (input.activeWorktree?.directory ?? ownerDirectory) : (input.activeDirectory ?? current.executionContext.activeDirectory) + const activeDirectory = canonicalDirectory(activeDirectoryInput) const activeWorktree = hasActiveWorktree - ? (input.activeWorktree ?? undefined) - : canonicalDirectory(activeDirectory) === canonicalDirectory(ownerDirectory) + ? input.activeWorktree + ? { ...input.activeWorktree, directory: canonicalDirectory(input.activeWorktree.directory) } + : undefined + : activeDirectory === ownerDirectory ? undefined : current.executionContext.activeWorktree + ? { + ...current.executionContext.activeWorktree, + directory: canonicalDirectory(current.executionContext.activeWorktree.directory), + } + : undefined const next: SessionExecutionContext = { ownerDirectory, activeDirectory, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 441c7d39..250cf8e0 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -51,6 +51,7 @@ describe("session.created event", () => { test("executionContext for a new git session is rooted at project worktree, not entry directory", async () => { await using tmp = await tmpdir({ git: true }) const subdir = path.join(tmp.path, "packages", "app") + await fs.mkdir(subdir, { recursive: true }) await Bun.write(path.join(subdir, ".keep"), "") await Instance.provide({ @@ -179,8 +180,9 @@ describe("session.created event", () => { test("updateExecutionContext keeps active directory and worktree metadata synchronized", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "feature-c") + const worktreeInput = `${worktree}${path.sep}` const activeWorktree = { - directory: worktree, + directory: worktreeInput, name: "feature-c", branch: "pawwork/feature-c", source: "created" as const, @@ -195,17 +197,24 @@ describe("session.created event", () => { sessionID: session.id, activeWorktree, }) - expect(entered.executionContext.activeDirectory).toBe(worktree) - expect(entered.executionContext.activeWorktree).toEqual(activeWorktree) + expect(entered.executionContext.activeDirectory).toBe(canonicalDirectory(worktree)) + expect(entered.executionContext.activeWorktree).toEqual({ + ...activeWorktree, + directory: canonicalDirectory(worktree), + }) const nested = path.join(worktree, "nested") + const nestedInput = `${nested}${path.sep}` const movedByDirectory = await SessionNs.updateExecutionContext({ sessionID: session.id, - activeDirectory: nested, + activeDirectory: nestedInput, activeWorktree: undefined, }) - expect(movedByDirectory.executionContext.activeDirectory).toBe(nested) - expect(movedByDirectory.executionContext.activeWorktree).toEqual(activeWorktree) + expect(movedByDirectory.executionContext.activeDirectory).toBe(canonicalDirectory(nested)) + expect(movedByDirectory.executionContext.activeWorktree).toEqual({ + ...activeWorktree, + directory: canonicalDirectory(worktree), + }) const clearedByWorktree = await SessionNs.updateExecutionContext({ sessionID: session.id, From 131872e99affd439b3cd7c506541d24bbcc515fa Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 09:03:09 +0800 Subject: [PATCH 63/65] fix: address worktree review regressions --- .../migration.sql | 2 +- .../snapshot.json | 4 +- .../src/session/execution-context-store.ts | 17 +------ .../opencode/src/session/execution-context.ts | 20 ++++++++- packages/opencode/src/session/session.ts | 32 +++++++------ packages/opencode/src/session/subagent-run.ts | 2 +- packages/opencode/src/tool/agent.ts | 2 + packages/opencode/src/tool/enter-worktree.ts | 9 ++-- packages/opencode/src/tool/exit-worktree.ts | 2 +- .../opencode/src/worktree/gitignore-guard.ts | 19 ++++++-- packages/opencode/src/worktree/index.ts | 5 ++- .../opencode/test/project/worktree.test.ts | 45 +++++++++++++++++++ .../opencode/test/session/session.test.ts | 40 ++++++++++++++++- packages/opencode/test/tool/agent.test.ts | 2 + 14 files changed, 154 insertions(+), 47 deletions(-) diff --git a/packages/opencode/migration/20260501081615_session_execution_context/migration.sql b/packages/opencode/migration/20260501081615_session_execution_context/migration.sql index 3b62a6ca..4279afcc 100644 --- a/packages/opencode/migration/20260501081615_session_execution_context/migration.sql +++ b/packages/opencode/migration/20260501081615_session_execution_context/migration.sql @@ -1 +1 @@ -ALTER TABLE `session` ADD `execution_context` text DEFAULT 'null'; \ No newline at end of file +ALTER TABLE `session` ADD `execution_context` text DEFAULT NULL; diff --git a/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json b/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json index d736229e..01992a1f 100644 --- a/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json +++ b/packages/opencode/migration/20260501081615_session_execution_context/snapshot.json @@ -736,7 +736,7 @@ "type": "text", "notNull": false, "autoincrement": false, - "default": "'null'", + "default": null, "generated": null, "name": "execution_context", "entityType": "columns", @@ -1518,4 +1518,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/session/execution-context-store.ts b/packages/opencode/src/session/execution-context-store.ts index 1422f3fc..994bd7d6 100644 --- a/packages/opencode/src/session/execution-context-store.ts +++ b/packages/opencode/src/session/execution-context-store.ts @@ -1,25 +1,10 @@ -import fs from "node:fs" -import path from "path" import { Database, eq, isNull } from "../storage/db" import { ProjectTable } from "../project/project.sql" import { SessionTable } from "./session.sql" -import { rootContext } from "./execution-context" +import { canonicalDirectory, rootContext } from "./execution-context" type Tx = Pick -export function canonicalDirectory(input: string) { - const abs = path.resolve(input) - const real = (() => { - try { - return fs.realpathSync.native(abs) - } catch { - return abs - } - })() - const normalized = path.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized -} - export function backfillExecutionContextRows(d: Tx) { const rows = d .select({ id: SessionTable.id, directory: SessionTable.directory, project_id: SessionTable.project_id }) diff --git a/packages/opencode/src/session/execution-context.ts b/packages/opencode/src/session/execution-context.ts index c6b03d86..6ab39c61 100644 --- a/packages/opencode/src/session/execution-context.ts +++ b/packages/opencode/src/session/execution-context.ts @@ -1,3 +1,5 @@ +import fs from "node:fs" +import path from "path" import z from "zod" export const ActiveWorktree = z.object({ @@ -16,10 +18,24 @@ export const SessionExecutionContext = z.object({ }) export type SessionExecutionContext = z.infer +export function canonicalDirectory(input: string) { + const abs = path.resolve(input) + const real = (() => { + try { + return fs.realpathSync.native(abs) + } catch { + return abs + } + })() + const normalized = path.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized +} + export function rootContext(ownerDirectory: string): SessionExecutionContext { + const directory = canonicalDirectory(ownerDirectory) return { - ownerDirectory, - activeDirectory: ownerDirectory, + ownerDirectory: directory, + activeDirectory: directory, lastChangedAt: Date.now(), } } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index b7d34b77..c9efc8c3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,8 +32,8 @@ import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" import { SubagentRunWriterContext, SubagentRunGuardViolation, lifecycleFieldsChanged } from "./subagent-run-context" -import { ActiveWorktree, SessionExecutionContext, rootContext } from "./execution-context" -import { backfillExecutionContextRows, canonicalDirectory } from "./execution-context-store" +import { ActiveWorktree, SessionExecutionContext, canonicalDirectory, rootContext } from "./execution-context" +import { backfillExecutionContextRows } from "./execution-context-store" const log = Log.create({ service: "session" }) @@ -96,7 +96,7 @@ function recoverExecutionContext(row: SessionRow) { lastChangedAt: typeof record.lastChangedAt === "number" && Number.isFinite(record.lastChangedAt) ? record.lastChangedAt - : Date.now(), + : row.time_updated, }) return recovered.success ? recovered.data : undefined } @@ -110,16 +110,19 @@ function isPersistedExecutionContextUsable(ctx: SessionExecutionContext) { } function normalizeExecutionContext(ctx: SessionExecutionContext): SessionExecutionContext { + const ownerDirectory = canonicalDirectory(ctx.ownerDirectory) + const activeDirectory = canonicalDirectory(ctx.activeDirectory) return { ...ctx, - ownerDirectory: canonicalDirectory(ctx.ownerDirectory), - activeDirectory: canonicalDirectory(ctx.activeDirectory), - activeWorktree: ctx.activeWorktree - ? { - ...ctx.activeWorktree, - directory: canonicalDirectory(ctx.activeWorktree.directory), - } - : undefined, + ownerDirectory, + activeDirectory, + activeWorktree: + ctx.activeWorktree && activeDirectory !== ownerDirectory + ? { + ...ctx.activeWorktree, + directory: canonicalDirectory(ctx.activeWorktree.directory), + } + : undefined, } } @@ -529,7 +532,7 @@ export const layer: Layer.Layer = Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service - yield* backfillExecutionContext + yield* backfillExecutionContextEffect() const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -559,8 +562,9 @@ export const layer: Layer.Layer = permission: input.permission, // ownerDirectory is the project root for git projects and never moves. For non-git // projects Instance.worktree is "/" today, so keep the opened directory as the owner. - executionContext: - input.executionContext ?? rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), + executionContext: input.executionContext + ? normalizeExecutionContext(input.executionContext) + : rootContext(ctx.project.vcs === "git" ? ctx.worktree : input.directory), time: { created: Date.now(), updated: Date.now(), diff --git a/packages/opencode/src/session/subagent-run.ts b/packages/opencode/src/session/subagent-run.ts index 491a5dfd..7cb57d31 100644 --- a/packages/opencode/src/session/subagent-run.ts +++ b/packages/opencode/src/session/subagent-run.ts @@ -145,7 +145,7 @@ export const layer: Layer.Layer = Layer.effect( ) const activeForSession = (parentID: SessionID): Effect.Effect => - Effect.succeed((activeCounts.get(parentID) ?? 0) > 0) + getSlotLock(parentID).withPermits(1)(Effect.succeed((activeCounts.get(parentID) ?? 0) > 0)) const readPart = (toolCallID: string) => Effect.gen(function* () { diff --git a/packages/opencode/src/tool/agent.ts b/packages/opencode/src/tool/agent.ts index 90efe49b..648b5343 100644 --- a/packages/opencode/src/tool/agent.ts +++ b/packages/opencode/src/tool/agent.ts @@ -425,6 +425,8 @@ export const AgentTool = Tool.define( agent: next.name, tools: { agent: false, + "enter-worktree": false, + "exit-worktree": false, ...(canTodo ? {} : { todowrite: false }), ...Object.fromEntries( (cfg.experimental?.primary_tools ?? []).map((item) => [item, false]), diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index afca9967..f8bb9b95 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -11,6 +11,7 @@ import type { SessionID } from "../session/schema" import { SubagentRun } from "../session/subagent-run" import { currentBranch, gitCommonDir } from "./enter-worktree-git" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { canonicalDirectory } from "../session/execution-context" export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ const MAX_SLUG_LEN = 40 @@ -109,7 +110,7 @@ export const EnterWorktreeTool = Tool.define( const canonical = yield* Effect.promise(() => fs.realpath(params.path!).catch(() => path.resolve(params.path!)), ) - if (exec.activeDirectory === canonical) { + if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(canonical)) { const slug = exec.activeWorktree?.name ?? path.basename(canonical) return successResult({ activeDirectory: canonical, @@ -119,7 +120,7 @@ export const EnterWorktreeTool = Tool.define( state: "reused", }) } - if (exec.activeDirectory !== exec.ownerDirectory) { + if (canonicalDirectory(exec.activeDirectory) !== canonicalDirectory(exec.ownerDirectory)) { return yield* Effect.fail( new Error("This session is already inside another worktree. Call ExitWorktree first."), ) @@ -146,7 +147,7 @@ export const EnterWorktreeTool = Tool.define( // name= or no-arg branch const existing = params.name ? yield* Effect.promise(() => Worktree.lookupBySlug(params.name!)) : undefined const planned = existing ?? (yield* Effect.promise(() => Worktree.makeWorktreeInfo(params.name))) - if (exec.activeDirectory === planned.directory) { + if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(planned.directory)) { return successResult({ activeDirectory: planned.directory, ownerDirectory: exec.ownerDirectory, @@ -155,7 +156,7 @@ export const EnterWorktreeTool = Tool.define( state: "reused", }) } - if (exec.activeDirectory !== exec.ownerDirectory) { + if (canonicalDirectory(exec.activeDirectory) !== canonicalDirectory(exec.ownerDirectory)) { return yield* Effect.fail( new Error("This session is already inside another worktree. Call ExitWorktree first."), ) diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index 8ddcec92..f621cc6c 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -4,7 +4,7 @@ import DESCRIPTION from "./exit-worktree.txt" import * as Session from "../session/session" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" import { SubagentRun } from "../session/subagent-run" -import { canonicalDirectory } from "../session/execution-context-store" +import { canonicalDirectory } from "../session/execution-context" export const Parameters = Schema.Struct({}) diff --git a/packages/opencode/src/worktree/gitignore-guard.ts b/packages/opencode/src/worktree/gitignore-guard.ts index 1acc4ad0..81fc565d 100644 --- a/packages/opencode/src/worktree/gitignore-guard.ts +++ b/packages/opencode/src/worktree/gitignore-guard.ts @@ -27,10 +27,14 @@ function hasWorktreesIgnore(text: string) { .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")) - .some((line) => line === ".worktrees" || line === ".worktrees/" || line === "/.worktrees" || line === "/.worktrees/") + .some( + (line) => line === ".worktrees" || line === ".worktrees/" || line === "/.worktrees" || line === "/.worktrees/", + ) } -export async function ensureWorktreesIgnored(root: string): Promise<{ changed: boolean; file: string }> { +export async function ensureWorktreesIgnored( + root: string, +): Promise<{ changed: boolean; file: string; before?: string }> { const file = path.join(root, ".gitignore") const before = await fs.readFile(file, "utf8").catch((error: unknown) => { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined @@ -64,5 +68,14 @@ export async function ensureWorktreesIgnored(root: string): Promise<{ changed: b const prefix = before && before.length > 0 && !before.endsWith("\n") ? "\n" : "" const next = `${before ?? ""}${prefix}${ENTRY}\n` await fs.writeFile(file, next) - return { changed: true, file } + return { changed: true, file, before } +} + +export async function restoreWorktreesIgnored(change: { changed: boolean; file: string; before?: string }) { + if (!change.changed) return + if (change.before === undefined) { + await fs.rm(change.file, { force: true }) + return + } + await fs.writeFile(change.file, change.before) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index a0ac29e4..ba34321d 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -13,7 +13,7 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Scope, Context, Stream, Semaphore } from "effect" -import { ensureWorktreesIgnored } from "./gitignore-guard" +import { ensureWorktreesIgnored, restoreWorktreesIgnored } from "./gitignore-guard" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -380,11 +380,12 @@ export namespace Worktree { const setup = Effect.fnUntraced(function* (info: Info) { const ctx = yield* InstanceState.context - yield* Effect.promise(() => ensureWorktreesIgnored(ctx.worktree)) + const ignoreChange = yield* Effect.promise(() => ensureWorktreesIgnored(ctx.worktree)) const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: ctx.worktree, }) if (created.code !== 0) { + yield* Effect.promise(() => restoreWorktreesIgnored(ignoreChange)) throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" }) } diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 07c82612..561496d4 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,8 @@ const wintest = process.platform !== "win32" ? test : test.skip import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" +import { ProjectTable } from "../../src/project/project.sql" +import { Database, eq } from "../../src/storage/db" import { Worktree } from "../../src/worktree" import { tmpdir } from "../fixture/fixture" @@ -156,6 +158,22 @@ describe("Worktree", () => { const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text() expect(list).not.toContain("pawwork/blocked") }) + + test("restores .gitignore when git worktree add fails", async () => { + await using tmp = await tmpdir({ git: true }) + const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("bad-branch")) + + await expect( + withInstance(tmp.path, () => + Worktree.createFromInfo({ + ...info, + branch: "bad branch name", + }), + ), + ).rejects.toThrow("WorktreeCreateFailedError") + + await expect(Bun.file(path.join(tmp.path, ".gitignore")).text()).rejects.toThrow() + }) }) describe("createFromInfo", () => { @@ -204,6 +222,33 @@ describe("Worktree", () => { await withInstance(tmp.path, () => Worktree.remove({ directory: external })) }) + test("legacy string registry entries remain slug-addressable", async () => { + await using tmp = await tmpdir({ git: true }) + const legacy = path.join(tmp.path, "..", path.basename(tmp.path) + "-legacy") + await $`git worktree add ${legacy} -b legacy-${Date.now()}`.cwd(tmp.path).quiet() + + await withInstance(tmp.path, async () => { + Database.use((db) => + db + .update(ProjectTable) + .set({ sandboxes: [legacy] }) + .where(eq(ProjectTable.id, Instance.project.id)) + .run(), + ) + + const bySlug = await Worktree.lookupBySlug(path.basename(legacy)) + expect(bySlug?.directory).toBe(legacy) + expect(bySlug?.source).toBe("created") + + const listed = await Worktree.list() + expect(listed.some((entry) => entry.directory === legacy)).toBe(true) + + await Worktree.remove({ directory: legacy }) + const afterRemove = await Worktree.lookupBySlug(path.basename(legacy)) + expect(afterRemove).toBeUndefined() + }) + }) + test("rejects existing paths that are not attached git worktrees", async () => { await using tmp = await tmpdir({ git: true }) const unrelated = path.join(tmp.path, "not-a-worktree") diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 250cf8e0..7806da95 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "../fixture/fixture" import { Database, eq } from "../../src/storage/db" import { MessageTable, SessionTable } from "../../src/session/session.sql" import { ProjectTable } from "../../src/project/project.sql" -import { canonicalDirectory } from "../../src/session/execution-context-store" +import { canonicalDirectory } from "../../src/session/execution-context" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -460,6 +460,44 @@ describe("session.created event", () => { }) }) + test("drops stale activeWorktree metadata when active directory is the project root", async () => { + await using tmp = await tmpdir({ git: true }) + const worktree = path.join(tmp.path, ".worktrees", "pawwork", "stale-worktree") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await SessionNs.create({ title: "stale-active-worktree" }) + Database.use((db) => + db + .update(SessionTable) + .set({ + execution_context: { + ownerDirectory: tmp.path, + activeDirectory: `${tmp.path}${path.sep}`, + activeWorktree: { + directory: worktree, + name: "stale-worktree", + branch: "pawwork/stale-worktree", + source: "created", + }, + lastChangedAt: 123, + }, + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const loaded = await SessionNs.get(session.id) + expect(loaded.executionContext.ownerDirectory).toBe(canonicalDirectory(tmp.path)) + expect(loaded.executionContext.activeDirectory).toBe(canonicalDirectory(tmp.path)) + expect(loaded.executionContext.activeWorktree).toBeUndefined() + + await SessionNs.remove(session.id) + }, + }) + }) + test("fork preserves the source session executionContext", async () => { await using tmp = await tmpdir({ git: true }) const worktree = path.join(tmp.path, ".worktrees", "pawwork", "forked-work") diff --git a/packages/opencode/test/tool/agent.test.ts b/packages/opencode/test/tool/agent.test.ts index 7cc7ab62..c0b21816 100644 --- a/packages/opencode/test/tool/agent.test.ts +++ b/packages/opencode/test/tool/agent.test.ts @@ -550,6 +550,8 @@ describe("tool.agent", () => { ]) expect(seen?.tools).toEqual({ agent: false, + "enter-worktree": false, + "exit-worktree": false, todowrite: false, bash: false, read: false, From 8595bc9246a2ea412f4c8248b8fe54a4a15dc8ee Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 09:25:38 +0800 Subject: [PATCH 64/65] fix: address worktree path review --- .../opencode/src/session/execution-context.ts | 11 +++++++++-- packages/opencode/src/session/session.ts | 14 +++++++------- packages/opencode/src/tool/enter-worktree.ts | 10 +++++----- packages/opencode/src/tool/exit-worktree.ts | 7 ++----- packages/opencode/src/worktree/index.ts | 19 ++++++++++--------- .../opencode/test/project/worktree.test.ts | 3 +++ .../opencode/test/session/session.test.ts | 4 ++-- 7 files changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/session/execution-context.ts b/packages/opencode/src/session/execution-context.ts index 6ab39c61..8884e7b1 100644 --- a/packages/opencode/src/session/execution-context.ts +++ b/packages/opencode/src/session/execution-context.ts @@ -27,8 +27,15 @@ export function canonicalDirectory(input: string) { return abs } })() - const normalized = path.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized + return path.normalize(real) +} + +export function sameDirectory(left: string, right: string) { + const leftDirectory = canonicalDirectory(left) + const rightDirectory = canonicalDirectory(right) + return process.platform === "win32" + ? leftDirectory.localeCompare(rightDirectory, undefined, { sensitivity: "accent" }) === 0 + : leftDirectory === rightDirectory } export function rootContext(ownerDirectory: string): SessionExecutionContext { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c9efc8c3..728813de 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,7 +32,7 @@ import { Permission } from "@/permission" import { Global } from "@/global" import { Effect, Layer, Option, Context } from "effect" import { SubagentRunWriterContext, SubagentRunGuardViolation, lifecycleFieldsChanged } from "./subagent-run-context" -import { ActiveWorktree, SessionExecutionContext, canonicalDirectory, rootContext } from "./execution-context" +import { ActiveWorktree, SessionExecutionContext, canonicalDirectory, rootContext, sameDirectory } from "./execution-context" import { backfillExecutionContextRows } from "./execution-context-store" const log = Log.create({ service: "session" }) @@ -98,7 +98,7 @@ function recoverExecutionContext(row: SessionRow) { ? record.lastChangedAt : row.time_updated, }) - return recovered.success ? recovered.data : undefined + return recovered.success ? normalizeExecutionContext(recovered.data) : undefined } function isPersistedExecutionContextUsable(ctx: SessionExecutionContext) { @@ -117,7 +117,7 @@ function normalizeExecutionContext(ctx: SessionExecutionContext): SessionExecuti ownerDirectory, activeDirectory, activeWorktree: - ctx.activeWorktree && activeDirectory !== ownerDirectory + ctx.activeWorktree && !sameDirectory(activeDirectory, ownerDirectory) ? { ...ctx.activeWorktree, directory: canonicalDirectory(ctx.activeWorktree.directory), @@ -836,7 +836,7 @@ export const layer: Layer.Layer = ? input.activeWorktree ? { ...input.activeWorktree, directory: canonicalDirectory(input.activeWorktree.directory) } : undefined - : activeDirectory === ownerDirectory + : sameDirectory(activeDirectory, ownerDirectory) ? undefined : current.executionContext.activeWorktree ? { @@ -885,10 +885,10 @@ export const layer: Layer.Layer = for (const row of rows) { const session = fromRow(row, projects.get(row.project_id)) const exec = session.executionContext - if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(exec.ownerDirectory)) continue + if (sameDirectory(exec.activeDirectory, exec.ownerDirectory)) continue if ( - canonicalDirectory(exec.activeDirectory) === target || - (exec.activeWorktree?.directory && canonicalDirectory(exec.activeWorktree.directory) === target) + sameDirectory(exec.activeDirectory, target) || + (exec.activeWorktree?.directory && sameDirectory(exec.activeWorktree.directory, target)) ) { return session } diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index f8bb9b95..a6142a23 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -11,7 +11,7 @@ import type { SessionID } from "../session/schema" import { SubagentRun } from "../session/subagent-run" import { currentBranch, gitCommonDir } from "./enter-worktree-git" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { canonicalDirectory } from "../session/execution-context" +import { canonicalDirectory, sameDirectory } from "../session/execution-context" export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ const MAX_SLUG_LEN = 40 @@ -110,7 +110,7 @@ export const EnterWorktreeTool = Tool.define( const canonical = yield* Effect.promise(() => fs.realpath(params.path!).catch(() => path.resolve(params.path!)), ) - if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(canonical)) { + if (sameDirectory(exec.activeDirectory, canonical)) { const slug = exec.activeWorktree?.name ?? path.basename(canonical) return successResult({ activeDirectory: canonical, @@ -120,7 +120,7 @@ export const EnterWorktreeTool = Tool.define( state: "reused", }) } - if (canonicalDirectory(exec.activeDirectory) !== canonicalDirectory(exec.ownerDirectory)) { + if (!sameDirectory(exec.activeDirectory, exec.ownerDirectory)) { return yield* Effect.fail( new Error("This session is already inside another worktree. Call ExitWorktree first."), ) @@ -147,7 +147,7 @@ export const EnterWorktreeTool = Tool.define( // name= or no-arg branch const existing = params.name ? yield* Effect.promise(() => Worktree.lookupBySlug(params.name!)) : undefined const planned = existing ?? (yield* Effect.promise(() => Worktree.makeWorktreeInfo(params.name))) - if (canonicalDirectory(exec.activeDirectory) === canonicalDirectory(planned.directory)) { + if (sameDirectory(exec.activeDirectory, planned.directory)) { return successResult({ activeDirectory: planned.directory, ownerDirectory: exec.ownerDirectory, @@ -156,7 +156,7 @@ export const EnterWorktreeTool = Tool.define( state: "reused", }) } - if (canonicalDirectory(exec.activeDirectory) !== canonicalDirectory(exec.ownerDirectory)) { + if (!sameDirectory(exec.activeDirectory, exec.ownerDirectory)) { return yield* Effect.fail( new Error("This session is already inside another worktree. Call ExitWorktree first."), ) diff --git a/packages/opencode/src/tool/exit-worktree.ts b/packages/opencode/src/tool/exit-worktree.ts index f621cc6c..06443a33 100644 --- a/packages/opencode/src/tool/exit-worktree.ts +++ b/packages/opencode/src/tool/exit-worktree.ts @@ -4,7 +4,7 @@ import DESCRIPTION from "./exit-worktree.txt" import * as Session from "../session/session" import { hasInFlightToolCallsExcept, hasRunningSubagents } from "../session/state-machine-guard" import { SubagentRun } from "../session/subagent-run" -import { canonicalDirectory } from "../session/execution-context" +import { sameDirectory } from "../session/execution-context" export const Parameters = Schema.Struct({}) @@ -40,10 +40,7 @@ export const ExitWorktreeTool = Tool.define( previousDirectory?: string previousSource?: "created" | "existing" } - if ( - canonicalDirectory(exec.activeDirectory) === canonicalDirectory(exec.ownerDirectory) && - exec.activeWorktree === undefined - ) { + if (sameDirectory(exec.activeDirectory, exec.ownerDirectory) && exec.activeWorktree === undefined) { const metadata: ExitMetadata = { activeDirectory: exec.ownerDirectory } return { title: "Already at project root", diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index ba34321d..ffb4816e 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -21,6 +21,7 @@ import { makeRuntime } from "@/effect/run-service" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" import { Session } from "../session" +import { sameDirectory } from "../session/execution-context" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -272,7 +273,7 @@ export namespace Worktree { const entries = yield* readRegistry() const next: Info[] = [] for (const entry of entries) { - if ((yield* canonical(entry.directory)) !== target) next.push(entry) + if (!sameDirectory(yield* canonical(entry.directory), target)) next.push(entry) } next.push(info) yield* writeRegistry(next) @@ -287,7 +288,7 @@ export namespace Worktree { const entries = yield* readRegistry() const next: Info[] = [] for (const entry of entries) { - if ((yield* canonical(entry.directory)) !== target) next.push(entry) + if (!sameDirectory(yield* canonical(entry.directory), target)) next.push(entry) } yield* writeRegistry(next) }), @@ -298,15 +299,16 @@ export namespace Worktree { const target = yield* canonical(directory) const entries = yield* readRegistry() for (const entry of entries) { - if ((yield* canonical(entry.directory)) === target) return entry + if (sameDirectory(yield* canonical(entry.directory), target)) return entry } return undefined }) const lookupBySlug = Effect.fn("Worktree.lookupBySlug")(function* (slug: string) { + const normalizedSlug = slugify(slug) const entries = yield* readRegistry() // `name` addresses managed PawWork worktrees only. Worktrees joined by absolute path remain path-addressable. - return entries.find((entry) => entry.name === slug && entry.source === "created") + return entries.find((entry) => entry.name === normalizedSlug && entry.source === "created") }) const registerExistingByPath = Effect.fn("Worktree.registerExistingByPath")(function* (directory: string) { @@ -463,8 +465,7 @@ export namespace Worktree { const canonical = Effect.fnUntraced(function* (input: string) { const abs = pathSvc.resolve(input) const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs))) - const normalized = pathSvc.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized + return pathSvc.normalize(real) }) function parseWorktreeList(text: string) { @@ -493,7 +494,7 @@ export namespace Worktree { for (const item of entries) { if (!item.path) continue const key = yield* canonical(item.path) - if (key === directory) return item + if (sameDirectory(key, directory)) return item } return undefined }) @@ -645,7 +646,7 @@ export namespace Worktree { (entry) => Effect.gen(function* () { const target = yield* canonical(pathSvc.resolve(root, entry)) - if (target === base) return + if (sameDirectory(target, base)) return if (!target.startsWith(`${base}${pathSvc.sep}`)) return yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore) }), @@ -671,7 +672,7 @@ export namespace Worktree { const directory = yield* canonical(input.directory) const primary = yield* canonical(Instance.worktree) - if (directory === primary) { + if (sameDirectory(directory, primary)) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 561496d4..275c97e5 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -205,6 +205,9 @@ describe("Worktree", () => { expect(bySlug?.directory).toBe(created.directory) expect(bySlug?.source).toBe("created") + const byRawName = await withInstance(tmp.path, () => Worktree.lookupBySlug("Feature A")) + expect(byRawName?.directory).toBe(created.directory) + const external = path.join(tmp.path, "..", path.basename(tmp.path) + "-external") await $`git worktree add ${external} -b external-${Date.now()}`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 7806da95..681b1cb0 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -481,8 +481,8 @@ describe("session.created event", () => { branch: "pawwork/stale-worktree", source: "created", }, - lastChangedAt: 123, - }, + lastChangedAt: "invalid", + } as any, }) .where(eq(SessionTable.id, session.id)) .run(), From 574b6a590c9a4abb993c64a0d4533b9b7d4a83d9 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 2 May 2026 09:39:13 +0800 Subject: [PATCH 65/65] fix: preserve worktree path entry metadata --- packages/opencode/src/tool/enter-worktree.ts | 6 +++--- packages/opencode/test/tool/enter-worktree.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/enter-worktree.ts b/packages/opencode/src/tool/enter-worktree.ts index a6142a23..d48b03c3 100644 --- a/packages/opencode/src/tool/enter-worktree.ts +++ b/packages/opencode/src/tool/enter-worktree.ts @@ -127,14 +127,14 @@ export const EnterWorktreeTool = Tool.define( } const ownerCommon = yield* gitCommonDir(spawner, exec.ownerDirectory) const targetCommon = yield* gitCommonDir(spawner, canonical) - if (!ownerCommon || !targetCommon || ownerCommon !== targetCommon) { + if (!ownerCommon || !targetCommon || !sameDirectory(ownerCommon, targetCommon)) { return yield* Effect.fail( new Error(`Path ${canonical} is not part of the same git repository as the project.`), ) } const branch = yield* currentBranch(spawner, canonical) const info = yield* Effect.promise(() => Worktree.registerExistingByPath(canonical)) - yield* applyEnter(ctx.sessionID, { ...info, branch: info.branch || branch }, "existing") + yield* applyEnter(ctx.sessionID, { ...info, branch: info.branch || branch }, info.source) return successResult({ activeDirectory: canonical, ownerDirectory: exec.ownerDirectory, @@ -172,7 +172,7 @@ export const EnterWorktreeTool = Tool.define( } else { const ownerCommon = yield* gitCommonDir(spawner, exec.ownerDirectory) const targetCommon = yield* gitCommonDir(spawner, planned.directory) - if (!ownerCommon || !targetCommon || ownerCommon !== targetCommon) { + if (!ownerCommon || !targetCommon || !sameDirectory(ownerCommon, targetCommon)) { return yield* Effect.fail( new Error(`Managed worktree directory ${planned.directory} exists but is not a git worktree.`), ) diff --git a/packages/opencode/test/tool/enter-worktree.test.ts b/packages/opencode/test/tool/enter-worktree.test.ts index ed80fc7c..528fa65b 100644 --- a/packages/opencode/test/tool/enter-worktree.test.ts +++ b/packages/opencode/test/tool/enter-worktree.test.ts @@ -95,6 +95,10 @@ it.live("enter-worktree and exit-worktree update the session execution context", }) const alreadyRoot = yield* exit.execute({}, toolContext(session.id)) expect(alreadyRoot.title).toBe("Already at project root") + + yield* enter.execute({ path: activeDirectory }, toolContext(session.id)) + const pathExit = yield* exit.execute({}, toolContext(session.id)) + expect(pathExit.metadata.previousSource).toBe("created") }), { git: true }, ),