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
forge/cli/session.py ADDED
@@ -0,0 +1,892 @@
1
+ """Session management CLI commands.
2
+
3
+ Commands for managing Claude Code sessions:
4
+ - start: Create and start a new session
5
+ - resume: Resume a session (reattach or --fresh for context handoff)
6
+ - fork: Fork an existing session
7
+ - delete: Delete a session
8
+ - list: List all sessions
9
+ - show: Show the current or named session
10
+ - switch: Switch to a different session
11
+ - shell: Open a shell in a sidecar session
12
+ - set/reset: Manage session overrides
13
+ - incognito: Start an incognito session
14
+
15
+ Lifecycle commands (start, resume, fork, incognito) live in session_lifecycle.py.
16
+ Management commands (delete, list, clean, show, etc.) live in session_manage.py.
17
+ Both are re-exported here so ``patch("forge.cli.session.XXX")`` keeps working.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+
27
+ import click
28
+ from rich.console import Console
29
+
30
+ from forge.core.paths import display_path
31
+ from forge.core.reactive.env import (
32
+ FORGE_SUBPROCESS_BASE_URL_VAR,
33
+ FORGE_SUBPROCESS_PROXY_ID_VAR,
34
+ FORGE_SUBPROCESS_PROXY_VAR,
35
+ FORGE_SUBPROCESS_TEMPLATE_VAR,
36
+ )
37
+ from forge.core.state import parse_iso
38
+ from forge.session import (
39
+ LAUNCH_MODE_HOST,
40
+ LAUNCH_MODE_SIDECAR,
41
+ ActiveSessionEntry,
42
+ ForgeSessionError,
43
+ SessionIndexEntry,
44
+ SessionManager,
45
+ SessionState,
46
+ )
47
+ from forge.session.exceptions import (
48
+ AmbiguousSessionError,
49
+ SessionNotFoundError,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # Shared console for Rich output
55
+ console = Console()
56
+
57
+
58
+ # --- Routing resolution ---
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ResolvedRouting:
63
+ """Resolved proxy routing for a session launch.
64
+
65
+ Produced by _resolve_routing_from_cli() and threaded through
66
+ launch_new_session, resume, fork, etc.
67
+ """
68
+
69
+ template: str | None = None
70
+ base_url: str | None = None
71
+ proxy_id: str | None = None
72
+ context_limit: int | None = None
73
+
74
+ @property
75
+ def is_direct(self) -> bool:
76
+ return self.base_url is None
77
+
78
+
79
+ def _resolve_routing_from_cli(
80
+ *,
81
+ proxy_name: str | None,
82
+ direct: bool,
83
+ ) -> ResolvedRouting:
84
+ """Resolve --proxy/--no-proxy CLI flags to a ResolvedRouting.
85
+
86
+ Performs registry lookup + healthcheck for --proxy. Returns
87
+ a direct routing for --no-proxy. Callers must validate mutual
88
+ exclusivity before calling.
89
+
90
+ Raises click.ClickException on resolution/healthcheck failure.
91
+ """
92
+ if direct or not proxy_name:
93
+ return ResolvedRouting()
94
+
95
+ from forge.cli.claude import _get_context_limit_for_proxy, _healthcheck_proxy
96
+ from forge.proxy.proxies import (
97
+ ProxyRegistryCorruptedError,
98
+ ProxyRegistryStore,
99
+ ProxyResolutionError,
100
+ resolve_proxy,
101
+ )
102
+
103
+ store = ProxyRegistryStore()
104
+ try:
105
+ registry = store.read()
106
+ except ProxyRegistryCorruptedError as e:
107
+ raise click.ClickException(str(e))
108
+
109
+ try:
110
+ entry = resolve_proxy(registry, proxy_name)
111
+ except ProxyResolutionError as e:
112
+ raise click.ClickException(str(e))
113
+
114
+ try:
115
+ _healthcheck_proxy(
116
+ base_url=entry.base_url,
117
+ expected_template=entry.template,
118
+ expected_proxy_id=entry.proxy_id,
119
+ )
120
+ except ValueError as e:
121
+ msg = str(e)
122
+ if "not running" in msg:
123
+ msg += f"\nTip: Run 'forge proxy start {entry.proxy_id}' to start it."
124
+ raise click.ClickException(msg)
125
+
126
+ return ResolvedRouting(
127
+ template=entry.template,
128
+ base_url=entry.base_url,
129
+ proxy_id=entry.proxy_id,
130
+ context_limit=_get_context_limit_for_proxy(entry.proxy_id),
131
+ )
132
+
133
+
134
+ def _apply_routing_override_to_state(
135
+ *,
136
+ state: SessionState,
137
+ routing: ResolvedRouting | None,
138
+ direct: bool,
139
+ ) -> None:
140
+ """Apply a CLI routing override to an in-memory session state."""
141
+ if not routing and not direct:
142
+ return
143
+
144
+ from forge.session.models import LaunchIntent, ProxyIntent
145
+
146
+ # Explicit CLI routing beats any stale last-launch proxy snapshot.
147
+ state.confirmed.started_with_proxy = None
148
+
149
+ if direct:
150
+ state.intent.proxy = None
151
+ if state.intent.launch is None:
152
+ state.intent.launch = LaunchIntent(mode=LAUNCH_MODE_HOST)
153
+ else:
154
+ state.intent.launch.mode = LAUNCH_MODE_HOST
155
+ state.intent.launch.sidecar = None
156
+ return
157
+
158
+ assert routing is not None
159
+ state.intent.proxy = ProxyIntent(
160
+ template=routing.template or "",
161
+ base_url=routing.base_url or "",
162
+ )
163
+
164
+
165
+ def _persist_routing_override(
166
+ *,
167
+ forge_root: Path,
168
+ session_name: str,
169
+ routing: ResolvedRouting | None,
170
+ direct: bool,
171
+ ) -> None:
172
+ """Persist a --proxy/--no-proxy CLI override into the session manifest.
173
+
174
+ Called after manager.fork_session()/resume_session() creates the child
175
+ so the intent reflects the override, not the inherited parent routing.
176
+ This ensures --no-launch forks retain the requested proxy.
177
+
178
+ Only persists intent changes -- confirmed.started_with_proxy is hook-owned
179
+ and must not be cleared on disk before a successful launch. The in-memory
180
+ clearing in _apply_routing_override_to_state() is sufficient for the
181
+ current launch; the SessionStart hook will update confirmed on success.
182
+ """
183
+ if not routing and not direct:
184
+ return
185
+
186
+ from forge.session import SessionStore
187
+ from forge.session.models import LaunchIntent, ProxyIntent
188
+
189
+ store = SessionStore(str(forge_root), session_name)
190
+
191
+ def _mutate(m: SessionState) -> None:
192
+ if direct:
193
+ m.intent.proxy = None
194
+ if m.intent.launch is None:
195
+ m.intent.launch = LaunchIntent(mode=LAUNCH_MODE_HOST)
196
+ else:
197
+ m.intent.launch.mode = LAUNCH_MODE_HOST
198
+ m.intent.launch.sidecar = None
199
+ elif routing is not None:
200
+ m.intent.proxy = ProxyIntent(
201
+ template=routing.template or "",
202
+ base_url=routing.base_url or "",
203
+ )
204
+
205
+ try:
206
+ store.update(timeout_s=5.0, mutate=_mutate)
207
+ except Exception:
208
+ logger.debug("Failed to persist routing override to manifest", exc_info=True)
209
+
210
+
211
+ def _cwd_forge_root() -> str | None:
212
+ """Resolve forge_root from CWD for project-scoped session lookups."""
213
+ try:
214
+ from forge.core.ops.context import find_forge_root
215
+
216
+ fr = find_forge_root(Path.cwd().resolve())
217
+ return str(fr) if fr else None
218
+ except Exception:
219
+ return None
220
+
221
+
222
+ def _session_scope_key(name: str, entry: SessionIndexEntry) -> tuple[str, str]:
223
+ """Return the list/cleanup identity tuple for a session entry."""
224
+ return (name, entry.forge_root or entry.worktree_path)
225
+
226
+
227
+ def _session_list_location(entry: SessionIndexEntry) -> str:
228
+ """Return a short location label for human session-list disambiguation."""
229
+ if entry.relative_path and entry.relative_path != ".":
230
+ return entry.relative_path
231
+
232
+ root = entry.forge_root or entry.worktree_path
233
+ return Path(root).name if root else "."
234
+
235
+
236
+ def _default_context_limit() -> int:
237
+ from forge.runtime_config import get_runtime_config
238
+
239
+ return get_runtime_config().context_limit
240
+
241
+
242
+ def _resolve_context_limit(proxy_ref: str | None) -> int:
243
+ """Compute context limit by resolving a proxy for the given proxy_id or template name.
244
+
245
+ Uses resolve_proxy_optional() which tries exact proxy_id match first,
246
+ then unique active template match. Falls back to _default_context_limit()
247
+ if no match, ambiguous, or config is malformed.
248
+
249
+ Args:
250
+ proxy_ref: Proxy ID or template name (e.g., "openrouter-gemini").
251
+
252
+ Returns:
253
+ Context window size in tokens, or _default_context_limit() if no match found.
254
+ """
255
+ if not proxy_ref:
256
+ return _default_context_limit()
257
+
258
+ try:
259
+ from forge.config.loader import load_proxy_instance_config
260
+ from forge.core.models import get_context_window_tokens
261
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy_optional
262
+
263
+ store = ProxyRegistryStore()
264
+ registry = store.read()
265
+
266
+ entry = resolve_proxy_optional(registry, proxy_ref)
267
+ if entry is None:
268
+ logger.debug(f"No matching proxy found for '{proxy_ref}', using default")
269
+ return _default_context_limit()
270
+
271
+ proxy_config = load_proxy_instance_config(entry.proxy_id)
272
+ if proxy_config is None:
273
+ logger.debug(f"No proxy config found for {entry.proxy_id}, using default")
274
+ return _default_context_limit()
275
+
276
+ tier = proxy_config.default_tier or "sonnet"
277
+ model = proxy_config.tiers.get(tier)
278
+ if not model:
279
+ logger.debug(f"No model for tier {tier} in proxy {entry.proxy_id}, using default")
280
+ return _default_context_limit()
281
+
282
+ context_limit = get_context_window_tokens(model)
283
+ logger.debug(f"Computed context limit {context_limit} for '{proxy_ref}' via proxy {entry.proxy_id}")
284
+ return context_limit
285
+ except Exception as e:
286
+ logger.debug(f"Failed to compute context limit for '{proxy_ref}': {e}")
287
+ return _default_context_limit()
288
+
289
+
290
+ def _format_relative_time(iso_timestamp: str) -> str:
291
+ """Format an ISO timestamp as a human-readable relative time."""
292
+ try:
293
+ dt = parse_iso(iso_timestamp)
294
+ now = datetime.now(UTC)
295
+ delta = now - dt
296
+
297
+ seconds = delta.total_seconds()
298
+ if seconds < 60:
299
+ return "just now"
300
+ elif seconds < 3600:
301
+ minutes = int(seconds / 60)
302
+ return f"{minutes} min{'s' if minutes != 1 else ''} ago"
303
+ elif seconds < 86400:
304
+ hours = int(seconds / 3600)
305
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
306
+ elif seconds < 604800:
307
+ days = int(seconds / 86400)
308
+ return f"{days} day{'s' if days != 1 else ''} ago"
309
+ else:
310
+ weeks = int(seconds / 604800)
311
+ return f"{weeks} week{'s' if weeks != 1 else ''} ago"
312
+ except (ValueError, TypeError):
313
+ return "unknown"
314
+
315
+
316
+ def _get_session_type(
317
+ is_fork: bool,
318
+ is_incognito: bool,
319
+ parent_session: str | None,
320
+ ) -> str:
321
+ """Get a human-readable session type string."""
322
+ if is_incognito:
323
+ if is_fork and parent_session:
324
+ return f"fork of {parent_session} (incognito)"
325
+ return "incognito"
326
+ if is_fork and parent_session:
327
+ return f"fork of {parent_session}"
328
+ return "session"
329
+
330
+
331
+ def _get_effective_proxy_for_session(
332
+ state: SessionState,
333
+ ) -> tuple[str | None, str | None, str | None]:
334
+ """Resolve the best-known template/base_url/proxy_id for a session.
335
+
336
+ Returns (template, base_url, proxy_id). The proxy_id (when available)
337
+ enables deterministic context limit computation via exact registry
338
+ lookup, avoiding active-only template resolution.
339
+ """
340
+ if state.confirmed.started_with_proxy:
341
+ return (
342
+ state.confirmed.started_with_proxy.template,
343
+ state.confirmed.started_with_proxy.base_url,
344
+ state.confirmed.started_with_proxy.proxy_id,
345
+ )
346
+
347
+ if state.intent.proxy:
348
+ return state.intent.proxy.template, state.intent.proxy.base_url, None
349
+
350
+ return None, None, None
351
+
352
+
353
+ def _template_display_label(template: str | None) -> str:
354
+ """Return a user-facing routing label for list/detail views."""
355
+ return template or "direct"
356
+
357
+
358
+ def _print_routing_summary(*, template: str | None, base_url: str | None) -> None:
359
+ """Print routing details for a session launch summary."""
360
+ if base_url is None:
361
+ console.print(" Routing: direct")
362
+ console.print(" Base URL: default Anthropic")
363
+ return
364
+
365
+ if template is None:
366
+ console.print(" Routing: custom base URL")
367
+ console.print(f" Base URL: {base_url}")
368
+ return
369
+
370
+ console.print(f" Template: {template}")
371
+ console.print(f" Base URL: {base_url}")
372
+
373
+
374
+ def _build_session_env(
375
+ *,
376
+ session_name: str,
377
+ context_limit: int,
378
+ template: str | None,
379
+ base_url: str | None,
380
+ fork_name: str | None = None,
381
+ parent_session: str | None = None,
382
+ forge_root: str | None = None,
383
+ subprocess_proxy: str | None = None,
384
+ sidecar: bool = False,
385
+ ) -> tuple[dict[str, str], list[str]]:
386
+ """Build Claude env vars plus explicit unsets for a session launch."""
387
+ env_vars: dict[str, str] = {
388
+ "FORGE_SESSION": session_name,
389
+ }
390
+ if forge_root:
391
+ env_vars["FORGE_FORGE_ROOT"] = forge_root
392
+ unset_env_vars: list[str] = []
393
+
394
+ if base_url is None:
395
+ # Direct mode: don't touch CLAUDE_CODE_AUTO_COMPACT_WINDOW -- it's a
396
+ # native CC env var the user may have set. Only scrub Forge-managed vars.
397
+ unset_env_vars.append("ANTHROPIC_BASE_URL")
398
+ unset_env_vars.append("ACTIVE_TEMPLATE")
399
+ else:
400
+ # Proxy mode: set compaction window to match the routed model's context.
401
+ env_vars["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(context_limit)
402
+ env_vars["ANTHROPIC_BASE_URL"] = base_url
403
+ if template is None:
404
+ unset_env_vars.append("ACTIVE_TEMPLATE")
405
+ else:
406
+ env_vars["ACTIVE_TEMPLATE"] = template
407
+
408
+ if subprocess_proxy:
409
+ env_vars[FORGE_SUBPROCESS_PROXY_VAR] = subprocess_proxy
410
+ env_vars.update(_resolve_subprocess_proxy_launch_metadata(subprocess_proxy, sidecar=sidecar))
411
+
412
+ if fork_name is not None:
413
+ env_vars["FORGE_FORK_NAME"] = fork_name
414
+ if parent_session is not None:
415
+ env_vars["FORGE_PARENT_SESSION"] = parent_session
416
+
417
+ return env_vars, unset_env_vars
418
+
419
+
420
+ def _resolve_subprocess_proxy_launch_metadata(proxy_id: str, *, sidecar: bool = False) -> dict[str, str]:
421
+ """Resolve subprocess proxy metadata to inject into launched sessions."""
422
+ try:
423
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy_optional
424
+
425
+ registry = ProxyRegistryStore().read()
426
+ entry = resolve_proxy_optional(registry, proxy_id)
427
+ if entry is None:
428
+ return {}
429
+
430
+ base_url = _container_reachable_url(entry.base_url) if sidecar else entry.base_url
431
+ return {
432
+ FORGE_SUBPROCESS_BASE_URL_VAR: base_url,
433
+ FORGE_SUBPROCESS_PROXY_ID_VAR: entry.proxy_id,
434
+ FORGE_SUBPROCESS_TEMPLATE_VAR: entry.template,
435
+ }
436
+ except Exception as e:
437
+ logger.debug("Could not resolve subprocess proxy metadata for %s: %s", proxy_id, e)
438
+ return {}
439
+
440
+
441
+ def _container_reachable_url(base_url: str) -> str:
442
+ """Map host loopback proxy URLs to Docker's host gateway name."""
443
+ from urllib.parse import urlsplit, urlunsplit
444
+
445
+ parsed = urlsplit(base_url)
446
+ if parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
447
+ return base_url
448
+
449
+ host = "host.docker.internal"
450
+ netloc = f"{host}:{parsed.port}" if parsed.port else host
451
+ return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
452
+
453
+
454
+ def _resolve_extension_detection_root(cwd: Path) -> Path:
455
+ """Return the Forge project root to use for extension inheritance lookup."""
456
+ from forge.core.ops.context import find_forge_root
457
+ from forge.session.claude.paths import find_project_root
458
+
459
+ forge_root = find_forge_root(cwd)
460
+ if forge_root is not None:
461
+ return forge_root
462
+ try:
463
+ return find_project_root(str(cwd))
464
+ except FileNotFoundError:
465
+ return cwd.resolve()
466
+
467
+
468
+ def _resolve_worktree_extension_root(manifest: SessionState) -> Path | None:
469
+ """Return where extensions should be installed inside a target worktree.
470
+
471
+ Session state may stay anchored at the parent's forge_root for root-level
472
+ worktree sessions, but extensions must still land inside the new checkout.
473
+ Nested Forge projects instead install at the equivalent nested forge_root
474
+ within the worktree.
475
+ """
476
+ if not manifest.worktree or not manifest.worktree.is_worktree:
477
+ return None
478
+
479
+ worktree_root = Path(manifest.worktree.path)
480
+ if manifest.forge_root:
481
+ forge_root = Path(manifest.forge_root)
482
+ try:
483
+ forge_root.relative_to(worktree_root)
484
+ return forge_root
485
+ except ValueError:
486
+ pass
487
+ return worktree_root
488
+
489
+
490
+ def _detect_parent_extensions(parent_project_root: Path) -> tuple[str, str] | None:
491
+ """Detect parent's installed extensions for worktree inheritance.
492
+
493
+ Returns (profile, mode) or None if no extensions found.
494
+ Checks: LOCAL install at parent root -> USER install -> hook file detection fallback.
495
+ """
496
+ from forge.install.hooks import has_forge_hooks
497
+ from forge.install.tracking import TrackingStore
498
+
499
+ # Tiers 1-2: tracking store lookup (may fail if store is corrupt)
500
+ try:
501
+ store = TrackingStore()
502
+
503
+ # Tier 1: LOCAL installation at parent project root
504
+ local_install = store.get_installation("local", str(parent_project_root))
505
+ if local_install is not None:
506
+ return (local_install.profile, local_install.mode)
507
+
508
+ # Tier 2: USER-scope (global) installation
509
+ user_install = store.get_installation("user")
510
+ if user_install is not None:
511
+ return (user_install.profile, user_install.mode)
512
+
513
+ except Exception:
514
+ logger.debug(
515
+ "Tracking store lookup failed, falling through to hook detection",
516
+ exc_info=True,
517
+ )
518
+
519
+ # Tier 3: hook file detection fallback (independent of tracking store)
520
+ try:
521
+ if has_forge_hooks(parent_project_root):
522
+ return ("standard", "copy")
523
+ except Exception:
524
+ logger.debug("Hook detection failed", exc_info=True)
525
+
526
+ return None
527
+
528
+
529
+ def _prepare_sidecar_prompt_file(
530
+ *,
531
+ worktree_path: Path,
532
+ system_prompt_file: str | None,
533
+ ) -> tuple[str | None, list[tuple[str, str, str]]]:
534
+ """Map a host-side prompt file to a path visible inside the sidecar."""
535
+ if system_prompt_file is None:
536
+ return None, []
537
+
538
+ prompt_path = Path(system_prompt_file).resolve()
539
+ worktree_root = worktree_path.resolve()
540
+
541
+ try:
542
+ relative_prompt = prompt_path.relative_to(worktree_root)
543
+ except ValueError:
544
+ container_prompt = f"/tmp/{prompt_path.name}"
545
+ return container_prompt, [(str(prompt_path), container_prompt, "ro")]
546
+
547
+ return str(Path("/workspace") / relative_prompt), []
548
+
549
+
550
+ def _auto_install_extensions(
551
+ install_root: Path,
552
+ parent_project_root: Path,
553
+ *,
554
+ force_extensions: bool | None = None,
555
+ ) -> bool:
556
+ """Auto-install Forge extensions in a new worktree.
557
+
558
+ Args:
559
+ install_root: Root inside the target worktree where ``.claude/`` lives.
560
+ For root-level worktrees this is the checkout root; for nested Forge
561
+ projects it is the nested project root within that checkout.
562
+ force_extensions: True=force install, False=skip, None=auto-detect from parent.
563
+
564
+ Returns True if extensions were installed.
565
+ Non-blocking: catches all exceptions and warns on failure.
566
+ """
567
+ try:
568
+ if force_extensions is False:
569
+ return False
570
+
571
+ if force_extensions is True:
572
+ profile, mode = "standard", "copy"
573
+ else:
574
+ detected = _detect_parent_extensions(parent_project_root)
575
+ if detected is None:
576
+ console.print("[dim] Extensions: skipped (no parent extensions detected)[/dim]")
577
+ return False
578
+ profile, mode = detected
579
+
580
+ from forge.install.installer import Installer
581
+ from forge.install.models import InstallMode, InstallProfile, InstallScope
582
+
583
+ installer = Installer(
584
+ scope=InstallScope.LOCAL,
585
+ project_root=install_root,
586
+ )
587
+ plan = installer.init(
588
+ profile=InstallProfile(profile),
589
+ mode=InstallMode(mode),
590
+ )
591
+ if plan.has_conflicts:
592
+ console.print("[dim] Extensions: skipped (conflicts with existing files)[/dim]")
593
+ return False
594
+ n_modules = len(plan.modules)
595
+ console.print(f"[dim] Extensions: inherited ({profile} profile, {n_modules} modules)[/dim]")
596
+ return True
597
+
598
+ except Exception as e:
599
+ logger.debug("Extension auto-install failed", exc_info=True)
600
+ console.print(f"[dim] Extensions: failed to install ({e})[/dim]")
601
+ return False
602
+
603
+
604
+ def _get_active_session_entry(session_name: str, forge_root: str | None = None) -> ActiveSessionEntry | None:
605
+ """Return live runtime state for a session, if available."""
606
+ try:
607
+ from forge.session.active import ActiveSessionStore
608
+
609
+ return ActiveSessionStore().get_session(session_name, forge_root=forge_root)
610
+ except Exception:
611
+ logger.debug(
612
+ "Failed to read active-session registry for '%s'",
613
+ session_name,
614
+ exc_info=True,
615
+ )
616
+ return None
617
+
618
+
619
+ def _print_active_delete_warning(session_name: str, active_entry: ActiveSessionEntry) -> None:
620
+ """Print a warning before deleting a session that still appears live."""
621
+ console.print(
622
+ "[yellow]Warning:[/yellow] "
623
+ f"Session [bold]{session_name}[/bold] appears to still be active in a running Claude Code launch."
624
+ )
625
+ console.print(" Deleting it will remove Forge state while the Claude session keeps running until it exits.")
626
+ console.print(f" Launch mode: {active_entry.launch_mode}")
627
+ if active_entry.launcher_pid is not None:
628
+ console.print(f" Launcher PID: {active_entry.launcher_pid}")
629
+ if active_entry.container_name:
630
+ console.print(f" Container: {active_entry.container_name}")
631
+ console.print()
632
+
633
+
634
+ def _resolve_launch_mode(*, sidecar: bool, host_proxy: bool) -> str:
635
+ """Resolve host vs sidecar launch mode from CLI flags and runtime config."""
636
+ if sidecar:
637
+ return LAUNCH_MODE_SIDECAR
638
+ if host_proxy:
639
+ return LAUNCH_MODE_HOST
640
+
641
+ from forge.runtime_config import get_runtime_config
642
+
643
+ return LAUNCH_MODE_SIDECAR if get_runtime_config().proxy_mode == LAUNCH_MODE_SIDECAR else LAUNCH_MODE_HOST
644
+
645
+
646
+ def _get_runtime_base_url(*, use_sidecar: bool, effective_url: str | None) -> str | None:
647
+ """Return the base URL Claude should see for this launch."""
648
+ from forge.session import SIDECAR_RUNTIME_BASE_URL
649
+
650
+ return SIDECAR_RUNTIME_BASE_URL if use_sidecar else effective_url
651
+
652
+
653
+ def _get_launch_preferences(
654
+ state: SessionState,
655
+ ) -> tuple[bool, tuple[str, ...], str | None]:
656
+ """Return relaunch mode plus persisted sidecar options for a session."""
657
+ launch = state.intent.launch
658
+ if launch is None:
659
+ return state.confirmed.is_sandboxed, (), None
660
+
661
+ use_sidecar = launch.mode == LAUNCH_MODE_SIDECAR
662
+ if not use_sidecar or launch.sidecar is None:
663
+ return use_sidecar, (), None
664
+
665
+ return use_sidecar, tuple(launch.sidecar.mounts), launch.sidecar.image
666
+
667
+
668
+ def _combine_prompt_files(*, worktree_path: Path, session_name: str, prompt_files: list[Path]) -> str | None:
669
+ """Combine multiple prompt/context files into one appendable prompt file."""
670
+ existing = [path.resolve() for path in prompt_files if path.is_file()]
671
+ if not existing:
672
+ return None
673
+ if len(existing) == 1:
674
+ return str(existing[0])
675
+
676
+ launch_context_dir = worktree_path / ".forge" / "launch-context"
677
+ launch_context_dir.mkdir(parents=True, exist_ok=True)
678
+ combined_path = launch_context_dir / f"{session_name}.md"
679
+
680
+ sections: list[str] = []
681
+ for path in existing:
682
+ try:
683
+ content = path.read_text(encoding="utf-8").strip()
684
+ except FileNotFoundError:
685
+ continue
686
+ if not content:
687
+ continue
688
+ sections.append(f"<!-- Source: {path.name} -->\n{content}")
689
+
690
+ combined_path.write_text("\n\n".join(sections).rstrip() + "\n", encoding="utf-8")
691
+ return str(combined_path.resolve())
692
+
693
+
694
+ def _resolve_session_artifact_root(*, manager: SessionManager, state: SessionState) -> Path:
695
+ """Return the root used for forge-root-relative artifacts for a session."""
696
+ if state.forge_root:
697
+ return Path(state.forge_root)
698
+
699
+ worktree_path = Path(state.worktree.path) if state.worktree else Path.cwd()
700
+ return Path(manager.resolve_project_root(worktree_path))
701
+
702
+
703
+ def _generate_parent_handoff_context(
704
+ *,
705
+ manager: SessionManager,
706
+ manifest: SessionState,
707
+ parent_state: SessionState | None = None,
708
+ strategy: str = "structured",
709
+ inline_plan: bool = False,
710
+ ) -> tuple[Path | None, list[str]]:
711
+ """Generate a fresh parent-context handoff file for a forked session.
712
+
713
+ Writes ``<fork_forge_root>/.forge/prev_sessions/<parent>/generated.md`` (the
714
+ regeneratable cache) and copies it into ``children/<fork_name>.md`` (the
715
+ per-child authoritative file used at launch). Returns the child file path.
716
+ """
717
+ if not manifest.is_fork or not manifest.parent_session:
718
+ return None, []
719
+
720
+ from forge.session.prev_sessions import child_path as _child_path
721
+
722
+ fork_worktree = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
723
+ fork_artifact_root = Path(manifest.forge_root) if manifest.forge_root else fork_worktree
724
+ # Fallback path used when parent_state cannot be loaded: reuse an existing
725
+ # per-child file from a prior launch if available.
726
+ existing_child = _child_path(fork_artifact_root, manifest.parent_session, manifest.name)
727
+
728
+ if parent_state is None:
729
+ parent_entry = None
730
+ current_project_root = None
731
+ if manifest.worktree:
732
+ try:
733
+ current_project_root = manager.resolve_project_root(Path(manifest.worktree.path))
734
+ except Exception:
735
+ current_project_root = None
736
+
737
+ try:
738
+ if current_project_root is not None:
739
+ try:
740
+ siblings = [
741
+ entry
742
+ for name, entry in manager.list_sessions(
743
+ project_root_filter=current_project_root,
744
+ include_incognito=True,
745
+ )
746
+ if name == manifest.parent_session
747
+ ]
748
+ except Exception:
749
+ siblings = []
750
+ if len(siblings) == 1:
751
+ parent_entry = siblings[0]
752
+
753
+ if parent_entry is None:
754
+ parent_entry = manager.get_session_entry(manifest.parent_session)
755
+
756
+ parent_scope = parent_entry.forge_root or parent_entry.worktree_path
757
+ parent_state = manager.get_session(manifest.parent_session, forge_root=parent_scope)
758
+ except ForgeSessionError:
759
+ if existing_child.is_file():
760
+ return existing_child.resolve(), []
761
+ return None, []
762
+ except Exception:
763
+ if existing_child.is_file():
764
+ return existing_child.resolve(), []
765
+ return None, []
766
+
767
+ parent_worktree = Path(parent_state.worktree.path) if parent_state.worktree else Path.cwd()
768
+
769
+ project_root = _resolve_session_artifact_root(manager=manager, state=parent_state)
770
+
771
+ from forge.session.handoff import ResumeStrategy, process_handoff
772
+
773
+ try:
774
+ resume_strategy = ResumeStrategy(strategy)
775
+ except ValueError:
776
+ resume_strategy = ResumeStrategy.STRUCTURED
777
+
778
+ _parent_fr = parent_state.forge_root
779
+
780
+ def _get_session_safe(session_name: str) -> SessionState | None:
781
+ try:
782
+ return manager.get_session(session_name, forge_root=_parent_fr)
783
+ except ForgeSessionError:
784
+ return None
785
+
786
+ handoff_result = process_handoff(
787
+ parent_name=manifest.parent_session,
788
+ parent_state=parent_state,
789
+ forge_root=project_root,
790
+ parent_worktree_root=parent_worktree,
791
+ output_root=fork_artifact_root if fork_artifact_root.resolve() != project_root.resolve() else None,
792
+ strategy=resume_strategy,
793
+ depth=1,
794
+ get_session=_get_session_safe,
795
+ inline_plan=inline_plan,
796
+ child_name=manifest.name,
797
+ )
798
+ if handoff_result.context_file is None:
799
+ return None, handoff_result.warnings
800
+ return handoff_result.context_file.resolve(), handoff_result.warnings
801
+
802
+
803
+ def _handle_error(e: ForgeSessionError) -> None:
804
+ """Handle a ForgeSessionError and exit."""
805
+ console.print(f"[red]Error:[/red] {e}", style="red")
806
+ sys.exit(1)
807
+
808
+
809
+ def _hint_cross_project_session(name: str, forge_root: str | None) -> bool:
810
+ """Print a hint if a session exists in another forge_root.
811
+
812
+ Handles both unique and ambiguous (duplicate-name) cases.
813
+ Returns True if a cross-project hint was printed, False otherwise.
814
+ """
815
+ from rich.text import Text
816
+
817
+ from forge.session import IndexStore
818
+
819
+ if not forge_root:
820
+ return False
821
+ try:
822
+ entry = IndexStore().get_session(name, forge_root=None)
823
+ other_root = entry.forge_root or entry.worktree_path
824
+ if other_root and other_root != forge_root:
825
+ console.print(f"[red]Error:[/red] session '{name}' not found in current project")
826
+ console.print(f"\n[dim]Tip: Session '{name}' exists in:[/dim]")
827
+ console.print(
828
+ Text(display_path(other_root), style="dim", no_wrap=True),
829
+ soft_wrap=True,
830
+ )
831
+ console.print("[dim]Run the command from that directory instead.[/dim]")
832
+ return True
833
+ except AmbiguousSessionError as e:
834
+ console.print(f"[red]Error:[/red] session '{name}' not found in current project")
835
+ console.print(f"\n[dim]Tip: Session '{name}' exists in multiple projects:[/dim]")
836
+ for root in e.forge_roots:
837
+ console.print(
838
+ Text(f" - {display_path(root)}", style="dim", no_wrap=True),
839
+ soft_wrap=True,
840
+ )
841
+ console.print("[dim]Run the command from the target project directory.[/dim]")
842
+ return True
843
+ except (SessionNotFoundError, OSError):
844
+ # SessionNotFoundError: not in any project. OSError: index file unreadable.
845
+ pass
846
+ return False
847
+
848
+
849
+ # --- Click group ---
850
+
851
+
852
+ @click.group()
853
+ def session() -> None:
854
+ """Manage Claude Code sessions.
855
+
856
+ \b
857
+ Examples:
858
+ forge session start my-feature # Create a new session
859
+ forge session resume my-feature # Resume existing session
860
+ forge session list # List all sessions
861
+ """
862
+ pass
863
+
864
+
865
+ # Register subgroups attached to `session`. Done at module import so that
866
+ # `forge session handoff show` resolves on first call. Imported here (not at
867
+ # top of module) to avoid circular imports: session_handoff imports from this
868
+ # module's namespace (`_cwd_forge_root`, `_handle_error`, `console`).
869
+ def _register_subgroups() -> None:
870
+ from forge.cli.session_handoff import handoff_group # noqa: E402
871
+ from forge.cli.session_memory import memory_group # noqa: E402
872
+
873
+ session.add_command(handoff_group)
874
+ session.add_command(memory_group)
875
+
876
+
877
+ _register_subgroups()
878
+
879
+
880
+ # sys is imported by _handle_error above; keep it available for the re-exported modules
881
+ import sys # noqa: E402
882
+
883
+ # Re-export names that tests patch on forge.cli.session (originally top-level imports).
884
+ # These must be in this module's namespace for patch("forge.cli.session.XXX") to work.
885
+ from forge.core.naming import generate_unique_name as generate_unique_name # noqa: E402,F401
886
+ from forge.session import run_with_active_session as run_with_active_session # noqa: E402,F401
887
+ from forge.session.claude import invoke_claude as invoke_claude # noqa: E402,F401
888
+
889
+ # Re-export for backward compatibility (204 test references patch "forge.cli.session.XXX")
890
+ from .session_fork import * # noqa: E402,F401,F403
891
+ from .session_lifecycle import * # noqa: E402,F401,F403
892
+ from .session_manage import * # noqa: E402,F401,F403