multi-forge 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -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
|
+
)
|