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/session/active.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Runtime active-session registry for live Claude launches.
|
|
2
|
+
|
|
3
|
+
This registry is separate from session manifests and the global session index.
|
|
4
|
+
It stores ephemeral "session is currently launched" state so Forge can warn
|
|
5
|
+
before deleting a live session and self-heal stale entries after crashes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterator
|
|
17
|
+
|
|
18
|
+
import dacite
|
|
19
|
+
|
|
20
|
+
from forge.core.paths import get_forge_home
|
|
21
|
+
from forge.core.process import is_pid_alive
|
|
22
|
+
from forge.core.state import atomic_write_json, file_lock_for_target, now_iso
|
|
23
|
+
|
|
24
|
+
from .config import LAUNCH_MODE_HOST, LAUNCH_MODE_SIDECAR
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
ACTIVE_INDEX_VERSION = 1
|
|
29
|
+
ACTIVE_DIR = "sessions"
|
|
30
|
+
ACTIVE_FILENAME = "active.json"
|
|
31
|
+
CLI_LOCK_TIMEOUT_S = 5.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ActiveSessionEntry:
|
|
36
|
+
"""Ephemeral runtime state for a currently launched session."""
|
|
37
|
+
|
|
38
|
+
worktree_path: str
|
|
39
|
+
started_at: str
|
|
40
|
+
launch_mode: str = LAUNCH_MODE_HOST
|
|
41
|
+
launcher_pid: int | None = None
|
|
42
|
+
claude_session_id: str | None = None
|
|
43
|
+
container_name: str | None = None
|
|
44
|
+
forge_root: str | None = None # Scope axis (matches durable index)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ActiveSessionIndex:
|
|
49
|
+
"""All currently active sessions keyed by session name."""
|
|
50
|
+
|
|
51
|
+
version: int = ACTIVE_INDEX_VERSION
|
|
52
|
+
sessions: dict[str, ActiveSessionEntry] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_active_index_path() -> Path:
|
|
56
|
+
"""Return the runtime active-session registry path."""
|
|
57
|
+
return get_forge_home() / ACTIVE_DIR / ACTIVE_FILENAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ActiveSessionStore:
|
|
61
|
+
"""Manage the runtime active-session registry."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, index_path: Path | None = None) -> None:
|
|
64
|
+
self._index_path = index_path or get_active_index_path()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def index_path(self) -> Path:
|
|
68
|
+
"""Return the active-session registry path."""
|
|
69
|
+
return self._index_path
|
|
70
|
+
|
|
71
|
+
def exists(self) -> bool:
|
|
72
|
+
"""Return True when the registry file exists."""
|
|
73
|
+
return self._index_path.is_file()
|
|
74
|
+
|
|
75
|
+
def read(self) -> ActiveSessionIndex:
|
|
76
|
+
"""Read the registry, returning an empty registry when missing."""
|
|
77
|
+
if not self.exists():
|
|
78
|
+
return ActiveSessionIndex()
|
|
79
|
+
|
|
80
|
+
with open(self._index_path, encoding="utf-8") as f:
|
|
81
|
+
data = json.load(f)
|
|
82
|
+
|
|
83
|
+
version = data.get("version")
|
|
84
|
+
if version != ACTIVE_INDEX_VERSION or not self._has_current_key_shape(data):
|
|
85
|
+
logger.info("Discarding incompatible active-session registry (version=%s)", version)
|
|
86
|
+
empty = ActiveSessionIndex()
|
|
87
|
+
self.write(empty)
|
|
88
|
+
return empty
|
|
89
|
+
|
|
90
|
+
return dacite.from_dict(
|
|
91
|
+
data_class=ActiveSessionIndex,
|
|
92
|
+
data=data,
|
|
93
|
+
config=dacite.Config(strict=True),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _has_current_key_shape(self, data: dict[str, object]) -> bool:
|
|
97
|
+
"""Return True when the registry uses scoped session keys."""
|
|
98
|
+
from forge.session.identity import make_scoped_key, session_name_from_key
|
|
99
|
+
|
|
100
|
+
sessions = data.get("sessions")
|
|
101
|
+
if not isinstance(sessions, dict):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
for key, entry_data in sessions.items():
|
|
105
|
+
if not isinstance(key, str) or not isinstance(entry_data, dict):
|
|
106
|
+
return False
|
|
107
|
+
root = entry_data.get("forge_root") or entry_data.get("worktree_path")
|
|
108
|
+
if not isinstance(root, str) or not root:
|
|
109
|
+
return False
|
|
110
|
+
display_name = session_name_from_key(key)
|
|
111
|
+
if key != make_scoped_key(display_name, root):
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def write(self, index: ActiveSessionIndex) -> None:
|
|
116
|
+
"""Write the registry atomically."""
|
|
117
|
+
atomic_write_json(self._index_path, asdict(index))
|
|
118
|
+
|
|
119
|
+
def upsert_session(
|
|
120
|
+
self,
|
|
121
|
+
session_name: str,
|
|
122
|
+
*,
|
|
123
|
+
worktree_path: str,
|
|
124
|
+
launch_mode: str,
|
|
125
|
+
launcher_pid: int | None = None,
|
|
126
|
+
claude_session_id: str | None = None,
|
|
127
|
+
container_name: str | None = None,
|
|
128
|
+
forge_root: str | None = None,
|
|
129
|
+
) -> ActiveSessionEntry:
|
|
130
|
+
"""Create or replace a live-session entry."""
|
|
131
|
+
from forge.session.identity import make_scoped_key
|
|
132
|
+
|
|
133
|
+
launcher_pid = os.getpid() if launcher_pid is None else launcher_pid
|
|
134
|
+
effective_forge_root = forge_root or worktree_path
|
|
135
|
+
|
|
136
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
137
|
+
index = self.read()
|
|
138
|
+
entry = ActiveSessionEntry(
|
|
139
|
+
worktree_path=worktree_path,
|
|
140
|
+
started_at=now_iso(),
|
|
141
|
+
launch_mode=launch_mode,
|
|
142
|
+
launcher_pid=launcher_pid,
|
|
143
|
+
claude_session_id=claude_session_id,
|
|
144
|
+
container_name=container_name,
|
|
145
|
+
forge_root=effective_forge_root,
|
|
146
|
+
)
|
|
147
|
+
key = make_scoped_key(session_name, effective_forge_root)
|
|
148
|
+
index.sessions[key] = entry
|
|
149
|
+
self.write(index)
|
|
150
|
+
return entry
|
|
151
|
+
|
|
152
|
+
def update_uuid(self, session_name: str, claude_session_id: str, forge_root: str | None = None) -> bool:
|
|
153
|
+
"""Update the Claude UUID for an active session if it exists.
|
|
154
|
+
|
|
155
|
+
Best-effort: uses best-effort resolver when forge_root is None.
|
|
156
|
+
"""
|
|
157
|
+
from forge.session.identity import resolve_key_best_effort
|
|
158
|
+
|
|
159
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
160
|
+
index = self.read()
|
|
161
|
+
key = resolve_key_best_effort(index.sessions, session_name, forge_root)
|
|
162
|
+
if key is None:
|
|
163
|
+
return False
|
|
164
|
+
index.sessions[key].claude_session_id = claude_session_id
|
|
165
|
+
self.write(index)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def clear_session(self, session_name: str, forge_root: str | None = None) -> bool:
|
|
169
|
+
"""Remove an active-session entry by session name.
|
|
170
|
+
|
|
171
|
+
Uses strict resolution when forge_root is None.
|
|
172
|
+
"""
|
|
173
|
+
from forge.session.identity import resolve_key_strict
|
|
174
|
+
|
|
175
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
176
|
+
index = self.read()
|
|
177
|
+
key = resolve_key_strict(index.sessions, session_name, forge_root)
|
|
178
|
+
if key is None:
|
|
179
|
+
return False
|
|
180
|
+
del index.sessions[key]
|
|
181
|
+
self.write(index)
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
def clear_by_claude_session_id(self, claude_session_id: str) -> bool:
|
|
185
|
+
"""Remove an active-session entry by Claude UUID (scans all)."""
|
|
186
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
187
|
+
index = self.read()
|
|
188
|
+
removed = False
|
|
189
|
+
for key, entry in list(index.sessions.items()):
|
|
190
|
+
if entry.claude_session_id == claude_session_id:
|
|
191
|
+
del index.sessions[key]
|
|
192
|
+
removed = True
|
|
193
|
+
if removed:
|
|
194
|
+
self.write(index)
|
|
195
|
+
return removed
|
|
196
|
+
|
|
197
|
+
def get_session(self, session_name: str, forge_root: str | None = None) -> ActiveSessionEntry | None:
|
|
198
|
+
"""Return the live entry for a session, pruning stale entries.
|
|
199
|
+
|
|
200
|
+
Uses strict resolution when forge_root is None.
|
|
201
|
+
"""
|
|
202
|
+
from forge.session.identity import resolve_key_strict
|
|
203
|
+
|
|
204
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
205
|
+
index = self.read()
|
|
206
|
+
key = resolve_key_strict(index.sessions, session_name, forge_root)
|
|
207
|
+
|
|
208
|
+
if key is None:
|
|
209
|
+
return None
|
|
210
|
+
entry = index.sessions.get(key)
|
|
211
|
+
if entry is None:
|
|
212
|
+
return None
|
|
213
|
+
if self._entry_is_live(entry):
|
|
214
|
+
return entry
|
|
215
|
+
|
|
216
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
217
|
+
latest = self.read()
|
|
218
|
+
latest_entry = latest.sessions.get(key)
|
|
219
|
+
if latest_entry is None:
|
|
220
|
+
return None
|
|
221
|
+
if self._entry_is_live(latest_entry):
|
|
222
|
+
return latest_entry
|
|
223
|
+
del latest.sessions[key]
|
|
224
|
+
self.write(latest)
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def list_sessions(self) -> list[tuple[str, ActiveSessionEntry]]:
|
|
228
|
+
"""List all live sessions, pruning stale entries on read.
|
|
229
|
+
|
|
230
|
+
Returns display names (not compound keys).
|
|
231
|
+
"""
|
|
232
|
+
from forge.session.identity import session_name_from_key
|
|
233
|
+
|
|
234
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
235
|
+
index = self.read()
|
|
236
|
+
|
|
237
|
+
stale_keys = [key for key, entry in index.sessions.items() if not self._entry_is_live(entry)]
|
|
238
|
+
if stale_keys:
|
|
239
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
240
|
+
latest = self.read()
|
|
241
|
+
pruned_any = False
|
|
242
|
+
for key in stale_keys:
|
|
243
|
+
entry = latest.sessions.get(key)
|
|
244
|
+
if entry is None:
|
|
245
|
+
continue
|
|
246
|
+
if not self._entry_is_live(entry):
|
|
247
|
+
del latest.sessions[key]
|
|
248
|
+
pruned_any = True
|
|
249
|
+
if pruned_any:
|
|
250
|
+
self.write(latest)
|
|
251
|
+
index = latest
|
|
252
|
+
|
|
253
|
+
return sorted(
|
|
254
|
+
[(session_name_from_key(k), e) for k, e in index.sessions.items()],
|
|
255
|
+
key=lambda item: item[0],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def is_session_active(self, session_name: str, forge_root: str | None = None) -> bool:
|
|
259
|
+
"""Return True when the session still appears to be live.
|
|
260
|
+
|
|
261
|
+
Uses strict resolution when forge_root is None.
|
|
262
|
+
"""
|
|
263
|
+
return self.get_session(session_name, forge_root=forge_root) is not None
|
|
264
|
+
|
|
265
|
+
def _entry_is_live(self, entry: ActiveSessionEntry) -> bool:
|
|
266
|
+
"""Return True when the runtime entry still points at a live launch."""
|
|
267
|
+
if entry.launch_mode == LAUNCH_MODE_SIDECAR and entry.container_name:
|
|
268
|
+
try:
|
|
269
|
+
from forge.sidecar.docker import is_container_running
|
|
270
|
+
|
|
271
|
+
if is_container_running(entry.container_name):
|
|
272
|
+
return True
|
|
273
|
+
except Exception:
|
|
274
|
+
logger.debug("Failed to probe sidecar container liveness", exc_info=True)
|
|
275
|
+
|
|
276
|
+
if entry.launcher_pid is not None and is_pid_alive(entry.launcher_pid):
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@contextmanager
|
|
283
|
+
def track_active_session(
|
|
284
|
+
*,
|
|
285
|
+
session_name: str,
|
|
286
|
+
worktree_path: str,
|
|
287
|
+
launch_mode: str,
|
|
288
|
+
forge_root: str | None = None,
|
|
289
|
+
claude_session_id: str | None = None,
|
|
290
|
+
launcher_pid: int | None = None,
|
|
291
|
+
container_name: str | None = None,
|
|
292
|
+
) -> Iterator[None]:
|
|
293
|
+
"""Track a live Claude launch for the duration of a context manager."""
|
|
294
|
+
store = ActiveSessionStore()
|
|
295
|
+
effective_forge_root = forge_root or worktree_path
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
store.upsert_session(
|
|
299
|
+
session_name,
|
|
300
|
+
worktree_path=worktree_path,
|
|
301
|
+
launch_mode=launch_mode,
|
|
302
|
+
launcher_pid=launcher_pid,
|
|
303
|
+
claude_session_id=claude_session_id,
|
|
304
|
+
container_name=container_name,
|
|
305
|
+
forge_root=effective_forge_root,
|
|
306
|
+
)
|
|
307
|
+
except Exception:
|
|
308
|
+
logger.debug("Failed to register active session '%s'", session_name, exc_info=True)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
yield
|
|
312
|
+
finally:
|
|
313
|
+
try:
|
|
314
|
+
store.clear_session(session_name, forge_root=effective_forge_root)
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.debug("Failed to clear active session '%s'", session_name, exc_info=True)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def run_with_active_session(
|
|
320
|
+
*,
|
|
321
|
+
session_name: str,
|
|
322
|
+
worktree_path: Path,
|
|
323
|
+
launch_mode: str,
|
|
324
|
+
forge_root: str | None = None,
|
|
325
|
+
claude_session_id: str | None = None,
|
|
326
|
+
runner: Callable[[], int],
|
|
327
|
+
) -> int:
|
|
328
|
+
"""Track a live session while invoking a Claude launcher callback."""
|
|
329
|
+
container_name = f"forge-{session_name}" if launch_mode == LAUNCH_MODE_SIDECAR else None
|
|
330
|
+
|
|
331
|
+
with track_active_session(
|
|
332
|
+
session_name=session_name,
|
|
333
|
+
worktree_path=str(worktree_path),
|
|
334
|
+
launch_mode=launch_mode,
|
|
335
|
+
forge_root=forge_root,
|
|
336
|
+
claude_session_id=claude_session_id,
|
|
337
|
+
container_name=container_name,
|
|
338
|
+
):
|
|
339
|
+
return runner()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Session artifact helpers.
|
|
2
|
+
|
|
3
|
+
This module implements Forge-project-local artifact storage for sessions.
|
|
4
|
+
|
|
5
|
+
Artifacts are stored under the **Forge project root** (``forge_root``):
|
|
6
|
+
|
|
7
|
+
- <forge_root>/.forge/artifacts/<session_name>/plans/
|
|
8
|
+
- <forge_root>/.forge/artifacts/<session_name>/transcripts/
|
|
9
|
+
|
|
10
|
+
The session manifest records artifact paths under ``confirmed.artifacts`` as
|
|
11
|
+
**forge-root-relative** paths (e.g., ``.forge/artifacts/...``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import shutil
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .claude.paths import find_project_root
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ArtifactPaths:
|
|
28
|
+
"""Computed artifact roots for a session."""
|
|
29
|
+
|
|
30
|
+
forge_root: Path
|
|
31
|
+
artifacts_root_abs: Path
|
|
32
|
+
artifacts_root_rel: Path
|
|
33
|
+
|
|
34
|
+
plans_abs: Path
|
|
35
|
+
plans_rel: Path
|
|
36
|
+
|
|
37
|
+
transcripts_abs: Path
|
|
38
|
+
transcripts_rel: Path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_forge_root(cwd: Path) -> Path:
|
|
42
|
+
"""Resolve the Forge project root for artifact storage.
|
|
43
|
+
|
|
44
|
+
Preference order:
|
|
45
|
+
1) Walk up from *cwd* looking for ``.forge/`` (Forge project anchor)
|
|
46
|
+
2) Fallback to git-aware main-repo detection (worktree safe)
|
|
47
|
+
3) Fallback to walking upwards for a ``.git`` entry
|
|
48
|
+
4) Final fallback to cwd
|
|
49
|
+
|
|
50
|
+
In most managed sessions, the caller should prefer the session's
|
|
51
|
+
stored ``forge_root`` over this heuristic.
|
|
52
|
+
"""
|
|
53
|
+
# Prefer .forge/ directory as the Forge project anchor
|
|
54
|
+
from forge.core.ops.context import find_forge_root
|
|
55
|
+
|
|
56
|
+
forge_root = find_forge_root(cwd)
|
|
57
|
+
if forge_root is not None:
|
|
58
|
+
return forge_root
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from .worktree import get_main_repo_root
|
|
62
|
+
|
|
63
|
+
return get_main_repo_root(cwd)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.debug("get_main_repo_root failed: %s, trying find_project_root", e)
|
|
66
|
+
try:
|
|
67
|
+
return find_project_root(str(cwd))
|
|
68
|
+
except Exception as e2:
|
|
69
|
+
logger.debug("find_project_root failed: %s, falling back to cwd", e2)
|
|
70
|
+
return cwd.resolve()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_artifact_paths(forge_root: Path, session_name: str) -> ArtifactPaths:
|
|
74
|
+
"""Compute standard artifact directories for a session.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
forge_root: Forge project root (where .forge/ lives).
|
|
78
|
+
session_name: Forge session name.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
ArtifactPaths with absolute + forge-root-relative paths.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
forge_root = forge_root.resolve()
|
|
85
|
+
|
|
86
|
+
artifacts_root_rel = Path(".forge") / "artifacts" / session_name
|
|
87
|
+
artifacts_root_abs = forge_root / artifacts_root_rel
|
|
88
|
+
|
|
89
|
+
plans_rel = artifacts_root_rel / "plans"
|
|
90
|
+
plans_abs = forge_root / plans_rel
|
|
91
|
+
|
|
92
|
+
transcripts_rel = artifacts_root_rel / "transcripts"
|
|
93
|
+
transcripts_abs = forge_root / transcripts_rel
|
|
94
|
+
|
|
95
|
+
return ArtifactPaths(
|
|
96
|
+
forge_root=forge_root,
|
|
97
|
+
artifacts_root_abs=artifacts_root_abs,
|
|
98
|
+
artifacts_root_rel=artifacts_root_rel,
|
|
99
|
+
plans_abs=plans_abs,
|
|
100
|
+
plans_rel=plans_rel,
|
|
101
|
+
transcripts_abs=transcripts_abs,
|
|
102
|
+
transcripts_rel=transcripts_rel,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def resolve_artifact_path(forge_root: Path, stored_path: str | Path | None) -> Path | None:
|
|
107
|
+
"""Resolve a stored artifact path against the owning Forge project root.
|
|
108
|
+
|
|
109
|
+
Artifact paths recorded in manifests are normally forge-root-relative
|
|
110
|
+
(for example ``.forge/artifacts/...``), but this helper also accepts
|
|
111
|
+
absolute paths as a compatibility fallback.
|
|
112
|
+
"""
|
|
113
|
+
if stored_path is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
candidate = Path(stored_path).expanduser()
|
|
117
|
+
if candidate.is_absolute():
|
|
118
|
+
return candidate
|
|
119
|
+
return forge_root.resolve() / candidate
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure_dirs(paths: ArtifactPaths) -> None:
|
|
123
|
+
"""Create artifact directories if needed."""
|
|
124
|
+
|
|
125
|
+
paths.plans_abs.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
paths.transcripts_abs.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def safe_copy_file(src: Path, dst: Path, *, overwrite: bool = False) -> bool:
|
|
130
|
+
"""Copy a file with idempotent semantics.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
src: Source file.
|
|
134
|
+
dst: Destination file.
|
|
135
|
+
overwrite: Whether to overwrite if dst exists.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if a copy occurred, False if skipped.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
FileNotFoundError: if src does not exist.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
if not src.is_file():
|
|
145
|
+
raise FileNotFoundError(str(src))
|
|
146
|
+
|
|
147
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
if dst.exists() and not overwrite:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
shutil.copy2(src, dst)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def make_timestamp_suffix() -> str:
|
|
157
|
+
"""Return a filesystem-friendly UTC timestamp suffix (``YYYYMMDD_HHMMSS``)."""
|
|
158
|
+
from datetime import UTC, datetime
|
|
159
|
+
|
|
160
|
+
return datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def make_content_hash(data: bytes, *, length: int = 12) -> str:
|
|
164
|
+
"""Return a short hex digest for content-addressable filenames.
|
|
165
|
+
|
|
166
|
+
12 hex chars = 48 bits of entropy — enough that collisions across a single
|
|
167
|
+
user's plan history are not a practical concern.
|
|
168
|
+
"""
|
|
169
|
+
import hashlib
|
|
170
|
+
|
|
171
|
+
return hashlib.sha256(data).hexdigest()[:length]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def snapshot_plan_approved(
|
|
175
|
+
*,
|
|
176
|
+
paths: ArtifactPaths,
|
|
177
|
+
source_plan_path: Path,
|
|
178
|
+
) -> tuple[Path, Path]:
|
|
179
|
+
"""Snapshot an approved plan file into a human-readable destination.
|
|
180
|
+
|
|
181
|
+
Filename format: ``{stem}-{hash}.md`` where ``stem`` is the source plan's
|
|
182
|
+
filename stem and ``hash`` is a 12-char SHA-256 prefix of the file content.
|
|
183
|
+
Same source file with same content always produces the same path (dedup).
|
|
184
|
+
Different source filenames with identical content produce distinct paths —
|
|
185
|
+
accepted tradeoff for human-readable snapshot names.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
(snapshot_abs_path, snapshot_rel_path)
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
ensure_dirs(paths)
|
|
192
|
+
|
|
193
|
+
content = source_plan_path.read_bytes()
|
|
194
|
+
digest = make_content_hash(content)
|
|
195
|
+
stem = source_plan_path.stem or digest
|
|
196
|
+
dst_name = f"{stem}-{digest}.md"
|
|
197
|
+
|
|
198
|
+
snapshot_abs = paths.plans_abs / dst_name
|
|
199
|
+
snapshot_rel = paths.plans_rel / dst_name
|
|
200
|
+
|
|
201
|
+
safe_copy_file(source_plan_path, snapshot_abs, overwrite=False)
|
|
202
|
+
return snapshot_abs, snapshot_rel
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Claude Code integration utilities.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for:
|
|
4
|
+
- Path encoding and transcript path resolution
|
|
5
|
+
- Claude binary invocation
|
|
6
|
+
- Session data cleanup (transcripts, agent logs)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .cleanup import (
|
|
12
|
+
CleanupResult,
|
|
13
|
+
cleanup_session,
|
|
14
|
+
delete_session_data,
|
|
15
|
+
)
|
|
16
|
+
from .invoke import (
|
|
17
|
+
build_claude_args,
|
|
18
|
+
find_claude_binary,
|
|
19
|
+
invoke_claude,
|
|
20
|
+
is_claude_available,
|
|
21
|
+
)
|
|
22
|
+
from .paths import (
|
|
23
|
+
encode_project_path,
|
|
24
|
+
find_agent_logs,
|
|
25
|
+
find_project_root,
|
|
26
|
+
get_claude_home,
|
|
27
|
+
get_claude_projects_dir,
|
|
28
|
+
get_project_encoded_dir,
|
|
29
|
+
get_transcript_path,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Cleanup
|
|
34
|
+
"CleanupResult",
|
|
35
|
+
"cleanup_session",
|
|
36
|
+
"delete_session_data",
|
|
37
|
+
# Invoke
|
|
38
|
+
"build_claude_args",
|
|
39
|
+
"invoke_claude",
|
|
40
|
+
"find_claude_binary",
|
|
41
|
+
"is_claude_available",
|
|
42
|
+
# Paths
|
|
43
|
+
"encode_project_path",
|
|
44
|
+
"find_agent_logs",
|
|
45
|
+
"find_project_root",
|
|
46
|
+
"get_claude_home",
|
|
47
|
+
"get_claude_projects_dir",
|
|
48
|
+
"get_project_encoded_dir",
|
|
49
|
+
"get_transcript_path",
|
|
50
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Transcript and agent log cleanup utilities.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for deleting Claude session data:
|
|
4
|
+
- Transcript files (.jsonl)
|
|
5
|
+
- Agent log files (agent-*.jsonl)
|
|
6
|
+
|
|
7
|
+
Under the 1:1 session model, each Forge session has one current claude_session_id.
|
|
8
|
+
If /compact or /clear rolled over to a new UUID, older raw transcript UUIDs may
|
|
9
|
+
also be tracked via transcript artifacts and should be cleaned up too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .paths import find_agent_logs, get_transcript_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CleanupResult:
|
|
22
|
+
"""Result of a cleanup operation.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
deleted_transcripts: Paths to successfully deleted transcript files.
|
|
26
|
+
deleted_agent_logs: Paths to successfully deleted agent log files.
|
|
27
|
+
failed: List of (path, error_message) tuples for failed deletions.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
deleted_transcripts: list[Path] = field(default_factory=list)
|
|
31
|
+
deleted_agent_logs: list[Path] = field(default_factory=list)
|
|
32
|
+
failed: list[tuple[Path, str]] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def total_deleted(self) -> int:
|
|
36
|
+
"""Total number of files successfully deleted."""
|
|
37
|
+
return len(self.deleted_transcripts) + len(self.deleted_agent_logs)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def has_failures(self) -> bool:
|
|
41
|
+
"""Whether any deletions failed."""
|
|
42
|
+
return len(self.failed) > 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def delete_session_data(
|
|
46
|
+
project_root: str,
|
|
47
|
+
session_ids: list[str],
|
|
48
|
+
) -> CleanupResult:
|
|
49
|
+
"""Delete transcript and agent log files for given session IDs.
|
|
50
|
+
|
|
51
|
+
Best-effort: continues even if some deletions fail.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
project_root: Absolute path to project root (for transcript path encoding).
|
|
55
|
+
session_ids: List of Claude session UUIDs to clean up.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
CleanupResult with lists of deleted files and any failures.
|
|
59
|
+
"""
|
|
60
|
+
result = CleanupResult()
|
|
61
|
+
|
|
62
|
+
for session_id in session_ids:
|
|
63
|
+
# Delete transcript
|
|
64
|
+
transcript_path = get_transcript_path(project_root, session_id)
|
|
65
|
+
if transcript_path.exists():
|
|
66
|
+
try:
|
|
67
|
+
transcript_path.unlink()
|
|
68
|
+
result.deleted_transcripts.append(transcript_path)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
result.failed.append((transcript_path, str(e)))
|
|
71
|
+
|
|
72
|
+
# Delete agent logs
|
|
73
|
+
agent_logs = find_agent_logs(project_root, session_id)
|
|
74
|
+
for log_path in agent_logs:
|
|
75
|
+
try:
|
|
76
|
+
log_path.unlink()
|
|
77
|
+
result.deleted_agent_logs.append(log_path)
|
|
78
|
+
except OSError as e:
|
|
79
|
+
result.failed.append((log_path, str(e)))
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cleanup_session(
|
|
85
|
+
project_root: str,
|
|
86
|
+
claude_session_id: str | None,
|
|
87
|
+
artifact_session_ids: list[str] | None = None,
|
|
88
|
+
) -> CleanupResult:
|
|
89
|
+
"""Clean up session data for the session's tracked Claude UUIDs.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
project_root: Absolute path to project root.
|
|
93
|
+
claude_session_id: Session UUID (from confirmed.claude_session_id).
|
|
94
|
+
artifact_session_ids: Additional UUIDs referenced by transcript artifacts.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
CleanupResult with lists of deleted files and any failures.
|
|
98
|
+
"""
|
|
99
|
+
session_ids: list[str] = []
|
|
100
|
+
|
|
101
|
+
for session_id in [claude_session_id, *(artifact_session_ids or [])]:
|
|
102
|
+
if session_id and session_id not in session_ids:
|
|
103
|
+
session_ids.append(session_id)
|
|
104
|
+
|
|
105
|
+
return delete_session_data(project_root, session_ids)
|