multi-forge 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
forge/session/manager.py
ADDED
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
"""High-level session operations coordinating stores.
|
|
2
|
+
|
|
3
|
+
SessionManager provides the business logic for session lifecycle operations,
|
|
4
|
+
coordinating between SessionStore and IndexStore.
|
|
5
|
+
|
|
6
|
+
The CLI layer should be thin and delegate to this class for all operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from forge.core.naming import generate_unique_name
|
|
17
|
+
from forge.core.state import now_iso
|
|
18
|
+
|
|
19
|
+
from .artifacts import resolve_artifact_path
|
|
20
|
+
from .claude.paths import find_project_root
|
|
21
|
+
from .config import (
|
|
22
|
+
DEFAULT_PROXY_BASE_URL,
|
|
23
|
+
DEFAULT_PROXY_TEMPLATE,
|
|
24
|
+
LAUNCH_MODE_HOST,
|
|
25
|
+
LAUNCH_MODE_SIDECAR,
|
|
26
|
+
)
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
CannotForkIncognitoError,
|
|
29
|
+
ContextBudgetExceededError,
|
|
30
|
+
DirtyWorktreeError,
|
|
31
|
+
ForgeSessionError,
|
|
32
|
+
ManifestCorruptedError,
|
|
33
|
+
ManifestValidationError,
|
|
34
|
+
SessionExistsError,
|
|
35
|
+
SessionNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from .handoff import (
|
|
38
|
+
HandoffResult,
|
|
39
|
+
ResumeStrategy,
|
|
40
|
+
estimate_transcript_tokens,
|
|
41
|
+
process_handoff,
|
|
42
|
+
)
|
|
43
|
+
from .index import IndexStore
|
|
44
|
+
from .models import (
|
|
45
|
+
Derivation,
|
|
46
|
+
LaunchIntent,
|
|
47
|
+
SessionIndexEntry,
|
|
48
|
+
SessionState,
|
|
49
|
+
SidecarLaunchIntent,
|
|
50
|
+
create_session_state,
|
|
51
|
+
)
|
|
52
|
+
from .prev_sessions import child_path, child_path_rel, ensure_child, generated_path
|
|
53
|
+
from .store import SessionStore
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _inherited_launch_intent(parent_state: SessionState) -> LaunchIntent | None:
|
|
59
|
+
"""Return the launch intent a derived session should inherit."""
|
|
60
|
+
if parent_state.intent.launch is not None:
|
|
61
|
+
return deepcopy(parent_state.intent.launch)
|
|
62
|
+
|
|
63
|
+
if parent_state.confirmed.is_sandboxed:
|
|
64
|
+
return LaunchIntent(
|
|
65
|
+
mode=LAUNCH_MODE_SIDECAR,
|
|
66
|
+
sidecar=SidecarLaunchIntent(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tracked_transcript_session_ids(state: SessionState) -> list[str]:
|
|
73
|
+
"""Return distinct Claude session IDs referenced by transcript artifacts."""
|
|
74
|
+
transcripts = state.confirmed.artifacts.get("transcripts")
|
|
75
|
+
if not isinstance(transcripts, list):
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
session_ids: list[str] = []
|
|
79
|
+
for artifact in transcripts:
|
|
80
|
+
if not isinstance(artifact, dict):
|
|
81
|
+
continue
|
|
82
|
+
session_id = artifact.get("session_id")
|
|
83
|
+
if isinstance(session_id, str) and session_id and session_id not in session_ids:
|
|
84
|
+
session_ids.append(session_id)
|
|
85
|
+
return session_ids
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _latest_transcript_artifact_path(state: SessionState) -> str | None:
|
|
89
|
+
"""Return the latest copied transcript artifact path from confirmed state."""
|
|
90
|
+
transcripts = state.confirmed.artifacts.get("transcripts")
|
|
91
|
+
if not isinstance(transcripts, list) or not transcripts:
|
|
92
|
+
return None
|
|
93
|
+
latest = transcripts[-1]
|
|
94
|
+
if not isinstance(latest, dict):
|
|
95
|
+
return None
|
|
96
|
+
copied_path = latest.get("copied_path")
|
|
97
|
+
return copied_path if isinstance(copied_path, str) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SessionManager:
|
|
101
|
+
"""High-level session operations coordinating stores.
|
|
102
|
+
|
|
103
|
+
This class provides the business logic layer between CLI commands
|
|
104
|
+
and the underlying storage components.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
index_store: Global session index manager.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
index_store: IndexStore | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Initialize the session manager.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
index_store: Custom IndexStore instance. Creates default if None.
|
|
118
|
+
"""
|
|
119
|
+
self.index_store = index_store or IndexStore()
|
|
120
|
+
|
|
121
|
+
# -------------------------------------------------------------------------
|
|
122
|
+
# Query Operations
|
|
123
|
+
# -------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def list_sessions(
|
|
126
|
+
self,
|
|
127
|
+
include_incognito: bool = True,
|
|
128
|
+
*,
|
|
129
|
+
project_root_filter: str | None = None,
|
|
130
|
+
forge_root_filter: str | None = None,
|
|
131
|
+
) -> list[tuple[str, SessionIndexEntry]]:
|
|
132
|
+
"""List sessions from the index, optionally filtered by scope.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
include_incognito: Whether to include incognito sessions.
|
|
136
|
+
project_root_filter: If set, only return entries matching this project_root.
|
|
137
|
+
forge_root_filter: If set, only return entries matching this forge_root.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of (name, entry) tuples sorted by recency.
|
|
141
|
+
"""
|
|
142
|
+
return self.index_store.list_sessions(
|
|
143
|
+
include_incognito=include_incognito,
|
|
144
|
+
project_root_filter=project_root_filter,
|
|
145
|
+
forge_root_filter=forge_root_filter,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def get_session(self, name: str, forge_root: str | None = None) -> SessionState:
|
|
149
|
+
"""Get a session state by name, optionally scoped to a forge_root.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
name: Session display name.
|
|
153
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
154
|
+
"""
|
|
155
|
+
entry = self.index_store.get_session(name, forge_root=forge_root)
|
|
156
|
+
store = SessionStore(entry.forge_root or entry.worktree_path, name)
|
|
157
|
+
|
|
158
|
+
if not store.exists():
|
|
159
|
+
raise SessionNotFoundError(name)
|
|
160
|
+
|
|
161
|
+
return store.read()
|
|
162
|
+
|
|
163
|
+
def switch_session(self, name: str, forge_root: str | None = None) -> SessionState:
|
|
164
|
+
"""Load a session and update its last_accessed_at timestamp.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: Session display name.
|
|
168
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
169
|
+
"""
|
|
170
|
+
entry = self.index_store.get_session(name, forge_root=forge_root)
|
|
171
|
+
|
|
172
|
+
store = SessionStore(entry.forge_root or entry.worktree_path, name)
|
|
173
|
+
if not store.exists():
|
|
174
|
+
raise SessionNotFoundError(name)
|
|
175
|
+
|
|
176
|
+
state = store.read()
|
|
177
|
+
|
|
178
|
+
timestamp = now_iso()
|
|
179
|
+
|
|
180
|
+
store.update(timeout_s=5.0, mutate=lambda m: setattr(m, "last_accessed_at", timestamp))
|
|
181
|
+
|
|
182
|
+
entry_forge_root = entry.forge_root or entry.worktree_path
|
|
183
|
+
self.index_store.update_session(name, last_accessed_at=timestamp, forge_root=entry_forge_root)
|
|
184
|
+
|
|
185
|
+
return state
|
|
186
|
+
|
|
187
|
+
def session_exists(self, name: str, forge_root: str | None = None) -> bool:
|
|
188
|
+
"""Check if a session exists, optionally scoped to a forge_root.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
name: Session display name.
|
|
192
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
193
|
+
"""
|
|
194
|
+
return self.index_store.session_exists(name, forge_root=forge_root)
|
|
195
|
+
|
|
196
|
+
def get_session_entry(self, name: str, forge_root: str | None = None) -> SessionIndexEntry:
|
|
197
|
+
"""Get a session index entry by name, optionally scoped.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
name: Session display name.
|
|
201
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
202
|
+
"""
|
|
203
|
+
return self.index_store.get_session(name, forge_root=forge_root)
|
|
204
|
+
|
|
205
|
+
def get_session_store(self, name: str, forge_root: str | None = None) -> SessionStore:
|
|
206
|
+
"""Get a SessionStore for a session by name.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name: Session name to look up.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
SessionStore instance for the session's worktree.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
SessionNotFoundError: If session doesn't exist.
|
|
216
|
+
InvalidSessionNameError: If name is invalid.
|
|
217
|
+
"""
|
|
218
|
+
entry = self.index_store.get_session(name, forge_root=forge_root)
|
|
219
|
+
return SessionStore(entry.forge_root or entry.worktree_path, name)
|
|
220
|
+
|
|
221
|
+
def resolve_project_root(self, worktree_path: str | Path) -> str:
|
|
222
|
+
"""Resolve the project root for a worktree path.
|
|
223
|
+
|
|
224
|
+
For regular checkouts, this is the same as worktree_path.
|
|
225
|
+
For git worktrees, this finds the main repository.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
worktree_path: Path to the worktree.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Absolute path to the project root.
|
|
232
|
+
"""
|
|
233
|
+
from .worktree import get_main_repo_root
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
return str(get_main_repo_root(Path(worktree_path)))
|
|
237
|
+
except (ForgeSessionError, OSError):
|
|
238
|
+
# GitNotFoundError (no git), GitWorktreeError (not a repo), OSError (fs)
|
|
239
|
+
return str(Path(worktree_path).resolve())
|
|
240
|
+
|
|
241
|
+
# -------------------------------------------------------------------------
|
|
242
|
+
# Lifecycle Operations
|
|
243
|
+
# -------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def start_session(
|
|
246
|
+
self,
|
|
247
|
+
name: str,
|
|
248
|
+
*,
|
|
249
|
+
worktree_path: str | None = None,
|
|
250
|
+
create_worktree: bool = False,
|
|
251
|
+
branch: str | None = None,
|
|
252
|
+
proxy_template: str | None = None,
|
|
253
|
+
proxy_base_url: str | None = None,
|
|
254
|
+
direct: bool = False,
|
|
255
|
+
is_incognito: bool = False,
|
|
256
|
+
launch_mode: str = LAUNCH_MODE_HOST,
|
|
257
|
+
sidecar_mounts: list[str] | None = None,
|
|
258
|
+
sidecar_image: str | None = None,
|
|
259
|
+
direct_model: str | None = None,
|
|
260
|
+
claude_session_id: str | None = None,
|
|
261
|
+
) -> SessionState:
|
|
262
|
+
"""Create and register a new session.
|
|
263
|
+
|
|
264
|
+
Creates the session state, updates the index, and sets the
|
|
265
|
+
active session pointer. Does NOT invoke Claude - the CLI should
|
|
266
|
+
call invoke_claude separately.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
name: Human-friendly session name.
|
|
270
|
+
worktree_path: Path to worktree (defaults to cwd).
|
|
271
|
+
create_worktree: If True, create a new git worktree.
|
|
272
|
+
branch: Git branch name (defaults to session name if create_worktree).
|
|
273
|
+
proxy_template: Proxy template (defaults to config default when not direct).
|
|
274
|
+
proxy_base_url: Proxy base URL (defaults to config default when not direct).
|
|
275
|
+
direct: If True, create a direct Anthropic session with no proxy intent.
|
|
276
|
+
is_incognito: Whether session auto-deletes on exit.
|
|
277
|
+
launch_mode: How Forge should relaunch this session later.
|
|
278
|
+
sidecar_mounts: Raw sidecar mount specs to persist for relaunch.
|
|
279
|
+
sidecar_image: Optional sidecar image override to persist for relaunch.
|
|
280
|
+
direct_model: Optional Claude Code env-ready direct model pin.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The created session state with candidate UUID.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
SessionExistsError: If session name already exists.
|
|
287
|
+
InvalidSessionNameError: If name is invalid.
|
|
288
|
+
FileNotFoundError: If no git repository found.
|
|
289
|
+
BranchExistsError: If branch already exists (when create_worktree=True).
|
|
290
|
+
WorktreePathExistsError: If worktree path exists (when create_worktree=True).
|
|
291
|
+
InvalidBranchNameError: If explicit branch name is invalid.
|
|
292
|
+
"""
|
|
293
|
+
# Compute forge_root early for scoped collision check.
|
|
294
|
+
# For worktree sessions, use launch CWD (before worktree creation).
|
|
295
|
+
# For non-worktree sessions, use explicit worktree_path if provided.
|
|
296
|
+
from forge.core.ops.context import find_forge_root
|
|
297
|
+
|
|
298
|
+
launch_cwd = Path.cwd().resolve()
|
|
299
|
+
_early_search = Path(worktree_path).resolve() if worktree_path and not create_worktree else launch_cwd
|
|
300
|
+
_early_forge_root = find_forge_root(_early_search)
|
|
301
|
+
_early_fr_str = str(_early_forge_root) if _early_forge_root else None
|
|
302
|
+
|
|
303
|
+
if self.index_store.session_exists(name, forge_root=_early_fr_str):
|
|
304
|
+
raise SessionExistsError(name)
|
|
305
|
+
|
|
306
|
+
created_worktree = False
|
|
307
|
+
worktree_branch: str | None = branch
|
|
308
|
+
main_repo_root: Path | None = None
|
|
309
|
+
|
|
310
|
+
def _rollback_worktree(*, resolved_worktree_path: str | None) -> None:
|
|
311
|
+
if not created_worktree or resolved_worktree_path is None:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
from .worktree import cleanup_worktree
|
|
316
|
+
|
|
317
|
+
cleanup_worktree(
|
|
318
|
+
worktree_path=Path(resolved_worktree_path),
|
|
319
|
+
branch=worktree_branch,
|
|
320
|
+
delete_branch_flag=True,
|
|
321
|
+
force=True,
|
|
322
|
+
repo_root=main_repo_root,
|
|
323
|
+
)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.debug("Worktree rollback cleanup failed (non-critical): %s", e)
|
|
326
|
+
|
|
327
|
+
if create_worktree:
|
|
328
|
+
from .worktree import copy_runtime_config
|
|
329
|
+
from .worktree import create_worktree as git_create_worktree
|
|
330
|
+
from .worktree import get_main_repo_root
|
|
331
|
+
|
|
332
|
+
main_repo_root = get_main_repo_root()
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
# Create worktree first (external side effect).
|
|
336
|
+
wt_result = git_create_worktree(
|
|
337
|
+
session_name=name,
|
|
338
|
+
branch=branch,
|
|
339
|
+
cwd=main_repo_root,
|
|
340
|
+
)
|
|
341
|
+
created_worktree = True
|
|
342
|
+
worktree_path = wt_result.worktree_path
|
|
343
|
+
worktree_branch = wt_result.branch
|
|
344
|
+
|
|
345
|
+
# Copy runtime config (best-effort; does not raise).
|
|
346
|
+
copy_runtime_config(main_repo_root, Path(worktree_path))
|
|
347
|
+
|
|
348
|
+
except Exception:
|
|
349
|
+
# No Forge state has been written yet. Best-effort cleanup of any
|
|
350
|
+
# created worktree/branch.
|
|
351
|
+
_rollback_worktree(resolved_worktree_path=worktree_path)
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
if worktree_path is None:
|
|
355
|
+
worktree_path = str(Path.cwd().resolve())
|
|
356
|
+
else:
|
|
357
|
+
worktree_path = str(Path(worktree_path).resolve())
|
|
358
|
+
|
|
359
|
+
# Rule 1: sessions require `forge extension enable` (.forge/ must exist).
|
|
360
|
+
# For worktree sessions, use the launch CWD (captured before worktree
|
|
361
|
+
# creation) — the user's nested project dir, not the bare checkout.
|
|
362
|
+
from forge.core.ops.context import find_forge_root
|
|
363
|
+
|
|
364
|
+
forge_root_search = launch_cwd if created_worktree else Path(worktree_path)
|
|
365
|
+
resolved_forge_root = find_forge_root(forge_root_search)
|
|
366
|
+
if resolved_forge_root is None:
|
|
367
|
+
if created_worktree:
|
|
368
|
+
_rollback_worktree(resolved_worktree_path=worktree_path)
|
|
369
|
+
from .exceptions import ForgeNotEnabledError
|
|
370
|
+
|
|
371
|
+
raise ForgeNotEnabledError(str(forge_root_search))
|
|
372
|
+
|
|
373
|
+
# For worktree sessions with nested Forge projects, remap forge_root
|
|
374
|
+
# into the new worktree. Root-level projects (forge_root == repo root)
|
|
375
|
+
# keep the original forge_root so manifests stay under the main .forge/.
|
|
376
|
+
if created_worktree and main_repo_root is not None:
|
|
377
|
+
try:
|
|
378
|
+
relative = resolved_forge_root.relative_to(main_repo_root)
|
|
379
|
+
except ValueError:
|
|
380
|
+
relative = Path(".")
|
|
381
|
+
if str(relative) != ".":
|
|
382
|
+
# Nested project: remap to equivalent position in new worktree
|
|
383
|
+
forge_root_str = str(Path(worktree_path) / relative)
|
|
384
|
+
else:
|
|
385
|
+
# Root-level project: keep parent's forge_root
|
|
386
|
+
forge_root_str = str(resolved_forge_root)
|
|
387
|
+
else:
|
|
388
|
+
forge_root_str = str(resolved_forge_root)
|
|
389
|
+
|
|
390
|
+
# D5: Multiple sessions per worktree are allowed (per-session directories).
|
|
391
|
+
# Only check that THIS session name doesn't already have a manifest.
|
|
392
|
+
store = SessionStore(forge_root_str, name)
|
|
393
|
+
if store.exists():
|
|
394
|
+
if created_worktree:
|
|
395
|
+
_rollback_worktree(resolved_worktree_path=worktree_path)
|
|
396
|
+
raise SessionExistsError(name)
|
|
397
|
+
|
|
398
|
+
# Find project root - use main repo root if we created a worktree
|
|
399
|
+
if main_repo_root is not None:
|
|
400
|
+
project_root: str | Path = main_repo_root
|
|
401
|
+
else:
|
|
402
|
+
# For non-worktree sessions, find the project root
|
|
403
|
+
# (which is the same as worktree_path for regular checkouts)
|
|
404
|
+
project_root = find_project_root(worktree_path)
|
|
405
|
+
# checkout_root = git --show-toplevel (not CWD). For worktree-created sessions
|
|
406
|
+
# main_repo_root is the logical repo, not the checkout; use get_repo_root() instead.
|
|
407
|
+
from .worktree import get_repo_root
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
checkout_root_str: str | None = str(get_repo_root(Path(worktree_path)))
|
|
411
|
+
except Exception:
|
|
412
|
+
checkout_root_str = worktree_path # Fallback if not in a git repo
|
|
413
|
+
|
|
414
|
+
# relative_path = forge_root relative to checkout_root
|
|
415
|
+
relative_path_str: str | None = None
|
|
416
|
+
if forge_root_str and checkout_root_str:
|
|
417
|
+
try:
|
|
418
|
+
relative_path_str = str(Path(forge_root_str).relative_to(checkout_root_str))
|
|
419
|
+
except ValueError:
|
|
420
|
+
logger.warning(
|
|
421
|
+
"forge_root %s is not relative to checkout_root %s; defaulting to '.'",
|
|
422
|
+
forge_root_str,
|
|
423
|
+
checkout_root_str,
|
|
424
|
+
)
|
|
425
|
+
relative_path_str = "."
|
|
426
|
+
|
|
427
|
+
if direct:
|
|
428
|
+
template = None
|
|
429
|
+
base_url = None
|
|
430
|
+
else:
|
|
431
|
+
template = proxy_template or DEFAULT_PROXY_TEMPLATE
|
|
432
|
+
base_url = proxy_base_url or DEFAULT_PROXY_BASE_URL
|
|
433
|
+
|
|
434
|
+
# UUID pre-seeded if provided; SessionStart hook validates it
|
|
435
|
+
state = create_session_state(
|
|
436
|
+
name=name,
|
|
437
|
+
proxy_template=template,
|
|
438
|
+
proxy_base_url=base_url,
|
|
439
|
+
is_incognito=is_incognito,
|
|
440
|
+
worktree_path=worktree_path,
|
|
441
|
+
worktree_branch=worktree_branch,
|
|
442
|
+
launch_mode=launch_mode,
|
|
443
|
+
sidecar_mounts=sidecar_mounts,
|
|
444
|
+
sidecar_image=sidecar_image,
|
|
445
|
+
direct_model=direct_model,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if claude_session_id:
|
|
449
|
+
state.confirmed.claude_session_id = claude_session_id
|
|
450
|
+
|
|
451
|
+
if create_worktree and state.worktree:
|
|
452
|
+
state.worktree.is_worktree = True
|
|
453
|
+
|
|
454
|
+
# Set forge_root on session state for downstream consumers
|
|
455
|
+
state.forge_root = forge_root_str
|
|
456
|
+
|
|
457
|
+
# Commit phase: write Forge state only after external worktree creation succeeded.
|
|
458
|
+
store = SessionStore(forge_root_str, name)
|
|
459
|
+
|
|
460
|
+
wrote_manifest = False
|
|
461
|
+
added_to_index = False
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
store.write(state)
|
|
465
|
+
wrote_manifest = True
|
|
466
|
+
|
|
467
|
+
self.index_store.add_from_state(
|
|
468
|
+
state,
|
|
469
|
+
str(project_root),
|
|
470
|
+
checkout_root=checkout_root_str,
|
|
471
|
+
forge_root=forge_root_str,
|
|
472
|
+
relative_path=relative_path_str,
|
|
473
|
+
)
|
|
474
|
+
added_to_index = True
|
|
475
|
+
|
|
476
|
+
return state
|
|
477
|
+
|
|
478
|
+
except Exception:
|
|
479
|
+
# Best-effort rollback for partial state.
|
|
480
|
+
try:
|
|
481
|
+
if added_to_index:
|
|
482
|
+
self.index_store.remove_session(name)
|
|
483
|
+
except Exception as rollback_err:
|
|
484
|
+
logger.warning("Rollback failed (index entry): %s", rollback_err)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
if wrote_manifest and store.exists():
|
|
488
|
+
store.delete()
|
|
489
|
+
except Exception as rollback_err:
|
|
490
|
+
logger.warning("Rollback failed (manifest delete): %s", rollback_err)
|
|
491
|
+
|
|
492
|
+
# If we created a worktree, remove it (and branch) best-effort.
|
|
493
|
+
_rollback_worktree(resolved_worktree_path=worktree_path)
|
|
494
|
+
|
|
495
|
+
raise
|
|
496
|
+
|
|
497
|
+
def resume_session(
|
|
498
|
+
self,
|
|
499
|
+
parent_name: str,
|
|
500
|
+
*,
|
|
501
|
+
child_name: str | None = None,
|
|
502
|
+
strategy: str = "structured",
|
|
503
|
+
depth: int = 1,
|
|
504
|
+
context_limit: int | None = None,
|
|
505
|
+
token_estimate_multiplier: float = 1.0,
|
|
506
|
+
resume_mode: str = "handoff",
|
|
507
|
+
forge_root: str | None = None,
|
|
508
|
+
) -> tuple[SessionState, HandoffResult]:
|
|
509
|
+
"""Create a new session derived from a parent with context assembly.
|
|
510
|
+
|
|
511
|
+
Creates a new child session in the parent's worktree with context assembled
|
|
512
|
+
from the parent's history. This is used when context approaches limits and
|
|
513
|
+
the user wants to continue work with a fresh context window.
|
|
514
|
+
|
|
515
|
+
When ``resume_mode="native"``, context assembly is skipped entirely. The
|
|
516
|
+
caller is expected to launch Claude with ``--resume --fork-session`` to
|
|
517
|
+
carry full conversation history natively. No system_prompt_file is generated.
|
|
518
|
+
|
|
519
|
+
Does NOT invoke Claude - the CLI should call invoke_claude separately.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
parent_name: Parent session name to derive from.
|
|
523
|
+
child_name: Name for the child session (auto-generated if None).
|
|
524
|
+
strategy: Context assembly strategy (minimal/structured/full).
|
|
525
|
+
depth: How many ancestors to traverse (1 = parent only).
|
|
526
|
+
context_limit: Context limit for budget check (required for full strategy).
|
|
527
|
+
token_estimate_multiplier: Optional model-specific multiplier for heuristic budget checks.
|
|
528
|
+
resume_mode: "handoff" (assemble context file) or "native" (skip assembly).
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Tuple of (child session state, handoff result).
|
|
532
|
+
|
|
533
|
+
Raises:
|
|
534
|
+
SessionNotFoundError: If parent session doesn't exist.
|
|
535
|
+
SessionExistsError: If child_name already exists.
|
|
536
|
+
InvalidSessionNameError: If name is invalid.
|
|
537
|
+
ContextBudgetExceededError: If full strategy exceeds context limit.
|
|
538
|
+
"""
|
|
539
|
+
if resume_mode not in {"handoff", "native"}:
|
|
540
|
+
raise ValueError(f"Unsupported resume_mode: {resume_mode}")
|
|
541
|
+
|
|
542
|
+
parent_entry = self.index_store.get_session(parent_name, forge_root=forge_root)
|
|
543
|
+
parent_forge_root = parent_entry.forge_root or parent_entry.worktree_path
|
|
544
|
+
parent_store = SessionStore(parent_forge_root, parent_name)
|
|
545
|
+
if not parent_store.exists():
|
|
546
|
+
raise SessionNotFoundError(parent_name)
|
|
547
|
+
|
|
548
|
+
parent_state = parent_store.read()
|
|
549
|
+
|
|
550
|
+
name_was_auto = child_name is None
|
|
551
|
+
if name_was_auto:
|
|
552
|
+
child_name = self._generate_resume_name(parent_name, forge_root=parent_forge_root)
|
|
553
|
+
|
|
554
|
+
assert child_name is not None # narrowing: either provided or generated
|
|
555
|
+
|
|
556
|
+
if self.index_store.session_exists(child_name, forge_root=parent_forge_root):
|
|
557
|
+
raise SessionExistsError(child_name)
|
|
558
|
+
|
|
559
|
+
project_root = Path(self.resolve_project_root(parent_entry.worktree_path))
|
|
560
|
+
parent_artifact_root = Path(parent_entry.forge_root or parent_entry.worktree_path)
|
|
561
|
+
|
|
562
|
+
inherited_proxy = None
|
|
563
|
+
if parent_state.confirmed.started_with_proxy:
|
|
564
|
+
inherited_proxy = parent_state.confirmed.started_with_proxy.template
|
|
565
|
+
|
|
566
|
+
timestamp = now_iso()
|
|
567
|
+
|
|
568
|
+
parent_proxy_template = parent_state.intent.proxy.template if parent_state.intent.proxy else None
|
|
569
|
+
parent_proxy_base_url = parent_state.intent.proxy.base_url if parent_state.intent.proxy else None
|
|
570
|
+
|
|
571
|
+
# --- Native resume guard: when fork --into targets different
|
|
572
|
+
# forge_roots, reject native resume here. Claude Code's --resume only works
|
|
573
|
+
# within the same CWD's .claude/ project. For now, child always inherits
|
|
574
|
+
# parent's forge_root, so this is a no-op.
|
|
575
|
+
|
|
576
|
+
# --- Native mode: skip handoff, return early ---
|
|
577
|
+
if resume_mode == "native":
|
|
578
|
+
child_state = self._create_resume_child(
|
|
579
|
+
child_name=child_name,
|
|
580
|
+
parent_name=parent_name,
|
|
581
|
+
parent_state=parent_state,
|
|
582
|
+
parent_entry=parent_entry,
|
|
583
|
+
inherited_proxy=inherited_proxy,
|
|
584
|
+
parent_proxy_template=parent_proxy_template,
|
|
585
|
+
parent_proxy_base_url=parent_proxy_base_url,
|
|
586
|
+
)
|
|
587
|
+
# Resolve parent transcript path for traceability (best-effort)
|
|
588
|
+
transcript_artifact_path: str | None = None
|
|
589
|
+
transcripts = parent_state.confirmed.artifacts.get("transcripts", [])
|
|
590
|
+
if transcripts and isinstance(transcripts, list) and len(transcripts) > 0:
|
|
591
|
+
latest = transcripts[-1]
|
|
592
|
+
if isinstance(latest, dict):
|
|
593
|
+
transcript_artifact_path = latest.get("copied_path")
|
|
594
|
+
|
|
595
|
+
child_state.confirmed.derivation = Derivation(
|
|
596
|
+
parent_session=parent_name,
|
|
597
|
+
parent_transcript=transcript_artifact_path,
|
|
598
|
+
inherited_proxy=inherited_proxy,
|
|
599
|
+
resume_mode="native",
|
|
600
|
+
strategy=None,
|
|
601
|
+
depth=1,
|
|
602
|
+
resumed_at=timestamp,
|
|
603
|
+
lineage=[parent_name],
|
|
604
|
+
context_file=None,
|
|
605
|
+
parent_forge_root=parent_entry.forge_root or parent_entry.worktree_path,
|
|
606
|
+
parent_project_root=parent_entry.project_root,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
handoff_result = HandoffResult(
|
|
610
|
+
context_file=None,
|
|
611
|
+
context_file_rel=None,
|
|
612
|
+
transcript_artifact_path=transcript_artifact_path,
|
|
613
|
+
token_estimate=None,
|
|
614
|
+
lineage=[parent_name],
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
self._persist_resume_child(
|
|
618
|
+
child_state=child_state,
|
|
619
|
+
child_name=child_name,
|
|
620
|
+
parent_name=parent_name,
|
|
621
|
+
parent_entry=parent_entry,
|
|
622
|
+
project_root=project_root,
|
|
623
|
+
name_was_auto=name_was_auto,
|
|
624
|
+
)
|
|
625
|
+
return child_state, handoff_result
|
|
626
|
+
|
|
627
|
+
# --- Handoff mode: assemble context from parent history ---
|
|
628
|
+
try:
|
|
629
|
+
resume_strategy = ResumeStrategy(strategy)
|
|
630
|
+
except ValueError:
|
|
631
|
+
resume_strategy = ResumeStrategy.STRUCTURED
|
|
632
|
+
|
|
633
|
+
if resume_strategy == ResumeStrategy.FULL and context_limit is not None:
|
|
634
|
+
transcripts = parent_state.confirmed.artifacts.get("transcripts", [])
|
|
635
|
+
if transcripts and isinstance(transcripts, list) and len(transcripts) > 0:
|
|
636
|
+
latest = transcripts[-1]
|
|
637
|
+
if isinstance(latest, dict):
|
|
638
|
+
copied_path = latest.get("copied_path")
|
|
639
|
+
if isinstance(copied_path, str):
|
|
640
|
+
transcript_path = resolve_artifact_path(parent_artifact_root, copied_path)
|
|
641
|
+
if transcript_path is not None and transcript_path.is_file():
|
|
642
|
+
token_estimate = estimate_transcript_tokens(
|
|
643
|
+
transcript_path,
|
|
644
|
+
multiplier=token_estimate_multiplier,
|
|
645
|
+
)
|
|
646
|
+
if token_estimate > context_limit:
|
|
647
|
+
raise ContextBudgetExceededError(token_estimate, context_limit)
|
|
648
|
+
|
|
649
|
+
def get_session_safe(session_name: str) -> SessionState | None:
|
|
650
|
+
try:
|
|
651
|
+
return self.get_session(session_name, forge_root=parent_forge_root)
|
|
652
|
+
except SessionNotFoundError:
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
handoff_result = process_handoff(
|
|
656
|
+
parent_name=parent_name,
|
|
657
|
+
parent_state=parent_state,
|
|
658
|
+
forge_root=parent_artifact_root,
|
|
659
|
+
strategy=resume_strategy,
|
|
660
|
+
depth=depth,
|
|
661
|
+
get_session=get_session_safe,
|
|
662
|
+
child_name=child_name,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# claude_session_id stays None until the SessionStart hook fires
|
|
666
|
+
child_state = self._create_resume_child(
|
|
667
|
+
child_name=child_name,
|
|
668
|
+
parent_name=parent_name,
|
|
669
|
+
parent_state=parent_state,
|
|
670
|
+
parent_entry=parent_entry,
|
|
671
|
+
inherited_proxy=inherited_proxy,
|
|
672
|
+
parent_proxy_template=parent_proxy_template,
|
|
673
|
+
parent_proxy_base_url=parent_proxy_base_url,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
child_state.confirmed.derivation = Derivation(
|
|
677
|
+
parent_session=parent_name,
|
|
678
|
+
parent_transcript=handoff_result.transcript_artifact_path,
|
|
679
|
+
inherited_proxy=inherited_proxy,
|
|
680
|
+
resume_mode="handoff",
|
|
681
|
+
strategy=strategy,
|
|
682
|
+
depth=depth,
|
|
683
|
+
resumed_at=timestamp,
|
|
684
|
+
lineage=handoff_result.lineage,
|
|
685
|
+
context_file=handoff_result.context_file_rel,
|
|
686
|
+
parent_forge_root=parent_entry.forge_root or parent_entry.worktree_path,
|
|
687
|
+
parent_project_root=parent_entry.project_root,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
final_child_name = self._persist_resume_child(
|
|
691
|
+
child_state=child_state,
|
|
692
|
+
child_name=child_name,
|
|
693
|
+
parent_name=parent_name,
|
|
694
|
+
parent_entry=parent_entry,
|
|
695
|
+
project_root=project_root,
|
|
696
|
+
name_was_auto=name_was_auto,
|
|
697
|
+
)
|
|
698
|
+
if final_child_name != child_name:
|
|
699
|
+
handoff_result.context_file = child_path(parent_artifact_root, parent_name, final_child_name)
|
|
700
|
+
handoff_result.context_file_rel = child_path_rel(parent_name, final_child_name)
|
|
701
|
+
return child_state, handoff_result
|
|
702
|
+
|
|
703
|
+
def _create_resume_child(
|
|
704
|
+
self,
|
|
705
|
+
*,
|
|
706
|
+
child_name: str,
|
|
707
|
+
parent_name: str,
|
|
708
|
+
parent_state: SessionState,
|
|
709
|
+
parent_entry: SessionIndexEntry,
|
|
710
|
+
inherited_proxy: str | None,
|
|
711
|
+
parent_proxy_template: str | None,
|
|
712
|
+
parent_proxy_base_url: str | None,
|
|
713
|
+
) -> SessionState:
|
|
714
|
+
"""Create a child SessionState for resume (shared by native and handoff)."""
|
|
715
|
+
child_state = create_session_state(
|
|
716
|
+
name=child_name,
|
|
717
|
+
proxy_template=inherited_proxy or parent_proxy_template,
|
|
718
|
+
proxy_base_url=parent_proxy_base_url if (inherited_proxy or parent_proxy_template) else None,
|
|
719
|
+
is_incognito=parent_state.is_incognito,
|
|
720
|
+
worktree_path=parent_entry.worktree_path,
|
|
721
|
+
worktree_branch=parent_state.worktree.branch if parent_state.worktree else None,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
for field_name in ("subprocess_proxy", "policy", "memory", "system_prompt", "verification"):
|
|
725
|
+
parent_val = getattr(parent_state.intent, field_name, None)
|
|
726
|
+
if parent_val is not None:
|
|
727
|
+
setattr(child_state.intent, field_name, deepcopy(parent_val))
|
|
728
|
+
inherited_launch = _inherited_launch_intent(parent_state)
|
|
729
|
+
if inherited_launch is not None:
|
|
730
|
+
child_state.intent.launch = inherited_launch
|
|
731
|
+
|
|
732
|
+
child_state.parent_session = parent_name
|
|
733
|
+
child_state.is_fork = False # Same worktree, context continuation (not a fork)
|
|
734
|
+
# Propagate identity from parent
|
|
735
|
+
child_state.forge_root = parent_entry.forge_root or parent_state.forge_root
|
|
736
|
+
return child_state
|
|
737
|
+
|
|
738
|
+
def _persist_resume_child(
|
|
739
|
+
self,
|
|
740
|
+
*,
|
|
741
|
+
child_state: SessionState,
|
|
742
|
+
child_name: str,
|
|
743
|
+
parent_name: str,
|
|
744
|
+
parent_entry: SessionIndexEntry,
|
|
745
|
+
project_root: Path,
|
|
746
|
+
name_was_auto: bool,
|
|
747
|
+
) -> str:
|
|
748
|
+
"""Write child session to disk and index (shared by native and handoff).
|
|
749
|
+
|
|
750
|
+
Race protection: if an auto-generated name collides at add_from_state
|
|
751
|
+
(concurrent resume), retry once with a fresh timestamp suffix.
|
|
752
|
+
|
|
753
|
+
Returns the final persisted child name, which may differ from the
|
|
754
|
+
original auto-generated name after a retry.
|
|
755
|
+
"""
|
|
756
|
+
parent_forge_root = parent_entry.forge_root or parent_entry.worktree_path
|
|
757
|
+
for attempt in range(2):
|
|
758
|
+
child_store = SessionStore(parent_forge_root, child_name)
|
|
759
|
+
child_store.write(child_state)
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
self.index_store.add_from_state(
|
|
763
|
+
child_state,
|
|
764
|
+
str(project_root),
|
|
765
|
+
checkout_root=parent_entry.checkout_root,
|
|
766
|
+
forge_root=parent_entry.forge_root,
|
|
767
|
+
relative_path=parent_entry.relative_path,
|
|
768
|
+
)
|
|
769
|
+
break # Success
|
|
770
|
+
except SessionExistsError:
|
|
771
|
+
child_store.delete()
|
|
772
|
+
|
|
773
|
+
if not name_was_auto or attempt > 0:
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
derivation = child_state.confirmed.derivation
|
|
777
|
+
if derivation is not None and derivation.resume_mode == "handoff":
|
|
778
|
+
orphan_context = child_path(Path(parent_forge_root), parent_name, child_name)
|
|
779
|
+
generated_context = generated_path(Path(parent_forge_root), parent_name)
|
|
780
|
+
try:
|
|
781
|
+
if (
|
|
782
|
+
orphan_context.is_file()
|
|
783
|
+
and generated_context.is_file()
|
|
784
|
+
and orphan_context.read_bytes() == generated_context.read_bytes()
|
|
785
|
+
):
|
|
786
|
+
orphan_context.unlink()
|
|
787
|
+
except OSError:
|
|
788
|
+
logger.debug("Could not remove orphaned retry context file %s", orphan_context, exc_info=True)
|
|
789
|
+
|
|
790
|
+
child_name = self._generate_resume_name(parent_name, forge_root=parent_forge_root)
|
|
791
|
+
child_state.name = child_name
|
|
792
|
+
if derivation is not None and derivation.resume_mode == "handoff":
|
|
793
|
+
ensure_child(Path(parent_forge_root), parent_name, child_name)
|
|
794
|
+
derivation.context_file = child_path_rel(parent_name, child_name)
|
|
795
|
+
|
|
796
|
+
return child_name
|
|
797
|
+
|
|
798
|
+
def _load_existing_fork_target(
|
|
799
|
+
self,
|
|
800
|
+
*,
|
|
801
|
+
fork_name: str,
|
|
802
|
+
target_forge_root: str,
|
|
803
|
+
) -> tuple[SessionStore, SessionIndexEntry | None, SessionState | None]:
|
|
804
|
+
"""Return the existing manifest/index state for a fork target.
|
|
805
|
+
|
|
806
|
+
Uses the index self-healing path so stale index-only entries do not
|
|
807
|
+
block retries.
|
|
808
|
+
"""
|
|
809
|
+
target_store = SessionStore(target_forge_root, fork_name)
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
target_entry = self.index_store.get_session(fork_name, forge_root=target_forge_root)
|
|
813
|
+
except SessionNotFoundError:
|
|
814
|
+
target_entry = None
|
|
815
|
+
|
|
816
|
+
target_state: SessionState | None = None
|
|
817
|
+
if target_store.exists():
|
|
818
|
+
try:
|
|
819
|
+
target_state = target_store.read()
|
|
820
|
+
except (ManifestCorruptedError, ManifestValidationError):
|
|
821
|
+
target_state = None
|
|
822
|
+
|
|
823
|
+
return target_store, target_entry, target_state
|
|
824
|
+
|
|
825
|
+
def _can_force_replace_fork_target(
|
|
826
|
+
self,
|
|
827
|
+
*,
|
|
828
|
+
fork_name: str,
|
|
829
|
+
parent_name: str,
|
|
830
|
+
target_forge_root: str,
|
|
831
|
+
existing_state: SessionState | None,
|
|
832
|
+
expected_worktree_path: str,
|
|
833
|
+
expected_branch: str,
|
|
834
|
+
expected_is_worktree: bool,
|
|
835
|
+
expected_owns_worktree: bool,
|
|
836
|
+
) -> bool:
|
|
837
|
+
"""Return True when --force is replacing the stale child it created.
|
|
838
|
+
|
|
839
|
+
Replacement is intentionally narrow: the existing session must already
|
|
840
|
+
be a fork from this parent, point at the same target checkout/branch,
|
|
841
|
+
and be inactive.
|
|
842
|
+
"""
|
|
843
|
+
if existing_state is None:
|
|
844
|
+
return False
|
|
845
|
+
if not existing_state.is_fork or existing_state.parent_session != parent_name:
|
|
846
|
+
return False
|
|
847
|
+
if (
|
|
848
|
+
existing_state.forge_root is not None
|
|
849
|
+
and Path(existing_state.forge_root).resolve() != Path(target_forge_root).resolve()
|
|
850
|
+
):
|
|
851
|
+
return False
|
|
852
|
+
|
|
853
|
+
existing_worktree = existing_state.worktree
|
|
854
|
+
if existing_worktree is None:
|
|
855
|
+
return False
|
|
856
|
+
if Path(existing_worktree.path).resolve() != Path(expected_worktree_path).resolve():
|
|
857
|
+
return False
|
|
858
|
+
if existing_worktree.branch != expected_branch:
|
|
859
|
+
return False
|
|
860
|
+
if existing_worktree.is_worktree != expected_is_worktree:
|
|
861
|
+
return False
|
|
862
|
+
if expected_is_worktree and getattr(existing_worktree, "owns_worktree", True) != expected_owns_worktree:
|
|
863
|
+
return False
|
|
864
|
+
|
|
865
|
+
try:
|
|
866
|
+
from .active import ActiveSessionStore
|
|
867
|
+
|
|
868
|
+
if ActiveSessionStore().get_session(fork_name, forge_root=target_forge_root) is not None:
|
|
869
|
+
return False
|
|
870
|
+
except Exception as e:
|
|
871
|
+
logger.debug("Unable to verify active state for fork target '%s': %s", fork_name, e)
|
|
872
|
+
return False
|
|
873
|
+
|
|
874
|
+
return True
|
|
875
|
+
|
|
876
|
+
def fork_session(
|
|
877
|
+
self,
|
|
878
|
+
parent_name: str,
|
|
879
|
+
fork_name: str | None = None,
|
|
880
|
+
*,
|
|
881
|
+
direct: bool = False,
|
|
882
|
+
is_incognito: bool = False,
|
|
883
|
+
create_worktree: bool = False,
|
|
884
|
+
branch: str | None = None,
|
|
885
|
+
into_path: str | None = None,
|
|
886
|
+
forge_root: str | None = None,
|
|
887
|
+
force: bool = False,
|
|
888
|
+
) -> tuple[SessionState, SessionState]:
|
|
889
|
+
"""Fork an existing session.
|
|
890
|
+
|
|
891
|
+
By default the fork shares the parent's directory so Claude's
|
|
892
|
+
``--resume --fork-session`` can find the conversation (conversations
|
|
893
|
+
are project-scoped). Pass ``create_worktree=True`` for code
|
|
894
|
+
isolation in a separate git worktree, or ``into_path`` to land
|
|
895
|
+
in an existing worktree directory.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
parent_name: Session name to fork from.
|
|
899
|
+
fork_name: Name for the fork (auto-generated if None).
|
|
900
|
+
is_incognito: Whether the fork should auto-delete on exit.
|
|
901
|
+
create_worktree: Create a git worktree for the fork (default False).
|
|
902
|
+
branch: Override branch name (only used when create_worktree=True).
|
|
903
|
+
into_path: Fork into an existing worktree directory (normalized checkout root).
|
|
904
|
+
force: Replace only a conflicting target that is provably the same
|
|
905
|
+
stale fork (same parent + same target) and inactive. Hard
|
|
906
|
+
constraints still apply: BranchInUseError,
|
|
907
|
+
BranchNotMergedError, and non-worktree paths.
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
Tuple of (parent_manifest, fork_manifest).
|
|
911
|
+
|
|
912
|
+
Raises:
|
|
913
|
+
SessionNotFoundError: If parent doesn't exist.
|
|
914
|
+
CannotForkIncognitoError: If parent is incognito.
|
|
915
|
+
SessionExistsError: If fork_name already exists (and not force).
|
|
916
|
+
BranchExistsError: If branch already exists (create_worktree only, not force).
|
|
917
|
+
WorktreePathExistsError: If worktree path exists (create_worktree only, not force).
|
|
918
|
+
BranchInUseError: If branch is checked out elsewhere (force only).
|
|
919
|
+
BranchNotMergedError: If branch has unmerged work (force only).
|
|
920
|
+
"""
|
|
921
|
+
parent = self.get_session(parent_name, forge_root=forge_root)
|
|
922
|
+
parent_entry = self.index_store.get_session(parent_name, forge_root=forge_root)
|
|
923
|
+
parent_forge_root = parent_entry.forge_root or parent_entry.worktree_path
|
|
924
|
+
|
|
925
|
+
if parent.is_incognito:
|
|
926
|
+
raise CannotForkIncognitoError(parent_name)
|
|
927
|
+
|
|
928
|
+
if fork_name is None:
|
|
929
|
+
existing = {name for name, _ in self.list_sessions(forge_root_filter=parent_forge_root)}
|
|
930
|
+
fork_name = generate_unique_name(existing)
|
|
931
|
+
|
|
932
|
+
parent_worktree_path = Path(parent.worktree.path) if parent.worktree else Path.cwd()
|
|
933
|
+
parent_relative = parent_entry.relative_path or "."
|
|
934
|
+
|
|
935
|
+
target_forge_root: str | None = None
|
|
936
|
+
target_store: SessionStore | None = None
|
|
937
|
+
target_entry: SessionIndexEntry | None = None
|
|
938
|
+
target_state: SessionState | None = None
|
|
939
|
+
replace_stale_target_state = False
|
|
940
|
+
created_worktree = False
|
|
941
|
+
rollback_worktree_path: str | None = None
|
|
942
|
+
rollback_worktree_branch: str | None = None
|
|
943
|
+
rollback_repo_root: Path | None = None
|
|
944
|
+
|
|
945
|
+
def _rollback_created_worktree() -> None:
|
|
946
|
+
if not created_worktree or rollback_worktree_path is None:
|
|
947
|
+
return
|
|
948
|
+
try:
|
|
949
|
+
from .worktree import cleanup_worktree
|
|
950
|
+
|
|
951
|
+
cleanup_worktree(
|
|
952
|
+
worktree_path=Path(rollback_worktree_path),
|
|
953
|
+
branch=rollback_worktree_branch,
|
|
954
|
+
delete_branch_flag=True,
|
|
955
|
+
force=True,
|
|
956
|
+
repo_root=rollback_repo_root,
|
|
957
|
+
)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
logger.warning("Fork rollback cleanup failed for '%s': %s", rollback_worktree_path, e)
|
|
960
|
+
|
|
961
|
+
if into_path is not None:
|
|
962
|
+
# Fork into an existing worktree (--into): land at the equivalent
|
|
963
|
+
# forge_root position in the target checkout.
|
|
964
|
+
from .worktree import get_main_repo_root
|
|
965
|
+
|
|
966
|
+
target_checkout_root = into_path # Already normalized to checkout root by CLI
|
|
967
|
+
target_forge_root = str(Path(target_checkout_root) / parent_relative)
|
|
968
|
+
|
|
969
|
+
# Validate: target must have Forge enabled at that position
|
|
970
|
+
if not (Path(target_forge_root) / ".forge").is_dir():
|
|
971
|
+
raise ForgeSessionError(
|
|
972
|
+
f"No Forge project at {target_forge_root}. "
|
|
973
|
+
f"Run 'forge extension enable' in {target_forge_root} first, "
|
|
974
|
+
"or use --worktree to create a new checkout with auto-enable."
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
fork_worktree_path = target_checkout_root
|
|
978
|
+
fork_branch: str | None = branch # CLI resolves branch from git
|
|
979
|
+
project_root = str(get_main_repo_root(Path(into_path)))
|
|
980
|
+
is_into = True
|
|
981
|
+
|
|
982
|
+
assert target_forge_root is not None
|
|
983
|
+
target_store, target_entry, target_state = self._load_existing_fork_target(
|
|
984
|
+
fork_name=fork_name,
|
|
985
|
+
target_forge_root=target_forge_root,
|
|
986
|
+
)
|
|
987
|
+
target_conflict_exists = target_store.exists() or target_entry is not None
|
|
988
|
+
if target_conflict_exists:
|
|
989
|
+
if not force:
|
|
990
|
+
raise SessionExistsError(fork_name)
|
|
991
|
+
|
|
992
|
+
replace_stale_target_state = self._can_force_replace_fork_target(
|
|
993
|
+
fork_name=fork_name,
|
|
994
|
+
parent_name=parent_name,
|
|
995
|
+
target_forge_root=target_forge_root,
|
|
996
|
+
existing_state=target_state,
|
|
997
|
+
expected_worktree_path=fork_worktree_path,
|
|
998
|
+
expected_branch=fork_branch or fork_name,
|
|
999
|
+
expected_is_worktree=True,
|
|
1000
|
+
expected_owns_worktree=False,
|
|
1001
|
+
)
|
|
1002
|
+
if not replace_stale_target_state:
|
|
1003
|
+
raise SessionExistsError(fork_name)
|
|
1004
|
+
elif create_worktree:
|
|
1005
|
+
from .worktree import (
|
|
1006
|
+
copy_runtime_config,
|
|
1007
|
+
)
|
|
1008
|
+
from .worktree import create_worktree as git_create_worktree
|
|
1009
|
+
from .worktree import (
|
|
1010
|
+
get_main_repo_root,
|
|
1011
|
+
resolve_worktree_path,
|
|
1012
|
+
sanitize_branch_name,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
repo_root = get_main_repo_root(parent_worktree_path)
|
|
1016
|
+
target_worktree_path = resolve_worktree_path(repo_root, fork_name)
|
|
1017
|
+
target_forge_root = str(target_worktree_path / parent_relative)
|
|
1018
|
+
target_branch = branch or sanitize_branch_name(fork_name)
|
|
1019
|
+
target_store, target_entry, target_state = self._load_existing_fork_target(
|
|
1020
|
+
fork_name=fork_name,
|
|
1021
|
+
target_forge_root=target_forge_root,
|
|
1022
|
+
)
|
|
1023
|
+
target_conflict_exists = target_store.exists() or target_entry is not None
|
|
1024
|
+
if target_conflict_exists:
|
|
1025
|
+
if not force:
|
|
1026
|
+
raise SessionExistsError(fork_name)
|
|
1027
|
+
|
|
1028
|
+
replace_stale_target_state = self._can_force_replace_fork_target(
|
|
1029
|
+
fork_name=fork_name,
|
|
1030
|
+
parent_name=parent_name,
|
|
1031
|
+
target_forge_root=target_forge_root,
|
|
1032
|
+
existing_state=target_state,
|
|
1033
|
+
expected_worktree_path=str(target_worktree_path),
|
|
1034
|
+
expected_branch=target_branch,
|
|
1035
|
+
expected_is_worktree=True,
|
|
1036
|
+
expected_owns_worktree=True,
|
|
1037
|
+
)
|
|
1038
|
+
if not replace_stale_target_state:
|
|
1039
|
+
raise SessionExistsError(fork_name)
|
|
1040
|
+
wt_result = git_create_worktree(
|
|
1041
|
+
session_name=fork_name,
|
|
1042
|
+
branch=branch,
|
|
1043
|
+
cwd=repo_root,
|
|
1044
|
+
force=force,
|
|
1045
|
+
replace_owned_stale_state=replace_stale_target_state,
|
|
1046
|
+
)
|
|
1047
|
+
created_worktree = True
|
|
1048
|
+
rollback_worktree_path = wt_result.worktree_path
|
|
1049
|
+
rollback_worktree_branch = wt_result.branch
|
|
1050
|
+
rollback_repo_root = repo_root
|
|
1051
|
+
copy_runtime_config(repo_root, Path(wt_result.worktree_path))
|
|
1052
|
+
|
|
1053
|
+
fork_worktree_path = wt_result.worktree_path
|
|
1054
|
+
fork_branch = wt_result.branch
|
|
1055
|
+
project_root = str(repo_root)
|
|
1056
|
+
is_into = False
|
|
1057
|
+
else:
|
|
1058
|
+
target_forge_root = parent_forge_root
|
|
1059
|
+
fork_worktree_path = str(parent_worktree_path)
|
|
1060
|
+
fork_branch = parent.worktree.branch if parent.worktree else None
|
|
1061
|
+
project_root = str(find_project_root(fork_worktree_path))
|
|
1062
|
+
is_into = False
|
|
1063
|
+
assert target_forge_root is not None
|
|
1064
|
+
target_store, target_entry, target_state = self._load_existing_fork_target(
|
|
1065
|
+
fork_name=fork_name,
|
|
1066
|
+
target_forge_root=target_forge_root,
|
|
1067
|
+
)
|
|
1068
|
+
target_conflict_exists = target_store.exists() or target_entry is not None
|
|
1069
|
+
if target_conflict_exists:
|
|
1070
|
+
if not force:
|
|
1071
|
+
raise SessionExistsError(fork_name)
|
|
1072
|
+
|
|
1073
|
+
replace_stale_target_state = self._can_force_replace_fork_target(
|
|
1074
|
+
fork_name=fork_name,
|
|
1075
|
+
parent_name=parent_name,
|
|
1076
|
+
target_forge_root=target_forge_root,
|
|
1077
|
+
existing_state=target_state,
|
|
1078
|
+
expected_worktree_path=fork_worktree_path,
|
|
1079
|
+
expected_branch=fork_branch or fork_name,
|
|
1080
|
+
expected_is_worktree=False,
|
|
1081
|
+
expected_owns_worktree=False,
|
|
1082
|
+
)
|
|
1083
|
+
if not replace_stale_target_state:
|
|
1084
|
+
raise SessionExistsError(fork_name)
|
|
1085
|
+
|
|
1086
|
+
if direct:
|
|
1087
|
+
fork_proxy_template = None
|
|
1088
|
+
fork_proxy_base_url = None
|
|
1089
|
+
else:
|
|
1090
|
+
fork_proxy_template = parent.intent.proxy.template if parent.intent.proxy else None
|
|
1091
|
+
fork_proxy_base_url = parent.intent.proxy.base_url if parent.intent.proxy else None
|
|
1092
|
+
|
|
1093
|
+
fork_state = create_session_state(
|
|
1094
|
+
name=fork_name,
|
|
1095
|
+
proxy_template=fork_proxy_template,
|
|
1096
|
+
proxy_base_url=fork_proxy_base_url,
|
|
1097
|
+
parent_session=parent_name,
|
|
1098
|
+
is_fork=True,
|
|
1099
|
+
is_incognito=is_incognito,
|
|
1100
|
+
worktree_path=fork_worktree_path,
|
|
1101
|
+
worktree_branch=fork_branch,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
for field_name in ("subprocess_proxy", "policy", "memory", "system_prompt", "verification"):
|
|
1105
|
+
parent_val = getattr(parent.intent, field_name, None)
|
|
1106
|
+
if parent_val is not None:
|
|
1107
|
+
setattr(fork_state.intent, field_name, deepcopy(parent_val))
|
|
1108
|
+
inherited_launch = _inherited_launch_intent(parent)
|
|
1109
|
+
if inherited_launch is not None:
|
|
1110
|
+
fork_state.intent.launch = inherited_launch
|
|
1111
|
+
# Direct mode: force host launch (sidecar requires a proxy)
|
|
1112
|
+
if direct and fork_state.intent.launch and fork_state.intent.launch.mode != LAUNCH_MODE_HOST:
|
|
1113
|
+
fork_state.intent.launch.mode = LAUNCH_MODE_HOST
|
|
1114
|
+
fork_state.intent.launch.sidecar = None
|
|
1115
|
+
|
|
1116
|
+
if (create_worktree or is_into) and fork_state.worktree:
|
|
1117
|
+
fork_state.worktree.is_worktree = True
|
|
1118
|
+
if is_into and fork_state.worktree:
|
|
1119
|
+
fork_state.worktree.owns_worktree = False
|
|
1120
|
+
|
|
1121
|
+
# Compute identity fields for the fork target.
|
|
1122
|
+
fork_forge_root: str | None
|
|
1123
|
+
fork_relative_path: str | None
|
|
1124
|
+
if is_into:
|
|
1125
|
+
assert target_forge_root is not None
|
|
1126
|
+
fork_forge_root = target_forge_root
|
|
1127
|
+
fork_checkout_root = fork_worktree_path
|
|
1128
|
+
fork_relative_path = parent_entry.relative_path or "."
|
|
1129
|
+
elif create_worktree:
|
|
1130
|
+
# Fresh worktree has no .forge/; propagate parent's relative position.
|
|
1131
|
+
parent_relative = parent_entry.relative_path or "."
|
|
1132
|
+
fork_forge_root = str(Path(fork_worktree_path) / parent_relative)
|
|
1133
|
+
fork_checkout_root = fork_worktree_path
|
|
1134
|
+
fork_relative_path = parent_relative
|
|
1135
|
+
else:
|
|
1136
|
+
# Same-worktree fork: auto-detect
|
|
1137
|
+
from forge.core.ops.context import find_forge_root
|
|
1138
|
+
|
|
1139
|
+
fork_forge_root_path = find_forge_root(Path(fork_worktree_path))
|
|
1140
|
+
fork_forge_root = str(fork_forge_root_path) if fork_forge_root_path else None
|
|
1141
|
+
fork_checkout_root = fork_worktree_path
|
|
1142
|
+
fork_relative_path = None
|
|
1143
|
+
if fork_forge_root and fork_checkout_root:
|
|
1144
|
+
try:
|
|
1145
|
+
fork_relative_path = str(Path(fork_forge_root).relative_to(fork_checkout_root))
|
|
1146
|
+
except ValueError:
|
|
1147
|
+
fork_relative_path = "."
|
|
1148
|
+
|
|
1149
|
+
fork_state.forge_root = fork_forge_root
|
|
1150
|
+
fork_resume_mode = "handoff" if (create_worktree or is_into) else "native"
|
|
1151
|
+
# For handoff-mode forks the per-child file is created lazily at launch
|
|
1152
|
+
# (see _generate_parent_handoff_context). We pre-record the reference
|
|
1153
|
+
# here so GC knows the fork's child file belongs to this session, even
|
|
1154
|
+
# if launch happens later.
|
|
1155
|
+
fork_context_file_rel = child_path_rel(parent_name, fork_name) if fork_resume_mode == "handoff" else None
|
|
1156
|
+
fork_state.confirmed.derivation = Derivation(
|
|
1157
|
+
parent_session=parent_name,
|
|
1158
|
+
parent_transcript=_latest_transcript_artifact_path(parent),
|
|
1159
|
+
inherited_proxy=fork_proxy_template,
|
|
1160
|
+
resume_mode=fork_resume_mode,
|
|
1161
|
+
strategy=None,
|
|
1162
|
+
depth=1,
|
|
1163
|
+
resumed_at=now_iso(),
|
|
1164
|
+
lineage=[parent_name],
|
|
1165
|
+
context_file=fork_context_file_rel,
|
|
1166
|
+
parent_forge_root=parent_entry.forge_root or parent_entry.worktree_path,
|
|
1167
|
+
parent_project_root=parent_entry.project_root,
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
fork_store = SessionStore(fork_forge_root or fork_worktree_path, fork_name)
|
|
1171
|
+
restore_target_state = replace_stale_target_state and not create_worktree
|
|
1172
|
+
replaced_target_state = False
|
|
1173
|
+
wrote_manifest = False
|
|
1174
|
+
added_to_index = False
|
|
1175
|
+
|
|
1176
|
+
def _restore_previous_target_state() -> None:
|
|
1177
|
+
if not restore_target_state or not replaced_target_state or target_store is None or target_state is None:
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
try:
|
|
1181
|
+
target_store.write(target_state)
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
logger.warning("Failed to restore fork target manifest '%s': %s", fork_name, e)
|
|
1184
|
+
|
|
1185
|
+
if target_entry is None:
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
try:
|
|
1189
|
+
self.index_store.add_from_state(
|
|
1190
|
+
target_state,
|
|
1191
|
+
target_entry.project_root,
|
|
1192
|
+
checkout_root=target_entry.checkout_root,
|
|
1193
|
+
forge_root=target_entry.forge_root,
|
|
1194
|
+
relative_path=target_entry.relative_path,
|
|
1195
|
+
)
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
logger.warning("Failed to restore fork target index entry '%s': %s", fork_name, e)
|
|
1198
|
+
|
|
1199
|
+
try:
|
|
1200
|
+
# Stale session cleanup: only clear the actual target namespace after
|
|
1201
|
+
# all validation succeeds. Git worktree replacement (if any) has
|
|
1202
|
+
# already happened, so this only swaps the session metadata layer.
|
|
1203
|
+
if replace_stale_target_state:
|
|
1204
|
+
effective_fork_root = fork_forge_root or fork_worktree_path
|
|
1205
|
+
try:
|
|
1206
|
+
self.delete_session(
|
|
1207
|
+
fork_name,
|
|
1208
|
+
delete_worktree=False,
|
|
1209
|
+
delete_branch=False,
|
|
1210
|
+
force=True,
|
|
1211
|
+
forge_root=effective_fork_root,
|
|
1212
|
+
)
|
|
1213
|
+
except SessionNotFoundError:
|
|
1214
|
+
pass
|
|
1215
|
+
|
|
1216
|
+
stale_store = SessionStore(effective_fork_root, fork_name)
|
|
1217
|
+
if stale_store.exists():
|
|
1218
|
+
stale_store.delete()
|
|
1219
|
+
|
|
1220
|
+
try:
|
|
1221
|
+
from .active import ActiveSessionStore
|
|
1222
|
+
|
|
1223
|
+
ActiveSessionStore().clear_session(fork_name, forge_root=effective_fork_root)
|
|
1224
|
+
except Exception as e:
|
|
1225
|
+
logger.debug("Failed to clear active session '%s' (non-critical): %s", fork_name, e)
|
|
1226
|
+
|
|
1227
|
+
replaced_target_state = True
|
|
1228
|
+
|
|
1229
|
+
fork_store.write(fork_state)
|
|
1230
|
+
wrote_manifest = True
|
|
1231
|
+
|
|
1232
|
+
self.index_store.add_from_state(
|
|
1233
|
+
fork_state,
|
|
1234
|
+
project_root,
|
|
1235
|
+
checkout_root=fork_checkout_root,
|
|
1236
|
+
forge_root=fork_forge_root,
|
|
1237
|
+
relative_path=fork_relative_path,
|
|
1238
|
+
)
|
|
1239
|
+
added_to_index = True
|
|
1240
|
+
|
|
1241
|
+
return parent, fork_state
|
|
1242
|
+
|
|
1243
|
+
except Exception:
|
|
1244
|
+
try:
|
|
1245
|
+
if added_to_index:
|
|
1246
|
+
self.index_store.remove_session(fork_name, forge_root=fork_forge_root)
|
|
1247
|
+
except Exception as rollback_err:
|
|
1248
|
+
logger.warning("Fork rollback failed (index entry): %s", rollback_err)
|
|
1249
|
+
|
|
1250
|
+
try:
|
|
1251
|
+
if wrote_manifest and fork_store.exists():
|
|
1252
|
+
fork_store.delete()
|
|
1253
|
+
except Exception as rollback_err:
|
|
1254
|
+
logger.warning("Fork rollback failed (manifest delete): %s", rollback_err)
|
|
1255
|
+
|
|
1256
|
+
if create_worktree:
|
|
1257
|
+
_rollback_created_worktree()
|
|
1258
|
+
else:
|
|
1259
|
+
_restore_previous_target_state()
|
|
1260
|
+
|
|
1261
|
+
raise
|
|
1262
|
+
|
|
1263
|
+
def relaunch_session(
|
|
1264
|
+
self,
|
|
1265
|
+
parent_name: str,
|
|
1266
|
+
*,
|
|
1267
|
+
child_name: str | None = None,
|
|
1268
|
+
forge_root: str | None = None,
|
|
1269
|
+
) -> tuple[SessionState, SessionState]:
|
|
1270
|
+
"""Create a child session for relaunching a previously-used parent.
|
|
1271
|
+
|
|
1272
|
+
Lightweight derivation: inherits intent/overrides/proxy, sets
|
|
1273
|
+
parent_session lineage. Does NOT pre-seed claude_session_id
|
|
1274
|
+
(launch-owned). Does NOT assemble context (unlike resume_session).
|
|
1275
|
+
|
|
1276
|
+
The caller should launch Claude with ``--resume --fork-session``
|
|
1277
|
+
using the parent's claude_session_id so the conversation carries
|
|
1278
|
+
over into a distinct new Claude UUID.
|
|
1279
|
+
|
|
1280
|
+
Args:
|
|
1281
|
+
parent_name: Session to relaunch.
|
|
1282
|
+
child_name: Name for the child (auto-generated if None).
|
|
1283
|
+
|
|
1284
|
+
Returns:
|
|
1285
|
+
Tuple of (parent_state, child_state).
|
|
1286
|
+
|
|
1287
|
+
Raises:
|
|
1288
|
+
SessionNotFoundError: If parent doesn't exist.
|
|
1289
|
+
"""
|
|
1290
|
+
parent = self.get_session(parent_name, forge_root=forge_root)
|
|
1291
|
+
parent_entry = self.index_store.get_session(parent_name, forge_root=forge_root)
|
|
1292
|
+
parent_forge_root = parent_entry.forge_root or parent_entry.worktree_path
|
|
1293
|
+
|
|
1294
|
+
if child_name is None:
|
|
1295
|
+
child_name = self._generate_relaunch_name(parent_name, forge_root=parent_forge_root)
|
|
1296
|
+
|
|
1297
|
+
if self.index_store.session_exists(child_name, forge_root=parent_forge_root):
|
|
1298
|
+
raise SessionExistsError(child_name)
|
|
1299
|
+
|
|
1300
|
+
parent_worktree_path = parent_entry.worktree_path
|
|
1301
|
+
project_root = parent_entry.project_root
|
|
1302
|
+
|
|
1303
|
+
proxy_template = parent.intent.proxy.template if parent.intent.proxy else None
|
|
1304
|
+
proxy_base_url = parent.intent.proxy.base_url if parent.intent.proxy else None
|
|
1305
|
+
|
|
1306
|
+
child_state = create_session_state(
|
|
1307
|
+
name=child_name,
|
|
1308
|
+
proxy_template=proxy_template,
|
|
1309
|
+
proxy_base_url=proxy_base_url,
|
|
1310
|
+
parent_session=parent_name,
|
|
1311
|
+
is_fork=True,
|
|
1312
|
+
is_incognito=parent.is_incognito,
|
|
1313
|
+
worktree_path=parent_worktree_path,
|
|
1314
|
+
worktree_branch=parent.worktree.branch if parent.worktree else None,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
for field_name in ("subprocess_proxy", "policy", "memory", "system_prompt", "verification"):
|
|
1318
|
+
parent_val = getattr(parent.intent, field_name, None)
|
|
1319
|
+
if parent_val is not None:
|
|
1320
|
+
setattr(child_state.intent, field_name, deepcopy(parent_val))
|
|
1321
|
+
inherited_launch = _inherited_launch_intent(parent)
|
|
1322
|
+
if inherited_launch is not None:
|
|
1323
|
+
child_state.intent.launch = inherited_launch
|
|
1324
|
+
child_state.overrides = deepcopy(parent.overrides)
|
|
1325
|
+
|
|
1326
|
+
# Propagate identity from parent
|
|
1327
|
+
child_state.forge_root = parent_entry.forge_root or parent.forge_root
|
|
1328
|
+
|
|
1329
|
+
child_store = SessionStore(parent_entry.forge_root or parent_worktree_path, child_name)
|
|
1330
|
+
child_store.write(child_state)
|
|
1331
|
+
self.index_store.add_from_state(
|
|
1332
|
+
child_state,
|
|
1333
|
+
project_root,
|
|
1334
|
+
checkout_root=parent_entry.checkout_root,
|
|
1335
|
+
forge_root=parent_entry.forge_root,
|
|
1336
|
+
relative_path=parent_entry.relative_path,
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
return parent, child_state
|
|
1340
|
+
|
|
1341
|
+
def _generate_relaunch_name(self, parent_name: str, forge_root: str | None = None) -> str:
|
|
1342
|
+
"""Generate a unique name for a relaunched session (project-scoped)."""
|
|
1343
|
+
existing = {name for name, _ in self.list_sessions(forge_root_filter=forge_root)}
|
|
1344
|
+
return generate_unique_name(existing)
|
|
1345
|
+
|
|
1346
|
+
def _generate_resume_name(self, parent_name: str, forge_root: str | None = None) -> str:
|
|
1347
|
+
"""Generate a unique name for a resumed session (project-scoped)."""
|
|
1348
|
+
base_name = f"{parent_name}-resumed"
|
|
1349
|
+
if not self.index_store.session_exists(base_name, forge_root=forge_root):
|
|
1350
|
+
return base_name
|
|
1351
|
+
|
|
1352
|
+
from datetime import datetime
|
|
1353
|
+
|
|
1354
|
+
suffix = datetime.now().strftime("%H%M%S")
|
|
1355
|
+
return f"{parent_name}-resumed-{suffix}"
|
|
1356
|
+
|
|
1357
|
+
def _find_co_resident_sessions(self, worktree_path: str, exclude: str) -> list[str]:
|
|
1358
|
+
"""Find other sessions living in the same worktree directory.
|
|
1359
|
+
|
|
1360
|
+
Uses list_sessions() (self-healing) to avoid stale entries blocking cleanup.
|
|
1361
|
+
"""
|
|
1362
|
+
normalized = str(Path(worktree_path).resolve())
|
|
1363
|
+
return [
|
|
1364
|
+
name
|
|
1365
|
+
for name, entry in self.index_store.list_sessions()
|
|
1366
|
+
if str(Path(entry.worktree_path).resolve()) == normalized and name != exclude
|
|
1367
|
+
]
|
|
1368
|
+
|
|
1369
|
+
def delete_session(
|
|
1370
|
+
self,
|
|
1371
|
+
name: str,
|
|
1372
|
+
*,
|
|
1373
|
+
delete_transcripts: bool = True,
|
|
1374
|
+
delete_worktree: bool = True,
|
|
1375
|
+
delete_branch: bool = False,
|
|
1376
|
+
force: bool = False,
|
|
1377
|
+
forge_root: str | None = None,
|
|
1378
|
+
) -> None:
|
|
1379
|
+
"""Delete a session and optionally its worktree and transcripts.
|
|
1380
|
+
|
|
1381
|
+
Removes the session from the index, deletes the manifest, and
|
|
1382
|
+
optionally cleans up the git worktree and transcript files.
|
|
1383
|
+
|
|
1384
|
+
Args:
|
|
1385
|
+
name: Session name to delete.
|
|
1386
|
+
delete_transcripts: Whether to delete transcript files (default True).
|
|
1387
|
+
delete_worktree: Whether to remove the git worktree (default True).
|
|
1388
|
+
delete_branch: Whether to delete the git branch (default False).
|
|
1389
|
+
force: Force removal even with uncommitted changes (default False).
|
|
1390
|
+
|
|
1391
|
+
Raises:
|
|
1392
|
+
SessionNotFoundError: If session doesn't exist.
|
|
1393
|
+
InvalidSessionNameError: If name is invalid.
|
|
1394
|
+
DirtyWorktreeError: If worktree has uncommitted changes and force=False.
|
|
1395
|
+
"""
|
|
1396
|
+
from .claude.cleanup import cleanup_session
|
|
1397
|
+
|
|
1398
|
+
entry = self.index_store.get_session(name, forge_root=forge_root)
|
|
1399
|
+
entry_forge_root = entry.forge_root or entry.worktree_path
|
|
1400
|
+
store = SessionStore(entry_forge_root, name)
|
|
1401
|
+
|
|
1402
|
+
state = None
|
|
1403
|
+
_raw_data: dict[str, Any] | None = None
|
|
1404
|
+
if store.exists():
|
|
1405
|
+
try:
|
|
1406
|
+
state = store.read()
|
|
1407
|
+
except (ManifestCorruptedError, ManifestValidationError):
|
|
1408
|
+
if not force:
|
|
1409
|
+
raise
|
|
1410
|
+
# Best-effort: read raw JSON for cleanup-relevant fields
|
|
1411
|
+
# even though full deserialization failed.
|
|
1412
|
+
_raw_data = store.read_raw()
|
|
1413
|
+
logger.warning(
|
|
1414
|
+
"Manifest corrupted; force-deleting with best-effort cleanup "
|
|
1415
|
+
"(transcript/worktree cleanup may be incomplete)"
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
# Build cleanup hints from raw data when state is unavailable
|
|
1419
|
+
_claude_session_id: str | None = None
|
|
1420
|
+
_worktree_info: dict[str, Any] | None = None
|
|
1421
|
+
if state:
|
|
1422
|
+
_claude_session_id = state.confirmed.claude_session_id
|
|
1423
|
+
if state.worktree:
|
|
1424
|
+
_worktree_info = {
|
|
1425
|
+
"path": state.worktree.path,
|
|
1426
|
+
"is_worktree": state.worktree.is_worktree,
|
|
1427
|
+
"owns_worktree": getattr(state.worktree, "owns_worktree", True),
|
|
1428
|
+
"branch": state.worktree.branch,
|
|
1429
|
+
}
|
|
1430
|
+
elif _raw_data:
|
|
1431
|
+
confirmed = _raw_data.get("confirmed", {})
|
|
1432
|
+
if isinstance(confirmed, dict):
|
|
1433
|
+
_claude_session_id = confirmed.get("claude_session_id")
|
|
1434
|
+
wt = _raw_data.get("worktree")
|
|
1435
|
+
if isinstance(wt, dict) and wt.get("path"):
|
|
1436
|
+
_worktree_info = {
|
|
1437
|
+
"path": wt["path"],
|
|
1438
|
+
"is_worktree": wt.get("is_worktree", False),
|
|
1439
|
+
"owns_worktree": wt.get("owns_worktree", True),
|
|
1440
|
+
"branch": wt.get("branch"),
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
# Worktree cleanup decision: determine BEFORE any destructive work whether
|
|
1444
|
+
# we'll remove the worktree. This lets the dirty preflight block everything
|
|
1445
|
+
# (transcripts + worktree + index removal) atomically.
|
|
1446
|
+
_should_cleanup_worktree = False
|
|
1447
|
+
if delete_worktree and _worktree_info and _worktree_info["is_worktree"]:
|
|
1448
|
+
_owns = _worktree_info["owns_worktree"]
|
|
1449
|
+
co_residents = self._find_co_resident_sessions(_worktree_info["path"], exclude=name)
|
|
1450
|
+
if co_residents:
|
|
1451
|
+
logger.info(
|
|
1452
|
+
"Skipping worktree removal: %d other session(s) present (%s)",
|
|
1453
|
+
len(co_residents),
|
|
1454
|
+
", ".join(co_residents[:3]),
|
|
1455
|
+
)
|
|
1456
|
+
elif not _owns:
|
|
1457
|
+
logger.info("Skipping worktree removal: session does not own worktree (--into)")
|
|
1458
|
+
else:
|
|
1459
|
+
_should_cleanup_worktree = True
|
|
1460
|
+
|
|
1461
|
+
# Dirty-worktree preflight: only check if we'll actually remove the worktree.
|
|
1462
|
+
# Runs before transcript cleanup so DirtyWorktreeError blocks all destructive work.
|
|
1463
|
+
# Shared worktrees (co-residents or --into) skip this entirely.
|
|
1464
|
+
if _should_cleanup_worktree and _worktree_info:
|
|
1465
|
+
from .worktree import is_worktree_dirty
|
|
1466
|
+
|
|
1467
|
+
worktree_path = Path(_worktree_info["path"])
|
|
1468
|
+
if not force and worktree_path.exists() and is_worktree_dirty(worktree_path):
|
|
1469
|
+
raise DirtyWorktreeError(str(worktree_path))
|
|
1470
|
+
|
|
1471
|
+
if _should_cleanup_worktree and _worktree_info:
|
|
1472
|
+
from .worktree import cleanup_worktree
|
|
1473
|
+
|
|
1474
|
+
worktree_path = Path(_worktree_info["path"])
|
|
1475
|
+
branch = _worktree_info["branch"] if delete_branch else None
|
|
1476
|
+
|
|
1477
|
+
cleanup_result = cleanup_worktree(
|
|
1478
|
+
worktree_path=worktree_path,
|
|
1479
|
+
branch=branch,
|
|
1480
|
+
delete_branch_flag=delete_branch,
|
|
1481
|
+
force=force,
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
if cleanup_result.errors:
|
|
1485
|
+
raise ForgeSessionError(cleanup_result.errors[0])
|
|
1486
|
+
|
|
1487
|
+
if delete_transcripts and _claude_session_id:
|
|
1488
|
+
_artifact_ids = _tracked_transcript_session_ids(state) if state else [_claude_session_id]
|
|
1489
|
+
cleanup_session(
|
|
1490
|
+
project_root=entry.forge_root or entry.worktree_path,
|
|
1491
|
+
claude_session_id=_claude_session_id,
|
|
1492
|
+
artifact_session_ids=_artifact_ids,
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
self.index_store.remove_session(name, forge_root=entry_forge_root)
|
|
1496
|
+
|
|
1497
|
+
# Delete manifest file (only if worktree still exists or wasn't a worktree)
|
|
1498
|
+
if store.exists():
|
|
1499
|
+
store.delete()
|
|
1500
|
+
|
|
1501
|
+
try:
|
|
1502
|
+
from .active import ActiveSessionStore
|
|
1503
|
+
|
|
1504
|
+
ActiveSessionStore().clear_session(name, forge_root=entry_forge_root)
|
|
1505
|
+
except Exception as e:
|
|
1506
|
+
logger.debug("Failed to clear active session '%s' (non-critical): %s", name, e)
|