Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- Added worktree-backed conversation creation for development sessions: the workspace menu can now create a new conversation in a Hermes Agent git worktree, persist the worktree path/branch/repo metadata on the session, and show a sidebar worktree indicator without adding automatic cleanup yet. The underlying Agent helper may add `.worktrees/` to the repository `.gitignore` the first time a worktree is created for that repo (#1955).

## [v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse)

### Added
Expand Down
33 changes: 30 additions & 3 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@ def __init__(self, session_id: str=None, title: str='Untitled',
gateway_routing=None, gateway_routing_history=None,
llm_title_generated: bool=False,
parent_session_id: str=None,
worktree_path=None,
worktree_branch=None,
worktree_repo_root=None,
worktree_created_at=None,
enabled_toolsets=None,
composer_draft=None,
**kwargs):
Expand Down Expand Up @@ -370,6 +374,10 @@ def __init__(self, session_id: str=None, title: str='Untitled',
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
self.llm_title_generated = bool(llm_title_generated)
self.parent_session_id = parent_session_id
self.worktree_path = str(Path(worktree_path).expanduser().resolve()) if worktree_path else None
self.worktree_branch = str(worktree_branch) if worktree_branch else None
self.worktree_repo_root = str(Path(worktree_repo_root).expanduser().resolve()) if worktree_repo_root else None
self.worktree_created_at = worktree_created_at
self.is_cli_session = bool(kwargs.get('is_cli_session', False))
self.source_tag = kwargs.get('source_tag')
self.raw_source = kwargs.get('raw_source')
Expand Down Expand Up @@ -417,6 +425,7 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
'enabled_toolsets', 'composer_draft',
]
Expand Down Expand Up @@ -584,6 +593,12 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
**({
'worktree_path': self.worktree_path,
'worktree_branch': self.worktree_branch,
'worktree_repo_root': self.worktree_repo_root,
'worktree_created_at': self.worktree_created_at,
} if self.worktree_path else {}),
'user_message_count': sum(
1 for message in self.messages if _message_role(message) == 'user'
) if isinstance(self.messages, list) else 0,
Expand Down Expand Up @@ -896,7 +911,7 @@ def get_session(sid, metadata_only=False):
return s
raise KeyError(sid)

def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None):
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None):
"""Create a new in-memory session.

The session lives in the SESSIONS dict only — no disk write happens until
Expand All @@ -911,7 +926,9 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p

Crash-safety: if the process exits between session creation and first
message, the session is lost. Since it had no messages, there is
nothing to lose.
nothing to lose. Worktree-backed sessions are the exception: they are
saved immediately because creating the session also creates real
filesystem state that must remain discoverable after restart.

*profile* — when supplied by the caller (e.g. from the request body sent
by the active browser tab), it is used directly so that concurrent clients
Expand All @@ -927,18 +944,26 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
wt = worktree_info if isinstance(worktree_info, dict) else None
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
s = Session(
workspace=workspace or get_last_workspace(),
workspace=workspace_path or get_last_workspace(),
model=effective_model,
model_provider=model_provider,
profile=profile,
project_id=project_id,
worktree_path=wt.get('path') if wt else None,
worktree_branch=wt.get('branch') if wt else None,
worktree_repo_root=wt.get('repo_root') if wt else None,
worktree_created_at=wt.get('created_at') if wt else None,
)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)
while len(SESSIONS) > SESSIONS_MAX:
SESSIONS.popitem(last=False)
if wt:
s.save()
return s

def _hide_from_default_sidebar(session: dict) -> bool:
Expand Down Expand Up @@ -1042,6 +1067,7 @@ def all_sessions(diag=None):
and s.get('message_count', 0) == 0
and not s.get('active_stream_id')
and not s.get('has_pending_user_message')
and not s.get('worktree_path')
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
# Backfill: sessions created before Sprint 22 have no profile tag.
Expand Down Expand Up @@ -1077,6 +1103,7 @@ def all_sessions(diag=None):
and len(s.messages) == 0
and not s.active_stream_id
and not s.pending_user_message
and not getattr(s, 'worktree_path', None)
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
for s in result:
Expand Down
21 changes: 20 additions & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3847,8 +3847,26 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/session/new":
try:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
except ValueError as e:
except (TypeError, ValueError) as e:
return bad(handler, str(e))
worktree_info = None
worktree_requested = (
body.get("worktree") is True
or str(body.get("worktree")).strip().lower() in {"1", "true", "yes", "on"}
)
if worktree_requested:
try:
from api.worktrees import create_worktree_for_workspace
base_workspace = workspace
if not base_workspace:
base_workspace = str(resolve_trusted_workspace(get_last_workspace()))
worktree_info = create_worktree_for_workspace(base_workspace)
workspace = worktree_info["path"]
except (TypeError, ValueError) as e:
return bad(handler, str(e), status=400)
except Exception as e:
logger.exception("failed to create worktree-backed session")
return bad(handler, f"Failed to create worktree: {e}", status=500)
model, model_provider = _session_model_state_from_request(
body.get("model"),
body.get("model_provider"),
Expand All @@ -3861,6 +3879,7 @@ def handle_post(handler, parsed) -> bool:
model_provider=model_provider,
profile=body.get("profile") or None,
project_id=body.get("project_id") or None,
worktree_info=worktree_info,
)
return j(handler, {"session": s.compact() | {"messages": s.messages}})

Expand Down
73 changes: 73 additions & 0 deletions api/worktrees.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Helpers for WebUI-managed Hermes Agent git worktrees."""

from __future__ import annotations

import subprocess
import time
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path

import logging

logger = logging.getLogger(__name__)


def find_git_repo_root(workspace: str | Path) -> Path:
"""Return the enclosing git repo root for *workspace*.

Use git itself instead of checking ``workspace/.git`` so nested workspaces
and linked git worktrees are both handled correctly.
"""
ws = Path(workspace).expanduser().resolve()
if not ws.is_dir():
raise ValueError("Workspace path does not exist or is not a directory")
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=ws,
text=True,
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError("Workspace is not inside a git repository") from exc
if result.returncode != 0:
raise ValueError("Workspace is not inside a git repository")
root = result.stdout.strip()
if not root:
raise ValueError("Workspace is not inside a git repository")
return Path(root).expanduser().resolve()


def _setup_agent_worktree(repo_root: str) -> dict:
try:
import api.config # noqa: F401 # ensure Hermes Agent dir is on sys.path
from cli import _setup_worktree
except Exception as exc:
raise RuntimeError("Hermes Agent worktree helper is unavailable") from exc
output = StringIO()
with redirect_stdout(output), redirect_stderr(output):
info = _setup_worktree(repo_root)
emitted = output.getvalue().strip()
if emitted:
logger.debug("Hermes Agent worktree helper output: %s", emitted)
if not info:
raise RuntimeError("Hermes Agent failed to create a git worktree")
return info


def create_worktree_for_workspace(workspace: str | Path) -> dict:
repo_root = find_git_repo_root(workspace)
info = _setup_agent_worktree(str(repo_root))
path = info.get("path")
branch = info.get("branch")
if not path or not branch:
raise RuntimeError("Hermes Agent returned incomplete worktree metadata")
return {
"path": str(Path(path).expanduser().resolve()),
"branch": str(branch),
"repo_root": str(Path(info.get("repo_root") or repo_root).expanduser().resolve()),
"created_at": time.time(),
}
Binary file added docs/pr-media/1955/after-workspace-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-media/1955/before-workspace-menu.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ const LOCALES = {
model_group_configured: 'Configured',
ws_search_placeholder: 'Search workspaces…',
ws_no_results: 'No workspaces found',
workspace_new_worktree_conversation: 'New conversation in worktree',
workspace_new_worktree_conversation_meta: 'Create an isolated git worktree for this workspace.',
workspace_worktree_created: 'Worktree conversation created',
workspace_worktree_failed: 'Worktree creation failed: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: 'Applies to this conversation from your next message.',
model_scope_toast: 'Applies to this conversation from your next message.',
// commands.js
Expand Down Expand Up @@ -1236,6 +1241,11 @@ const LOCALES = {
model_group_configured: '設定済み',
ws_search_placeholder: 'ワークスペースを検索…',
ws_no_results: 'ワークスペースが見つかりません',
workspace_new_worktree_conversation: 'worktree で新しい会話',
workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。',
workspace_worktree_created: 'worktree 会話を作成しました',
workspace_worktree_failed: 'worktree の作成に失敗しました: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: '次回のメッセージからこの会話に適用されます。',
model_scope_toast: '次回のメッセージからこの会話に適用されます。',
// commands.js
Expand Down Expand Up @@ -2383,6 +2393,11 @@ const LOCALES = {
model_group_configured: 'Настроенные',
ws_search_placeholder: 'Поиск рабочих пространств…',
ws_no_results: 'Рабочие пространства не найдены',
workspace_new_worktree_conversation: 'Новый разговор в worktree',
workspace_new_worktree_conversation_meta: 'Создать изолированный git worktree для этого рабочего пространства.',
workspace_worktree_created: 'Разговор в worktree создан',
workspace_worktree_failed: 'Не удалось создать worktree: ',
session_worktree_badge: 'Worktree',
model_search_placeholder: 'Поиск моделей…',
model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.',
session_toolsets: 'Session Toolsets', // TODO: translate
Expand Down Expand Up @@ -3320,6 +3335,11 @@ const LOCALES = {
model_group_configured: 'Configurados',
ws_search_placeholder: 'Buscar espacios de trabajo…',
ws_no_results: 'No se encontraron espacios de trabajo',
workspace_new_worktree_conversation: 'Nueva conversación en worktree',
workspace_new_worktree_conversation_meta: 'Crear un git worktree aislado para este espacio de trabajo.',
workspace_worktree_created: 'Conversación en worktree creada',
workspace_worktree_failed: 'Error al crear worktree: ',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session Toolsets', // TODO: translate
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
session_toolsets_global: 'Global (default)', // TODO: translate
Expand Down Expand Up @@ -4882,6 +4902,11 @@ const LOCALES = {
model_group_configured: 'Konfiguriert',
ws_search_placeholder: 'Arbeitsbereiche suchen…',
ws_no_results: 'Keine Arbeitsbereiche gefunden',
workspace_new_worktree_conversation: 'Neue Unterhaltung in Worktree',
workspace_new_worktree_conversation_meta: 'Erstellt einen isolierten git worktree für diesen Arbeitsbereich.',
workspace_worktree_created: 'Worktree-Unterhaltung erstellt',
workspace_worktree_failed: 'Worktree-Erstellung fehlgeschlagen: ',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session Toolsets', // TODO: translate
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
session_toolsets_global: 'Global (default)', // TODO: translate
Expand Down Expand Up @@ -5358,6 +5383,11 @@ const LOCALES = {
model_group_configured: '已配置',
ws_search_placeholder: '搜索工作区…',
ws_no_results: '未找到工作区',
workspace_new_worktree_conversation: '在 worktree 中新建对话',
workspace_new_worktree_conversation_meta: '为此工作区创建隔离的 git worktree。',
workspace_worktree_created: '已创建 worktree 对话',
workspace_worktree_failed: 'Worktree 创建失败:',
session_worktree_badge: 'Worktree',
session_toolsets: 'Session 工具集',
session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)',
session_toolsets_global: '全局(默认)',
Expand Down Expand Up @@ -7451,6 +7481,11 @@ const LOCALES = {
model_group_configured: 'Configurados',
ws_search_placeholder: 'Buscar espaços de trabalho…',
ws_no_results: 'Nenhum espaço de trabalho encontrado',
workspace_new_worktree_conversation: 'Nova conversa em worktree',
workspace_new_worktree_conversation_meta: 'Cria um git worktree isolado para este espaço de trabalho.',
workspace_worktree_created: 'Conversa em worktree criada',
workspace_worktree_failed: 'Falha ao criar worktree: ',
session_worktree_badge: 'Worktree',
// commands.js
cmd_clear: 'Limpar mensagens da conversa',
cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])',
Expand Down Expand Up @@ -8407,6 +8442,11 @@ const LOCALES = {
model_group_configured: '구성됨',
ws_search_placeholder: '워크스페이스 검색…',
ws_no_results: '워크스페이스를 찾을 수 없습니다',
workspace_new_worktree_conversation: 'worktree에서 새 대화',
workspace_new_worktree_conversation_meta: '이 워크스페이스용 격리된 git worktree를 만듭니다.',
workspace_worktree_created: 'worktree 대화가 생성되었습니다',
workspace_worktree_failed: 'worktree 생성 실패: ',
session_worktree_badge: 'Worktree',
model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.',
model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.',
// commands.js
Expand Down
18 changes: 18 additions & 0 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -3695,6 +3695,24 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){

// ── Footer actions ────────────────────────────────────────────────────────
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
t('workspace_new_worktree_conversation'),
t('workspace_new_worktree_conversation_meta'),
li('git-branch',12),
async()=>{
closeWsDropdown();
try{
await newSession(false,{worktree:true});
await renderSessionList();
const msg=$('msg');
if(msg)msg.focus();
showToast(t('workspace_worktree_created'));
}catch(e){
showToast(t('workspace_worktree_failed')+(e&&e.message?e.message:e),'error');
}
}
));
dd.appendChild(document.createElement('div')).className='ws-divider';
dd.appendChild(_renderWorkspaceAction(
t('workspace_choose_path'),
t('workspace_choose_path_meta'),
Expand Down
11 changes: 10 additions & 1 deletion static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ function _markPollingCompletionUnreadTransitions(sessions) {
}
}

async function newSession(flash){
async function newSession(flash, options={}){
updateQueueBadge();
S.toolCalls=[];
clearLiveToolCards();
Expand Down Expand Up @@ -371,6 +371,7 @@ async function newSession(flash){
workspace:inheritWs,
profile:S.activeProfile||'default',
};
if(options&&options.worktree) reqBody.worktree=true;
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
S.session=data.session;S.messages=data.session.messages||[];
Expand Down Expand Up @@ -2581,6 +2582,14 @@ function renderSessionListFromCache(){
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
if(s.worktree_path){
const wtInd=document.createElement('span');
wtInd.className='session-worktree-indicator';
wtInd.innerHTML=li('git-branch',12);
const wtLabel=(typeof t==='function'?t('session_worktree_badge'):'Worktree');
wtInd.title=`${wtLabel}: ${s.worktree_branch||s.worktree_path}`;
titleRow.appendChild(wtInd);
}
// Parent session indicator for forked/branched sessions (#465)
if(s.parent_session_id){
const branchInd=document.createElement('span');
Expand Down
Loading
Loading