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,2053 @@
1
+ """Session lifecycle commands: start, resume, fork, incognito.
2
+
3
+ Split from session.py for file-size compliance. All public and private
4
+ names are re-exported by session.py so that ``patch("forge.cli.session.XXX")``
5
+ continues to work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import uuid as _uuid
16
+ from pathlib import Path
17
+ from typing import cast
18
+
19
+ import click
20
+
21
+ from forge.core.paths import display_path
22
+ from forge.core.state import now_iso
23
+ from forge.session import (
24
+ LAUNCH_MODE_HOST,
25
+ LAUNCH_MODE_SIDECAR,
26
+ ForgeSessionError,
27
+ SessionExistsError,
28
+ SessionIndexEntry,
29
+ SessionManager,
30
+ SessionState,
31
+ SessionStore,
32
+ )
33
+ from forge.session.claude import build_claude_args
34
+ from forge.session.direct_model import (
35
+ apply_direct_model_env,
36
+ resolve_direct_model_pin,
37
+ token_estimate_multiplier_for_direct_model,
38
+ )
39
+ from forge.session.exceptions import (
40
+ BranchExistsError,
41
+ InvalidBranchNameError,
42
+ SessionNotFoundError,
43
+ WorktreePathExistsError,
44
+ )
45
+
46
+
47
+ # Names that tests patch on forge.cli.session (invoke_claude,
48
+ # run_with_active_session, SessionManager, generate_unique_name) must be
49
+ # accessed through the parent module at call time. We use _sess() to get
50
+ # the module from sys.modules (already loaded by the time any function runs).
51
+ def _sess(): # type: ignore[return]
52
+ return sys.modules["forge.cli.session"]
53
+
54
+
55
+ from forge.cli.session import ( # noqa: E402
56
+ ResolvedRouting,
57
+ _apply_routing_override_to_state,
58
+ _combine_prompt_files,
59
+ _get_active_session_entry,
60
+ _get_effective_proxy_for_session,
61
+ _get_launch_preferences,
62
+ _get_runtime_base_url,
63
+ _handle_error,
64
+ _hint_cross_project_session,
65
+ _persist_routing_override,
66
+ _print_routing_summary,
67
+ _resolve_extension_detection_root,
68
+ _resolve_launch_mode,
69
+ _resolve_worktree_extension_root,
70
+ console,
71
+ logger,
72
+ )
73
+ from forge.cli.session import session as _session_untyped # noqa: E402
74
+
75
+ session = cast(click.Group, _session_untyped) # type: ignore[has-type] # circular re-export
76
+
77
+ from forge.cli.session_addendum import ( # noqa: E402
78
+ resolve_addendum_content_for_proxy,
79
+ write_managed_addendum,
80
+ )
81
+
82
+ # Functions below are accessed through _sess() because tests patch them
83
+ # on forge.cli.session. Direct imports would bypass those patches.
84
+ # _auto_install_extensions, _build_session_env, _cwd_forge_root,
85
+ # _detect_parent_extensions, _generate_parent_handoff_context,
86
+ # _prepare_sidecar_prompt_file, _resolve_context_limit
87
+
88
+ __all__ = [
89
+ # Public functions
90
+ "launch_new_session",
91
+ # Click commands
92
+ "start",
93
+ "resume",
94
+ "incognito",
95
+ # Private helpers (needed for re-export to forge.cli.session namespace)
96
+ "_launch_claude_for_session",
97
+ "_launch_in_place",
98
+ "_reconnect_in_place",
99
+ "_launch_as_child",
100
+ "_resume_fresh",
101
+ "_resume_fresh_native",
102
+ "_pick_session",
103
+ "_print_context_path",
104
+ "_print_post_exit_tip",
105
+ "_resume_tip_command",
106
+ "_print_branch_exists_tip",
107
+ "_has_confirmed_claude_session",
108
+ "_is_resumable_session",
109
+ "_has_resumable_transcript",
110
+ "_has_resumable_claude_session",
111
+ "_get_deferred_same_dir_fork_resume_id",
112
+ "_resolve_manifest_prompt_file",
113
+ "_infer_launch_confirmation",
114
+ "_persist_fork_handoff_derivation",
115
+ "_warn_if_hooks_missing",
116
+ "_warn_if_version_outdated",
117
+ ]
118
+
119
+
120
+ def _has_confirmed_claude_session(state: SessionState) -> bool:
121
+ """Whether this session has durable evidence of a resumable Claude conversation."""
122
+ if not state.confirmed.claude_session_id:
123
+ return False
124
+ if state.confirmed.confirmed_by is not None:
125
+ return True
126
+ return _has_resumable_transcript(state)
127
+
128
+
129
+ def _is_resumable_session(state: SessionState) -> bool:
130
+ """Whether this session has a resumable Claude conversation.
131
+
132
+ Reconnect should allow the same fallback evidence as normal relaunch:
133
+ either a hook-confirmed session or a transcript-backed session when the
134
+ hook missed confirmation (for example, lock contention). Pre-seeded UUIDs
135
+ without other evidence are still rejected.
136
+ """
137
+ return bool(state.confirmed.claude_session_id and _has_resumable_claude_session(state))
138
+
139
+
140
+ def _has_resumable_transcript(state: SessionState) -> bool:
141
+ """Whether we can infer an existing Claude conversation from transcript state."""
142
+ session_id = state.confirmed.claude_session_id
143
+ if not session_id or state.confirmed.is_sandboxed:
144
+ return False
145
+
146
+ transcript_path = state.confirmed.transcript_path
147
+ if transcript_path and Path(transcript_path).is_file():
148
+ return True
149
+
150
+ try:
151
+ from forge.session.claude.paths import (
152
+ get_transcript_path,
153
+ resolve_claude_project_root,
154
+ )
155
+
156
+ # Check persisted launch root first, then computed root
157
+ if state.confirmed.claude_project_root:
158
+ if get_transcript_path(state.confirmed.claude_project_root, session_id).is_file():
159
+ return True
160
+ return get_transcript_path(resolve_claude_project_root(state), session_id).is_file()
161
+ except Exception:
162
+ return False
163
+
164
+
165
+ def _has_resumable_claude_session(state: SessionState) -> bool:
166
+ """Whether Claude can be resumed for this session."""
167
+ return _has_confirmed_claude_session(state) or _has_resumable_transcript(state)
168
+
169
+
170
+ def _get_deferred_same_dir_fork_resume_id(
171
+ *,
172
+ manager: SessionManager,
173
+ manifest: SessionState,
174
+ ) -> str | None:
175
+ """Return the parent UUID when launching a never-started same-dir fork."""
176
+ if not manifest.is_fork or not manifest.parent_session:
177
+ return None
178
+
179
+ if manifest.worktree and manifest.worktree.is_worktree:
180
+ return None
181
+
182
+ confirmed = manifest.confirmed
183
+ if (
184
+ confirmed.claude_session_id is not None
185
+ or confirmed.transcript_path is not None
186
+ or confirmed.confirmed_by is not None
187
+ ):
188
+ return None
189
+
190
+ try:
191
+ parent_state = manager.get_session(manifest.parent_session, forge_root=manifest.forge_root)
192
+ except ForgeSessionError:
193
+ return None
194
+
195
+ return parent_state.confirmed.claude_session_id
196
+
197
+
198
+ def _warn_if_hooks_missing(project_path: Path) -> None:
199
+ """Warn if no Forge hooks are installed before launching Claude.
200
+
201
+ Args:
202
+ project_path: Forge project root (where .claude/ lives). Use forge_root,
203
+ not worktree/checkout root, so nested projects find the correct settings.
204
+ """
205
+ from forge.install.hooks import has_forge_hooks
206
+
207
+ if has_forge_hooks(project_path):
208
+ return
209
+
210
+ console.print(
211
+ "[yellow]Warning:[/yellow] Forge hooks are not installed. "
212
+ "State tracking, policy enforcement, verification, and search indexing "
213
+ "will not be active."
214
+ )
215
+ console.print("[dim]Tip: Run 'forge extension enable' to install hooks.[/dim]")
216
+
217
+
218
+ def _warn_if_version_outdated() -> None:
219
+ """Warn if Claude Code version is below the minimum required by Forge."""
220
+ from forge.install.version import check_minimum_version
221
+
222
+ result = check_minimum_version()
223
+ if result.ok or result.version is None:
224
+ return # Don't warn if we can't detect (hooks warning covers that)
225
+
226
+ console.print(
227
+ f"[yellow]Warning:[/yellow] Claude Code {result.version} is below "
228
+ f"minimum {result.minimum}. Some features may not work correctly."
229
+ )
230
+ console.print("[dim]Tip: Run 'claude update' to upgrade.[/dim]")
231
+
232
+
233
+ def _infer_launch_confirmation(
234
+ *,
235
+ store: "SessionStore",
236
+ manifest: SessionState,
237
+ session_id: str | None,
238
+ ) -> None:
239
+ """Backfill transcript/runtime confirmation after a successful host launch."""
240
+ if session_id is None or manifest.confirmed.is_sandboxed:
241
+ return
242
+
243
+ try:
244
+ from forge.session.claude.paths import (
245
+ get_transcript_path,
246
+ resolve_claude_project_root,
247
+ )
248
+ except ImportError:
249
+ return
250
+
251
+ # Prefer persisted launch root; fall back to computed root
252
+ if manifest.confirmed.claude_project_root:
253
+ transcript_path = get_transcript_path(manifest.confirmed.claude_project_root, session_id)
254
+ else:
255
+ transcript_path = get_transcript_path(resolve_claude_project_root(manifest), session_id)
256
+ if not transcript_path.is_file():
257
+ return
258
+
259
+ def _mutate(state: SessionState) -> None:
260
+ # 1:1 model: overwrite UUID directly (no accumulation)
261
+ state.confirmed.claude_session_id = session_id
262
+ state.confirmed.transcript_path = str(transcript_path)
263
+ state.confirmed.confirmed_at = now_iso()
264
+ if state.confirmed.confirmed_by is None:
265
+ state.confirmed.confirmed_by = "cli:launch:inferred"
266
+
267
+ store.update(timeout_s=5.0, mutate=_mutate)
268
+
269
+
270
+ def _resolve_manifest_prompt_file(manifest: SessionState) -> Path | None:
271
+ """Resolve a session's configured system prompt file, if any."""
272
+ if manifest.intent.system_prompt is None or manifest.intent.system_prompt.file is None:
273
+ return None
274
+ prompt_path = Path(manifest.intent.system_prompt.file).expanduser()
275
+ return prompt_path.resolve() if prompt_path.exists() else None
276
+
277
+
278
+ def _persist_fork_handoff_derivation(
279
+ *,
280
+ manifest: SessionState,
281
+ strategy: str,
282
+ context_path: Path | None,
283
+ ) -> SessionState:
284
+ """Persist handoff-specific derivation details for a worktree fork."""
285
+ worktree_path = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
286
+ forge_root = Path(manifest.forge_root) if manifest.forge_root else worktree_path
287
+
288
+ context_file: str | None = None
289
+ if context_path is not None:
290
+ try:
291
+ context_file = str(context_path.relative_to(forge_root))
292
+ except ValueError:
293
+ context_file = str(context_path)
294
+
295
+ def _mutate(m: SessionState) -> None:
296
+ if m.confirmed.derivation is None:
297
+ from forge.session.models import Derivation
298
+
299
+ m.confirmed.derivation = Derivation(parent_session=m.parent_session or "")
300
+ m.confirmed.derivation.resume_mode = "handoff"
301
+ m.confirmed.derivation.strategy = strategy
302
+ m.confirmed.derivation.context_file = context_file
303
+
304
+ return SessionStore(str(forge_root), manifest.name).update(timeout_s=5.0, mutate=_mutate)
305
+
306
+
307
+ def _is_legacy_flat_handoff_path(path: Path) -> bool:
308
+ """Return True for pre-0.2.0 ``.forge/prev_sessions/<parent>.md`` artifacts."""
309
+ return path.suffix == ".md" and path.parent.name == "prev_sessions"
310
+
311
+
312
+ def _resolve_derivation_context_file(manifest: SessionState) -> Path | None:
313
+ """Resolve a persisted handoff context file for a never-launched child."""
314
+ derivation = manifest.confirmed.derivation
315
+ if derivation is None or not derivation.context_file:
316
+ return None
317
+
318
+ context_path = Path(derivation.context_file).expanduser()
319
+ if _is_legacy_flat_handoff_path(context_path):
320
+ parent = derivation.parent_session or manifest.parent_session or "<parent>"
321
+ console.print(
322
+ "[red]Error:[/red] Legacy handoff artifact format is no longer supported: " f"{display_path(context_path)}"
323
+ )
324
+ console.print(
325
+ "[dim]Tip: run "
326
+ f"'forge session resume {parent} --fresh' to regenerate a per-child handoff artifact.[/dim]"
327
+ )
328
+ sys.exit(1)
329
+ if not context_path.is_absolute():
330
+ worktree_path = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
331
+ forge_root = Path(manifest.forge_root) if manifest.forge_root else worktree_path
332
+ context_path = forge_root / context_path
333
+
334
+ return context_path.resolve() if context_path.is_file() else None
335
+
336
+
337
+ def _launch_claude_for_session(
338
+ *,
339
+ manifest: SessionState,
340
+ session_id: str | None,
341
+ resume_id: str | None,
342
+ effective_template: str | None,
343
+ runtime_base_url: str | None,
344
+ context_limit: int,
345
+ use_sidecar: bool,
346
+ mounts: tuple[str, ...] = (),
347
+ image: str | None = None,
348
+ fork_session: bool = False,
349
+ register_fork: bool = False,
350
+ system_prompt_file: str | None = None,
351
+ name: str | None = None,
352
+ extra_args: list[str] | None = None,
353
+ proxy_id: str | None = None,
354
+ ) -> int:
355
+ """Launch Claude for a session, handling sidecar/host split."""
356
+ worktree_path = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
357
+ # State lives under forge_root (may differ from worktree_path in nested projects)
358
+ forge_root = Path(manifest.forge_root) if manifest.forge_root else worktree_path
359
+ # Claude Code project root: where Claude finds .claude/ and stores conversations.
360
+ # For nested projects this is forge_root; for root-level worktrees it's worktree_path.
361
+ from forge.session.claude.paths import resolve_claude_project_root
362
+
363
+ launch_root = Path(resolve_claude_project_root(manifest))
364
+
365
+ # Prefer persisted launch root (set by SessionStart hook) over computed
366
+ # root. This handles sessions created before the nested-project CWD fix
367
+ # (7a1bbe9) where the conversation lives under the old checkout-root
368
+ # namespace. The persisted value is authoritative; the computed root is
369
+ # the fallback for sessions that predate the field.
370
+ if manifest.confirmed.claude_project_root:
371
+ launch_root = Path(manifest.confirmed.claude_project_root)
372
+
373
+ register_fork_env = fork_session or register_fork
374
+ fork_name = manifest.name if register_fork_env else None
375
+ parent_session = manifest.parent_session if register_fork_env else None
376
+
377
+ env_vars, unset_env_vars = _sess()._build_session_env(
378
+ session_name=manifest.name,
379
+ context_limit=context_limit,
380
+ template=effective_template,
381
+ base_url=runtime_base_url,
382
+ fork_name=fork_name,
383
+ parent_session=parent_session,
384
+ forge_root=manifest.forge_root,
385
+ subprocess_proxy=manifest.intent.subprocess_proxy,
386
+ sidecar=use_sidecar,
387
+ )
388
+
389
+ _sess()._warn_if_hooks_missing(forge_root)
390
+ _sess()._warn_if_version_outdated()
391
+
392
+ addendum_content = resolve_addendum_content_for_proxy(proxy_id)
393
+ if addendum_content:
394
+ addendum_path = write_managed_addendum(forge_root, manifest.name, addendum_content)
395
+ prompt_files = [addendum_path]
396
+ if system_prompt_file:
397
+ prompt_files.append(Path(system_prompt_file))
398
+ system_prompt_file = _combine_prompt_files(
399
+ worktree_path=worktree_path,
400
+ session_name=manifest.name,
401
+ prompt_files=prompt_files,
402
+ )
403
+
404
+ from forge.session import SessionStore
405
+
406
+ store = SessionStore(str(forge_root), manifest.name)
407
+
408
+ # Persist launch root on first launch so reconnect can use the exact CWD
409
+ if not manifest.confirmed.claude_project_root:
410
+ _lr = str(launch_root)
411
+ store.update(
412
+ timeout_s=5.0,
413
+ mutate=lambda m: setattr(m.confirmed, "claude_project_root", _lr),
414
+ )
415
+
416
+ if use_sidecar:
417
+ if effective_template is None or runtime_base_url is None:
418
+ console.print("[red]Error:[/red] Direct sessions are not supported with --sidecar")
419
+ sys.exit(1)
420
+
421
+ # Recover proxy_id from base_url when not explicitly provided (relaunch paths)
422
+ if proxy_id is None and runtime_base_url is not None:
423
+ try:
424
+ from forge.proxy.proxies import ProxyRegistryStore as _PStore
425
+
426
+ _entry = _PStore().find_by_base_url(runtime_base_url)
427
+ if _entry is not None:
428
+ proxy_id = _entry.proxy_id
429
+ except Exception:
430
+ pass # Best-effort; falls back to template scan
431
+
432
+ from forge.sidecar import get_secrets_for_template, run_sidecar_session
433
+ from forge.sidecar.container import ContainerExistsError, parse_mounts
434
+ from forge.sidecar.docker import is_docker_available
435
+
436
+ if not is_docker_available():
437
+ console.print("[red]Error:[/red] Docker is not available or not running")
438
+ sys.exit(1)
439
+
440
+ store.update(timeout_s=5.0, mutate=lambda m: setattr(m.confirmed, "is_sandboxed", True))
441
+
442
+ try:
443
+ extra_mounts = parse_mounts(mounts) if mounts else []
444
+ except ValueError as e:
445
+ console.print(f"[red]Error:[/red] {e}")
446
+ sys.exit(1)
447
+
448
+ claude_dir = launch_root / ".claude"
449
+ forge_dir = launch_root / ".forge"
450
+ sidecar_home = forge_dir / "sidecar-home"
451
+ claude_dir.mkdir(parents=True, exist_ok=True)
452
+ forge_dir.mkdir(parents=True, exist_ok=True)
453
+ sidecar_home.mkdir(parents=True, exist_ok=True)
454
+ sidecar_prompt_file, prompt_mounts = _sess()._prepare_sidecar_prompt_file(
455
+ worktree_path=launch_root,
456
+ system_prompt_file=system_prompt_file,
457
+ )
458
+ standard_mounts = [
459
+ (str(claude_dir), "/workspace/.claude", "rw"),
460
+ (str(forge_dir), "/workspace/.forge", "rw"),
461
+ (str(sidecar_home), "/root/.claude", "rw"),
462
+ ]
463
+ all_mounts = standard_mounts + prompt_mounts + extra_mounts
464
+ claude_args = build_claude_args(
465
+ session_id=session_id,
466
+ resume_id=resume_id,
467
+ fork_session=fork_session,
468
+ name=name,
469
+ model=None,
470
+ system_prompt_file=sidecar_prompt_file,
471
+ extra_args=extra_args,
472
+ )
473
+
474
+ secrets = get_secrets_for_template(effective_template)
475
+ container_env = {**env_vars, **secrets}
476
+
477
+ if "LITELLM_BASE_URL" not in container_env:
478
+ try:
479
+ from forge.config.loader import load_proxy_instance_config
480
+ from forge.proxy.proxies import ProxyRegistryStore as _Store
481
+ from forge.proxy.proxies import resolve_proxy_optional
482
+
483
+ _resolved_pid = proxy_id
484
+ if not _resolved_pid and effective_template:
485
+ _registry = _Store().read()
486
+ _resolved = resolve_proxy_optional(_registry, effective_template)
487
+ if _resolved:
488
+ _resolved_pid = _resolved.proxy_id
489
+
490
+ if _resolved_pid:
491
+ _pcfg = load_proxy_instance_config(_resolved_pid)
492
+ if _pcfg and _pcfg.upstream_base_url:
493
+ container_env["LITELLM_BASE_URL"] = _pcfg.upstream_base_url
494
+ except Exception:
495
+ pass # Best-effort; user can export LITELLM_BASE_URL manually
496
+
497
+ from forge.runtime_config import get_runtime_config
498
+
499
+ sidecar_image = image or get_runtime_config().sidecar_image
500
+ console.print("[cyan]Starting sidecar session in container[/cyan]")
501
+ console.print(f" Image: {sidecar_image}")
502
+ console.print()
503
+
504
+ try:
505
+ return _sess().run_with_active_session(
506
+ session_name=manifest.name,
507
+ worktree_path=worktree_path,
508
+ launch_mode=LAUNCH_MODE_SIDECAR,
509
+ forge_root=manifest.forge_root,
510
+ claude_session_id=session_id,
511
+ runner=lambda: run_sidecar_session(
512
+ image=sidecar_image,
513
+ template=effective_template,
514
+ session_name=manifest.name,
515
+ project_dir=launch_root,
516
+ extra_mounts=all_mounts,
517
+ context_limit=context_limit,
518
+ env_vars=container_env,
519
+ claude_args=claude_args,
520
+ ),
521
+ )
522
+ except ContainerExistsError as e:
523
+ store.update(
524
+ timeout_s=5.0,
525
+ mutate=lambda m: setattr(m.confirmed, "is_sandboxed", False),
526
+ )
527
+ console.print(f"[red]Error:[/red] {e}")
528
+ sys.exit(1)
529
+ except Exception:
530
+ store.update(
531
+ timeout_s=5.0,
532
+ mutate=lambda m: setattr(m.confirmed, "is_sandboxed", False),
533
+ )
534
+ raise
535
+
536
+ store.update(timeout_s=5.0, mutate=lambda m: setattr(m.confirmed, "is_sandboxed", False))
537
+
538
+ # Best-effort: recover proxy_id from base_url for host launches (resume/reconnect
539
+ # paths don't pass proxy_id explicitly). Falls back to no proxy_id, which means
540
+ # model_alternatives won't apply on this launch.
541
+ if proxy_id is None and runtime_base_url is not None:
542
+ try:
543
+ from forge.proxy.proxies import ProxyRegistryStore as _PRS
544
+
545
+ _entry = _PRS().find_by_base_url(runtime_base_url)
546
+ if _entry is not None:
547
+ proxy_id = _entry.proxy_id
548
+ except Exception:
549
+ logger.debug("proxy_id recovery from base_url failed", exc_info=True)
550
+
551
+ if runtime_base_url is None:
552
+ # Direct mode: apply explicit --model or fall back to default_direct_model
553
+ from forge.runtime_config import get_default_direct_model
554
+
555
+ direct_model = manifest.intent.launch.direct_model if manifest.intent.launch else None
556
+ direct_model = direct_model or get_default_direct_model()
557
+ error = apply_direct_model_env(env_vars, direct_model)
558
+ if error:
559
+ console.print(f"[red]Error:[/red] {error}")
560
+ return 1
561
+ elif manifest.intent.launch and manifest.intent.launch.direct_model and proxy_id:
562
+ # Proxy mode with explicit --model: apply model pin so Claude Code sends
563
+ # the right model name in requests (proxy resolves via model_alternatives).
564
+ # Only apply if the proxy actually configures alternatives for this model.
565
+ from forge.config.loader import load_proxy_instance_config
566
+
567
+ proxy_cfg = load_proxy_instance_config(proxy_id)
568
+ if proxy_cfg and proxy_cfg.model_alternatives:
569
+ dm = manifest.intent.launch.direct_model
570
+ pin = resolve_direct_model_pin(dm)
571
+ alt_models = proxy_cfg.model_alternatives.get(pin.tier, {})
572
+ if pin.canonical_model in alt_models:
573
+ error = apply_direct_model_env(env_vars, dm)
574
+ if error:
575
+ console.print(f"[red]Error:[/red] {error}")
576
+ return 1
577
+
578
+ exit_code = _sess().run_with_active_session(
579
+ session_name=manifest.name,
580
+ worktree_path=worktree_path,
581
+ launch_mode=LAUNCH_MODE_HOST,
582
+ forge_root=manifest.forge_root,
583
+ claude_session_id=session_id,
584
+ runner=lambda: _sess().invoke_claude(
585
+ session_id=session_id,
586
+ resume_id=resume_id,
587
+ fork_session=fork_session,
588
+ name=name,
589
+ model=None,
590
+ system_prompt_file=system_prompt_file,
591
+ env_vars=env_vars,
592
+ unset_env_vars=unset_env_vars,
593
+ extra_args=extra_args,
594
+ cwd=str(launch_root),
595
+ ),
596
+ )
597
+ if exit_code == 0 and not fork_session:
598
+ _sess()._infer_launch_confirmation(store=store, manifest=manifest, session_id=resume_id or session_id)
599
+
600
+ _print_post_exit_tip(manifest)
601
+
602
+ return exit_code
603
+
604
+
605
+ def _print_post_exit_tip(manifest: SessionState) -> None:
606
+ """Print session tips after Claude exits.
607
+
608
+ Printed from the parent launcher process (not a hook) because Claude
609
+ Code suppresses SessionEnd hook output (anthropics/claude-code#9090).
610
+ """
611
+ if manifest.is_incognito or not manifest.name:
612
+ return
613
+ # Claude sometimes leaves the cursor mid-line on exit, so clear the
614
+ # current line before printing the Forge-owned tip.
615
+ try:
616
+ console.file.write("\r\x1b[2K")
617
+ console.file.flush()
618
+ except Exception:
619
+ logger.debug("Terminal line clear failed before post-exit tip", exc_info=True)
620
+ resume_cmd = _resume_tip_command(manifest)
621
+ console.print(f"\n[dim]Tip: Reconnect to this conversation with:[/dim]\n" f"[dim] {resume_cmd}[/dim]")
622
+
623
+
624
+ def _resume_tip_command(manifest: SessionState) -> str:
625
+ """Return the shell command to resume a session from the correct directory."""
626
+ assert manifest.name # callers guard on manifest.name first
627
+
628
+ resume_cmd = f"forge session resume {shlex.quote(manifest.name)}"
629
+ if not manifest.worktree or not manifest.worktree.is_worktree:
630
+ return resume_cmd
631
+
632
+ resume_root = manifest.forge_root
633
+ if not resume_root:
634
+ from forge.session.claude.paths import resolve_claude_project_root
635
+
636
+ resume_root = resolve_claude_project_root(manifest)
637
+
638
+ return f"cd {shlex.quote(display_path(resume_root))} && {resume_cmd}"
639
+
640
+
641
+ def _print_branch_exists_tip(e: BranchExistsError) -> None:
642
+ """Print contextual tip for a branch that already exists."""
643
+ console.print(f"[red]Error:[/red] {e}")
644
+ if e.worktree:
645
+ console.print("\n[dim]Tip: Use --branch to specify a different branch name.[/dim]")
646
+ else:
647
+ console.print(
648
+ f"\n[dim]Tip: Delete with `git branch -d {e.branch}` or use --branch to specify a different name.[/dim]"
649
+ )
650
+
651
+
652
+ def _resume_token_estimate_multiplier(
653
+ *,
654
+ parent_state: SessionState,
655
+ effective_proxy_ref: str | None,
656
+ ) -> float:
657
+ """Return a model-specific heuristic multiplier for fresh full-resume checks."""
658
+ if effective_proxy_ref is not None:
659
+ # v1 only applies tokenizer safety margins to direct Claude pins. Avoid
660
+ # proxy config I/O in the resume hot path until proxy-routed 4.7 needs it.
661
+ return 1.0
662
+
663
+ from forge.runtime_config import get_default_direct_model
664
+
665
+ direct_model = parent_state.intent.launch.direct_model if parent_state.intent.launch else None
666
+ direct_model = direct_model or get_default_direct_model()
667
+ if not direct_model:
668
+ return 1.0
669
+ try:
670
+ return token_estimate_multiplier_for_direct_model(direct_model)
671
+ except ValueError:
672
+ return 1.0
673
+
674
+
675
+ # --- Shared session creation + launch ---
676
+
677
+
678
+ def launch_new_session(
679
+ *,
680
+ name: str,
681
+ template: str | None = None,
682
+ base_url: str | None = None,
683
+ direct: bool = False,
684
+ incognito: bool = False,
685
+ system_prompt: str | None = None,
686
+ system_prompt_file: str | None = None,
687
+ worktree: bool = False,
688
+ branch: str | None = None,
689
+ sidecar: bool = False,
690
+ host_proxy: bool = False,
691
+ mounts: tuple[str, ...] = (),
692
+ image: str | None = None,
693
+ no_launch: bool = False,
694
+ extensions: bool | None = None,
695
+ extra_args: list[str] | None = None,
696
+ context_limit_override: int | None = None,
697
+ proxy_display: str | None = None,
698
+ proxy_id: str | None = None,
699
+ supervise_target: str | None = None,
700
+ supervisor_proxy: str | None = None,
701
+ supervisor_direct: bool = False,
702
+ subprocess_proxy: str | None = None,
703
+ direct_model: str | None = None,
704
+ ) -> int:
705
+ """Create a new session and launch Claude.
706
+
707
+ This is the shared implementation behind ``forge session start``,
708
+ ``forge session incognito``, and ``forge claude start``.
709
+
710
+ Returns the Claude exit code (0 on success). Never calls ``sys.exit``
711
+ so callers can wrap with cleanup (incognito) or other post-processing.
712
+ """
713
+ # --- flag validation ---
714
+ if branch and not worktree:
715
+ console.print("[red]Error:[/red] --branch requires --worktree")
716
+ return 1
717
+ if sidecar and host_proxy:
718
+ console.print("[red]Error:[/red] --sidecar and --host-proxy are mutually exclusive")
719
+ return 1
720
+ if direct and (template or base_url):
721
+ console.print("[red]Error:[/red] --no-proxy cannot be combined with --template or --base-url")
722
+ return 1
723
+ if direct and sidecar:
724
+ console.print("[red]Error:[/red] --no-proxy cannot be combined with --sidecar")
725
+ return 1
726
+ if direct and host_proxy:
727
+ console.print("[red]Error:[/red] --no-proxy cannot be combined with --host-proxy")
728
+ return 1
729
+ if direct_model and sidecar:
730
+ console.print("[red]Error:[/red] --model cannot be combined with --sidecar")
731
+ return 1
732
+ if direct_model and host_proxy:
733
+ console.print("[red]Error:[/red] --model cannot be combined with --host-proxy")
734
+ return 1
735
+ if incognito and no_launch:
736
+ console.print("[red]Error:[/red] --incognito and --no-launch are mutually exclusive")
737
+ return 1
738
+ if no_launch and (system_prompt or system_prompt_file):
739
+ console.print("[red]Error:[/red] --system-prompt is launch-only and lost with --no-launch")
740
+ return 1
741
+
742
+ launch_mode = LAUNCH_MODE_HOST if direct else _resolve_launch_mode(sidecar=sidecar, host_proxy=host_proxy)
743
+ use_sidecar = launch_mode == LAUNCH_MODE_SIDECAR
744
+ manager = _sess().SessionManager()
745
+
746
+ normalized_direct_model: str | None = None
747
+ direct_model_pin = None
748
+ if direct_model:
749
+ try:
750
+ direct_model_pin = resolve_direct_model_pin(direct_model)
751
+ normalized_direct_model = direct_model_pin.env_model
752
+ except ValueError as e:
753
+ console.print(f"[red]Error:[/red] {e}")
754
+ return 1
755
+
756
+ # Validate --model against proxy model_alternatives when in proxy mode
757
+ if direct_model_pin and proxy_id and not direct:
758
+ from forge.config.loader import load_proxy_instance_config
759
+
760
+ try:
761
+ proxy_cfg = load_proxy_instance_config(proxy_id)
762
+ if proxy_cfg is None:
763
+ raise FileNotFoundError(proxy_id)
764
+ except Exception:
765
+ console.print(f"[red]Error:[/red] Could not load proxy config for '{proxy_id}'")
766
+ return 1
767
+ tier = direct_model_pin.tier
768
+ # Strip [1m] suffix for alternative lookup (context pinning, not routing)
769
+ lookup_model = direct_model_pin.canonical_model
770
+ alt_models = proxy_cfg.model_alternatives.get(tier, {})
771
+ if lookup_model not in alt_models:
772
+ available = ", ".join(sorted(alt_models.keys())) if alt_models else "(none configured)"
773
+ console.print(
774
+ f"[red]Error:[/red] Proxy '{proxy_id}' does not configure model alternative "
775
+ f"for '{lookup_model}' in tier '{tier}'. Available alternatives: {available}"
776
+ )
777
+ return 1
778
+
779
+ # Resolve system prompt to absolute path BEFORE worktree creation
780
+ # (worktree changes cwd so relative paths would break).
781
+ prompt_file: str | None = None
782
+ if system_prompt_file:
783
+ prompt_file = str(Path(system_prompt_file).resolve())
784
+ elif system_prompt:
785
+ claude_dir = Path.cwd() / ".claude"
786
+ claude_dir.mkdir(exist_ok=True)
787
+ prompt_file_path = claude_dir / "forge.system-prompt.generated.md"
788
+ prompt_file_path.write_text(system_prompt)
789
+ prompt_file = str(prompt_file_path)
790
+
791
+ # Validate supervisor target and proxy BEFORE creating the session to avoid half-created state
792
+ _supervisor_source_state = None
793
+ if supervise_target:
794
+ from forge.guard.semantic.supervisor import validate_supervisor_target
795
+
796
+ try:
797
+ _supervisor_source_state = validate_supervisor_target(
798
+ supervise_target, forge_root=_sess()._cwd_forge_root()
799
+ )
800
+ except ValueError as e:
801
+ console.print(f"[red]Error:[/red] {e}")
802
+ return 1
803
+ if supervisor_proxy:
804
+ from forge.guard.semantic.supervisor import preflight_supervisor_proxy
805
+
806
+ try:
807
+ supervisor_proxy = preflight_supervisor_proxy(supervisor_proxy)
808
+ except ValueError as e:
809
+ console.print(f"[red]Error:[/red] {e}")
810
+ return 1
811
+
812
+ pre_seeded_uuid = str(_uuid.uuid4())
813
+ try:
814
+ manifest = manager.start_session(
815
+ name=name,
816
+ proxy_template=template,
817
+ proxy_base_url=base_url,
818
+ direct=direct,
819
+ is_incognito=incognito,
820
+ create_worktree=worktree,
821
+ branch=branch,
822
+ launch_mode=launch_mode,
823
+ sidecar_mounts=list(mounts) if use_sidecar else None,
824
+ sidecar_image=image if use_sidecar else None,
825
+ direct_model=normalized_direct_model,
826
+ claude_session_id=pre_seeded_uuid,
827
+ )
828
+ except SessionExistsError as e:
829
+ console.print(f"[red]Error:[/red] {e}")
830
+ console.print(f"\n[dim]Tip: Use 'forge session resume {name}' to continue,[/dim]")
831
+ console.print(f"[dim]or 'forge session delete {name}' to remove it first.[/dim]")
832
+ return 1
833
+ except BranchExistsError as e:
834
+ _print_branch_exists_tip(e)
835
+ return 1
836
+ except WorktreePathExistsError as e:
837
+ console.print(f"[red]Error:[/red] {e}")
838
+ console.print("\n[dim]Tip: Remove the directory or use a different session name.[/dim]")
839
+ return 1
840
+ except InvalidBranchNameError as e:
841
+ console.print(f"[red]Error:[/red] {e}")
842
+ return 1
843
+ except ForgeSessionError as e:
844
+ console.print(f"[red]Error:[/red] {e}", style="red")
845
+ return 1
846
+ except FileNotFoundError as e:
847
+ console.print(f"[red]Error:[/red] {e}", style="red")
848
+ return 1
849
+
850
+ # --- set subprocess proxy (if requested) ---
851
+ if subprocess_proxy:
852
+ manifest.intent.subprocess_proxy = subprocess_proxy
853
+ _sp_forge_root = manifest.forge_root or str(Path.cwd())
854
+ from forge.session.store import SessionStore as _SPStore
855
+
856
+ _SPStore(_sp_forge_root, manifest.name).update(
857
+ timeout_s=5.0,
858
+ mutate=lambda m: setattr(m.intent, "subprocess_proxy", subprocess_proxy),
859
+ )
860
+ manifest = _SPStore(_sp_forge_root, manifest.name).read()
861
+
862
+ # --- wire supervisor (if requested) ---
863
+ if supervise_target and _supervisor_source_state is not None:
864
+ from forge.guard.semantic.supervisor import (
865
+ apply_supervisor_routing,
866
+ apply_supervisor_to_intent,
867
+ )
868
+ from forge.session.models import SupervisorConfig
869
+ from forge.session.store import SessionStore
870
+
871
+ _sup_forge_root = manifest.forge_root or (manifest.worktree.path if manifest.worktree else str(Path.cwd()))
872
+ sup_config = SupervisorConfig(
873
+ resume_id=supervise_target,
874
+ forge_root=_supervisor_source_state.forge_root or _sup_forge_root,
875
+ )
876
+ apply_supervisor_routing(
877
+ sup_config,
878
+ _supervisor_source_state,
879
+ supervisor_proxy=supervisor_proxy,
880
+ supervisor_direct=supervisor_direct,
881
+ current_proxy_id=proxy_id,
882
+ current_template=template,
883
+ current_direct=direct,
884
+ )
885
+
886
+ forge_root = _sup_forge_root
887
+ store = SessionStore(forge_root, manifest.name)
888
+ store.update(timeout_s=5.0, mutate=lambda m: apply_supervisor_to_intent(m, sup_config))
889
+ manifest = store.read()
890
+
891
+ # --- compute launch parameters ---
892
+ effective_template = manifest.intent.proxy.template if manifest.intent.proxy else None
893
+ effective_url = manifest.intent.proxy.base_url if manifest.intent.proxy else None
894
+
895
+ context_limit = (
896
+ context_limit_override
897
+ if context_limit_override is not None
898
+ else _sess()._resolve_context_limit(effective_template)
899
+ )
900
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=effective_url)
901
+
902
+ # --- output ---
903
+ label = "incognito session" if incognito else "session"
904
+ console.print(f"Created {label} [green]{manifest.name}[/green]")
905
+ if proxy_display:
906
+ console.print(f" Proxy: {proxy_display} ({effective_template}) @ {runtime_base_url}")
907
+ else:
908
+ _print_routing_summary(template=effective_template, base_url=runtime_base_url)
909
+ if manifest.worktree and manifest.worktree.is_worktree:
910
+ console.print(f" Worktree: {display_path(manifest.worktree.path)}")
911
+ console.print(f" Branch: {manifest.worktree.branch}")
912
+ if supervise_target:
913
+ console.print(f" Supervisor: {supervise_target}")
914
+ if incognito:
915
+ console.print("[yellow] (will auto-delete on exit)[/yellow]")
916
+
917
+ # --- extensions ---
918
+ if manifest.worktree and manifest.worktree.is_worktree:
919
+ extension_root = _resolve_worktree_extension_root(manifest)
920
+ if extension_root is not None:
921
+ _sess()._auto_install_extensions(
922
+ install_root=extension_root,
923
+ parent_project_root=_resolve_extension_detection_root(Path.cwd()),
924
+ force_extensions=extensions,
925
+ )
926
+ elif extensions is True:
927
+ console.print("[dim]Tip: --extensions only applies with --worktree.[/dim]")
928
+ console.print()
929
+
930
+ # --- no-launch early exit ---
931
+ if no_launch:
932
+ console.print("[dim]Session created (--no-launch: Claude not started)[/dim]")
933
+ return 0
934
+
935
+ # --- launch Claude ---
936
+ # Incognito cleanup wraps only the launch phase so that validation/creation
937
+ # failures do NOT trigger deletion of a potentially pre-existing session.
938
+ if incognito:
939
+ exit_code = 0
940
+ try:
941
+ exit_code = _launch_claude_for_session(
942
+ manifest=manifest,
943
+ session_id=pre_seeded_uuid,
944
+ resume_id=None,
945
+ effective_template=effective_template,
946
+ runtime_base_url=runtime_base_url,
947
+ context_limit=context_limit,
948
+ use_sidecar=use_sidecar,
949
+ mounts=mounts,
950
+ image=image,
951
+ system_prompt_file=prompt_file,
952
+ name=manifest.name,
953
+ extra_args=extra_args,
954
+ proxy_id=proxy_id,
955
+ )
956
+ finally:
957
+ console.print(f"\n[dim]Cleaning up incognito session '{manifest.name}'...[/dim]")
958
+ try:
959
+ _sess().SessionManager().delete_session(
960
+ manifest.name,
961
+ delete_transcripts=True,
962
+ force=True,
963
+ forge_root=manifest.forge_root,
964
+ )
965
+ console.print("[green]Cleanup complete.[/green]")
966
+ except ForgeSessionError as e:
967
+ console.print(f"[yellow]Cleanup warning:[/yellow] {e}")
968
+ return exit_code
969
+
970
+ return _launch_claude_for_session(
971
+ manifest=manifest,
972
+ session_id=pre_seeded_uuid,
973
+ resume_id=None,
974
+ effective_template=effective_template,
975
+ runtime_base_url=runtime_base_url,
976
+ context_limit=context_limit,
977
+ use_sidecar=use_sidecar,
978
+ mounts=mounts,
979
+ image=image,
980
+ system_prompt_file=prompt_file,
981
+ name=manifest.name,
982
+ extra_args=extra_args,
983
+ proxy_id=proxy_id,
984
+ )
985
+
986
+
987
+ @session.command()
988
+ @click.argument("name", required=False)
989
+ @click.option(
990
+ "--proxy",
991
+ "proxy_name",
992
+ type=str,
993
+ default=None,
994
+ help="Proxy to use (proxy_id or template name)",
995
+ )
996
+ @click.option(
997
+ "--no-proxy",
998
+ "direct",
999
+ is_flag=True,
1000
+ help="Bypass the proxy and talk to Anthropic directly",
1001
+ )
1002
+ @click.option("--incognito", "-i", is_flag=True, help="Auto-delete session on exit")
1003
+ @click.option("--system-prompt", "-s", help="Append system prompt text")
1004
+ @click.option(
1005
+ "--system-prompt-file",
1006
+ "-S",
1007
+ type=click.Path(exists=True),
1008
+ help="Append system prompt from file",
1009
+ )
1010
+ @click.option("--worktree", "-w", is_flag=True, help="Create git worktree for session isolation")
1011
+ @click.option("--branch", "-b", help="Override branch name (requires --worktree)")
1012
+ @click.option(
1013
+ "--model",
1014
+ "direct_model",
1015
+ type=str,
1016
+ default=None,
1017
+ help="Pin the Claude model for direct sessions (for example: claude-opus-4-7 or claude-sonnet-4-6[1m])",
1018
+ )
1019
+ @click.option("--sidecar", is_flag=True, help="Run with bundled proxy in Docker container")
1020
+ @click.option("--host-proxy", is_flag=True, help="Use host proxy (overrides config)")
1021
+ @click.option("--mount", "mounts", multiple=True, help="Extra mounts (host:container[:ro|rw])")
1022
+ @click.option("--image", default=None, help="Docker image for sidecar mode")
1023
+ @click.option(
1024
+ "--no-launch",
1025
+ is_flag=True,
1026
+ help="Create session without launching Claude",
1027
+ )
1028
+ @click.option(
1029
+ "--extensions/--no-extensions",
1030
+ default=None,
1031
+ help="Auto-install extensions in worktree (default: inherit from parent)",
1032
+ )
1033
+ @click.option(
1034
+ "--supervise",
1035
+ "supervise_target",
1036
+ type=str,
1037
+ default=None,
1038
+ help="Session name to use as plan supervisor (enables policy enforcement)",
1039
+ )
1040
+ @click.option(
1041
+ "--supervisor-proxy",
1042
+ type=str,
1043
+ default=None,
1044
+ help="Proxy for supervisor routing (requires --supervise)",
1045
+ )
1046
+ @click.option(
1047
+ "--no-supervisor-proxy",
1048
+ "supervisor_direct",
1049
+ is_flag=True,
1050
+ default=False,
1051
+ help="Force supervisor to use direct Anthropic routing (requires --supervise)",
1052
+ )
1053
+ @click.option(
1054
+ "--subprocess-proxy",
1055
+ "subprocess_proxy",
1056
+ type=str,
1057
+ default=None,
1058
+ help="Route subprocesses (supervisor, panel, handoff) through this proxy while main session is direct",
1059
+ )
1060
+ def start(
1061
+ name: str | None,
1062
+ proxy_name: str | None,
1063
+ direct: bool,
1064
+ incognito: bool,
1065
+ system_prompt: str | None,
1066
+ system_prompt_file: str | None,
1067
+ worktree: bool,
1068
+ branch: str | None,
1069
+ direct_model: str | None,
1070
+ sidecar: bool,
1071
+ host_proxy: bool,
1072
+ mounts: tuple[str, ...],
1073
+ image: str | None,
1074
+ no_launch: bool,
1075
+ extensions: bool | None,
1076
+ supervise_target: str | None,
1077
+ supervisor_proxy: str | None,
1078
+ supervisor_direct: bool,
1079
+ subprocess_proxy: str | None,
1080
+ ) -> None:
1081
+ """Create and start a new session.
1082
+
1083
+ With --worktree/-w, creates an isolated git worktree for the session.
1084
+ This enables parallel work without manifest conflicts.
1085
+
1086
+ With --sidecar, runs Claude Code and proxy inside a Docker container
1087
+ with lifecycle coupling. The project directory is mounted at /workspace.
1088
+
1089
+ With --subprocess-proxy, the main session talks to Anthropic directly
1090
+ (free subscription) while panels, supervisors, and handoff agents route
1091
+ through the named proxy for cost tracking and multi-model access.
1092
+
1093
+ For resuming existing sessions, use ``forge session resume``.
1094
+
1095
+ \b
1096
+ Examples:
1097
+ forge session start # Auto-named, no proxy
1098
+ forge session start my-feature # Named session, no proxy
1099
+ forge session start my-feature --proxy openrouter-gemini # With proxy routing
1100
+ forge session start my-feature --subprocess-proxy openrouter-anthropic # Direct + proxied subprocesses
1101
+ forge session start my-feature --worktree # Isolated worktree
1102
+ forge session start my-feature --supervise planner # With plan supervision
1103
+ """
1104
+ if direct and proxy_name:
1105
+ console.print("[red]Error:[/red] --no-proxy and --proxy are mutually exclusive")
1106
+ sys.exit(1)
1107
+ if supervisor_proxy and supervisor_direct:
1108
+ console.print("[red]Error:[/red] --supervisor-proxy and --no-supervisor-proxy are mutually exclusive")
1109
+ sys.exit(1)
1110
+ if (supervisor_proxy or supervisor_direct) and not supervise_target:
1111
+ console.print("[red]Error:[/red] --supervisor-proxy/--no-supervisor-proxy require --supervise")
1112
+ sys.exit(1)
1113
+ if subprocess_proxy and proxy_name:
1114
+ console.print(
1115
+ "[red]Error:[/red] --subprocess-proxy is for direct-mode sessions; use --proxy alone for full proxy routing"
1116
+ )
1117
+ sys.exit(1)
1118
+
1119
+ # Default to direct mode when neither --proxy nor --no-proxy is given,
1120
+ # unless --sidecar or --host-proxy is specified (both imply proxy mode).
1121
+ if not proxy_name and not direct and not sidecar and not host_proxy:
1122
+ direct = True
1123
+
1124
+ routing: ResolvedRouting | None = None
1125
+ if proxy_name:
1126
+ routing = _sess()._resolve_routing_from_cli(proxy_name=proxy_name, direct=False)
1127
+
1128
+ # CWD validation: must be at repo root; --worktree requires main repo
1129
+ from forge.cli.guards import require_main_repo_root, require_repo_root
1130
+
1131
+ if worktree:
1132
+ require_main_repo_root()
1133
+ else:
1134
+ require_repo_root()
1135
+
1136
+ if name is None:
1137
+ _fr = _sess()._cwd_forge_root()
1138
+ existing = {n for n, _ in _sess().SessionManager().list_sessions(forge_root_filter=_fr)}
1139
+ name = _sess().generate_unique_name(existing)
1140
+
1141
+ sys.exit(
1142
+ launch_new_session(
1143
+ name=name,
1144
+ template=routing.template if routing else None,
1145
+ base_url=routing.base_url if routing else None,
1146
+ direct=direct,
1147
+ incognito=incognito,
1148
+ system_prompt=system_prompt,
1149
+ system_prompt_file=system_prompt_file,
1150
+ worktree=worktree,
1151
+ branch=branch,
1152
+ sidecar=sidecar,
1153
+ host_proxy=host_proxy,
1154
+ mounts=mounts,
1155
+ image=image,
1156
+ no_launch=no_launch,
1157
+ extensions=extensions,
1158
+ proxy_id=routing.proxy_id if routing else None,
1159
+ proxy_display=routing.proxy_id if routing else None,
1160
+ context_limit_override=routing.context_limit if routing else None,
1161
+ supervise_target=supervise_target,
1162
+ supervisor_proxy=supervisor_proxy,
1163
+ supervisor_direct=supervisor_direct,
1164
+ subprocess_proxy=subprocess_proxy,
1165
+ direct_model=direct_model,
1166
+ )
1167
+ )
1168
+
1169
+
1170
+ @session.command()
1171
+ @click.argument("name", required=False)
1172
+ @click.option(
1173
+ "--proxy",
1174
+ "proxy_name",
1175
+ type=str,
1176
+ default=None,
1177
+ help="Proxy to use (proxy_id or template name)",
1178
+ )
1179
+ @click.option(
1180
+ "--no-proxy",
1181
+ "direct",
1182
+ is_flag=True,
1183
+ default=False,
1184
+ help="Bypass the proxy and talk to Anthropic directly",
1185
+ )
1186
+ @click.option(
1187
+ "--fresh",
1188
+ is_flag=True,
1189
+ default=False,
1190
+ help="Start a fresh Claude conversation with context assembled from the session's history",
1191
+ )
1192
+ @click.option(
1193
+ "--child-name",
1194
+ "-n",
1195
+ "child_name",
1196
+ help="Name for the derived session (only with --fresh, auto-generated if not provided)",
1197
+ )
1198
+ @click.option(
1199
+ "--strategy",
1200
+ "-s",
1201
+ type=click.Choice(["minimal", "structured", "full", "ai-curated"]),
1202
+ default="structured",
1203
+ help="Context assembly strategy (only with --fresh, default: structured)",
1204
+ )
1205
+ @click.option(
1206
+ "--depth",
1207
+ "-d",
1208
+ type=int,
1209
+ default=1,
1210
+ help="Lineage traversal depth (only with --fresh, 1=parent only)",
1211
+ )
1212
+ @click.option(
1213
+ "--resume-mode",
1214
+ "resume_mode",
1215
+ type=click.Choice(["native", "handoff"]),
1216
+ default=None,
1217
+ help="Context transfer: native (full conversation via --fork-session) or handoff (assembled summary). Default: handoff.",
1218
+ )
1219
+ @click.option(
1220
+ "--review",
1221
+ is_flag=True,
1222
+ default=False,
1223
+ help="Open the generated child context in $EDITOR before launch (only with --fresh handoff mode).",
1224
+ )
1225
+ @click.option(
1226
+ "--force",
1227
+ "-f",
1228
+ is_flag=True,
1229
+ help="Bypass active-session guard (launches as new child)",
1230
+ )
1231
+ def resume(
1232
+ name: str | None,
1233
+ proxy_name: str | None,
1234
+ direct: bool,
1235
+ fresh: bool,
1236
+ child_name: str | None,
1237
+ strategy: str,
1238
+ depth: int,
1239
+ resume_mode: str | None,
1240
+ review: bool,
1241
+ force: bool,
1242
+ ) -> None:
1243
+ """Resume a session.
1244
+
1245
+ By default, reattaches to the existing Claude conversation (Ctrl+C
1246
+ recovery). If the session was never launched, launches it in-place.
1247
+
1248
+ Use --fresh to start a new Claude conversation with context assembled
1249
+ from the session's history. This is useful when context approaches
1250
+ limits and you want a clean slate with a summary of what happened.
1251
+
1252
+ Use --fresh --resume-mode native to carry full conversation history
1253
+ via --fork-session (lossless but lost on /compact).
1254
+
1255
+ \b
1256
+ Examples:
1257
+ forge session resume my-session # Reattach to conversation
1258
+ forge session resume my-session --fresh # Fresh conversation with context
1259
+ forge session resume my-session --fresh -s full # Full transcript in context
1260
+ forge session resume my-session --fresh --resume-mode native # Full conversation history
1261
+ forge session resume my-session --proxy my-proxy # Reattach with different routing
1262
+ forge session resume my-session --fresh --no-proxy # Fresh conversation, direct mode
1263
+ """
1264
+ if direct and proxy_name:
1265
+ console.print("[red]Error:[/red] --no-proxy and --proxy are mutually exclusive")
1266
+ sys.exit(1)
1267
+
1268
+ if resume_mode and not fresh:
1269
+ console.print("[red]Error:[/red] --resume-mode requires --fresh")
1270
+ sys.exit(1)
1271
+
1272
+ if not fresh and child_name:
1273
+ console.print("[red]Error:[/red] --child-name requires --fresh")
1274
+ sys.exit(1)
1275
+
1276
+ if review and not fresh:
1277
+ console.print("[red]Error:[/red] --review requires --fresh")
1278
+ sys.exit(1)
1279
+
1280
+ if review and resume_mode == "native":
1281
+ console.print(
1282
+ "[red]Error:[/red] --review is only meaningful in handoff mode; "
1283
+ "native resume carries the parent conversation verbatim with no editable artifact."
1284
+ )
1285
+ sys.exit(1)
1286
+
1287
+ routing: ResolvedRouting | None = None
1288
+ if proxy_name:
1289
+ routing = _sess()._resolve_routing_from_cli(proxy_name=proxy_name, direct=False)
1290
+
1291
+ manager = _sess().SessionManager()
1292
+
1293
+ if name is None:
1294
+ sessions = manager.list_sessions(include_incognito=True)
1295
+ if not sessions:
1296
+ console.print("[dim]No sessions to resume.[/dim]")
1297
+ console.print("\n[dim]Tip: Run 'forge session start <name>'.[/dim]")
1298
+ return
1299
+
1300
+ name = _pick_session(sessions, manager, prompt="Select session to resume")
1301
+ if name is None:
1302
+ console.print("[dim]Cancelled[/dim]")
1303
+ sys.exit(0)
1304
+
1305
+ _fr = _sess()._cwd_forge_root()
1306
+ try:
1307
+ manifest = manager.get_session(name, forge_root=_fr)
1308
+ except SessionNotFoundError:
1309
+ if not _hint_cross_project_session(name, _fr):
1310
+ console.print(f"[red]Error:[/red] session '{name}' not found")
1311
+ sys.exit(1)
1312
+ except ForgeSessionError as e:
1313
+ _handle_error(e)
1314
+ return
1315
+
1316
+ if fresh:
1317
+ effective_resume_mode = resume_mode or "handoff"
1318
+
1319
+ # Warn about handoff-only flags with native mode
1320
+ if effective_resume_mode == "native":
1321
+ ctx = click.get_current_context()
1322
+ if ctx.get_parameter_source("strategy") == click.core.ParameterSource.COMMANDLINE:
1323
+ console.print("[dim]Tip: --strategy is ignored with --resume-mode native.[/dim]")
1324
+ if ctx.get_parameter_source("depth") == click.core.ParameterSource.COMMANDLINE:
1325
+ console.print("[dim]Tip: --depth is ignored with --resume-mode native.[/dim]")
1326
+
1327
+ if effective_resume_mode == "native":
1328
+ # Native requires a hook-confirmed session (UUID + confirmed_by/transcript evidence).
1329
+ # A pre-seeded UUID alone is not enough — there must be a real conversation to resume.
1330
+ if not _is_resumable_session(manifest):
1331
+ console.print(
1332
+ "[red]Error:[/red] --resume-mode native requires a parent with a confirmed "
1333
+ "Claude session (hook-confirmed or transcript-backed). "
1334
+ "Use --resume-mode handoff for transcript-artifact-based resume."
1335
+ )
1336
+ sys.exit(1)
1337
+ _resume_fresh_native(
1338
+ manager=manager,
1339
+ parent=name,
1340
+ parent_state=manifest,
1341
+ child_name=child_name,
1342
+ routing=routing,
1343
+ direct=direct,
1344
+ )
1345
+ else:
1346
+ _resume_fresh(
1347
+ manager=manager,
1348
+ parent=name,
1349
+ parent_state=manifest,
1350
+ child_name=child_name,
1351
+ strategy=strategy,
1352
+ depth=depth,
1353
+ routing=routing,
1354
+ direct=direct,
1355
+ review=review,
1356
+ )
1357
+ elif not _has_confirmed_claude_session(manifest):
1358
+ _launch_in_place(
1359
+ manager=manager,
1360
+ name=name,
1361
+ manifest=manifest,
1362
+ routing=routing,
1363
+ direct=direct,
1364
+ )
1365
+ elif _is_resumable_session(manifest):
1366
+ active_entry = _get_active_session_entry(name, forge_root=manifest.forge_root)
1367
+ if active_entry is not None and not force:
1368
+ console.print(
1369
+ f"[red]Error:[/red] Cannot reconnect: session [bold]{name}[/bold] appears to still be active."
1370
+ )
1371
+ console.print(f" Launch mode: {active_entry.launch_mode}")
1372
+ if active_entry.launcher_pid is not None:
1373
+ console.print(f" Launcher PID: {active_entry.launcher_pid}")
1374
+ if active_entry.container_name:
1375
+ console.print(f" Container: {active_entry.container_name}")
1376
+ console.print(
1377
+ "[dim]Tip: Reconnect is only available after the previous launch has exited."
1378
+ " Return to that launch if it is still running, or stop it cleanly and retry.[/dim]"
1379
+ )
1380
+ sys.exit(1)
1381
+ elif active_entry is not None and force:
1382
+ console.print(
1383
+ f"[yellow]Warning:[/yellow] Session [bold]{name}[/bold] appears active "
1384
+ f"(PID {active_entry.launcher_pid}). Launching as new child (--force)."
1385
+ )
1386
+ _launch_as_child(
1387
+ manager=manager,
1388
+ parent_name=name,
1389
+ parent=manifest,
1390
+ routing=routing,
1391
+ direct=direct,
1392
+ )
1393
+ else:
1394
+ _reconnect_in_place(
1395
+ manager=manager,
1396
+ name=name,
1397
+ manifest=manifest,
1398
+ routing=routing,
1399
+ direct=direct,
1400
+ )
1401
+ else:
1402
+ _launch_as_child(
1403
+ manager=manager,
1404
+ parent_name=name,
1405
+ parent=manifest,
1406
+ routing=routing,
1407
+ direct=direct,
1408
+ )
1409
+
1410
+
1411
+ def _launch_in_place(
1412
+ *,
1413
+ manager: SessionManager,
1414
+ name: str,
1415
+ manifest: SessionState,
1416
+ routing: ResolvedRouting | None = None,
1417
+ direct: bool = False,
1418
+ ) -> None:
1419
+ """Launch a never-used session in-place (satisfies 1:1)."""
1420
+ manager.switch_session(name, forge_root=manifest.forge_root)
1421
+
1422
+ worktree_path = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
1423
+ _apply_routing_override_to_state(state=manifest, routing=routing, direct=direct)
1424
+ _persist_routing_override(
1425
+ forge_root=Path(manifest.forge_root) if manifest.forge_root else worktree_path,
1426
+ session_name=manifest.name,
1427
+ routing=routing,
1428
+ direct=direct,
1429
+ )
1430
+
1431
+ effective_template, effective_url, effective_proxy_id = _get_effective_proxy_for_session(manifest)
1432
+ context_limit = _sess()._resolve_context_limit(effective_proxy_id or effective_template)
1433
+ use_sidecar, mounts, image = _get_launch_preferences(manifest)
1434
+ prompt_files: list[Path] = []
1435
+
1436
+ configured_prompt = _resolve_manifest_prompt_file(manifest)
1437
+ if configured_prompt is not None:
1438
+ prompt_files.append(configured_prompt)
1439
+
1440
+ # Check for deferred same-dir fork (never-started fork should resume parent)
1441
+ fork_session = False
1442
+ resume_id: str | None = None
1443
+ session_id: str | None = None
1444
+ prompt_warnings: list[str] = []
1445
+ parent_resume_id = _get_deferred_same_dir_fork_resume_id(manager=manager, manifest=manifest)
1446
+ if parent_resume_id is not None:
1447
+ resume_id = parent_resume_id
1448
+ fork_session = True
1449
+ launch_action = "Fork parent Claude conversation"
1450
+ else:
1451
+ session_id = str(_uuid.uuid4())
1452
+ persisted_context = _resolve_derivation_context_file(manifest)
1453
+ if persisted_context is not None:
1454
+ prompt_files.append(persisted_context)
1455
+ launch_action = "Start fresh Claude session with parent context"
1456
+ else:
1457
+ fork_context, prompt_warnings = _sess()._generate_parent_handoff_context(manager=manager, manifest=manifest)
1458
+ if fork_context is not None:
1459
+ prompt_files.append(fork_context)
1460
+ launch_action = "Start fresh Claude session with parent context"
1461
+ else:
1462
+ launch_action = "Start fresh Claude session"
1463
+
1464
+ # Write pre-seeded UUID to manifest + index (after worktree_path is resolved)
1465
+ forge_root_path = Path(manifest.forge_root) if manifest.forge_root else worktree_path
1466
+ if session_id is not None:
1467
+ try:
1468
+ from forge.session import SessionStore
1469
+
1470
+ store = SessionStore(str(forge_root_path), manifest.name)
1471
+ store.update(
1472
+ timeout_s=5.0,
1473
+ mutate=lambda m: setattr(m.confirmed, "claude_session_id", session_id),
1474
+ )
1475
+ manager.index_store.sync_uuid_from_state(manifest.name, store.read())
1476
+ except Exception:
1477
+ logger.debug("Pre-seed UUID write failed (hook will reconcile)", exc_info=True)
1478
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=effective_url)
1479
+ prompt_file = _combine_prompt_files(
1480
+ worktree_path=worktree_path,
1481
+ session_name=manifest.name,
1482
+ prompt_files=prompt_files,
1483
+ )
1484
+
1485
+ console.print(f"Launching session [green]{manifest.name}[/green]")
1486
+ _print_routing_summary(template=effective_template, base_url=runtime_base_url)
1487
+ console.print(f" Action: {launch_action}")
1488
+ if manifest.worktree and manifest.worktree.is_worktree:
1489
+ console.print(f" Worktree: {display_path(worktree_path)}")
1490
+ console.print(f" Branch: {manifest.worktree.branch}")
1491
+ if prompt_file:
1492
+ _print_context_path(prompt_file, worktree_path)
1493
+ for w in prompt_warnings:
1494
+ console.print(f"[yellow]Warning:[/yellow] {w}")
1495
+ console.print()
1496
+
1497
+ exit_code = _launch_claude_for_session(
1498
+ manifest=manifest,
1499
+ session_id=session_id,
1500
+ resume_id=resume_id,
1501
+ effective_template=effective_template,
1502
+ runtime_base_url=runtime_base_url,
1503
+ context_limit=context_limit,
1504
+ use_sidecar=use_sidecar,
1505
+ mounts=mounts,
1506
+ image=image,
1507
+ fork_session=fork_session,
1508
+ system_prompt_file=prompt_file,
1509
+ name=manifest.name,
1510
+ proxy_id=effective_proxy_id,
1511
+ )
1512
+ sys.exit(exit_code)
1513
+
1514
+
1515
+ def _reconnect_in_place(
1516
+ *,
1517
+ manager: SessionManager,
1518
+ name: str,
1519
+ manifest: SessionState,
1520
+ routing: ResolvedRouting | None = None,
1521
+ direct: bool = False,
1522
+ ) -> None:
1523
+ """Reconnect to the same Claude conversation without creating a child.
1524
+
1525
+ Advanced escape hatch for resuming in-place after the previous launch has
1526
+ fully ended. Relaxes the 1:1 invariant (new process invocation on the same
1527
+ Forge session) but is gated: a resumable conversation must exist.
1528
+
1529
+ The caller is responsible for the active-session check (see resume()
1530
+ dispatch) -- this function assumes the session is not active.
1531
+ """
1532
+ if not _is_resumable_session(manifest):
1533
+ console.print("[red]Error:[/red] Cannot reconnect: no resumable Claude conversation was found.")
1534
+ console.print(
1535
+ f"[dim]Tip: Use 'forge session resume {name}' to reattach, or --fresh to start a new conversation.[/dim]"
1536
+ )
1537
+ sys.exit(1)
1538
+
1539
+ claude_session_id = manifest.confirmed.claude_session_id
1540
+ assert claude_session_id is not None # _is_resumable_session guarantees this
1541
+
1542
+ manager.switch_session(name, forge_root=manifest.forge_root)
1543
+
1544
+ worktree_path = Path(manifest.worktree.path) if manifest.worktree else Path.cwd()
1545
+ _apply_routing_override_to_state(state=manifest, routing=routing, direct=direct)
1546
+ _persist_routing_override(
1547
+ forge_root=Path(manifest.forge_root) if manifest.forge_root else worktree_path,
1548
+ session_name=manifest.name,
1549
+ routing=routing,
1550
+ direct=direct,
1551
+ )
1552
+
1553
+ effective_template, effective_url, effective_proxy_id = _get_effective_proxy_for_session(manifest)
1554
+ context_limit = _sess()._resolve_context_limit(effective_proxy_id or effective_template)
1555
+ use_sidecar, mounts, image = _get_launch_preferences(manifest)
1556
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=effective_url)
1557
+
1558
+ console.print(f"Reconnecting to session [green]{name}[/green]")
1559
+ _print_routing_summary(template=effective_template, base_url=runtime_base_url)
1560
+ console.print(" Action: Reconnect to existing Claude conversation")
1561
+ console.print(f" UUID: {claude_session_id[:8]}...")
1562
+ if manifest.worktree and manifest.worktree.is_worktree:
1563
+ console.print(f" Worktree: {display_path(worktree_path)}")
1564
+ console.print(f" Branch: {manifest.worktree.branch}")
1565
+ console.print()
1566
+
1567
+ exit_code = _launch_claude_for_session(
1568
+ manifest=manifest,
1569
+ session_id=None,
1570
+ resume_id=claude_session_id,
1571
+ effective_template=effective_template,
1572
+ runtime_base_url=runtime_base_url,
1573
+ context_limit=context_limit,
1574
+ use_sidecar=use_sidecar,
1575
+ mounts=mounts,
1576
+ image=image,
1577
+ fork_session=False,
1578
+ name=manifest.name,
1579
+ proxy_id=effective_proxy_id,
1580
+ )
1581
+ sys.exit(exit_code)
1582
+
1583
+
1584
+ def _launch_as_child(
1585
+ *,
1586
+ manager: SessionManager,
1587
+ parent_name: str,
1588
+ parent: SessionState,
1589
+ routing: ResolvedRouting | None = None,
1590
+ direct: bool = False,
1591
+ ) -> None:
1592
+ """Create a child session and resume the parent's Claude conversation.
1593
+
1594
+ Routes through _launch_claude_for_session() so sidecar sessions relaunch
1595
+ through the sidecar path with stored mounts/image settings.
1596
+ """
1597
+ try:
1598
+ parent, child = manager.relaunch_session(parent_name, forge_root=parent.forge_root)
1599
+ except ForgeSessionError as e:
1600
+ _handle_error(e)
1601
+ return
1602
+
1603
+ worktree_path = Path(child.worktree.path) if child.worktree else Path.cwd()
1604
+ _apply_routing_override_to_state(state=child, routing=routing, direct=direct)
1605
+ _persist_routing_override(
1606
+ forge_root=Path(child.forge_root) if child.forge_root else worktree_path,
1607
+ session_name=child.name,
1608
+ routing=routing,
1609
+ direct=direct,
1610
+ )
1611
+
1612
+ effective_template, effective_url, effective_proxy_id = _get_effective_proxy_for_session(child)
1613
+ context_limit = _sess()._resolve_context_limit(effective_proxy_id or effective_template)
1614
+ use_sidecar, mounts, image = _get_launch_preferences(child)
1615
+
1616
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=effective_url)
1617
+
1618
+ console.print(f"Relaunching [green]{parent_name}[/green] as [green]{child.name}[/green]")
1619
+ _print_routing_summary(template=effective_template, base_url=runtime_base_url)
1620
+ console.print(" Action: Resume parent conversation in new session")
1621
+ console.print(f" Parent: {parent_name}")
1622
+ if child.worktree and child.worktree.is_worktree:
1623
+ console.print(f" Worktree: {display_path(worktree_path)}")
1624
+ console.print(f" Branch: {child.worktree.branch}")
1625
+ console.print()
1626
+
1627
+ # Child is a same-dir fork: use --resume --fork-session with parent's UUID
1628
+ exit_code = _launch_claude_for_session(
1629
+ manifest=child,
1630
+ session_id=None,
1631
+ resume_id=parent.confirmed.claude_session_id,
1632
+ effective_template=effective_template,
1633
+ runtime_base_url=runtime_base_url,
1634
+ context_limit=context_limit,
1635
+ use_sidecar=use_sidecar,
1636
+ mounts=mounts,
1637
+ image=image,
1638
+ fork_session=True,
1639
+ name=child.name,
1640
+ proxy_id=effective_proxy_id,
1641
+ )
1642
+ sys.exit(exit_code)
1643
+
1644
+
1645
+ def _print_context_path(prompt_file: str, worktree_path: Path) -> None:
1646
+ """Print context file path, relative if possible."""
1647
+ prompt_path = Path(prompt_file)
1648
+ try:
1649
+ console.print(f" Context: {prompt_path.relative_to(worktree_path)}")
1650
+ except ValueError:
1651
+ console.print(f" Context: {display_path(prompt_path)}")
1652
+
1653
+
1654
+ def _pick_session(
1655
+ sessions: list[tuple[str, SessionIndexEntry]],
1656
+ manager: SessionManager,
1657
+ prompt: str = "Select a session",
1658
+ ) -> str | None:
1659
+ """Interactive session picker using Rich.
1660
+
1661
+ Args:
1662
+ sessions: List of (name, entry) tuples.
1663
+ manager: SessionManager for looking up manifest details.
1664
+ prompt: Prompt text to display.
1665
+
1666
+ Returns:
1667
+ Selected session name, or None if cancelled.
1668
+ """
1669
+ from rich.table import Table
1670
+
1671
+ from forge.cli.session import _format_relative_time
1672
+
1673
+ if not sessions:
1674
+ return None
1675
+
1676
+ console.print(f"\n[bold]{prompt}:[/bold]\n")
1677
+
1678
+ table = Table(show_header=True, header_style="bold", box=None)
1679
+ table.add_column("#", justify="right", width=3)
1680
+ table.add_column("NAME")
1681
+ table.add_column("TEMPLATE")
1682
+ table.add_column("LAST USED")
1683
+
1684
+ for i, (session_name, entry) in enumerate(sessions, 1):
1685
+ proxy_template = "direct"
1686
+ try:
1687
+ manifest = manager.get_session(session_name, forge_root=entry.forge_root)
1688
+ if manifest.intent.proxy:
1689
+ proxy_template = manifest.intent.proxy.template
1690
+ except ForgeSessionError:
1691
+ pass
1692
+
1693
+ last_used = _format_relative_time(entry.last_accessed_at)
1694
+
1695
+ table.add_row(str(i), session_name, proxy_template, last_used)
1696
+
1697
+ console.print(table)
1698
+ console.print()
1699
+
1700
+ try:
1701
+ choice = click.prompt("Enter number (or 'q' to cancel)", default="1")
1702
+ if choice.lower() in ("q", "quit", "cancel"):
1703
+ return None
1704
+
1705
+ choice_int = int(choice)
1706
+ if choice_int < 1 or choice_int > len(sessions):
1707
+ console.print("[red]Invalid choice[/red]")
1708
+ return None
1709
+
1710
+ return sessions[choice_int - 1][0]
1711
+ except (ValueError, click.Abort):
1712
+ return None
1713
+
1714
+
1715
+ def _open_in_editor(file_path: Path, *, resume_session_name: str | None = None) -> None:
1716
+ """Open ``file_path`` in $EDITOR. Aborts launch on non-zero exit (git-commit-style).
1717
+
1718
+ The file is edited in place; no temp file dance because the per-child
1719
+ context file is the authoritative artifact.
1720
+ """
1721
+ editor = os.environ.get("EDITOR", "vim")
1722
+ editor_argv = shlex.split(editor)
1723
+ if not editor_argv:
1724
+ console.print("[red]Error:[/red] $EDITOR is empty. Set $EDITOR to an available editor.")
1725
+ sys.exit(1)
1726
+ if not shutil.which(editor_argv[0]):
1727
+ console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
1728
+ sys.exit(1)
1729
+
1730
+ result = subprocess.run([*editor_argv, str(file_path)])
1731
+ if result.returncode != 0:
1732
+ resume_tip = (
1733
+ f"forge session resume {resume_session_name}"
1734
+ if resume_session_name
1735
+ else "forge session resume <child-name>"
1736
+ )
1737
+ console.print(
1738
+ f"[red]Aborted:[/red] editor exited with code {result.returncode}. Session not launched.\n"
1739
+ f"[dim]Tip: The handoff file at {display_path(file_path)} is preserved; "
1740
+ f"run '{resume_tip}' to launch with the current content.[/dim]"
1741
+ )
1742
+ sys.exit(result.returncode)
1743
+
1744
+
1745
+ def _resume_fresh(
1746
+ *,
1747
+ manager: SessionManager,
1748
+ parent: str,
1749
+ parent_state: SessionState,
1750
+ child_name: str | None,
1751
+ strategy: str,
1752
+ depth: int,
1753
+ routing: ResolvedRouting | None,
1754
+ direct: bool,
1755
+ review: bool = False,
1756
+ ) -> None:
1757
+ """Create a fresh child session with context assembled from parent.
1758
+
1759
+ This is the --fresh path of ``forge session resume``. Creates a new
1760
+ derived session with a context summary, then launches Claude fresh.
1761
+ When ``review`` is True, opens the per-child handoff file in $EDITOR
1762
+ before launching (user can curate the context).
1763
+ """
1764
+ # Routing for context limit: --proxy/--no-proxy override > parent's effective routing.
1765
+ if routing:
1766
+ effective_proxy_ref = routing.proxy_id
1767
+ elif direct:
1768
+ effective_proxy_ref = None
1769
+ else:
1770
+ effective_template, _, effective_proxy_id = _get_effective_proxy_for_session(parent_state)
1771
+ effective_proxy_ref = effective_proxy_id or effective_template
1772
+
1773
+ context_limit = _sess()._resolve_context_limit(effective_proxy_ref)
1774
+ token_multiplier = _resume_token_estimate_multiplier(
1775
+ parent_state=parent_state,
1776
+ effective_proxy_ref=effective_proxy_ref,
1777
+ )
1778
+
1779
+ try:
1780
+ child_manifest, handoff_result = manager.resume_session(
1781
+ parent,
1782
+ child_name=child_name,
1783
+ strategy=strategy,
1784
+ depth=depth,
1785
+ context_limit=context_limit,
1786
+ token_estimate_multiplier=token_multiplier,
1787
+ forge_root=parent_state.forge_root,
1788
+ )
1789
+ except ForgeSessionError as e:
1790
+ _handle_error(e)
1791
+ return
1792
+
1793
+ child_worktree_path = Path(child_manifest.worktree.path) if child_manifest.worktree else Path.cwd()
1794
+ _persist_routing_override(
1795
+ forge_root=Path(child_manifest.forge_root) if child_manifest.forge_root else child_worktree_path,
1796
+ session_name=child_manifest.name,
1797
+ routing=routing,
1798
+ direct=direct,
1799
+ )
1800
+ _apply_routing_override_to_state(state=child_manifest, routing=routing, direct=direct)
1801
+
1802
+ console.print(f"[dim]Context assembled: {handoff_result.context_file_rel}[/dim]")
1803
+ if handoff_result.warnings:
1804
+ for warning in handoff_result.warnings:
1805
+ console.print(f"[yellow]Warning:[/yellow] {warning}")
1806
+ console.print()
1807
+
1808
+ if review and handoff_result.context_file is not None:
1809
+ console.print(f"[dim]Opening {handoff_result.context_file_rel} in $EDITOR for review...[/dim]")
1810
+ _open_in_editor(handoff_result.context_file, resume_session_name=child_manifest.name)
1811
+
1812
+ console.print(f"Created derived session [green]{child_manifest.name}[/green] from [cyan]{parent}[/cyan]")
1813
+ console.print(f"[dim]Strategy: {strategy}, Depth: {depth}[/dim]")
1814
+ console.print()
1815
+
1816
+ # Launch Claude as a NEW session (not resuming parent's conversation)
1817
+ child_worktree = Path(child_manifest.worktree.path) if child_manifest.worktree else Path.cwd()
1818
+ prompt_files: list[Path] = []
1819
+ configured_prompt = _resolve_manifest_prompt_file(child_manifest)
1820
+ if configured_prompt is not None:
1821
+ prompt_files.append(configured_prompt)
1822
+ if handoff_result.context_file is not None:
1823
+ prompt_files.append(handoff_result.context_file.resolve())
1824
+ prompt_file = _combine_prompt_files(
1825
+ worktree_path=child_worktree,
1826
+ session_name=child_manifest.name,
1827
+ prompt_files=prompt_files,
1828
+ )
1829
+
1830
+ launch_template, launch_base_url, launch_proxy_id = _get_effective_proxy_for_session(child_manifest)
1831
+
1832
+ use_sidecar, mounts, image = _get_launch_preferences(child_manifest)
1833
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=launch_base_url)
1834
+
1835
+ pre_seeded_uuid = str(_uuid.uuid4())
1836
+ try:
1837
+ from forge.session import SessionStore
1838
+
1839
+ _store_root = Path(child_manifest.forge_root) if child_manifest.forge_root else child_worktree_path
1840
+ _store = SessionStore(str(_store_root), child_manifest.name)
1841
+ _store.update(
1842
+ timeout_s=5.0,
1843
+ mutate=lambda m: setattr(m.confirmed, "claude_session_id", pre_seeded_uuid),
1844
+ )
1845
+ manager.index_store.sync_uuid_from_state(child_manifest.name, _store.read())
1846
+ except Exception:
1847
+ logger.debug("Pre-seed UUID write failed (hook will reconcile)", exc_info=True)
1848
+
1849
+ _print_routing_summary(template=launch_template, base_url=runtime_base_url)
1850
+ console.print()
1851
+
1852
+ exit_code = _launch_claude_for_session(
1853
+ manifest=child_manifest,
1854
+ session_id=pre_seeded_uuid,
1855
+ resume_id=None,
1856
+ effective_template=launch_template,
1857
+ runtime_base_url=runtime_base_url,
1858
+ context_limit=context_limit,
1859
+ use_sidecar=use_sidecar,
1860
+ mounts=mounts,
1861
+ image=image,
1862
+ fork_session=False,
1863
+ system_prompt_file=prompt_file,
1864
+ name=child_manifest.name,
1865
+ proxy_id=launch_proxy_id,
1866
+ )
1867
+
1868
+ sys.exit(exit_code)
1869
+
1870
+
1871
+ def _resume_fresh_native(
1872
+ *,
1873
+ manager: SessionManager,
1874
+ parent: str,
1875
+ parent_state: SessionState,
1876
+ child_name: str | None,
1877
+ routing: ResolvedRouting | None,
1878
+ direct: bool,
1879
+ ) -> None:
1880
+ """Create a child session with native conversation resume.
1881
+
1882
+ Uses --resume --fork-session to carry full conversation history into a new
1883
+ Forge session. No context assembly or system_prompt_file generation.
1884
+
1885
+ Requires the parent to have a confirmed claude_session_id (caller validates).
1886
+ """
1887
+ # Routing for context limit: --proxy/--no-proxy override > parent's effective routing.
1888
+ if routing:
1889
+ effective_proxy_ref = routing.proxy_id
1890
+ elif direct:
1891
+ effective_proxy_ref = None
1892
+ else:
1893
+ effective_template, _, effective_proxy_id = _get_effective_proxy_for_session(parent_state)
1894
+ effective_proxy_ref = effective_proxy_id or effective_template
1895
+
1896
+ context_limit = _sess()._resolve_context_limit(effective_proxy_ref)
1897
+
1898
+ try:
1899
+ child_manifest, _handoff = manager.resume_session(
1900
+ parent,
1901
+ child_name=child_name,
1902
+ resume_mode="native",
1903
+ forge_root=parent_state.forge_root,
1904
+ )
1905
+ except ForgeSessionError as e:
1906
+ _handle_error(e)
1907
+ return
1908
+
1909
+ child_worktree_path = Path(child_manifest.worktree.path) if child_manifest.worktree else Path.cwd()
1910
+ _persist_routing_override(
1911
+ forge_root=Path(child_manifest.forge_root) if child_manifest.forge_root else child_worktree_path,
1912
+ session_name=child_manifest.name,
1913
+ routing=routing,
1914
+ direct=direct,
1915
+ )
1916
+ _apply_routing_override_to_state(state=child_manifest, routing=routing, direct=direct)
1917
+
1918
+ parent_uuid = parent_state.confirmed.claude_session_id
1919
+ assert parent_uuid is not None # caller validated
1920
+
1921
+ console.print(f"Created derived session [green]{child_manifest.name}[/green] from [cyan]{parent}[/cyan]")
1922
+ console.print("[dim]Mode: Native resume (full conversation history via --fork-session)[/dim]")
1923
+ console.print()
1924
+
1925
+ launch_template, launch_base_url, launch_proxy_id = _get_effective_proxy_for_session(child_manifest)
1926
+ use_sidecar, mounts, image = _get_launch_preferences(child_manifest)
1927
+ runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=launch_base_url)
1928
+
1929
+ _print_routing_summary(template=launch_template, base_url=runtime_base_url)
1930
+ console.print()
1931
+
1932
+ exit_code = _launch_claude_for_session(
1933
+ manifest=child_manifest,
1934
+ session_id=None,
1935
+ resume_id=parent_uuid,
1936
+ effective_template=launch_template,
1937
+ runtime_base_url=runtime_base_url,
1938
+ context_limit=context_limit,
1939
+ use_sidecar=use_sidecar,
1940
+ mounts=mounts,
1941
+ image=image,
1942
+ fork_session=True,
1943
+ name=child_manifest.name,
1944
+ proxy_id=launch_proxy_id,
1945
+ )
1946
+
1947
+ sys.exit(exit_code)
1948
+
1949
+
1950
+ @session.command()
1951
+ @click.argument("name", required=False)
1952
+ @click.option(
1953
+ "--proxy",
1954
+ "proxy_name",
1955
+ type=str,
1956
+ default=None,
1957
+ help="Proxy to use (proxy_id or template name)",
1958
+ )
1959
+ @click.option(
1960
+ "--no-proxy",
1961
+ "direct",
1962
+ is_flag=True,
1963
+ help="Bypass the proxy and talk to Anthropic directly",
1964
+ )
1965
+ @click.option("--system-prompt", "-s", help="Append system prompt text")
1966
+ @click.option(
1967
+ "--system-prompt-file",
1968
+ "-S",
1969
+ type=click.Path(exists=True),
1970
+ help="Append system prompt from file",
1971
+ )
1972
+ @click.option("--worktree", "-w", is_flag=True, help="Create git worktree for session isolation")
1973
+ @click.option("--branch", "-b", help="Override branch name (requires --worktree)")
1974
+ @click.option("--sidecar", is_flag=True, help="Run with bundled proxy in Docker container")
1975
+ @click.option("--host-proxy", is_flag=True, help="Use host proxy (overrides config)")
1976
+ @click.option("--mount", "mounts", multiple=True, help="Extra mounts (host:container[:ro|rw])")
1977
+ @click.option("--image", default=None, help="Docker image for sidecar mode")
1978
+ @click.option(
1979
+ "--extensions/--no-extensions",
1980
+ default=None,
1981
+ help="Auto-install extensions in worktree (default: inherit from parent)",
1982
+ )
1983
+ def incognito(
1984
+ name: str | None,
1985
+ proxy_name: str | None,
1986
+ direct: bool,
1987
+ system_prompt: str | None,
1988
+ system_prompt_file: str | None,
1989
+ worktree: bool,
1990
+ branch: str | None,
1991
+ sidecar: bool,
1992
+ host_proxy: bool,
1993
+ mounts: tuple[str, ...],
1994
+ image: str | None,
1995
+ extensions: bool | None,
1996
+ ) -> None:
1997
+ """Start an incognito session.
1998
+
1999
+ Shortcut for ``forge session start --incognito``. The session is
2000
+ automatically deleted when exited.
2001
+
2002
+ \b
2003
+ Examples:
2004
+ forge session incognito # Auto-named
2005
+ forge session incognito --proxy openrouter-gemini # With proxy
2006
+ forge session incognito my-test # Custom name
2007
+ """
2008
+ if direct and proxy_name:
2009
+ console.print("[red]Error:[/red] --no-proxy and --proxy are mutually exclusive")
2010
+ sys.exit(1)
2011
+
2012
+ # Default to direct mode when neither --proxy nor --no-proxy is given,
2013
+ # unless --sidecar or --host-proxy is specified (both imply proxy mode).
2014
+ if not proxy_name and not direct and not sidecar and not host_proxy:
2015
+ direct = True
2016
+
2017
+ routing: ResolvedRouting | None = None
2018
+ if proxy_name:
2019
+ routing = _sess()._resolve_routing_from_cli(proxy_name=proxy_name, direct=False)
2020
+
2021
+ from forge.cli.guards import require_repo_root
2022
+
2023
+ require_repo_root()
2024
+
2025
+ if name is None:
2026
+ _fr = _sess()._cwd_forge_root()
2027
+ existing = {n for n, _ in _sess().SessionManager().list_sessions(forge_root_filter=_fr)}
2028
+ name = _sess().generate_unique_name(existing)
2029
+
2030
+ # Incognito cleanup is handled inside launch_new_session() so that
2031
+ # validation/creation failures don't trigger deletion of existing sessions.
2032
+ sys.exit(
2033
+ launch_new_session(
2034
+ name=name,
2035
+ template=routing.template if routing else None,
2036
+ base_url=routing.base_url if routing else None,
2037
+ direct=direct,
2038
+ incognito=True,
2039
+ system_prompt=system_prompt,
2040
+ system_prompt_file=system_prompt_file,
2041
+ worktree=worktree,
2042
+ branch=branch,
2043
+ sidecar=sidecar,
2044
+ host_proxy=host_proxy,
2045
+ mounts=mounts,
2046
+ image=image,
2047
+ no_launch=False,
2048
+ extensions=extensions,
2049
+ proxy_id=routing.proxy_id if routing else None,
2050
+ proxy_display=routing.proxy_id if routing else None,
2051
+ context_limit_override=routing.context_limit if routing else None,
2052
+ )
2053
+ )