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/core/ops/proxy.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Shared proxy operations (command-core).
|
|
2
|
+
|
|
3
|
+
These operations are UI-agnostic and can be invoked from both:
|
|
4
|
+
|
|
5
|
+
- the CLI (`forge proxy ...`), and
|
|
6
|
+
- the in-chat direct command dispatcher (`%proxy ...`).
|
|
7
|
+
|
|
8
|
+
They return structured data and raise typed exceptions on failure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from forge.config.loader import load_proxy_instance_config
|
|
17
|
+
from forge.config.schema import ProxyInstanceConfig
|
|
18
|
+
from forge.proxy.proxies import (
|
|
19
|
+
ProxyEntry,
|
|
20
|
+
ProxyRegistryCorruptedError,
|
|
21
|
+
ProxyRegistryStore,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .context import ExecutionContext
|
|
25
|
+
from .session import ForgeOpError
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ListProxiesItem:
|
|
32
|
+
proxy_id: str
|
|
33
|
+
entry: ProxyEntry
|
|
34
|
+
config: ProxyInstanceConfig | None # None if proxy.yaml missing/corrupt
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ListProxiesResult:
|
|
39
|
+
proxies: list[ListProxiesItem]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ShowProxyResult:
|
|
44
|
+
proxy_id: str
|
|
45
|
+
entry: ProxyEntry | None # None if proxy has config file but no registry entry
|
|
46
|
+
config: ProxyInstanceConfig | None
|
|
47
|
+
config_yaml: str | None # Raw YAML content for display
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def list_proxies(*, ctx: ExecutionContext) -> ListProxiesResult:
|
|
51
|
+
"""List all registered proxies with their configurations.
|
|
52
|
+
|
|
53
|
+
This is a global operation (lists from ~/.forge/proxies/index.json).
|
|
54
|
+
The ctx is accepted for API consistency.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
ctx: execution context (unused, for API consistency).
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
ListProxiesResult with proxy entries and configs.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ForgeOpError: if the proxy registry cannot be read.
|
|
64
|
+
"""
|
|
65
|
+
_log.debug("list_proxies: cwd=%s", ctx.cwd)
|
|
66
|
+
|
|
67
|
+
store = ProxyRegistryStore()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
registry = store.read()
|
|
71
|
+
except ProxyRegistryCorruptedError as e:
|
|
72
|
+
raise ForgeOpError(f"Proxy registry error: {e}") from e
|
|
73
|
+
|
|
74
|
+
items: list[ListProxiesItem] = []
|
|
75
|
+
for proxy_id, entry in registry.proxies.items():
|
|
76
|
+
# Best-effort config load
|
|
77
|
+
config: ProxyInstanceConfig | None = None
|
|
78
|
+
try:
|
|
79
|
+
config = load_proxy_instance_config(proxy_id)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
_log.debug("Failed to load config for proxy %r: %s", proxy_id, e)
|
|
82
|
+
|
|
83
|
+
items.append(ListProxiesItem(proxy_id=proxy_id, entry=entry, config=config))
|
|
84
|
+
|
|
85
|
+
# Sort by proxy_id for consistent output
|
|
86
|
+
items.sort(key=lambda x: x.proxy_id)
|
|
87
|
+
|
|
88
|
+
return ListProxiesResult(proxies=items)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def show_proxy(*, ctx: ExecutionContext, proxy_id: str) -> ShowProxyResult:
|
|
92
|
+
"""Show details for a specific proxy.
|
|
93
|
+
|
|
94
|
+
The proxy must have either a registry entry or a config file (or both).
|
|
95
|
+
Registry info (status, PID) is best-effort enrichment.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
ctx: execution context (unused, for API consistency).
|
|
99
|
+
proxy_id: the proxy ID to show.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
ShowProxyResult with entry, config, and raw YAML.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ForgeOpError: if the proxy is not found in both registry and filesystem.
|
|
106
|
+
"""
|
|
107
|
+
_log.debug("show_proxy: proxy_id=%s", proxy_id)
|
|
108
|
+
|
|
109
|
+
# Best-effort registry lookup
|
|
110
|
+
entry: ProxyEntry | None = None
|
|
111
|
+
store = ProxyRegistryStore()
|
|
112
|
+
try:
|
|
113
|
+
registry = store.read()
|
|
114
|
+
entry = registry.proxies.get(proxy_id)
|
|
115
|
+
except ProxyRegistryCorruptedError:
|
|
116
|
+
_log.debug("Registry unreadable, proceeding without registry info")
|
|
117
|
+
|
|
118
|
+
# Load config
|
|
119
|
+
config: ProxyInstanceConfig | None = None
|
|
120
|
+
config_yaml: str | None = None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
config = load_proxy_instance_config(proxy_id)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
_log.debug("Failed to load config for proxy %r: %s", proxy_id, e)
|
|
126
|
+
|
|
127
|
+
# Load raw YAML for display
|
|
128
|
+
from forge.config.loader import get_proxy_file_path
|
|
129
|
+
|
|
130
|
+
proxy_path = get_proxy_file_path(proxy_id)
|
|
131
|
+
if proxy_path.exists():
|
|
132
|
+
try:
|
|
133
|
+
config_yaml = proxy_path.read_text()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
_log.debug("Failed to read proxy file %s: %s", proxy_path, e)
|
|
136
|
+
|
|
137
|
+
# Must have at least one source of truth
|
|
138
|
+
if entry is None and config_yaml is None:
|
|
139
|
+
raise ForgeOpError(f"Proxy '{proxy_id}' not found")
|
|
140
|
+
|
|
141
|
+
return ShowProxyResult(
|
|
142
|
+
proxy_id=proxy_id,
|
|
143
|
+
entry=entry,
|
|
144
|
+
config=config,
|
|
145
|
+
config_yaml=config_yaml,
|
|
146
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Repo-wide session resolution.
|
|
2
|
+
|
|
3
|
+
Shared two-tier resolver used by session CLI commands and guard CLI.
|
|
4
|
+
Resolves a named session with current-project preference, falling back
|
|
5
|
+
to a repo-scoped scan when the session lives in a sibling forge_root.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from forge.session import SessionIndexEntry, SessionManager, SessionState, SessionStore
|
|
15
|
+
from forge.session.exceptions import (
|
|
16
|
+
AmbiguousSessionError,
|
|
17
|
+
ForgeSessionError,
|
|
18
|
+
SessionNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ResolvedSession:
|
|
26
|
+
"""Result of repo-wide session resolution."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
entry: SessionIndexEntry
|
|
30
|
+
store: SessionStore
|
|
31
|
+
state: SessionState
|
|
32
|
+
forge_root: str
|
|
33
|
+
is_cross_project: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_session_repo_wide(
|
|
37
|
+
name: str,
|
|
38
|
+
cwd_forge_root: str | None,
|
|
39
|
+
*,
|
|
40
|
+
manager: SessionManager | None = None,
|
|
41
|
+
) -> ResolvedSession:
|
|
42
|
+
"""Resolve a named session with repo-wide scope and current-project preference.
|
|
43
|
+
|
|
44
|
+
Two-tier resolution (no global fast path to prevent cross-repo jumps):
|
|
45
|
+
|
|
46
|
+
1. Tier 1: Try cwd_forge_root (O(1) compound-key index lookup).
|
|
47
|
+
2. Tier 2: Repo-scoped scan (project_root_filter) for cross-worktree matches.
|
|
48
|
+
|
|
49
|
+
Tiebreaker: if multiple matches in the same repo, prefer cwd_forge_root.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SessionNotFoundError: session not found anywhere in the repo.
|
|
53
|
+
AmbiguousSessionError: multiple matches, none in cwd_forge_root.
|
|
54
|
+
"""
|
|
55
|
+
if manager is None:
|
|
56
|
+
manager = SessionManager()
|
|
57
|
+
|
|
58
|
+
# Tier 1: same project (O(1) index lookup)
|
|
59
|
+
if cwd_forge_root is not None:
|
|
60
|
+
try:
|
|
61
|
+
entry = manager.get_session_entry(name, forge_root=cwd_forge_root)
|
|
62
|
+
store = SessionStore(entry.root, name)
|
|
63
|
+
return ResolvedSession(
|
|
64
|
+
name=name,
|
|
65
|
+
entry=entry,
|
|
66
|
+
store=store,
|
|
67
|
+
state=store.read(),
|
|
68
|
+
forge_root=entry.root,
|
|
69
|
+
is_cross_project=False,
|
|
70
|
+
)
|
|
71
|
+
except (ForgeSessionError, FileNotFoundError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Tier 2: repo-scoped scan (cross-worktree)
|
|
75
|
+
project_root = _derive_project_root(cwd_forge_root, manager)
|
|
76
|
+
if project_root is None:
|
|
77
|
+
raise SessionNotFoundError(name)
|
|
78
|
+
|
|
79
|
+
siblings = manager.list_sessions(project_root_filter=project_root)
|
|
80
|
+
matches = [(n, e) for n, e in siblings if n == name]
|
|
81
|
+
|
|
82
|
+
if not matches:
|
|
83
|
+
raise SessionNotFoundError(name)
|
|
84
|
+
|
|
85
|
+
if len(matches) == 1:
|
|
86
|
+
e = matches[0][1]
|
|
87
|
+
store = SessionStore(e.root, name)
|
|
88
|
+
return ResolvedSession(
|
|
89
|
+
name=name,
|
|
90
|
+
entry=e,
|
|
91
|
+
store=store,
|
|
92
|
+
state=store.read(),
|
|
93
|
+
forge_root=e.root,
|
|
94
|
+
is_cross_project=e.root != cwd_forge_root,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Multiple matches: prefer current forge_root as tiebreaker
|
|
98
|
+
if cwd_forge_root is not None:
|
|
99
|
+
for _, e in matches:
|
|
100
|
+
if e.root == cwd_forge_root:
|
|
101
|
+
store = SessionStore(e.root, name)
|
|
102
|
+
return ResolvedSession(
|
|
103
|
+
name=name, entry=e, store=store, state=store.read(), forge_root=e.root, is_cross_project=False
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
roots = [e.root for _, e in matches]
|
|
107
|
+
raise AmbiguousSessionError(name, roots)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _derive_project_root(cwd_forge_root: str | None, manager: SessionManager) -> str | None:
|
|
111
|
+
"""Derive the logical repo root for Tier 2 scanning.
|
|
112
|
+
|
|
113
|
+
Uses manager.resolve_project_root first (git subprocess), then falls
|
|
114
|
+
back to ExecutionContext path walking (handles fake .git dirs in tests
|
|
115
|
+
and directories that aren't real git worktrees).
|
|
116
|
+
"""
|
|
117
|
+
if cwd_forge_root is not None:
|
|
118
|
+
try:
|
|
119
|
+
pr = manager.resolve_project_root(cwd_forge_root)
|
|
120
|
+
# resolve_project_root falls back to returning the input path
|
|
121
|
+
# when git fails. That's not useful for repo-scoped filtering,
|
|
122
|
+
# so fall through to CWD-based derivation.
|
|
123
|
+
if pr != str(Path(cwd_forge_root).resolve()):
|
|
124
|
+
return pr
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
from forge.core.ops.context import ExecutionContext
|
|
130
|
+
|
|
131
|
+
ctx = ExecutionContext.from_cwd()
|
|
132
|
+
return str(ctx.project_root)
|
|
133
|
+
except Exception:
|
|
134
|
+
_log.debug("Could not derive project_root from CWD or forge_root=%s", cwd_forge_root)
|
|
135
|
+
return None
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Shared session operations (command-core).
|
|
2
|
+
|
|
3
|
+
These operations are UI-agnostic and can be invoked from both:
|
|
4
|
+
|
|
5
|
+
- the CLI (`forge session ...`), and
|
|
6
|
+
- the in-chat direct command dispatcher (`%session ...`).
|
|
7
|
+
|
|
8
|
+
They return structured data and raise typed exceptions on failure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from forge.session import (
|
|
19
|
+
ForgeSessionError,
|
|
20
|
+
SessionIndexEntry,
|
|
21
|
+
SessionManager,
|
|
22
|
+
SessionState,
|
|
23
|
+
SessionStore,
|
|
24
|
+
clear_overrides,
|
|
25
|
+
compute_effective_intent,
|
|
26
|
+
delete_override,
|
|
27
|
+
set_override,
|
|
28
|
+
)
|
|
29
|
+
from forge.session.exceptions import (
|
|
30
|
+
InvalidOverrideKeyError,
|
|
31
|
+
InvalidOverrideValueError,
|
|
32
|
+
)
|
|
33
|
+
from forge.session.overrides import parse_value, validate_key
|
|
34
|
+
|
|
35
|
+
from .context import ExecutionContext
|
|
36
|
+
|
|
37
|
+
_log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ForgeOpError(RuntimeError):
|
|
41
|
+
"""Raised when a command-core operation fails."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ListSessionsItem:
|
|
46
|
+
name: str
|
|
47
|
+
entry: SessionIndexEntry
|
|
48
|
+
proxy_template: str | None
|
|
49
|
+
is_active: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ListSessionsResult:
|
|
54
|
+
sessions: list[ListSessionsItem]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
VALID_SCOPES = {"repo", "project", "all"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _scope_filters(ctx: ExecutionContext, scope: str) -> tuple[str | None, str | None]:
|
|
61
|
+
"""Compute (project_root_filter, forge_root_filter) for a given scope.
|
|
62
|
+
|
|
63
|
+
Shared by list_sessions() and list_sessions_older_than() to ensure
|
|
64
|
+
identical fallback behavior.
|
|
65
|
+
"""
|
|
66
|
+
if scope == "repo":
|
|
67
|
+
return str(ctx.project_root), None
|
|
68
|
+
if scope == "project":
|
|
69
|
+
if ctx.forge_root is not None:
|
|
70
|
+
return None, str(ctx.forge_root)
|
|
71
|
+
_log.debug("No forge_root for --scope project, falling back to repo scope")
|
|
72
|
+
return str(ctx.project_root), None
|
|
73
|
+
# scope == "all"
|
|
74
|
+
return None, None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_sessions(*, ctx: ExecutionContext, include_incognito: bool, scope: str = "repo") -> ListSessionsResult:
|
|
78
|
+
"""List sessions with lightweight derived metadata.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
ctx: execution context (provides project_root and forge_root for filtering).
|
|
82
|
+
include_incognito: whether to include incognito sessions.
|
|
83
|
+
scope: filtering scope:
|
|
84
|
+
- ``"repo"``: sessions in the same logical repo (project_root match). Default.
|
|
85
|
+
- ``"project"``: sessions in the same Forge project (forge_root match).
|
|
86
|
+
- ``"all"``: no filtering (global).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
ListSessionsResult.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ForgeOpError: if the session subsystem fails or scope is invalid.
|
|
93
|
+
"""
|
|
94
|
+
if scope not in VALID_SCOPES:
|
|
95
|
+
raise ForgeOpError(f"Invalid scope: {scope!r}. Must be one of {VALID_SCOPES}")
|
|
96
|
+
|
|
97
|
+
_log.debug(
|
|
98
|
+
"list_sessions: cwd=%s, project_root=%s, forge_root=%s, scope=%s",
|
|
99
|
+
ctx.cwd,
|
|
100
|
+
ctx.project_root,
|
|
101
|
+
ctx.forge_root,
|
|
102
|
+
scope,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
manager = SessionManager()
|
|
106
|
+
project_root_filter, forge_root_filter = _scope_filters(ctx, scope)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
sessions = manager.list_sessions(
|
|
110
|
+
include_incognito=include_incognito,
|
|
111
|
+
project_root_filter=project_root_filter,
|
|
112
|
+
forge_root_filter=forge_root_filter,
|
|
113
|
+
)
|
|
114
|
+
except ForgeSessionError as e:
|
|
115
|
+
raise ForgeOpError(str(e)) from e
|
|
116
|
+
|
|
117
|
+
items: list[ListSessionsItem] = []
|
|
118
|
+
for name, entry in sessions:
|
|
119
|
+
proxy_template: str | None = None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
manifest = manager.get_session(name, forge_root=entry.forge_root or entry.worktree_path)
|
|
123
|
+
if manifest.intent.proxy:
|
|
124
|
+
proxy_template = manifest.intent.proxy.template
|
|
125
|
+
else:
|
|
126
|
+
proxy_template = "direct"
|
|
127
|
+
except ForgeSessionError as e:
|
|
128
|
+
# Best-effort: listing should not fail if a manifest is missing/corrupt.
|
|
129
|
+
_log.debug("Failed to read manifest for session %r: %s", name, e)
|
|
130
|
+
|
|
131
|
+
items.append(
|
|
132
|
+
ListSessionsItem(
|
|
133
|
+
name=name,
|
|
134
|
+
entry=entry,
|
|
135
|
+
proxy_template=proxy_template,
|
|
136
|
+
is_active=False,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return ListSessionsResult(sessions=items)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def list_sessions_older_than(
|
|
144
|
+
*,
|
|
145
|
+
older_than_days: int,
|
|
146
|
+
include_incognito: bool = True,
|
|
147
|
+
project_root_filter: str | None = None,
|
|
148
|
+
forge_root_filter: str | None = None,
|
|
149
|
+
) -> list[tuple[str, SessionIndexEntry]]:
|
|
150
|
+
"""List sessions whose last_accessed_at is older than the threshold.
|
|
151
|
+
|
|
152
|
+
Entries with unparseable timestamps are excluded (they cannot be confirmed
|
|
153
|
+
as old). This is a shared op used by both CLI and %session list.
|
|
154
|
+
Respects the same scope filters as list_sessions().
|
|
155
|
+
"""
|
|
156
|
+
from datetime import UTC, datetime
|
|
157
|
+
|
|
158
|
+
from forge.core.state import parse_iso
|
|
159
|
+
|
|
160
|
+
manager = SessionManager()
|
|
161
|
+
all_sessions = manager.list_sessions(
|
|
162
|
+
include_incognito=include_incognito,
|
|
163
|
+
project_root_filter=project_root_filter,
|
|
164
|
+
forge_root_filter=forge_root_filter,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
result: list[tuple[str, SessionIndexEntry]] = []
|
|
168
|
+
for name, entry in all_sessions:
|
|
169
|
+
try:
|
|
170
|
+
dt = parse_iso(entry.last_accessed_at)
|
|
171
|
+
age_days = (datetime.now(UTC) - dt).total_seconds() / 86400
|
|
172
|
+
except (ValueError, TypeError, AttributeError):
|
|
173
|
+
continue
|
|
174
|
+
if age_days > older_than_days:
|
|
175
|
+
result.append((name, entry))
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- Session resolution ---
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class ResolveSessionResult:
|
|
184
|
+
"""Result of resolving a session by name or CWD."""
|
|
185
|
+
|
|
186
|
+
store: SessionStore
|
|
187
|
+
state: SessionState
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_session(*, ctx: ExecutionContext, session_name: str | None = None) -> ResolveSessionResult:
|
|
191
|
+
"""Resolve a session by explicit name or current session from CWD.
|
|
192
|
+
|
|
193
|
+
Named sessions use repo-wide two-tier resolution (current forge_root
|
|
194
|
+
preference, then repo-scoped scan). Unnamed falls back to $FORGE_SESSION.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
ctx: execution context (provides forge_root for scoped resolution).
|
|
198
|
+
session_name: explicit session name. If None, resolves current session.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
ResolveSessionResult with store and state.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ForgeOpError: if no active session or session not found.
|
|
205
|
+
"""
|
|
206
|
+
manager = SessionManager()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if session_name:
|
|
210
|
+
from forge.core.ops.resolution import resolve_session_repo_wide
|
|
211
|
+
|
|
212
|
+
cwd_fr = str(ctx.forge_root) if ctx.forge_root else None
|
|
213
|
+
resolved = resolve_session_repo_wide(session_name, cwd_fr, manager=manager)
|
|
214
|
+
return ResolveSessionResult(store=resolved.store, state=resolved.state)
|
|
215
|
+
else:
|
|
216
|
+
env_name = os.environ.get("FORGE_SESSION")
|
|
217
|
+
if env_name:
|
|
218
|
+
store = manager.get_session_store(env_name)
|
|
219
|
+
state = store.read()
|
|
220
|
+
else:
|
|
221
|
+
raise ForgeOpError("No session specified. Use --session or set $FORGE_SESSION.")
|
|
222
|
+
except ForgeSessionError as e:
|
|
223
|
+
raise ForgeOpError(str(e)) from e
|
|
224
|
+
|
|
225
|
+
return ResolveSessionResult(store=store, state=state)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- Session override mutations ---
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class SetOverrideResult:
|
|
233
|
+
"""Result of setting a session override."""
|
|
234
|
+
|
|
235
|
+
key: str
|
|
236
|
+
value: Any
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def set_session_override(
|
|
240
|
+
*,
|
|
241
|
+
ctx: ExecutionContext,
|
|
242
|
+
session_name: str | None = None,
|
|
243
|
+
key: str,
|
|
244
|
+
value_str: str,
|
|
245
|
+
) -> SetOverrideResult:
|
|
246
|
+
"""Validate, apply, and persist a session override.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
ctx: execution context.
|
|
250
|
+
session_name: explicit session name. If None, resolves current session.
|
|
251
|
+
key: dot-notation override key (e.g., "agent", "proxy.template").
|
|
252
|
+
value_str: string value (parsed as JSON first, then as string).
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
SetOverrideResult with key and parsed value.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
ForgeOpError: on invalid key, invalid value, validation failure, or IO error.
|
|
259
|
+
"""
|
|
260
|
+
resolved = resolve_session(ctx=ctx, session_name=session_name)
|
|
261
|
+
store = resolved.store
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Validate key before acquiring lock (wildcards handled by set_override)
|
|
265
|
+
if "*" not in key:
|
|
266
|
+
validate_key(key)
|
|
267
|
+
|
|
268
|
+
parsed_value = parse_value(value_str)
|
|
269
|
+
|
|
270
|
+
# Apply + validate + persist atomically under lock.
|
|
271
|
+
# The mutate callback receives the fresh state from disk, avoiding TOCTOU.
|
|
272
|
+
def _mutate(m: SessionState) -> None:
|
|
273
|
+
set_override(m.overrides, key, parsed_value)
|
|
274
|
+
compute_effective_intent(m, strict=True, override_key=key)
|
|
275
|
+
|
|
276
|
+
store.update(timeout_s=5.0, mutate=_mutate)
|
|
277
|
+
|
|
278
|
+
return SetOverrideResult(key=key, value=parsed_value)
|
|
279
|
+
|
|
280
|
+
except (InvalidOverrideKeyError, InvalidOverrideValueError) as e:
|
|
281
|
+
raise ForgeOpError(str(e)) from e
|
|
282
|
+
except ForgeSessionError as e:
|
|
283
|
+
raise ForgeOpError(str(e)) from e
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class ResetOverridesResult:
|
|
288
|
+
"""Result of resetting session overrides."""
|
|
289
|
+
|
|
290
|
+
cleared_all: bool
|
|
291
|
+
key: str | None # None if cleared all
|
|
292
|
+
was_present: bool # whether the key had an override (or overrides existed)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reset_session_overrides(
|
|
296
|
+
*,
|
|
297
|
+
ctx: ExecutionContext,
|
|
298
|
+
session_name: str | None = None,
|
|
299
|
+
key: str | None = None,
|
|
300
|
+
) -> ResetOverridesResult:
|
|
301
|
+
"""Delete a single override or clear all overrides.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
ctx: execution context.
|
|
305
|
+
session_name: explicit session name. If None, resolves current session.
|
|
306
|
+
key: override key to delete. If None, clears all overrides.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
ResetOverridesResult.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ForgeOpError: on invalid key or IO error.
|
|
313
|
+
"""
|
|
314
|
+
resolved = resolve_session(ctx=ctx, session_name=session_name)
|
|
315
|
+
store = resolved.store
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
if key:
|
|
319
|
+
# Mutate under lock: delete_override on fresh state
|
|
320
|
+
result_holder: dict[str, Any] = {}
|
|
321
|
+
|
|
322
|
+
def _mutate_delete(m: SessionState) -> None:
|
|
323
|
+
result_holder["deleted"] = delete_override(m.overrides, key)
|
|
324
|
+
|
|
325
|
+
store.update(timeout_s=5.0, mutate=_mutate_delete)
|
|
326
|
+
return ResetOverridesResult(
|
|
327
|
+
cleared_all=False,
|
|
328
|
+
key=key,
|
|
329
|
+
was_present=result_holder.get("deleted", False),
|
|
330
|
+
)
|
|
331
|
+
else:
|
|
332
|
+
# Peek at current state to report whether overrides existed
|
|
333
|
+
had_overrides = bool(resolved.state.overrides)
|
|
334
|
+
if had_overrides:
|
|
335
|
+
store.update(
|
|
336
|
+
timeout_s=5.0,
|
|
337
|
+
mutate=lambda m: clear_overrides(m.overrides),
|
|
338
|
+
)
|
|
339
|
+
return ResetOverridesResult(cleared_all=True, key=None, was_present=had_overrides)
|
|
340
|
+
|
|
341
|
+
except InvalidOverrideKeyError as e:
|
|
342
|
+
raise ForgeOpError(str(e)) from e
|
|
343
|
+
except ForgeSessionError as e:
|
|
344
|
+
raise ForgeOpError(str(e)) from e
|