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