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,1336 @@
|
|
|
1
|
+
"""Session management commands: delete, list, clean, show, context, shell, set, reset.
|
|
2
|
+
|
|
3
|
+
Split from session.py for file-size compliance. All public and private
|
|
4
|
+
names are re-exported by session.py so that ``patch("forge.cli.session.XXX")``
|
|
5
|
+
continues to work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from forge.core.ops.session_context import SessionContext
|
|
21
|
+
from forge.core.paths import display_path
|
|
22
|
+
from forge.core.state import parse_iso
|
|
23
|
+
from forge.session import (
|
|
24
|
+
ForgeSessionError,
|
|
25
|
+
IndexStore,
|
|
26
|
+
SessionIndexEntry,
|
|
27
|
+
SessionManager,
|
|
28
|
+
SessionState,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sess(): # type: ignore[return]
|
|
33
|
+
"""Access forge.cli.session at runtime to respect test patches."""
|
|
34
|
+
return sys.modules["forge.cli.session"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
from forge.cli.session import ( # noqa: E402
|
|
38
|
+
_cwd_forge_root,
|
|
39
|
+
_format_relative_time,
|
|
40
|
+
_get_active_session_entry,
|
|
41
|
+
_get_session_type,
|
|
42
|
+
_handle_error,
|
|
43
|
+
_hint_cross_project_session,
|
|
44
|
+
_print_active_delete_warning,
|
|
45
|
+
_session_list_location,
|
|
46
|
+
_session_scope_key,
|
|
47
|
+
_template_display_label,
|
|
48
|
+
console,
|
|
49
|
+
logger,
|
|
50
|
+
)
|
|
51
|
+
from forge.cli.session import session as _session_untyped # noqa: E402
|
|
52
|
+
|
|
53
|
+
session = cast(click.Group, _session_untyped) # type: ignore[has-type] # circular re-export
|
|
54
|
+
|
|
55
|
+
from forge.session.exceptions import ( # noqa: E402
|
|
56
|
+
AmbiguousSessionError,
|
|
57
|
+
DirtyWorktreeError,
|
|
58
|
+
SessionNotFoundError,
|
|
59
|
+
)
|
|
60
|
+
from forge.session.plan_resolution import ( # noqa: E402
|
|
61
|
+
PlanInfo,
|
|
62
|
+
latest_snapshot_path,
|
|
63
|
+
preferred_plan_path,
|
|
64
|
+
resolve_displayed_plan_path,
|
|
65
|
+
resolve_path_against,
|
|
66
|
+
resolve_plan_info,
|
|
67
|
+
resolve_plan_launch_root,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Click commands
|
|
72
|
+
"delete",
|
|
73
|
+
"list_sessions",
|
|
74
|
+
"clean",
|
|
75
|
+
"show",
|
|
76
|
+
"context_cmd",
|
|
77
|
+
"shell",
|
|
78
|
+
"set_override",
|
|
79
|
+
"reset",
|
|
80
|
+
# Private helpers (needed for re-export to forge.cli.session namespace)
|
|
81
|
+
"_delete_single_session",
|
|
82
|
+
"_print_session_list_tips",
|
|
83
|
+
"_clean_sessions_dry_run",
|
|
84
|
+
"_build_show_json",
|
|
85
|
+
"_empty_show_plan_json",
|
|
86
|
+
"_build_show_plan_json",
|
|
87
|
+
"_print_session_context",
|
|
88
|
+
"_print_session_summary",
|
|
89
|
+
"_print_plan_info",
|
|
90
|
+
"_print_session_detail",
|
|
91
|
+
"_flatten_overrides",
|
|
92
|
+
"_format_value",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@session.command()
|
|
97
|
+
@click.argument("names", nargs=-1)
|
|
98
|
+
@click.option("--all", "-a", "delete_all", is_flag=True, help="Delete all sessions")
|
|
99
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
|
|
100
|
+
@click.option("--force", "-f", is_flag=True, help="Override dirty-worktree and corruption guards")
|
|
101
|
+
@click.option("--keep-transcripts", "-k", is_flag=True, help="Keep transcript files")
|
|
102
|
+
@click.option("--keep-worktree", "-K", is_flag=True, help="Preserve worktree directory")
|
|
103
|
+
@click.option("--delete-branch", "-d", is_flag=True, help="Also delete git branch")
|
|
104
|
+
def delete(
|
|
105
|
+
names: tuple[str, ...],
|
|
106
|
+
delete_all: bool,
|
|
107
|
+
yes: bool,
|
|
108
|
+
force: bool,
|
|
109
|
+
keep_transcripts: bool,
|
|
110
|
+
keep_worktree: bool,
|
|
111
|
+
delete_branch: bool,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Delete one or more sessions and their data.
|
|
114
|
+
|
|
115
|
+
\b
|
|
116
|
+
Examples:
|
|
117
|
+
forge session delete my-session
|
|
118
|
+
forge session delete my-session --yes # Skip confirmation
|
|
119
|
+
forge session delete my-session --yes --force # Skip confirmation + override dirty worktree
|
|
120
|
+
forge session delete --all --yes
|
|
121
|
+
|
|
122
|
+
By default, removes the worktree directory but keeps the git branch.
|
|
123
|
+
Use --delete-branch to also delete the branch.
|
|
124
|
+
Use --keep-worktree to preserve the worktree directory.
|
|
125
|
+
"""
|
|
126
|
+
if delete_all and names:
|
|
127
|
+
console.print("[red]Error:[/red] Cannot combine --all with explicit session names")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
if not delete_all and not names:
|
|
131
|
+
console.print("[red]Error:[/red] Provide session name(s) or use --all")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
manager = _sess().SessionManager()
|
|
135
|
+
_fr = _cwd_forge_root()
|
|
136
|
+
|
|
137
|
+
if delete_all:
|
|
138
|
+
if _fr is None:
|
|
139
|
+
console.print("[red]Error:[/red] --all requires being inside a Forge project (directory with .forge/)")
|
|
140
|
+
console.print("[dim]Tip: Use explicit session names instead, or cd into a Forge project.[/dim]")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
all_sessions = manager.list_sessions(include_incognito=True, forge_root_filter=_fr)
|
|
143
|
+
if not all_sessions:
|
|
144
|
+
console.print("[dim]No sessions to delete.[/dim]")
|
|
145
|
+
return
|
|
146
|
+
targets = [name for name, _ in all_sessions]
|
|
147
|
+
|
|
148
|
+
active_targets = [
|
|
149
|
+
(target, active_entry)
|
|
150
|
+
for target in targets
|
|
151
|
+
if (active_entry := _get_active_session_entry(target, forge_root=_fr)) is not None
|
|
152
|
+
]
|
|
153
|
+
console.print(f"About to delete [bold]all {len(targets)} session(s)[/bold]:")
|
|
154
|
+
for t in targets:
|
|
155
|
+
console.print(f" - {t}")
|
|
156
|
+
if active_targets:
|
|
157
|
+
console.print()
|
|
158
|
+
console.print(
|
|
159
|
+
"[yellow]Warning:[/yellow] "
|
|
160
|
+
"The following sessions appear to still be active in running Claude Code launches:"
|
|
161
|
+
)
|
|
162
|
+
for target, active_entry in active_targets:
|
|
163
|
+
details = [active_entry.launch_mode]
|
|
164
|
+
if active_entry.container_name:
|
|
165
|
+
details.append(active_entry.container_name)
|
|
166
|
+
elif active_entry.launcher_pid is not None:
|
|
167
|
+
details.append(f"pid {active_entry.launcher_pid}")
|
|
168
|
+
console.print(f" - {target} ({', '.join(details)})")
|
|
169
|
+
console.print(
|
|
170
|
+
" Deleting them will remove Forge state while Claude keeps running until those launches exit."
|
|
171
|
+
)
|
|
172
|
+
console.print()
|
|
173
|
+
if not yes:
|
|
174
|
+
if not click.confirm("Are you sure you want to delete all sessions?"):
|
|
175
|
+
console.print("[dim]Cancelled[/dim]")
|
|
176
|
+
sys.exit(0)
|
|
177
|
+
else:
|
|
178
|
+
targets = list(dict.fromkeys(names))
|
|
179
|
+
|
|
180
|
+
deleted = 0
|
|
181
|
+
failed = 0
|
|
182
|
+
|
|
183
|
+
for name in targets:
|
|
184
|
+
# Resolve across forge_roots within the repo (named deletes only)
|
|
185
|
+
actual_fr = _fr
|
|
186
|
+
if not delete_all:
|
|
187
|
+
try:
|
|
188
|
+
from forge.core.ops.resolution import resolve_session_repo_wide
|
|
189
|
+
|
|
190
|
+
resolved = resolve_session_repo_wide(name, _fr, manager=manager)
|
|
191
|
+
actual_fr = resolved.forge_root
|
|
192
|
+
if resolved.is_cross_project:
|
|
193
|
+
console.print(f"[dim]Deleting session from {display_path(actual_fr)}[/dim]")
|
|
194
|
+
except AmbiguousSessionError as e:
|
|
195
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
196
|
+
failed += 1
|
|
197
|
+
continue
|
|
198
|
+
except SessionNotFoundError:
|
|
199
|
+
pass # Fall through to _delete_single_session for orphan handling
|
|
200
|
+
except ForgeSessionError:
|
|
201
|
+
# Manifest corrupt but session exists in index -- resolve the
|
|
202
|
+
# forge_root from the index so force-delete can clean it up.
|
|
203
|
+
try:
|
|
204
|
+
entry = IndexStore().get_session(name, forge_root=None)
|
|
205
|
+
idx_fr = entry.root
|
|
206
|
+
if idx_fr:
|
|
207
|
+
actual_fr = idx_fr
|
|
208
|
+
except (SessionNotFoundError, AmbiguousSessionError, ForgeSessionError):
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
_sess()._delete_single_session(
|
|
213
|
+
manager=manager,
|
|
214
|
+
name=name,
|
|
215
|
+
yes=yes or delete_all,
|
|
216
|
+
force=force,
|
|
217
|
+
keep_transcripts=keep_transcripts,
|
|
218
|
+
keep_worktree=keep_worktree,
|
|
219
|
+
delete_branch=delete_branch,
|
|
220
|
+
forge_root=actual_fr,
|
|
221
|
+
)
|
|
222
|
+
console.print(f"Deleted session [green]{name}[/green]")
|
|
223
|
+
deleted += 1
|
|
224
|
+
except SystemExit as e:
|
|
225
|
+
if len(targets) == 1:
|
|
226
|
+
raise
|
|
227
|
+
if e.code not in (0, None):
|
|
228
|
+
failed += 1
|
|
229
|
+
except DirtyWorktreeError as e:
|
|
230
|
+
if len(targets) == 1:
|
|
231
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
232
|
+
console.print("\n[dim]Tip: Use --force to remove anyway, or commit/stash your changes first.[/dim]")
|
|
233
|
+
raise SystemExit(1)
|
|
234
|
+
console.print(f"[red]Error:[/red] {name}: {e}")
|
|
235
|
+
failed += 1
|
|
236
|
+
except ForgeSessionError as e:
|
|
237
|
+
if len(targets) == 1:
|
|
238
|
+
_handle_error(e)
|
|
239
|
+
else:
|
|
240
|
+
console.print(f"[red]Error:[/red] {name}: {e}")
|
|
241
|
+
failed += 1
|
|
242
|
+
except Exception as e:
|
|
243
|
+
console.print(f"[red]Error:[/red] {name}: {e}")
|
|
244
|
+
failed += 1
|
|
245
|
+
|
|
246
|
+
if len(targets) > 1:
|
|
247
|
+
parts = [f"{deleted} deleted"]
|
|
248
|
+
if failed:
|
|
249
|
+
parts.append(f"{failed} failed")
|
|
250
|
+
console.print(f"\n[dim]Summary: {', '.join(parts)}[/dim]")
|
|
251
|
+
|
|
252
|
+
if failed:
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _delete_single_session(
|
|
257
|
+
*,
|
|
258
|
+
manager: SessionManager,
|
|
259
|
+
name: str,
|
|
260
|
+
yes: bool,
|
|
261
|
+
force: bool,
|
|
262
|
+
keep_transcripts: bool,
|
|
263
|
+
keep_worktree: bool,
|
|
264
|
+
delete_branch: bool,
|
|
265
|
+
forge_root: str | None = None,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Delete a single session, handling orphans and confirmation.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
yes: Skip confirmation prompts (informational output stays visible).
|
|
271
|
+
force: Override dirty-worktree and corruption guards.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
SystemExit: If user cancels or session not found.
|
|
275
|
+
DirtyWorktreeError: If worktree has uncommitted changes and not force.
|
|
276
|
+
ForgeSessionError: On other session errors.
|
|
277
|
+
"""
|
|
278
|
+
if not manager.session_exists(name, forge_root=forge_root):
|
|
279
|
+
from forge.session.store import SessionStore
|
|
280
|
+
|
|
281
|
+
orphan_store = SessionStore(str(Path.cwd()), name)
|
|
282
|
+
if orphan_store.session_dir.is_dir():
|
|
283
|
+
import shutil
|
|
284
|
+
|
|
285
|
+
console.print(
|
|
286
|
+
f"Found orphaned session directory [bold]{name}[/bold] " "(exists on disk but not in session index)"
|
|
287
|
+
)
|
|
288
|
+
console.print(f" Path: {display_path(orphan_store.session_dir)}")
|
|
289
|
+
if not yes:
|
|
290
|
+
if not click.confirm("Delete this orphaned session directory?"):
|
|
291
|
+
console.print("[dim]Cancelled[/dim]")
|
|
292
|
+
raise SystemExit(0)
|
|
293
|
+
|
|
294
|
+
shutil.rmtree(orphan_store.session_dir)
|
|
295
|
+
try:
|
|
296
|
+
from forge.session.active import ActiveSessionStore
|
|
297
|
+
|
|
298
|
+
ActiveSessionStore().clear_session(name, forge_root=forge_root)
|
|
299
|
+
except Exception:
|
|
300
|
+
logger.debug(
|
|
301
|
+
"Failed to clear active-session entry for orphan '%s'",
|
|
302
|
+
name,
|
|
303
|
+
exc_info=True,
|
|
304
|
+
)
|
|
305
|
+
console.print(f"Cleaned up orphaned session directory [green]{name}[/green]")
|
|
306
|
+
return
|
|
307
|
+
console.print(f"[red]Error:[/red] session '{name}' not found")
|
|
308
|
+
raise SystemExit(1)
|
|
309
|
+
|
|
310
|
+
# Informational output -- always visible (--yes only skips prompts, not info)
|
|
311
|
+
active_entry = _get_active_session_entry(name, forge_root=forge_root)
|
|
312
|
+
if active_entry is not None:
|
|
313
|
+
_print_active_delete_warning(name, active_entry)
|
|
314
|
+
try:
|
|
315
|
+
manifest = manager.get_session(name, forge_root=forge_root)
|
|
316
|
+
|
|
317
|
+
console.print(f"About to delete session [bold]{name}[/bold]")
|
|
318
|
+
|
|
319
|
+
if manifest.confirmed.claude_session_id:
|
|
320
|
+
console.print(f" UUID: {manifest.confirmed.claude_session_id}")
|
|
321
|
+
|
|
322
|
+
if manifest.worktree and manifest.worktree.is_worktree:
|
|
323
|
+
if keep_worktree:
|
|
324
|
+
console.print(f" [dim]Worktree will be kept: {display_path(manifest.worktree.path)}[/dim]")
|
|
325
|
+
else:
|
|
326
|
+
console.print(f" Worktree will be removed: {display_path(manifest.worktree.path)}")
|
|
327
|
+
if delete_branch:
|
|
328
|
+
console.print(f" Branch will be deleted: {manifest.worktree.branch}")
|
|
329
|
+
else:
|
|
330
|
+
console.print(f" [dim]Branch will be kept: {manifest.worktree.branch}[/dim]")
|
|
331
|
+
|
|
332
|
+
if not keep_transcripts:
|
|
333
|
+
console.print(" [dim]Transcript files will also be deleted[/dim]")
|
|
334
|
+
else:
|
|
335
|
+
console.print(" [dim]Transcript files will be kept[/dim]")
|
|
336
|
+
|
|
337
|
+
console.print()
|
|
338
|
+
except ForgeSessionError:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
if not yes:
|
|
342
|
+
if not click.confirm("Are you sure you want to delete this session?"):
|
|
343
|
+
console.print("[dim]Cancelled[/dim]")
|
|
344
|
+
raise SystemExit(0)
|
|
345
|
+
|
|
346
|
+
manager.delete_session(
|
|
347
|
+
name,
|
|
348
|
+
delete_transcripts=not keep_transcripts,
|
|
349
|
+
delete_worktree=not keep_worktree,
|
|
350
|
+
delete_branch=delete_branch,
|
|
351
|
+
force=force,
|
|
352
|
+
forge_root=forge_root,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@session.command("list")
|
|
357
|
+
@click.option(
|
|
358
|
+
"--include-incognito/--no-incognito",
|
|
359
|
+
"-i/-I",
|
|
360
|
+
default=True,
|
|
361
|
+
help="Include incognito sessions",
|
|
362
|
+
)
|
|
363
|
+
@click.option(
|
|
364
|
+
"--older-than",
|
|
365
|
+
type=int,
|
|
366
|
+
default=None,
|
|
367
|
+
metavar="DAYS",
|
|
368
|
+
help="Only show sessions not accessed in DAYS days",
|
|
369
|
+
)
|
|
370
|
+
@click.option(
|
|
371
|
+
"--scope",
|
|
372
|
+
type=click.Choice(["repo", "project", "all"], case_sensitive=False),
|
|
373
|
+
default="repo",
|
|
374
|
+
help="Scope: repo (default, same logical repo), project (same forge_root), all (global)",
|
|
375
|
+
)
|
|
376
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
377
|
+
def list_sessions(include_incognito: bool, older_than: int | None, scope: str, as_json: bool) -> None:
|
|
378
|
+
"""List sessions.
|
|
379
|
+
|
|
380
|
+
\b
|
|
381
|
+
Examples:
|
|
382
|
+
forge session list # Sessions in current repo
|
|
383
|
+
forge session list --scope all # All sessions globally
|
|
384
|
+
forge session list --older-than 30 # Old sessions in current repo
|
|
385
|
+
"""
|
|
386
|
+
if older_than is not None and older_than < 1:
|
|
387
|
+
console.print("[red]Error:[/red] --older-than must be >= 1")
|
|
388
|
+
sys.exit(1)
|
|
389
|
+
|
|
390
|
+
from forge.core.ops.context import ExecutionContext
|
|
391
|
+
from forge.core.ops.session import ForgeOpError
|
|
392
|
+
from forge.core.ops.session import list_sessions as list_sessions_op
|
|
393
|
+
|
|
394
|
+
ctx = ExecutionContext.from_cwd()
|
|
395
|
+
|
|
396
|
+
if older_than is not None:
|
|
397
|
+
from forge.core.ops.session import _scope_filters, list_sessions_older_than
|
|
398
|
+
|
|
399
|
+
pr_filter, fr_filter = _scope_filters(ctx, scope)
|
|
400
|
+
old_sessions = list_sessions_older_than(
|
|
401
|
+
older_than_days=older_than,
|
|
402
|
+
include_incognito=include_incognito,
|
|
403
|
+
project_root_filter=pr_filter,
|
|
404
|
+
forge_root_filter=fr_filter,
|
|
405
|
+
)
|
|
406
|
+
old_scope_keys = {_session_scope_key(name, entry) for name, entry in old_sessions}
|
|
407
|
+
else:
|
|
408
|
+
old_scope_keys = None
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
result = list_sessions_op(ctx=ctx, include_incognito=include_incognito, scope=scope)
|
|
412
|
+
except ForgeOpError as e:
|
|
413
|
+
if as_json:
|
|
414
|
+
import json
|
|
415
|
+
|
|
416
|
+
click.echo(json.dumps({"error": str(e)}, indent=2), err=True)
|
|
417
|
+
else:
|
|
418
|
+
console.print(f"[red]Error:[/red] {e}", style="red")
|
|
419
|
+
sys.exit(1)
|
|
420
|
+
|
|
421
|
+
items = result.sessions
|
|
422
|
+
if old_scope_keys is not None:
|
|
423
|
+
items = [item for item in items if _session_scope_key(item.name, item.entry) in old_scope_keys]
|
|
424
|
+
|
|
425
|
+
if as_json:
|
|
426
|
+
import json
|
|
427
|
+
|
|
428
|
+
data = []
|
|
429
|
+
for item in items:
|
|
430
|
+
data.append(
|
|
431
|
+
{
|
|
432
|
+
"name": item.name,
|
|
433
|
+
"proxy_template": item.proxy_template,
|
|
434
|
+
"last_accessed_at": item.entry.last_accessed_at,
|
|
435
|
+
"is_active": item.is_active,
|
|
436
|
+
"worktree_path": item.entry.worktree_path,
|
|
437
|
+
"forge_root": item.entry.forge_root,
|
|
438
|
+
"checkout_root": item.entry.checkout_root,
|
|
439
|
+
"relative_path": item.entry.relative_path,
|
|
440
|
+
"is_fork": item.entry.is_fork,
|
|
441
|
+
"is_incognito": item.entry.is_incognito,
|
|
442
|
+
"parent_session": item.entry.parent_session,
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
if not items:
|
|
449
|
+
if older_than is not None:
|
|
450
|
+
console.print(f"[dim]No sessions older than {older_than} days.[/dim]")
|
|
451
|
+
else:
|
|
452
|
+
console.print("[dim]No sessions found.[/dim]")
|
|
453
|
+
console.print("\n[dim]Tip: Run 'forge session start <name>'.[/dim]")
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
duplicate_names = {item.name for item in items if sum(1 for other in items if other.name == item.name) > 1}
|
|
457
|
+
|
|
458
|
+
table = Table(show_header=True, header_style="bold")
|
|
459
|
+
table.add_column("NAME")
|
|
460
|
+
if duplicate_names:
|
|
461
|
+
table.add_column("LOCATION")
|
|
462
|
+
table.add_column("TEMPLATE")
|
|
463
|
+
table.add_column("LAST USED")
|
|
464
|
+
|
|
465
|
+
for item in items:
|
|
466
|
+
entry = item.entry
|
|
467
|
+
proxy_template = item.proxy_template or "direct"
|
|
468
|
+
last_used = _format_relative_time(entry.last_accessed_at)
|
|
469
|
+
row = [item.name]
|
|
470
|
+
if duplicate_names:
|
|
471
|
+
row.append(_session_list_location(entry))
|
|
472
|
+
row.extend([proxy_template, last_used])
|
|
473
|
+
table.add_row(*row)
|
|
474
|
+
|
|
475
|
+
console.print(table)
|
|
476
|
+
|
|
477
|
+
if older_than is None:
|
|
478
|
+
_print_session_list_tips(items)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _print_session_list_tips(items: list) -> None:
|
|
482
|
+
"""Print contextual tips after session list output."""
|
|
483
|
+
count = len(items)
|
|
484
|
+
|
|
485
|
+
if count == 1:
|
|
486
|
+
name = items[0].name if hasattr(items[0], "name") else "name"
|
|
487
|
+
console.print("\n[dim]Tip: Resume or start a session:[/dim]")
|
|
488
|
+
console.print(f"[dim] forge session resume {name} # resume this session[/dim]")
|
|
489
|
+
console.print("[dim] forge session start <name> # start a new session[/dim]")
|
|
490
|
+
elif count > 0:
|
|
491
|
+
console.print("\n[dim]Tip: Work with sessions:[/dim]")
|
|
492
|
+
console.print("[dim] forge session resume <name> # resume a session[/dim]")
|
|
493
|
+
console.print("[dim] forge session show <name> # inspect session details[/dim]")
|
|
494
|
+
|
|
495
|
+
console.print("\n[dim]Tip: Clean up sessions:[/dim]")
|
|
496
|
+
console.print("[dim] forge session delete <name> # delete a specific session[/dim]")
|
|
497
|
+
console.print("[dim] forge session clean --older-than 30 # bulk clean old sessions[/dim]")
|
|
498
|
+
console.print("[dim] forge config set session_retention_days=90 # auto-cleanup on startup[/dim]")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@session.command("clean")
|
|
502
|
+
@click.option(
|
|
503
|
+
"--older-than",
|
|
504
|
+
type=int,
|
|
505
|
+
required=True,
|
|
506
|
+
metavar="DAYS",
|
|
507
|
+
help="Delete sessions not accessed in DAYS days",
|
|
508
|
+
)
|
|
509
|
+
@click.option("--dry-run", "-n", is_flag=True, help="Show what would be deleted without deleting")
|
|
510
|
+
@click.option("--force", "-f", is_flag=True, help="Bypass dirty-worktree protection")
|
|
511
|
+
@click.option(
|
|
512
|
+
"--keep-transcripts",
|
|
513
|
+
"-k",
|
|
514
|
+
is_flag=True,
|
|
515
|
+
help="Keep Claude transcript files (~/.claude/projects/*.jsonl). Forge artifact snapshots (.forge/artifacts/) are always preserved",
|
|
516
|
+
)
|
|
517
|
+
@click.option(
|
|
518
|
+
"--delete-worktree",
|
|
519
|
+
is_flag=True,
|
|
520
|
+
help="Also remove worktree directories (default: keep)",
|
|
521
|
+
)
|
|
522
|
+
@click.option(
|
|
523
|
+
"--delete-branch",
|
|
524
|
+
"-d",
|
|
525
|
+
is_flag=True,
|
|
526
|
+
help="Also delete git branches (requires --delete-worktree)",
|
|
527
|
+
)
|
|
528
|
+
def clean(
|
|
529
|
+
older_than: int,
|
|
530
|
+
dry_run: bool,
|
|
531
|
+
force: bool,
|
|
532
|
+
keep_transcripts: bool,
|
|
533
|
+
delete_worktree: bool,
|
|
534
|
+
delete_branch: bool,
|
|
535
|
+
) -> None:
|
|
536
|
+
"""Delete sessions older than a given age.
|
|
537
|
+
|
|
538
|
+
\b
|
|
539
|
+
Examples:
|
|
540
|
+
forge session clean --older-than 30 # Delete sessions > 30 days old
|
|
541
|
+
forge session clean --older-than 30 --dry-run # Preview what would be cleaned
|
|
542
|
+
forge session clean --older-than 90 -k # Keep transcript files
|
|
543
|
+
|
|
544
|
+
Active sessions are always skipped. Worktrees are preserved by default
|
|
545
|
+
(use --delete-worktree to remove them).
|
|
546
|
+
"""
|
|
547
|
+
if older_than < 1:
|
|
548
|
+
console.print("[red]Error:[/red] --older-than must be >= 1")
|
|
549
|
+
sys.exit(1)
|
|
550
|
+
|
|
551
|
+
if delete_branch and not delete_worktree:
|
|
552
|
+
console.print("[red]Error:[/red] --delete-branch requires --delete-worktree")
|
|
553
|
+
sys.exit(1)
|
|
554
|
+
|
|
555
|
+
if dry_run:
|
|
556
|
+
_clean_sessions_dry_run(older_than)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
from forge.session.cleanup import clean_old_sessions
|
|
560
|
+
|
|
561
|
+
result = clean_old_sessions(
|
|
562
|
+
older_than_days=older_than,
|
|
563
|
+
delete_transcripts=not keep_transcripts,
|
|
564
|
+
delete_worktree=delete_worktree,
|
|
565
|
+
delete_branch=delete_branch,
|
|
566
|
+
force=force,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if result.is_empty:
|
|
570
|
+
console.print(f"[dim]No sessions older than {older_than} days found.[/dim]")
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
if result.aborted:
|
|
574
|
+
console.print("[red]Error:[/red] Session cleanup aborted before evaluation completed.")
|
|
575
|
+
console.print(f" [dim]{result.aborted_error}[/dim]")
|
|
576
|
+
elif result.has_only_skips:
|
|
577
|
+
console.print("[dim]No sessions cleaned.[/dim]")
|
|
578
|
+
|
|
579
|
+
if result.deleted:
|
|
580
|
+
console.print(
|
|
581
|
+
f"Cleaned {len(result.deleted)} session{'s' if len(result.deleted) != 1 else ''}"
|
|
582
|
+
f" older than {older_than} days."
|
|
583
|
+
)
|
|
584
|
+
elif not result.aborted:
|
|
585
|
+
console.print("[dim]No sessions cleaned.[/dim]")
|
|
586
|
+
|
|
587
|
+
if result.skipped_active:
|
|
588
|
+
console.print(
|
|
589
|
+
f"[dim]Kept {len(result.skipped_active)} active session{'s' if len(result.skipped_active) != 1 else ''}.[/dim]"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
if result.skipped_unparseable:
|
|
593
|
+
console.print(
|
|
594
|
+
f"[dim]Skipped {len(result.skipped_unparseable)} session{'s' if len(result.skipped_unparseable) != 1 else ''}"
|
|
595
|
+
f" with unparseable timestamps.[/dim]"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if result.should_exit_nonzero:
|
|
599
|
+
console.print(
|
|
600
|
+
f"[yellow]Encountered {result.summary_failed_count} cleanup {result.summary_failed_label}.[/yellow]"
|
|
601
|
+
)
|
|
602
|
+
for name, err in result.failure_items():
|
|
603
|
+
console.print(f" [dim]{name}: {err}[/dim]")
|
|
604
|
+
sys.exit(1)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _clean_sessions_dry_run(older_than_days: int) -> None:
|
|
608
|
+
"""Preview which sessions would be cleaned.
|
|
609
|
+
|
|
610
|
+
Iterates all sessions directly (same path as clean_old_sessions) so that
|
|
611
|
+
unparseable timestamps and active-registry errors are visible in the preview.
|
|
612
|
+
"""
|
|
613
|
+
from forge.session.active import ActiveSessionStore
|
|
614
|
+
|
|
615
|
+
manager = _sess().SessionManager()
|
|
616
|
+
all_sessions = manager.list_sessions(include_incognito=True)
|
|
617
|
+
|
|
618
|
+
# One-pass active lookup -- fail-closed matches cleanup behavior
|
|
619
|
+
active_store = ActiveSessionStore()
|
|
620
|
+
registry_error = False
|
|
621
|
+
try:
|
|
622
|
+
active_entries = active_store.list_sessions()
|
|
623
|
+
active_identities = {(name, ae.forge_root or ae.worktree_path) for name, ae in active_entries}
|
|
624
|
+
except Exception:
|
|
625
|
+
active_identities = set()
|
|
626
|
+
registry_error = True
|
|
627
|
+
|
|
628
|
+
table = Table(show_header=True, header_style="bold")
|
|
629
|
+
table.add_column("SESSION")
|
|
630
|
+
table.add_column("AGE")
|
|
631
|
+
table.add_column("STATUS")
|
|
632
|
+
|
|
633
|
+
deletable = 0
|
|
634
|
+
skipped = 0
|
|
635
|
+
any_old = False
|
|
636
|
+
for name, entry in all_sessions:
|
|
637
|
+
try:
|
|
638
|
+
dt = parse_iso(entry.last_accessed_at)
|
|
639
|
+
age_days = int((datetime.now(UTC) - dt).total_seconds() / 86400)
|
|
640
|
+
except (ValueError, TypeError, AttributeError):
|
|
641
|
+
table.add_row(name, "?", "[dim]unparseable timestamp (skip)[/dim]")
|
|
642
|
+
skipped += 1
|
|
643
|
+
any_old = True
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
if age_days <= older_than_days:
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
any_old = True
|
|
650
|
+
age_str = f"{age_days}d"
|
|
651
|
+
if (name, entry.forge_root or entry.worktree_path) in active_identities:
|
|
652
|
+
table.add_row(name, age_str, "[yellow]active (skip)[/yellow]")
|
|
653
|
+
skipped += 1
|
|
654
|
+
else:
|
|
655
|
+
table.add_row(name, age_str, "[green]will delete[/green]")
|
|
656
|
+
deletable += 1
|
|
657
|
+
|
|
658
|
+
if not any_old:
|
|
659
|
+
console.print(f"[dim]No sessions older than {older_than_days} days found.[/dim]")
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
console.print(table)
|
|
663
|
+
|
|
664
|
+
if registry_error:
|
|
665
|
+
console.print(
|
|
666
|
+
"[yellow]Warning:[/yellow] Could not read active session registry."
|
|
667
|
+
" Actual cleanup would abort to protect running sessions."
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
console.print(
|
|
671
|
+
f"\n[dim]Would delete {deletable} session{'s' if deletable != 1 else ''}"
|
|
672
|
+
+ (f", skip {skipped}" if skipped else "")
|
|
673
|
+
+ ".[/dim]"
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@session.command()
|
|
678
|
+
@click.argument("session_id", required=False)
|
|
679
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
680
|
+
@click.option(
|
|
681
|
+
"--field",
|
|
682
|
+
"field_path",
|
|
683
|
+
help="Extract a single dotted field (e.g., model_family, proxy.template). Missing path exits 1; null value prints empty.",
|
|
684
|
+
)
|
|
685
|
+
def show(session_id: str | None, as_json: bool, field_path: str | None) -> None:
|
|
686
|
+
"""Show session details.
|
|
687
|
+
|
|
688
|
+
SESSION_ID can be a Forge session name or a Claude session UUID.
|
|
689
|
+
Without SESSION_ID, resolves from $FORGE_SESSION.
|
|
690
|
+
|
|
691
|
+
\b
|
|
692
|
+
Examples:
|
|
693
|
+
forge session show my-session # Full details
|
|
694
|
+
forge session show # Current session
|
|
695
|
+
forge session show my-session --json # JSON output
|
|
696
|
+
forge session show my-session --field model_family # Extract field
|
|
697
|
+
"""
|
|
698
|
+
import json
|
|
699
|
+
|
|
700
|
+
from forge.core.ops.session_context import (
|
|
701
|
+
SessionContextError,
|
|
702
|
+
extract_field,
|
|
703
|
+
get_session_context,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# When no argument and no env var: for human mode, show a helpful message.
|
|
707
|
+
# For --json/--field, fall through to get_session_context() which builds
|
|
708
|
+
# env-derived context (backward compat with old `session context --json`).
|
|
709
|
+
if session_id is None and not os.environ.get("FORGE_SESSION") and not (as_json or field_path):
|
|
710
|
+
console.print("[dim]No session specified. Use a name or launch through Forge.[/dim]")
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
ctx = get_session_context(session_id)
|
|
715
|
+
except AmbiguousSessionError as e:
|
|
716
|
+
if as_json:
|
|
717
|
+
click.echo(json.dumps({"error": str(e)}, indent=2))
|
|
718
|
+
else:
|
|
719
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
720
|
+
sys.exit(1)
|
|
721
|
+
except SessionContextError as e:
|
|
722
|
+
if as_json:
|
|
723
|
+
click.echo(json.dumps({"error": str(e)}, indent=2))
|
|
724
|
+
else:
|
|
725
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
726
|
+
sys.exit(1)
|
|
727
|
+
|
|
728
|
+
# Resolve the forge_root once -- either from get_session_context's prior
|
|
729
|
+
# UUID/name lookup (preserves exact scope for UUIDs) or via the two-tier
|
|
730
|
+
# repo-wide resolver as fallback.
|
|
731
|
+
from forge.core.ops.resolution import resolve_session_repo_wide
|
|
732
|
+
from forge.core.ops.session_context import resolve_session_identifier
|
|
733
|
+
|
|
734
|
+
manager = _sess().SessionManager()
|
|
735
|
+
_fr = _cwd_forge_root()
|
|
736
|
+
|
|
737
|
+
# get_session_context already resolved the identifier (UUID or name) to
|
|
738
|
+
# an exact (name, forge_root). Reuse that forge_root so UUID lookups
|
|
739
|
+
# don't get re-resolved by name (which could pick the wrong duplicate).
|
|
740
|
+
resolved_fr: str | None = None
|
|
741
|
+
try:
|
|
742
|
+
_, id_forge_root = resolve_session_identifier(session_id)
|
|
743
|
+
resolved_fr = id_forge_root
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|
|
746
|
+
|
|
747
|
+
def _load_state_and_entry() -> tuple[SessionState | None, SessionIndexEntry | None, bool]:
|
|
748
|
+
"""Load manifest + entry, returning (state, entry, is_cross_project)."""
|
|
749
|
+
if resolved_fr is not None:
|
|
750
|
+
try:
|
|
751
|
+
st = manager.get_session(ctx.session_name, forge_root=resolved_fr)
|
|
752
|
+
ent = manager.get_session_entry(ctx.session_name, forge_root=resolved_fr)
|
|
753
|
+
is_cross = resolved_fr != _fr if _fr else False
|
|
754
|
+
return st, ent, is_cross
|
|
755
|
+
except ForgeSessionError:
|
|
756
|
+
pass
|
|
757
|
+
# Fallback: two-tier repo-wide resolution
|
|
758
|
+
try:
|
|
759
|
+
res = resolve_session_repo_wide(ctx.session_name, _fr, manager=manager)
|
|
760
|
+
return res.state, res.entry, res.is_cross_project
|
|
761
|
+
except (SessionNotFoundError, AmbiguousSessionError, ForgeSessionError):
|
|
762
|
+
return None, None, False
|
|
763
|
+
|
|
764
|
+
if as_json or field_path:
|
|
765
|
+
state, _, _ = _load_state_and_entry()
|
|
766
|
+
data = _build_show_json(state, ctx)
|
|
767
|
+
|
|
768
|
+
if field_path:
|
|
769
|
+
try:
|
|
770
|
+
value = extract_field(data, field_path)
|
|
771
|
+
except KeyError:
|
|
772
|
+
console.print(f"[red]Error:[/red] Field '{field_path}' not found")
|
|
773
|
+
sys.exit(1)
|
|
774
|
+
if value is None:
|
|
775
|
+
click.echo("")
|
|
776
|
+
elif isinstance(value, str):
|
|
777
|
+
click.echo(value)
|
|
778
|
+
else:
|
|
779
|
+
click.echo(json.dumps(value))
|
|
780
|
+
return
|
|
781
|
+
|
|
782
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
783
|
+
return
|
|
784
|
+
|
|
785
|
+
state, entry, is_cross_project = _load_state_and_entry()
|
|
786
|
+
if state is None or entry is None:
|
|
787
|
+
console.print(f"[red]Error:[/red] session '{ctx.session_name}' not found")
|
|
788
|
+
sys.exit(1)
|
|
789
|
+
|
|
790
|
+
if is_cross_project:
|
|
791
|
+
console.print(f"[dim]Showing session from {display_path(resolved_fr or '')}[/dim]\n")
|
|
792
|
+
|
|
793
|
+
_print_session_detail(state, entry, ctx)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@session.command("context", hidden=True)
|
|
797
|
+
@click.argument("session_id", required=False)
|
|
798
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
799
|
+
@click.option(
|
|
800
|
+
"--field",
|
|
801
|
+
"field_path",
|
|
802
|
+
help="Extract a single dotted field (e.g., model_family, proxy.template). Missing path exits 1; null value prints empty.",
|
|
803
|
+
)
|
|
804
|
+
def context_cmd(session_id: str | None, as_json: bool, field_path: str | None) -> None:
|
|
805
|
+
"""Show session context (metadata, proxy, model family).
|
|
806
|
+
|
|
807
|
+
Deprecated: use ``forge session show`` instead.
|
|
808
|
+
|
|
809
|
+
SESSION_ID can be a Forge session name or a Claude session UUID.
|
|
810
|
+
Without SESSION_ID, resolves from $FORGE_SESSION.
|
|
811
|
+
|
|
812
|
+
\b
|
|
813
|
+
Examples:
|
|
814
|
+
forge session context # current session
|
|
815
|
+
forge session context --json # full JSON
|
|
816
|
+
forge session context --field model_family # just the family
|
|
817
|
+
forge session context abc-123-uuid --json # by Claude UUID
|
|
818
|
+
"""
|
|
819
|
+
import json
|
|
820
|
+
|
|
821
|
+
from forge.core.ops.session_context import (
|
|
822
|
+
SessionContextError,
|
|
823
|
+
extract_field,
|
|
824
|
+
get_session_context,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
ctx = get_session_context(session_id)
|
|
829
|
+
except SessionContextError as e:
|
|
830
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
831
|
+
raise SystemExit(1) from None
|
|
832
|
+
|
|
833
|
+
data = ctx.to_dict()
|
|
834
|
+
|
|
835
|
+
if field_path:
|
|
836
|
+
try:
|
|
837
|
+
value = extract_field(data, field_path)
|
|
838
|
+
except KeyError:
|
|
839
|
+
console.print(f"[red]Error:[/red] Field '{field_path}' not found")
|
|
840
|
+
raise SystemExit(1) from None
|
|
841
|
+
# Raw value output for scripting -- no JSON wrapper, no quotes for strings.
|
|
842
|
+
# None prints empty (jq -r convention) so callers can tell "field exists but unset".
|
|
843
|
+
if value is None:
|
|
844
|
+
click.echo("")
|
|
845
|
+
elif isinstance(value, str):
|
|
846
|
+
click.echo(value)
|
|
847
|
+
else:
|
|
848
|
+
click.echo(json.dumps(value))
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
if as_json:
|
|
852
|
+
click.echo(json.dumps(data, indent=2))
|
|
853
|
+
return
|
|
854
|
+
|
|
855
|
+
_print_session_context(ctx)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _build_show_json(
|
|
859
|
+
state: SessionState | None,
|
|
860
|
+
ctx: SessionContext,
|
|
861
|
+
) -> dict[str, Any]:
|
|
862
|
+
"""Build merged JSON for ``session show --json``.
|
|
863
|
+
|
|
864
|
+
Manifest data at the top level, computed context nested under ``context``.
|
|
865
|
+
"""
|
|
866
|
+
data: dict[str, Any] = {
|
|
867
|
+
"session_name": ctx.session_name,
|
|
868
|
+
"claude_session_id": ctx.claude_session_id,
|
|
869
|
+
"created_at": ctx.created_at,
|
|
870
|
+
"is_fork": ctx.is_fork,
|
|
871
|
+
"is_incognito": ctx.is_incognito,
|
|
872
|
+
"parent_session": ctx.parent_session,
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if state:
|
|
876
|
+
data["last_accessed_at"] = state.last_accessed_at
|
|
877
|
+
data["intent"] = {
|
|
878
|
+
"agent": state.intent.agent,
|
|
879
|
+
"proxy": (
|
|
880
|
+
{
|
|
881
|
+
"template": state.intent.proxy.template,
|
|
882
|
+
"base_url": state.intent.proxy.base_url,
|
|
883
|
+
}
|
|
884
|
+
if state.intent.proxy
|
|
885
|
+
else None
|
|
886
|
+
),
|
|
887
|
+
}
|
|
888
|
+
data["confirmed"] = {
|
|
889
|
+
"claude_session_id": state.confirmed.claude_session_id,
|
|
890
|
+
"transcript_path": state.confirmed.transcript_path,
|
|
891
|
+
"confirmed_at": state.confirmed.confirmed_at,
|
|
892
|
+
"confirmed_by": state.confirmed.confirmed_by,
|
|
893
|
+
"latest_plan_path": state.confirmed.latest_plan_path,
|
|
894
|
+
"artifacts": dict(state.confirmed.artifacts),
|
|
895
|
+
"derivation": (dataclasses.asdict(state.confirmed.derivation) if state.confirmed.derivation else None),
|
|
896
|
+
"is_sandboxed": state.confirmed.is_sandboxed,
|
|
897
|
+
"claude_project_root": state.confirmed.claude_project_root,
|
|
898
|
+
"policy": (dataclasses.asdict(state.confirmed.policy) if state.confirmed.policy else None),
|
|
899
|
+
}
|
|
900
|
+
data["overrides"] = dict(state.overrides)
|
|
901
|
+
data["worktree"] = {"path": state.worktree.path, "branch": state.worktree.branch} if state.worktree else None
|
|
902
|
+
else:
|
|
903
|
+
data["last_accessed_at"] = None
|
|
904
|
+
data["intent"] = None
|
|
905
|
+
data["confirmed"] = None
|
|
906
|
+
data["overrides"] = {}
|
|
907
|
+
data["worktree"] = {"path": ctx.worktree_path} if ctx.worktree_path else None
|
|
908
|
+
|
|
909
|
+
data["plan"] = _build_show_plan_json(state)
|
|
910
|
+
data["project_root"] = ctx.project_root
|
|
911
|
+
|
|
912
|
+
data["context"] = {
|
|
913
|
+
"model_family": ctx.model_family,
|
|
914
|
+
"main_model": ctx.main_model,
|
|
915
|
+
"models": dict(ctx.models),
|
|
916
|
+
"proxy": {
|
|
917
|
+
"template": ctx.proxy.template,
|
|
918
|
+
"base_url": ctx.proxy.base_url,
|
|
919
|
+
"proxy_id": ctx.proxy.proxy_id,
|
|
920
|
+
"is_direct": ctx.proxy.is_direct,
|
|
921
|
+
},
|
|
922
|
+
"policy": {
|
|
923
|
+
"enabled": ctx.policy.enabled,
|
|
924
|
+
"fail_mode": ctx.policy.fail_mode,
|
|
925
|
+
"bundles": list(ctx.policy.bundles),
|
|
926
|
+
"supervisor_resume_id": ctx.policy.supervisor_resume_id,
|
|
927
|
+
},
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
# Top-level aliases for backward compat with old `session context --field`
|
|
931
|
+
data["model_family"] = ctx.model_family
|
|
932
|
+
data["main_model"] = ctx.main_model
|
|
933
|
+
data["models"] = dict(ctx.models)
|
|
934
|
+
data["proxy"] = data["context"]["proxy"]
|
|
935
|
+
data["policy"] = data["context"]["policy"]
|
|
936
|
+
|
|
937
|
+
return data
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _empty_show_plan_json() -> dict[str, Any]:
|
|
941
|
+
"""Return the resolved plan shape used by `session show --json`."""
|
|
942
|
+
return {
|
|
943
|
+
"source": None,
|
|
944
|
+
"parent_session": None,
|
|
945
|
+
"draft_path": None,
|
|
946
|
+
"approved_snapshots": [],
|
|
947
|
+
"preferred_path": None,
|
|
948
|
+
"display_path": None,
|
|
949
|
+
"exists": None,
|
|
950
|
+
"kind": None,
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _build_show_plan_json(state: SessionState | None) -> dict[str, Any]:
|
|
955
|
+
"""Build the resolved/inherited plan view for machine-readable output."""
|
|
956
|
+
if state is None:
|
|
957
|
+
return _empty_show_plan_json()
|
|
958
|
+
|
|
959
|
+
current_forge_root = state.forge_root or (state.worktree.path if state.worktree else None)
|
|
960
|
+
if current_forge_root is None:
|
|
961
|
+
return _empty_show_plan_json()
|
|
962
|
+
|
|
963
|
+
plan_info = resolve_plan_info(state, current_forge_root=current_forge_root)
|
|
964
|
+
displayed = resolve_displayed_plan_path(
|
|
965
|
+
plan_info,
|
|
966
|
+
current_forge_root=current_forge_root,
|
|
967
|
+
current_launch_root=resolve_plan_launch_root(state),
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
if plan_info.approved_snapshots:
|
|
971
|
+
kind = "approved"
|
|
972
|
+
elif plan_info.draft_path:
|
|
973
|
+
kind = "draft"
|
|
974
|
+
else:
|
|
975
|
+
kind = None
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
"source": plan_info.source,
|
|
979
|
+
"parent_session": plan_info.parent_session,
|
|
980
|
+
"draft_path": plan_info.draft_path,
|
|
981
|
+
"approved_snapshots": list(plan_info.approved_snapshots),
|
|
982
|
+
"preferred_path": preferred_plan_path(plan_info),
|
|
983
|
+
"display_path": displayed.path if displayed else None,
|
|
984
|
+
"exists": displayed.exists if displayed else None,
|
|
985
|
+
"kind": kind,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _print_session_context(ctx: SessionContext) -> None:
|
|
990
|
+
"""Print session context in human-readable format."""
|
|
991
|
+
|
|
992
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
993
|
+
table.add_column("Key", style="dim")
|
|
994
|
+
table.add_column("Value")
|
|
995
|
+
|
|
996
|
+
table.add_row("Session", ctx.session_name)
|
|
997
|
+
if ctx.claude_session_id:
|
|
998
|
+
table.add_row("Claude UUID", ctx.claude_session_id)
|
|
999
|
+
table.add_row("Model Family", f"[cyan]{ctx.model_family}[/cyan]")
|
|
1000
|
+
|
|
1001
|
+
if ctx.proxy.is_direct:
|
|
1002
|
+
table.add_row("Proxy", "[dim]direct (no proxy)[/dim]")
|
|
1003
|
+
else:
|
|
1004
|
+
proxy_parts = []
|
|
1005
|
+
if ctx.proxy.template:
|
|
1006
|
+
proxy_parts.append(ctx.proxy.template)
|
|
1007
|
+
if ctx.proxy.base_url:
|
|
1008
|
+
proxy_parts.append(ctx.proxy.base_url)
|
|
1009
|
+
table.add_row("Proxy", " | ".join(proxy_parts))
|
|
1010
|
+
|
|
1011
|
+
if ctx.models:
|
|
1012
|
+
model_str = ", ".join(f"{t}={m}" for t, m in ctx.models.items())
|
|
1013
|
+
table.add_row("Models", model_str)
|
|
1014
|
+
|
|
1015
|
+
if ctx.worktree_path:
|
|
1016
|
+
table.add_row("Worktree", ctx.worktree_path)
|
|
1017
|
+
|
|
1018
|
+
if ctx.parent_session:
|
|
1019
|
+
table.add_row("Parent", ctx.parent_session)
|
|
1020
|
+
|
|
1021
|
+
if ctx.is_fork:
|
|
1022
|
+
table.add_row("Fork", "yes")
|
|
1023
|
+
|
|
1024
|
+
if ctx.policy.enabled:
|
|
1025
|
+
table.add_row("Policy", f"enabled (bundles: {', '.join(ctx.policy.bundles) or 'none'})")
|
|
1026
|
+
|
|
1027
|
+
console.print(table)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@session.command()
|
|
1031
|
+
@click.argument("name", required=False)
|
|
1032
|
+
def shell(name: str | None) -> None:
|
|
1033
|
+
"""Open a shell in a sidecar session container.
|
|
1034
|
+
|
|
1035
|
+
Without NAME, resolves from $FORGE_SESSION.
|
|
1036
|
+
Only works for sessions started with --sidecar.
|
|
1037
|
+
"""
|
|
1038
|
+
from forge.sidecar import exec_in_container, is_container_running
|
|
1039
|
+
|
|
1040
|
+
manager = _sess().SessionManager()
|
|
1041
|
+
|
|
1042
|
+
if name is None:
|
|
1043
|
+
env_name = os.environ.get("FORGE_SESSION")
|
|
1044
|
+
if env_name:
|
|
1045
|
+
name = env_name
|
|
1046
|
+
else:
|
|
1047
|
+
console.print("[red]Error:[/red] No session specified. Use a name or launch through Forge.")
|
|
1048
|
+
console.print("\n[dim]Tip: Run 'forge session start <name> --sidecar'.[/dim]")
|
|
1049
|
+
sys.exit(1)
|
|
1050
|
+
|
|
1051
|
+
_fr = _cwd_forge_root()
|
|
1052
|
+
if not manager.session_exists(name, forge_root=_fr):
|
|
1053
|
+
if not _hint_cross_project_session(name, _fr):
|
|
1054
|
+
console.print(f"[red]Error:[/red] Session '{name}' not found")
|
|
1055
|
+
sys.exit(1)
|
|
1056
|
+
|
|
1057
|
+
try:
|
|
1058
|
+
manifest = manager.get_session(name, forge_root=_fr)
|
|
1059
|
+
except ForgeSessionError as e:
|
|
1060
|
+
_handle_error(e)
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
if not manifest.confirmed.is_sandboxed:
|
|
1064
|
+
console.print(f"[red]Error:[/red] Session '{name}' is not a sidecar session")
|
|
1065
|
+
console.print("\nOnly sessions started with --sidecar can use shell.")
|
|
1066
|
+
console.print("Start a sidecar session with: [cyan]forge session start <name> --sidecar[/cyan]")
|
|
1067
|
+
sys.exit(1)
|
|
1068
|
+
|
|
1069
|
+
# Check if container is running (deterministic naming)
|
|
1070
|
+
container_name = f"forge-{name}"
|
|
1071
|
+
if not is_container_running(container_name):
|
|
1072
|
+
console.print(f"[red]Error:[/red] Container '{container_name}' is not running")
|
|
1073
|
+
console.print("\nThe sidecar session may have exited.")
|
|
1074
|
+
sys.exit(1)
|
|
1075
|
+
|
|
1076
|
+
console.print(f"Opening shell in container [cyan]{container_name}[/cyan]...")
|
|
1077
|
+
exit_code = exec_in_container(container_name, ["/bin/bash"])
|
|
1078
|
+
sys.exit(exit_code)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@session.command("set")
|
|
1082
|
+
@click.argument("key")
|
|
1083
|
+
@click.argument("value")
|
|
1084
|
+
@click.option("--session", "-s", "session_name", help="Target session (default: current from cwd)")
|
|
1085
|
+
def set_override(key: str, value: str, session_name: str | None) -> None:
|
|
1086
|
+
"""Set a mid-session override.
|
|
1087
|
+
|
|
1088
|
+
KEY is a dot-notation path relative to intent (e.g., agent, proxy.template).
|
|
1089
|
+
VALUE is parsed as JSON first, then as string.
|
|
1090
|
+
|
|
1091
|
+
\b
|
|
1092
|
+
Examples:
|
|
1093
|
+
forge session set agent custom-agent
|
|
1094
|
+
forge session set memory.tags '["tag1","tag2"]'
|
|
1095
|
+
forge session set proxy.* null # Clear all proxy fields
|
|
1096
|
+
"""
|
|
1097
|
+
from forge.core.ops.context import ExecutionContext
|
|
1098
|
+
from forge.core.ops.session import ForgeOpError
|
|
1099
|
+
from forge.core.ops.session import set_session_override as set_override_op
|
|
1100
|
+
|
|
1101
|
+
try:
|
|
1102
|
+
ctx = ExecutionContext.from_cwd()
|
|
1103
|
+
result = set_override_op(ctx=ctx, session_name=session_name, key=key, value_str=value)
|
|
1104
|
+
display_value = _format_value(result.value)
|
|
1105
|
+
console.print(f"Set [cyan]{result.key}[/cyan] = {display_value} [dim](override)[/dim]")
|
|
1106
|
+
|
|
1107
|
+
if key.startswith("verification"):
|
|
1108
|
+
from forge.install.hooks import has_forge_hook
|
|
1109
|
+
|
|
1110
|
+
if not has_forge_hook(ctx.worktree_root, "Stop"):
|
|
1111
|
+
console.print(
|
|
1112
|
+
"[yellow]Warning:[/yellow] Verification configured but Stop hook is not installed. "
|
|
1113
|
+
"Enforcement will not be active."
|
|
1114
|
+
)
|
|
1115
|
+
console.print("[dim]Tip: Run 'forge extension enable' to install hooks.[/dim]")
|
|
1116
|
+
except ForgeOpError as e:
|
|
1117
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1118
|
+
sys.exit(1)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
@session.command()
|
|
1122
|
+
@click.argument("key", required=False)
|
|
1123
|
+
@click.option("--all", "-a", "clear_all", is_flag=True, help="Clear all overrides")
|
|
1124
|
+
@click.option("--session", "-s", "session_name", help="Target session (default: current from cwd)")
|
|
1125
|
+
def reset(key: str | None, clear_all: bool, session_name: str | None) -> None:
|
|
1126
|
+
"""Reset overrides, reverting to intent values.
|
|
1127
|
+
|
|
1128
|
+
If KEY is provided, resets only that key.
|
|
1129
|
+
If --all or no key, clears all overrides.
|
|
1130
|
+
|
|
1131
|
+
Examples:
|
|
1132
|
+
|
|
1133
|
+
forge session reset agent # Reset single key
|
|
1134
|
+
|
|
1135
|
+
forge session reset # Clear all overrides
|
|
1136
|
+
|
|
1137
|
+
forge session reset --all # Clear all overrides (explicit)
|
|
1138
|
+
|
|
1139
|
+
forge session reset memory.* # Reset all memory.* overrides
|
|
1140
|
+
"""
|
|
1141
|
+
from forge.core.ops.context import ExecutionContext
|
|
1142
|
+
from forge.core.ops.session import ForgeOpError
|
|
1143
|
+
from forge.core.ops.session import reset_session_overrides as reset_overrides_op
|
|
1144
|
+
|
|
1145
|
+
if key and clear_all:
|
|
1146
|
+
console.print("[red]Error:[/red] Cannot specify both KEY and --all")
|
|
1147
|
+
sys.exit(1)
|
|
1148
|
+
|
|
1149
|
+
try:
|
|
1150
|
+
ctx = ExecutionContext.from_cwd()
|
|
1151
|
+
result = reset_overrides_op(ctx=ctx, session_name=session_name, key=key)
|
|
1152
|
+
|
|
1153
|
+
if result.cleared_all:
|
|
1154
|
+
if result.was_present:
|
|
1155
|
+
console.print("[green]Cleared all overrides[/green]")
|
|
1156
|
+
else:
|
|
1157
|
+
console.print("[dim]No overrides to clear[/dim]")
|
|
1158
|
+
else:
|
|
1159
|
+
if result.was_present:
|
|
1160
|
+
console.print(f"Reset [cyan]{result.key}[/cyan] [dim](now using intent value)[/dim]")
|
|
1161
|
+
else:
|
|
1162
|
+
console.print(f"[dim]No override for {result.key} (no-op)[/dim]")
|
|
1163
|
+
except ForgeOpError as e:
|
|
1164
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1165
|
+
sys.exit(1)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _print_session_summary(state: SessionState) -> None:
|
|
1169
|
+
"""Print a brief session summary."""
|
|
1170
|
+
console.print(f"[green]{state.name}[/green]", end="")
|
|
1171
|
+
|
|
1172
|
+
parts = [_template_display_label(state.intent.proxy.template) if state.intent.proxy else "direct"]
|
|
1173
|
+
console.print(f" ({', '.join(parts)})")
|
|
1174
|
+
|
|
1175
|
+
if state.worktree:
|
|
1176
|
+
console.print(f" [dim]{display_path(state.worktree.path)}[/dim]")
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _print_plan_info(plan_info: PlanInfo, *, current_forge_root: str, current_launch_root: str | None) -> None:
|
|
1180
|
+
"""Print a Plan subsection for `forge session show`, if any plan info applies.
|
|
1181
|
+
|
|
1182
|
+
Parent (inherited): one line, approved snapshot preferred.
|
|
1183
|
+
Self: approved snapshot line AND draft line when both exist (the draft is
|
|
1184
|
+
the live pointer for in-progress edits; the snapshot is the last approval).
|
|
1185
|
+
Paths are absolute, with ``(file missing)`` when not on disk.
|
|
1186
|
+
"""
|
|
1187
|
+
if plan_info.source is None:
|
|
1188
|
+
return
|
|
1189
|
+
|
|
1190
|
+
if plan_info.source == "parent":
|
|
1191
|
+
displayed = resolve_displayed_plan_path(
|
|
1192
|
+
plan_info,
|
|
1193
|
+
current_forge_root=current_forge_root,
|
|
1194
|
+
current_launch_root=current_launch_root,
|
|
1195
|
+
)
|
|
1196
|
+
if displayed is None:
|
|
1197
|
+
return
|
|
1198
|
+
kind = "approved snapshot" if plan_info.approved_snapshots else "draft"
|
|
1199
|
+
missing = "" if displayed.exists else " [dim](file missing)[/dim]"
|
|
1200
|
+
console.print(
|
|
1201
|
+
f" Plan (inherited from {plan_info.parent_session}, {kind}): {display_path(displayed.path)}{missing}"
|
|
1202
|
+
)
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
if plan_info.approved_snapshots:
|
|
1206
|
+
snap_rel = latest_snapshot_path(plan_info.approved_snapshots)
|
|
1207
|
+
if snap_rel is not None:
|
|
1208
|
+
d = resolve_path_against(snap_rel, current_forge_root)
|
|
1209
|
+
missing = "" if d.exists else " [dim](file missing)[/dim]"
|
|
1210
|
+
count = len(plan_info.approved_snapshots)
|
|
1211
|
+
console.print(f" Plans approved: {count} (latest: {display_path(d.path)}){missing}")
|
|
1212
|
+
if plan_info.draft_path:
|
|
1213
|
+
d = resolve_path_against(plan_info.draft_path, current_launch_root)
|
|
1214
|
+
missing = "" if d.exists else " [dim](file missing)[/dim]"
|
|
1215
|
+
console.print(f" Plan (draft): {display_path(d.path)}{missing}")
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _print_session_detail(
|
|
1219
|
+
state: SessionState,
|
|
1220
|
+
entry: SessionIndexEntry,
|
|
1221
|
+
ctx: SessionContext | None = None,
|
|
1222
|
+
) -> None:
|
|
1223
|
+
"""Print detailed session information with optional computed context."""
|
|
1224
|
+
|
|
1225
|
+
console.print(f"Session: [bold]{state.name}[/bold]")
|
|
1226
|
+
console.print("=" * 50)
|
|
1227
|
+
console.print()
|
|
1228
|
+
|
|
1229
|
+
console.print("[bold]Basic Info[/bold]")
|
|
1230
|
+
if state.confirmed.claude_session_id:
|
|
1231
|
+
console.print(f" UUID: {state.confirmed.claude_session_id}")
|
|
1232
|
+
console.print(f" Created: {state.created_at}")
|
|
1233
|
+
console.print(f" Last Used: {state.last_accessed_at}")
|
|
1234
|
+
|
|
1235
|
+
session_type = _get_session_type(state.is_fork, state.is_incognito, state.parent_session)
|
|
1236
|
+
console.print(f" Type: {session_type}")
|
|
1237
|
+
console.print()
|
|
1238
|
+
|
|
1239
|
+
console.print("[bold]Configuration (Intent)[/bold]")
|
|
1240
|
+
console.print(f" Agent: {state.intent.agent}")
|
|
1241
|
+
if state.intent.proxy:
|
|
1242
|
+
console.print(f" Routing: {_template_display_label(state.intent.proxy.template)}")
|
|
1243
|
+
console.print(f" Base URL: {state.intent.proxy.base_url}")
|
|
1244
|
+
else:
|
|
1245
|
+
console.print(" Routing: direct")
|
|
1246
|
+
console.print(" Base URL: default Anthropic")
|
|
1247
|
+
console.print()
|
|
1248
|
+
|
|
1249
|
+
if state.worktree:
|
|
1250
|
+
console.print("[bold]Worktree[/bold]")
|
|
1251
|
+
console.print(f" Path: {display_path(state.worktree.path)}")
|
|
1252
|
+
console.print(f" Branch: {state.worktree.branch}")
|
|
1253
|
+
console.print()
|
|
1254
|
+
|
|
1255
|
+
current_forge_root = (
|
|
1256
|
+
entry.forge_root or state.forge_root or (state.worktree.path if state.worktree else str(Path.cwd()))
|
|
1257
|
+
)
|
|
1258
|
+
plan_info = resolve_plan_info(state, current_forge_root=current_forge_root)
|
|
1259
|
+
current_launch_root = resolve_plan_launch_root(state)
|
|
1260
|
+
|
|
1261
|
+
# Confirmed state (from hooks)
|
|
1262
|
+
has_confirmed = (
|
|
1263
|
+
state.confirmed.claude_session_id
|
|
1264
|
+
or state.confirmed.transcript_path
|
|
1265
|
+
or plan_info.source
|
|
1266
|
+
or (state.confirmed.policy and state.confirmed.policy.decisions)
|
|
1267
|
+
)
|
|
1268
|
+
if has_confirmed:
|
|
1269
|
+
console.print("[bold]Confirmed State[/bold]")
|
|
1270
|
+
if state.confirmed.transcript_path:
|
|
1271
|
+
console.print(f" Transcript: {display_path(state.confirmed.transcript_path)}")
|
|
1272
|
+
if state.confirmed.confirmed_at:
|
|
1273
|
+
console.print(f" Confirmed At: {state.confirmed.confirmed_at}")
|
|
1274
|
+
if state.confirmed.confirmed_by:
|
|
1275
|
+
console.print(f" Confirmed By: {state.confirmed.confirmed_by}")
|
|
1276
|
+
_print_plan_info(
|
|
1277
|
+
plan_info,
|
|
1278
|
+
current_forge_root=current_forge_root,
|
|
1279
|
+
current_launch_root=current_launch_root,
|
|
1280
|
+
)
|
|
1281
|
+
if state.confirmed.policy and state.confirmed.policy.decisions:
|
|
1282
|
+
pc = state.confirmed.policy
|
|
1283
|
+
n = len(pc.decisions)
|
|
1284
|
+
last = pc.decisions[-1] if pc.decisions else None
|
|
1285
|
+
last_label = ""
|
|
1286
|
+
if last and isinstance(last, dict):
|
|
1287
|
+
last_decision = last.get("final_decision", "?")
|
|
1288
|
+
last_context = last.get("context_summary", "")
|
|
1289
|
+
last_label = f", last: {last_decision}"
|
|
1290
|
+
if last_context:
|
|
1291
|
+
last_label += f" ({last_context})"
|
|
1292
|
+
console.print(f" Policy Evals: {n} evaluation{'s' if n != 1 else ''}{last_label}")
|
|
1293
|
+
|
|
1294
|
+
# Active overrides
|
|
1295
|
+
if state.overrides:
|
|
1296
|
+
console.print()
|
|
1297
|
+
console.print("[bold]Active Overrides[/bold]")
|
|
1298
|
+
for key, value in _flatten_overrides(state.overrides):
|
|
1299
|
+
console.print(f" {key}: {_format_value(value)}")
|
|
1300
|
+
|
|
1301
|
+
if ctx:
|
|
1302
|
+
console.print()
|
|
1303
|
+
console.print("[bold]Computed Context[/bold]")
|
|
1304
|
+
console.print(f" Model Family: [cyan]{ctx.model_family}[/cyan]")
|
|
1305
|
+
if ctx.models:
|
|
1306
|
+
model_str = ", ".join(f"{t}={m}" for t, m in ctx.models.items())
|
|
1307
|
+
console.print(f" Models: {model_str}")
|
|
1308
|
+
if ctx.policy.enabled:
|
|
1309
|
+
bundles_str = ", ".join(ctx.policy.bundles) or "none"
|
|
1310
|
+
console.print(f" Policy: enabled (bundles: {bundles_str})")
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _flatten_overrides(
|
|
1314
|
+
overrides: dict,
|
|
1315
|
+
prefix: str = "",
|
|
1316
|
+
) -> list[tuple[str, object]]:
|
|
1317
|
+
"""Flatten nested override dict to dot-notation key-value pairs."""
|
|
1318
|
+
result: list[tuple[str, object]] = []
|
|
1319
|
+
for key, value in overrides.items():
|
|
1320
|
+
full_key = f"{prefix}{key}" if prefix else key
|
|
1321
|
+
if isinstance(value, dict):
|
|
1322
|
+
result.extend(_flatten_overrides(value, f"{full_key}."))
|
|
1323
|
+
else:
|
|
1324
|
+
result.append((full_key, value))
|
|
1325
|
+
return result
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _format_value(value: object) -> str:
|
|
1329
|
+
"""Format a value for display."""
|
|
1330
|
+
if value is None:
|
|
1331
|
+
return "[dim]null[/dim]"
|
|
1332
|
+
if isinstance(value, bool):
|
|
1333
|
+
return str(value).lower()
|
|
1334
|
+
if isinstance(value, str):
|
|
1335
|
+
return f'"{value}"'
|
|
1336
|
+
return repr(value)
|