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,548 @@
|
|
|
1
|
+
"""Session context introspection (command-core).
|
|
2
|
+
|
|
3
|
+
Builds a structured view of everything Forge knows about a session:
|
|
4
|
+
metadata, proxy routing, model family, tier mappings, and policy state.
|
|
5
|
+
|
|
6
|
+
Used by:
|
|
7
|
+
- ``forge session context`` CLI command
|
|
8
|
+
- Skills auto-detecting model family via ``--field model_family``
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from forge.session import (
|
|
20
|
+
ForgeSessionError,
|
|
21
|
+
SessionManager,
|
|
22
|
+
SessionState,
|
|
23
|
+
SessionStore,
|
|
24
|
+
compute_effective_intent,
|
|
25
|
+
)
|
|
26
|
+
from forge.session.exceptions import AmbiguousSessionError
|
|
27
|
+
from forge.session.index import IndexStore
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Vendor prefix → normalized family name
|
|
32
|
+
_VENDOR_TO_FAMILY: dict[str, str] = {
|
|
33
|
+
"openai": "openai",
|
|
34
|
+
"anthropic": "anthropic",
|
|
35
|
+
"vertex_ai": "gemini",
|
|
36
|
+
"vertex_ai_beta": "gemini",
|
|
37
|
+
"google": "gemini",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ProxyContext:
|
|
43
|
+
"""Proxy routing snapshot for a session."""
|
|
44
|
+
|
|
45
|
+
template: str | None = None
|
|
46
|
+
base_url: str | None = None
|
|
47
|
+
proxy_id: str | None = None
|
|
48
|
+
is_direct: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PolicyContext:
|
|
53
|
+
"""Policy state snapshot for a session."""
|
|
54
|
+
|
|
55
|
+
enabled: bool = False
|
|
56
|
+
fail_mode: str = "open"
|
|
57
|
+
bundles: list[str] = field(default_factory=list)
|
|
58
|
+
supervisor_resume_id: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class SessionContext:
|
|
63
|
+
"""Complete introspection view of a Forge session."""
|
|
64
|
+
|
|
65
|
+
session_name: str
|
|
66
|
+
claude_session_id: str | None = None
|
|
67
|
+
worktree_path: str | None = None
|
|
68
|
+
project_root: str | None = None
|
|
69
|
+
created_at: str | None = None
|
|
70
|
+
is_fork: bool = False
|
|
71
|
+
is_incognito: bool = False
|
|
72
|
+
parent_session: str | None = None
|
|
73
|
+
proxy: ProxyContext = field(default_factory=ProxyContext)
|
|
74
|
+
model_family: str = "anthropic"
|
|
75
|
+
main_model: str | None = None
|
|
76
|
+
models: dict[str, str] = field(default_factory=dict)
|
|
77
|
+
policy: PolicyContext = field(default_factory=PolicyContext)
|
|
78
|
+
overrides: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
"""Serialize to a plain dict for JSON output."""
|
|
82
|
+
return {
|
|
83
|
+
"session_name": self.session_name,
|
|
84
|
+
"claude_session_id": self.claude_session_id,
|
|
85
|
+
"worktree_path": self.worktree_path,
|
|
86
|
+
"project_root": self.project_root,
|
|
87
|
+
"created_at": self.created_at,
|
|
88
|
+
"is_fork": self.is_fork,
|
|
89
|
+
"is_incognito": self.is_incognito,
|
|
90
|
+
"parent_session": self.parent_session,
|
|
91
|
+
"proxy": {
|
|
92
|
+
"template": self.proxy.template,
|
|
93
|
+
"base_url": self.proxy.base_url,
|
|
94
|
+
"proxy_id": self.proxy.proxy_id,
|
|
95
|
+
"is_direct": self.proxy.is_direct,
|
|
96
|
+
},
|
|
97
|
+
"model_family": self.model_family,
|
|
98
|
+
"main_model": self.main_model,
|
|
99
|
+
"models": dict(self.models),
|
|
100
|
+
"policy": {
|
|
101
|
+
"enabled": self.policy.enabled,
|
|
102
|
+
"fail_mode": self.policy.fail_mode,
|
|
103
|
+
"bundles": list(self.policy.bundles),
|
|
104
|
+
"supervisor_resume_id": self.policy.supervisor_resume_id,
|
|
105
|
+
},
|
|
106
|
+
"overrides": dict(self.overrides),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_session_identifier(session: str | None = None) -> tuple[str, str | None]:
|
|
111
|
+
"""Resolve a session identifier to a Forge session name and forge_root.
|
|
112
|
+
|
|
113
|
+
Accepts a Forge session name, a Claude session UUID, or None.
|
|
114
|
+
|
|
115
|
+
Resolution order:
|
|
116
|
+
1. If ``session`` provided: try as name, then as UUID
|
|
117
|
+
2. ``$FORGE_SESSION`` env var
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
(session_name, forge_root) tuple. forge_root may be None if
|
|
121
|
+
resolved via env var without index context.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
SessionContextError: if no session can be resolved.
|
|
125
|
+
"""
|
|
126
|
+
manager = SessionManager()
|
|
127
|
+
|
|
128
|
+
# Derive forge_root from CWD for scoped lookups
|
|
129
|
+
_cwd_forge_root: str | None = None
|
|
130
|
+
try:
|
|
131
|
+
from forge.core.ops.context import find_forge_root
|
|
132
|
+
|
|
133
|
+
_fr = find_forge_root(Path.cwd().resolve())
|
|
134
|
+
if _fr:
|
|
135
|
+
_cwd_forge_root = str(_fr)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
if session:
|
|
140
|
+
# Try as Forge session name (scoped to current project first, then unscoped)
|
|
141
|
+
try:
|
|
142
|
+
entry = manager.get_session_entry(session, forge_root=_cwd_forge_root)
|
|
143
|
+
return session, entry.root
|
|
144
|
+
except AmbiguousSessionError:
|
|
145
|
+
raise # Propagate with location list intact
|
|
146
|
+
except ForgeSessionError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# Unscoped fallback for cross-project explicit references
|
|
150
|
+
try:
|
|
151
|
+
entry = manager.get_session_entry(session)
|
|
152
|
+
return session, entry.root
|
|
153
|
+
except AmbiguousSessionError:
|
|
154
|
+
raise # Propagate with location list intact
|
|
155
|
+
except ForgeSessionError as e:
|
|
156
|
+
# Check if it's corruption (in index but manifest bad) vs not found
|
|
157
|
+
try:
|
|
158
|
+
manager.get_session_entry(session)
|
|
159
|
+
except ForgeSessionError:
|
|
160
|
+
pass # Not in index — fall through to UUID lookup
|
|
161
|
+
else:
|
|
162
|
+
raise SessionContextError(str(e)) from e
|
|
163
|
+
|
|
164
|
+
# Try as Claude session UUID (cross-project)
|
|
165
|
+
index = IndexStore()
|
|
166
|
+
uuid_result = index.find_session_by_uuid(session)
|
|
167
|
+
if uuid_result:
|
|
168
|
+
return uuid_result[0], uuid_result[1]
|
|
169
|
+
|
|
170
|
+
# Fall back to scanning session manifests when the index is stale.
|
|
171
|
+
scan_result = _scan_manifests_for_uuid(session)
|
|
172
|
+
if scan_result:
|
|
173
|
+
return scan_result
|
|
174
|
+
|
|
175
|
+
raise SessionContextError(f"No session found for '{session}' (tried as name and UUID)")
|
|
176
|
+
|
|
177
|
+
# Fall back to env var. FORGE_SESSION is set by the Forge launcher, so the
|
|
178
|
+
# session is authoritative by convention. Try scoped first, then unscoped.
|
|
179
|
+
env_session = os.environ.get("FORGE_SESSION")
|
|
180
|
+
if env_session:
|
|
181
|
+
try:
|
|
182
|
+
entry = manager.get_session_entry(env_session, forge_root=_cwd_forge_root)
|
|
183
|
+
return env_session, entry.root
|
|
184
|
+
except ForgeSessionError:
|
|
185
|
+
try:
|
|
186
|
+
entry = manager.get_session_entry(env_session)
|
|
187
|
+
return env_session, entry.root
|
|
188
|
+
except ForgeSessionError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
raise SessionContextError("No session found (no argument, no $FORGE_SESSION)")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SessionContextError(RuntimeError):
|
|
195
|
+
"""Raised when session context cannot be built."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def detect_model_family(template: str | None) -> str:
|
|
199
|
+
"""Map a proxy template to a normalized model family name.
|
|
200
|
+
|
|
201
|
+
Loads the template config, reads the opus-tier model name,
|
|
202
|
+
and extracts the vendor prefix.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
``"openai"`` | ``"gemini"`` | ``"anthropic"``
|
|
206
|
+
"""
|
|
207
|
+
if template is None:
|
|
208
|
+
return "anthropic"
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
from forge.config.loader import load_config
|
|
212
|
+
|
|
213
|
+
cfg = load_config(template=template)
|
|
214
|
+
provider = cfg.proxy.get_provider()
|
|
215
|
+
|
|
216
|
+
# Get the opus-tier model (most representative)
|
|
217
|
+
opus_model = provider.tiers.opus
|
|
218
|
+
if not opus_model:
|
|
219
|
+
return "anthropic"
|
|
220
|
+
|
|
221
|
+
return _model_to_family(opus_model)
|
|
222
|
+
except Exception:
|
|
223
|
+
_log.debug("Failed to detect model family for template %r", template, exc_info=True)
|
|
224
|
+
return "anthropic"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _model_to_family(model_name: str) -> str:
|
|
228
|
+
"""Extract normalized family from a model name (possibly vendor-prefixed).
|
|
229
|
+
|
|
230
|
+
Examples:
|
|
231
|
+
``"openai/gpt-5.5"`` -> ``"openai"``
|
|
232
|
+
``"vertex_ai/gemini-3.1-pro"`` -> ``"gemini"``
|
|
233
|
+
``"gpt-5.5"`` -> ``"openai"``
|
|
234
|
+
``"claude-opus-4-6"`` -> ``"anthropic"``
|
|
235
|
+
"""
|
|
236
|
+
# If vendor-prefixed (e.g., "openai/gpt-5.5"), extract prefix
|
|
237
|
+
if "/" in model_name:
|
|
238
|
+
vendor = model_name.split("/", 1)[0]
|
|
239
|
+
family = _VENDOR_TO_FAMILY.get(vendor)
|
|
240
|
+
if family:
|
|
241
|
+
return family
|
|
242
|
+
|
|
243
|
+
# Infer from model name pattern
|
|
244
|
+
bare = model_name.split("/", 1)[-1].lower()
|
|
245
|
+
|
|
246
|
+
if bare.startswith("gpt-") or bare.startswith(("o1", "o3", "o4")):
|
|
247
|
+
return "openai"
|
|
248
|
+
if bare.startswith("claude-"):
|
|
249
|
+
return "anthropic"
|
|
250
|
+
if bare.startswith("gemini-"):
|
|
251
|
+
return "gemini"
|
|
252
|
+
|
|
253
|
+
return "anthropic"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_session_context(session: str | None = None) -> SessionContext:
|
|
257
|
+
"""Build a complete context view of a session.
|
|
258
|
+
|
|
259
|
+
The ``session`` arg accepts a Forge session name, a Claude session UUID,
|
|
260
|
+
or None (falls back to ``$FORGE_SESSION``).
|
|
261
|
+
|
|
262
|
+
When ``session`` is None and no Forge session can be resolved, falls back
|
|
263
|
+
to building context from environment variables (``ACTIVE_TEMPLATE``,
|
|
264
|
+
``ANTHROPIC_BASE_URL``). When an explicit session identifier is given
|
|
265
|
+
but cannot be resolved, raises instead of falling back.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
SessionContext with all available metadata.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
SessionContextError: if an explicit session identifier cannot be
|
|
272
|
+
resolved, or if the resolved session's state is corrupted.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
name, resolved_forge_root = resolve_session_identifier(session)
|
|
276
|
+
except SessionContextError:
|
|
277
|
+
if session is not None:
|
|
278
|
+
raise # Explicit session not found — fail-closed
|
|
279
|
+
return _build_env_context()
|
|
280
|
+
|
|
281
|
+
manager = SessionManager()
|
|
282
|
+
try:
|
|
283
|
+
state = manager.get_session(name, forge_root=resolved_forge_root)
|
|
284
|
+
entry = manager.get_session_entry(name, forge_root=resolved_forge_root)
|
|
285
|
+
except ForgeSessionError as e:
|
|
286
|
+
raise SessionContextError(str(e)) from e
|
|
287
|
+
|
|
288
|
+
proxy_ctx = _build_proxy_context(state)
|
|
289
|
+
family, models, main_model = _build_model_context(proxy_ctx, state)
|
|
290
|
+
policy_ctx = _build_policy_context(state)
|
|
291
|
+
|
|
292
|
+
return SessionContext(
|
|
293
|
+
session_name=name,
|
|
294
|
+
claude_session_id=state.confirmed.claude_session_id,
|
|
295
|
+
worktree_path=entry.worktree_path,
|
|
296
|
+
project_root=entry.project_root,
|
|
297
|
+
created_at=state.created_at,
|
|
298
|
+
is_fork=state.is_fork,
|
|
299
|
+
is_incognito=state.is_incognito,
|
|
300
|
+
parent_session=state.parent_session,
|
|
301
|
+
proxy=proxy_ctx,
|
|
302
|
+
model_family=family,
|
|
303
|
+
main_model=main_model,
|
|
304
|
+
models=models,
|
|
305
|
+
policy=policy_ctx,
|
|
306
|
+
overrides=dict(state.overrides),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _build_env_context() -> SessionContext:
|
|
311
|
+
"""Build a minimal context from environment variables when no Forge session exists.
|
|
312
|
+
|
|
313
|
+
Uses ``ACTIVE_TEMPLATE`` and ``ANTHROPIC_BASE_URL`` to infer proxy/model info.
|
|
314
|
+
Called only when ``session`` was None and resolution found nothing.
|
|
315
|
+
"""
|
|
316
|
+
template = os.environ.get("ACTIVE_TEMPLATE")
|
|
317
|
+
base_url = os.environ.get("ANTHROPIC_BASE_URL")
|
|
318
|
+
|
|
319
|
+
if template or base_url:
|
|
320
|
+
proxy_ctx = ProxyContext(
|
|
321
|
+
template=template,
|
|
322
|
+
base_url=base_url,
|
|
323
|
+
is_direct=False,
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
proxy_ctx = ProxyContext(is_direct=True)
|
|
327
|
+
|
|
328
|
+
family, models, main_model = _build_model_context(proxy_ctx, None)
|
|
329
|
+
|
|
330
|
+
return SessionContext(
|
|
331
|
+
session_name=os.environ.get("FORGE_SESSION", "(unknown)"),
|
|
332
|
+
claude_session_id=None,
|
|
333
|
+
model_family=family,
|
|
334
|
+
main_model=main_model,
|
|
335
|
+
models=models,
|
|
336
|
+
proxy=proxy_ctx,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _build_proxy_context(state: SessionState) -> ProxyContext:
|
|
341
|
+
"""Extract proxy info from confirmed (preferred) or intent."""
|
|
342
|
+
confirmed = state.confirmed.started_with_proxy
|
|
343
|
+
if confirmed:
|
|
344
|
+
return ProxyContext(
|
|
345
|
+
template=confirmed.template,
|
|
346
|
+
base_url=confirmed.base_url,
|
|
347
|
+
proxy_id=confirmed.proxy_id,
|
|
348
|
+
is_direct=False,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
intent = state.intent.proxy
|
|
352
|
+
if intent:
|
|
353
|
+
return ProxyContext(
|
|
354
|
+
template=intent.template,
|
|
355
|
+
base_url=intent.base_url,
|
|
356
|
+
proxy_id=None,
|
|
357
|
+
is_direct=False,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return ProxyContext(is_direct=True)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _build_policy_context(state: SessionState) -> PolicyContext:
|
|
364
|
+
"""Extract effective policy state from intent + overrides."""
|
|
365
|
+
enabled = False
|
|
366
|
+
fail_mode = "open"
|
|
367
|
+
bundles: list[str] = []
|
|
368
|
+
supervisor_resume_id: str | None = None
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
effective_intent = compute_effective_intent(state)
|
|
372
|
+
policy = effective_intent.policy
|
|
373
|
+
except Exception:
|
|
374
|
+
_log.debug("Failed to compute effective policy state for session %r", state.name, exc_info=True)
|
|
375
|
+
policy = state.intent.policy
|
|
376
|
+
|
|
377
|
+
if policy:
|
|
378
|
+
enabled = policy.enabled
|
|
379
|
+
fail_mode = policy.fail_mode or "open"
|
|
380
|
+
bundles = list(policy.bundles or [])
|
|
381
|
+
if policy.supervisor:
|
|
382
|
+
supervisor_resume_id = policy.supervisor.resume_id
|
|
383
|
+
|
|
384
|
+
return PolicyContext(
|
|
385
|
+
enabled=enabled,
|
|
386
|
+
fail_mode=fail_mode,
|
|
387
|
+
bundles=bundles,
|
|
388
|
+
supervisor_resume_id=supervisor_resume_id,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _scan_manifests_for_uuid(session_uuid: str) -> tuple[str, str] | None:
|
|
393
|
+
"""Search session manifests for a Claude UUID when the index is stale.
|
|
394
|
+
|
|
395
|
+
Returns (display_name, forge_root) to preserve project scope for
|
|
396
|
+
subsequent lookups, or None if not found.
|
|
397
|
+
"""
|
|
398
|
+
index = IndexStore()
|
|
399
|
+
try:
|
|
400
|
+
sessions = index.list_sessions(include_incognito=True)
|
|
401
|
+
except Exception:
|
|
402
|
+
_log.debug("Failed to list sessions while scanning manifests for UUID %r", session_uuid, exc_info=True)
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
for name, entry in sessions:
|
|
406
|
+
try:
|
|
407
|
+
store = SessionStore(entry.root, name)
|
|
408
|
+
if not store.exists():
|
|
409
|
+
continue
|
|
410
|
+
state = store.read()
|
|
411
|
+
except Exception:
|
|
412
|
+
_log.debug("Failed to read session manifest while scanning for UUID %r", session_uuid, exc_info=True)
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
if state.confirmed.claude_session_id == session_uuid:
|
|
416
|
+
return name, entry.root
|
|
417
|
+
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _build_model_context(proxy_ctx: ProxyContext, state: SessionState | None) -> tuple[str, dict[str, str], str | None]:
|
|
422
|
+
"""Return model family plus tier mappings using the best available proxy truth."""
|
|
423
|
+
if proxy_ctx.is_direct:
|
|
424
|
+
main_model = None
|
|
425
|
+
if state is not None and state.intent.launch is not None:
|
|
426
|
+
main_model = state.intent.launch.direct_model
|
|
427
|
+
if main_model is None:
|
|
428
|
+
main_model = _direct_main_model_from_env()
|
|
429
|
+
return "anthropic", {}, main_model
|
|
430
|
+
|
|
431
|
+
proxy_config = _load_proxy_instance_for_context(proxy_ctx)
|
|
432
|
+
if proxy_config is not None:
|
|
433
|
+
models = _proxy_instance_tier_models(proxy_config)
|
|
434
|
+
family = _family_from_models(models)
|
|
435
|
+
main_model = models.get(getattr(proxy_config, "default_tier", None) or "sonnet")
|
|
436
|
+
return family, models, main_model
|
|
437
|
+
|
|
438
|
+
template = proxy_ctx.template
|
|
439
|
+
family = detect_model_family(template)
|
|
440
|
+
models = _get_tier_models(template)
|
|
441
|
+
main_model = models.get("sonnet") or models.get("opus") or models.get("haiku")
|
|
442
|
+
return family, models, main_model
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _direct_main_model_from_env() -> str | None:
|
|
446
|
+
"""Infer the pinned direct Claude model from Claude Code env vars."""
|
|
447
|
+
tier = (os.environ.get("ANTHROPIC_MODEL") or "").lower()
|
|
448
|
+
env_by_tier = {
|
|
449
|
+
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
450
|
+
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
451
|
+
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
452
|
+
}
|
|
453
|
+
if tier in env_by_tier:
|
|
454
|
+
return os.environ.get(env_by_tier[tier])
|
|
455
|
+
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _load_proxy_instance_for_context(proxy_ctx: ProxyContext) -> Any | None:
|
|
460
|
+
"""Load proxy.yaml for this session when we can identify the concrete proxy."""
|
|
461
|
+
proxy_id = proxy_ctx.proxy_id
|
|
462
|
+
|
|
463
|
+
if proxy_id is None and proxy_ctx.base_url:
|
|
464
|
+
try:
|
|
465
|
+
from forge.proxy.proxies import ProxyRegistryStore
|
|
466
|
+
|
|
467
|
+
entry = ProxyRegistryStore().find_by_base_url(proxy_ctx.base_url)
|
|
468
|
+
if entry is not None:
|
|
469
|
+
proxy_id = entry.proxy_id
|
|
470
|
+
except Exception:
|
|
471
|
+
_log.debug("Failed to resolve proxy_id from base_url %r", proxy_ctx.base_url, exc_info=True)
|
|
472
|
+
|
|
473
|
+
if proxy_id is None:
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
from forge.config.loader import load_proxy_instance_config
|
|
478
|
+
|
|
479
|
+
return load_proxy_instance_config(proxy_id)
|
|
480
|
+
except Exception:
|
|
481
|
+
_log.debug("Failed to load proxy instance config for %r", proxy_id, exc_info=True)
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _proxy_instance_tier_models(proxy_config: Any) -> dict[str, str]:
|
|
486
|
+
"""Extract tier mappings from a proxy instance config."""
|
|
487
|
+
result: dict[str, str] = {}
|
|
488
|
+
if proxy_config.tiers.haiku:
|
|
489
|
+
result["haiku"] = proxy_config.tiers.haiku
|
|
490
|
+
if proxy_config.tiers.sonnet:
|
|
491
|
+
result["sonnet"] = proxy_config.tiers.sonnet
|
|
492
|
+
if proxy_config.tiers.opus:
|
|
493
|
+
result["opus"] = proxy_config.tiers.opus
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _family_from_models(models: dict[str, str]) -> str:
|
|
498
|
+
"""Choose a representative family from tier mappings."""
|
|
499
|
+
for tier in ("opus", "sonnet", "haiku"):
|
|
500
|
+
model_name = models.get(tier)
|
|
501
|
+
if model_name:
|
|
502
|
+
return _model_to_family(model_name)
|
|
503
|
+
return "anthropic"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _get_tier_models(template: str | None) -> dict[str, str]:
|
|
507
|
+
"""Load tier→model mappings from a template."""
|
|
508
|
+
if template is None:
|
|
509
|
+
return {}
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
from forge.config.loader import load_config
|
|
513
|
+
|
|
514
|
+
cfg = load_config(template=template)
|
|
515
|
+
provider = cfg.proxy.get_provider()
|
|
516
|
+
result: dict[str, str] = {}
|
|
517
|
+
if provider.tiers.haiku:
|
|
518
|
+
result["haiku"] = provider.tiers.haiku
|
|
519
|
+
if provider.tiers.sonnet:
|
|
520
|
+
result["sonnet"] = provider.tiers.sonnet
|
|
521
|
+
if provider.tiers.opus:
|
|
522
|
+
result["opus"] = provider.tiers.opus
|
|
523
|
+
return result
|
|
524
|
+
except Exception:
|
|
525
|
+
_log.debug("Failed to load tier models for template %r", template, exc_info=True)
|
|
526
|
+
return {}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def extract_field(data: dict[str, Any], field_path: str) -> Any:
|
|
530
|
+
"""Extract a value from a nested dict using dot notation.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
data: The dict to traverse.
|
|
534
|
+
field_path: Dot-separated path (e.g., ``"proxy.template"``).
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
The value at the path.
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
KeyError: if the path does not exist.
|
|
541
|
+
"""
|
|
542
|
+
current: Any = data
|
|
543
|
+
for part in field_path.split("."):
|
|
544
|
+
if isinstance(current, dict):
|
|
545
|
+
current = current[part]
|
|
546
|
+
else:
|
|
547
|
+
raise KeyError(f"Cannot traverse into {type(current).__name__} at '{part}'")
|
|
548
|
+
return current
|
forge/core/paths.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Core path utilities for Forge.
|
|
2
|
+
|
|
3
|
+
Provides the canonical location of the Forge home directory (~/.forge)
|
|
4
|
+
and related path constants. These are cross-cutting concerns used by
|
|
5
|
+
session, proxy, install, backend, and workqueue modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# The dotfile directory name used by Forge
|
|
14
|
+
FORGE_DIR = ".forge"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def display_path(path: str | Path) -> str:
|
|
18
|
+
"""Replace home directory prefix with ``~`` for shorter terminal display."""
|
|
19
|
+
s = str(path)
|
|
20
|
+
home = str(Path.home())
|
|
21
|
+
if s == home:
|
|
22
|
+
return "~"
|
|
23
|
+
if s.startswith(home + "/"):
|
|
24
|
+
return "~" + s[len(home) :]
|
|
25
|
+
return s
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_forge_home() -> Path:
|
|
29
|
+
"""Get the forge home directory (~/.forge).
|
|
30
|
+
|
|
31
|
+
Respects FORGE_HOME environment variable for testing/custom paths.
|
|
32
|
+
|
|
33
|
+
Note: we expand a leading "~" so values like "~/.forge" work correctly,
|
|
34
|
+
including in tests that monkeypatch HOME.
|
|
35
|
+
"""
|
|
36
|
+
if forge_home := os.environ.get("FORGE_HOME"):
|
|
37
|
+
return Path(forge_home).expanduser()
|
|
38
|
+
return Path.home() / FORGE_DIR
|
forge/core/process.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Process utilities for Forge.
|
|
2
|
+
|
|
3
|
+
Provides PID checking and port-based process discovery. Used by proxy
|
|
4
|
+
and backend lifecycle management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_pid_alive(pid: int) -> bool:
|
|
14
|
+
"""Return True if pid appears to refer to a running process.
|
|
15
|
+
|
|
16
|
+
Uses the standard POSIX check: ``os.kill(pid, 0)``.
|
|
17
|
+
|
|
18
|
+
Notes:
|
|
19
|
+
- If we don't have permission to signal the process
|
|
20
|
+
(``PermissionError``), we treat it as alive.
|
|
21
|
+
- PID reuse is possible but out of scope for stale pruning.
|
|
22
|
+
"""
|
|
23
|
+
if pid <= 0:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
os.kill(pid, 0)
|
|
28
|
+
return True
|
|
29
|
+
except ProcessLookupError:
|
|
30
|
+
return False
|
|
31
|
+
except PermissionError:
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_pid_by_port(port: int) -> int | None:
|
|
36
|
+
"""Find the PID of the process listening on the given TCP port.
|
|
37
|
+
|
|
38
|
+
Uses ``lsof`` on macOS/Linux. Returns None if no process is found,
|
|
39
|
+
``lsof`` is unavailable, or the command times out.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["lsof", "-ti", f"TCP:{port}", "-sTCP:LISTEN"],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=5,
|
|
47
|
+
)
|
|
48
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
49
|
+
return None
|
|
50
|
+
# lsof may return multiple PIDs (one per line); take the first
|
|
51
|
+
first_line = result.stdout.strip().splitlines()[0]
|
|
52
|
+
return int(first_line)
|
|
53
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
|
54
|
+
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared reactive library for Forge hook handlers and policies.
|
|
2
|
+
|
|
3
|
+
Provides utilities for subprocess management, caching, structured output
|
|
4
|
+
extraction, and LLM-based classification. These are the building blocks
|
|
5
|
+
for the semantic supervisor, handoff agent, and WorkflowPolicy.
|
|
6
|
+
|
|
7
|
+
Note: ``proxy.py`` is intentionally NOT re-exported here because it
|
|
8
|
+
lazy-imports ``forge.proxy.proxies`` (a top-level component). Consumers
|
|
9
|
+
import directly: ``from forge.core.reactive.proxy import lookup_proxy_base_url``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .env import (
|
|
13
|
+
FORGE_DEPTH_VAR,
|
|
14
|
+
FORGE_MAX_DEPTH,
|
|
15
|
+
build_claude_env,
|
|
16
|
+
can_use_bare,
|
|
17
|
+
get_forge_depth,
|
|
18
|
+
should_spawn_subprocesses,
|
|
19
|
+
)
|
|
20
|
+
from .session_runner import SessionResult, run_claude_session
|
|
21
|
+
from .structured_output import extract_json_from_response
|
|
22
|
+
from .tagger import tag_action
|
|
23
|
+
from .throttle import ThrottleCache, compute_cache_key
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"FORGE_DEPTH_VAR",
|
|
27
|
+
"FORGE_MAX_DEPTH",
|
|
28
|
+
"build_claude_env",
|
|
29
|
+
"can_use_bare",
|
|
30
|
+
"get_forge_depth",
|
|
31
|
+
"should_spawn_subprocesses",
|
|
32
|
+
"SessionResult",
|
|
33
|
+
"run_claude_session",
|
|
34
|
+
"extract_json_from_response",
|
|
35
|
+
"tag_action",
|
|
36
|
+
"ThrottleCache",
|
|
37
|
+
"compute_cache_key",
|
|
38
|
+
]
|