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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -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)